A friendly, point-and-click tool for Recruiters, HR, and Talent Acquisition to send personalized, templated rejection emails from a Google Sheet.
This README walks you through setup and day-to-day use. No coding required.
- Read candidates from a Google Sheet (your pipeline/export).
- Personalize subject & body using placeholders (name, role, company…).
- Choose plain-text and/or HTML templates.
- Add attachments (e.g., “Interview Tips” PDF).
- Run in Dry Run (no emails) or Send mode.
- Test Send To Me (sends one example to yourself).
- Progress bar + live log; safely resumes without re-sending people already marked as sent.
- Saves your last settings next to the app for easy reuse.
Place these two files together in a normal, writable folder on your computer:
your-folder/
├─ gui_rejections_app.py # the app you run
└─ rejections_core.py # the engine (don’t edit)
The app will create a rejections_gui_settings.json alongside these to remember your settings.
- Install Python 3.10+ (3.11/3.12 recommended).
- Open a terminal (Command Prompt/PowerShell on Windows; Terminal on macOS).
- (Optional but recommended) create a virtual environment.
- Install dependencies:
python -m pip install --upgrade pip
python -m pip install customtkinter jinja2 google-api-python-client google-auth google-auth-oauthlibTip (macOS): use
python3instead ofpythonif needed.
This gives the app permission to access your Sheets & Gmail.
- Go to Google Cloud Console → APIs & Services → Credentials.
- Create OAuth client ID → Desktop app.
- Download the JSON → save it next to the app as
credentials.json.
If your org restricts OAuth, your Google Workspace admin may need to approve the app scopes:
https://www.googleapis.com/auth/spreadsheetshttps://www.googleapis.com/auth/gmail.send
Have (at minimum) these columns in the first row (header):
email(candidate’s email)namerolecompany
Optional but useful columns (add if you want to use them in templates):
stage,reason,application_date- Gates:
send→ only rows withyesare processed (if this column exists)skip→ rows withyesare always skipped
- Status (auto-managed by the app):
sent_status(app writes “sent”)sent_at(app writes timestamp)
The app will auto-add
sent_statusandsent_atif missing.
Re-running the app will not re-send to rows wheresent_statusalready sayssent.
Put these next to the app, or anywhere on disk you can browse to.
template.txt (required; plain text)
Hi {{ name }},
Thank you for your interest in {{ company }} and the {{ role }} role.
After careful review, we’re moving forward with other candidates at this time.
We appreciate the time you spent applying{{ " and interviewing" if stage|lower == "onsite" else "" }} and wish you the best in your search.
Best,
{{ sender_name }}
{{ sender_title }}
template.html (optional; nicer formatting)
<p>Hi {{ name }},</p>
<p>Thank you for your interest in <strong>{{ company }}</strong> and the <strong>{{ role }}</strong> role.
After careful review, we’re moving forward with other candidates at this time.</p>
<p>We appreciate the time you spent applying{% if stage|lower == "onsite" %} and interviewing{% endif %} and wish you the best in your search.</p>
<p>Best,<br>
{{ sender_name }}<br>
{{ sender_title }}</p>Subject example (set in the app):
Regarding your application for {{ role }} at {{ company }}
Available placeholders: name, email, role, company, stage, reason, application_date, sender_name, sender_title.
-
In your terminal, cd into the folder and run:
python gui_rejections_app.py
-
The Config tab:
- Sender (email address): Use your Gmail/Workspace address or approved alias.
- Reply-To: Where replies should go (often a shared inbox).
- Cc/Bcc: Optional.
- Throttle: Seconds to wait between sends (default 2.0).
- Domain throttle: Extra delay per domain (optional; helps avoid bursts to the same company).
- Preview N: Limit to the first N candidates (leave 0 to send all eligible).
- Spreadsheet ID: Copy the long ID from your Google Sheet URL.
- Preferred Tab: The tab name (e.g.,
Applicants). Case-insensitive. - Read Range: Usually
A:Z(or narrow it if you want). - Credentials JSON: Browse to your downloaded
credentials.json. - Token JSON: A file the app creates after your first Google sign-in (defaults to
token.jsonnext to the app). - Sender Signature: Shown in templates if not overridden by environment variables.
-
Templates tab:
- Choose your
template.txt(required) and optionaltemplate.html.
- Choose your
-
Attachments tab:
- Add one or more files.
- Select any item and click Remove selected to remove it.
- Remove missing drops files that no longer exist on disk.
-
Run tab:
- Dry Run: Parses the sheet and renders emails without sending.
- Test Send To Me: Sends the first eligible email to you (From: your Sender; To: you; CC/BCC suppressed; subject prefixed with
[TEST]). - Send: Sends to all eligible rows (respecting gates and throttles).
- Cancel: Politely stops the run after the current item.
Settings are saved to
rejections_gui_settings.jsonright next to the app, so you don’t have to re-enter them next time.
A row is eligible if:
- Required fields are present:
email,name,role,company. - If a
sendcolumn exists, it must beyes. - If a
skipcolumn exists, it must not beyes. sent_statusis not alreadysent.
When an email is successfully sent (not dry run, not test mode), the app writes:
sent_status = sentsent_at = <timestamp>
You can safely re-run the app; previously sent rows are skipped.
- Start with Preview N = 5 and Dry Run to verify subjects and content.
- Use Test Send To Me to see exactly what lands in an inbox.
- Keep Throttle ≥ 2 seconds and consider a Domain throttle (e.g.,
5–10 s) for large sends. - Use Cc/Bcc sparingly.
- If sending from an alias, ensure your Gmail / Workspace is configured to Send mail as that alias.
On your first run, Google will open a browser window to ask for permission. After you approve:
- The app tries to write the token to the path in Token JSON (default
token.jsonnext to the app). - If that folder is read-only, the app falls back to saving
token.jsonunder your home folder at:and will show a clear message in the Run log like:~/.rejections_gui/token.json“Cannot write token to ‘…/token.json’. Wrote it to ‘~/.rejections_gui/token.json’ instead. Update your Token JSON path in the GUI to this location.”
Just copy that fallback path into the Token JSON field, click Save Settings, and you’re set.
Header row (row 1):
email | name | role | company | stage | reason | application_date | send | skip | sent_status | sent_at
Example rows:
jane@domain.com | Jane Doe | Product Manager | Acme | onsite | skills gap | 2025-08-01 | yes | |
joe@domain.com | Joe Lin | Backend Engineer | Acme | screen | scheduling | 2025-08-03 | yes | |
- If you don’t have a
sendcolumn, the app will process all rows that have the required fields and are not marked as sent. - Add
skip = yesfor any one-off rows you want to ignore.
You can use these placeholders in subject/text/HTML:
{{ name }},{{ email }},{{ role }},{{ company }}{{ stage }},{{ reason }},{{ application_date }}{{ sender_name }},{{ sender_title }}
Conditionals are supported (Jinja2):
{% if stage|lower == "onsite" %}
Thank you again for the time you spent with our team.
{% endif %}- Set Preview N = 10 → Dry Run
- Test Send To Me → read the actual message in your inbox
- Uncheck Dry run → Send
- Add
sendcolumn and setyesonly for rows you want. - Or add
skipfor rows to exclude.
- Go to Attachments → Add attachment(s) → pick your PDF.
- Reuse the same attachments next time (they’re saved in your settings file).
“Permission denied” when creating token
- You’re writing
token.jsonto a folder you can’t write to. - Check the Run log—if it says it saved to
~/.rejections_gui/token.json, copy that path into the Token JSON field and click Save Settings.
“Missing required columns”
- The app didn’t find one of:
email,name,role,company. - Fix your header row (row 1) so those names exist (spelling matters).
“No rows found” or wrong tab
- Confirm the Spreadsheet ID, Preferred Tab (tab name), and Read Range (
A:Zis a safe default). - Make sure you (and the Google account you’re signed in with) have at least Viewer access to the Sheet; Editor access is needed to write
sent_status/sent_at.
Gmail “send as” / alias isn’t honored
- Gmail must be configured to Send mail as that alias in your account settings. Ask IT if needed.
Messages appear blank
template.txtis required. If you only use HTML, the app will auto-create a plain-text fallback by stripping HTML tags.
Re-sending to the same candidates
- The app won’t resend rows with
sent_status == sent. - If you want to re-send, clear that cell for those rows.
Org blocks the app
- Your Workspace admin may need to approve the OAuth scopes or add the OAuth client to an allowlist.
- Least access: The app only requests access to send email and read/write the specific spreadsheet.
- Token storage: The token file is local to your machine (either next to the app or under
~/.rejections_gui/token.json). Treat it like a password. - Audit trail: Each sent row is stamped with
sent_status+sent_at. - Review content: Use Dry Run and Test Send To Me to double-check tone and accuracy.
- Respect candidate privacy: Keep attachments appropriate and avoid sensitive info unless required and approved.
Q: Can multiple recruiters use this?
Yes. Each recruiter can keep a copy of the folder on their machine with their own credentials.json and token.json, or share the same Sheet with appropriate access.
Q: Does it support rich HTML?
Yes—pick an HTML template. The app sends a multipart email containing both plain-text and HTML, so all email clients render something clean.
Q: Will the progress bar show total items?
Yes. It reflects the position within the eligible list (after filtering by send/skip/sent_status).
Q: Can I schedule or automate?
This app is designed for manual runs. If you need a scheduled job, ask your tech partner to wrap rejections_core.run_sender in a script or CI job.
- Delete the folder with the two
.pyfiles andrejections_gui_settings.json. - Optionally remove
~/.rejections_gui/token.jsonif it was created.
If you run into anything confusing, share:
- A screenshot of your Config tab,
- The last 20 lines from the Run tab log,
- The header row of your Google Sheet.
We’ll help you get it sorted quickly.