# Auto-draft replies to recruiters and headhunters when you're not interested

> Source: <https://gist.github.com/BryanOwens012/1b83e7184dcc76fff81f963816602ce8>
> Published: 2026-05-26 02:35:26+00:00

| /** | |
| * Recruiter auto-draft workflow | |
| * ============================================================================ | |
| * | |
| * WHAT THIS DOES | |
| * If you receive high volumes of recruiter, headhunter, and sourcer | |
| * outreach but you're not job searching, this script drafts polite | |
| * replies for you. It runs every 5 minutes on Google's servers via a | |
| * time-based trigger. On each tick, it: | |
| * | |
| * 1. Looks through your Gmail inbox for threads received since the | |
| * last run that it hasn't already touched (read or unread, either | |
| * way). | |
| * 2. For each thread, reads the ENTIRE thread, not just the latest | |
| * message. Recruiter conversations often span multiple back-and- | |
| * forth messages, and the original pitch, role, company, and comp | |
| * details are usually only in the earliest messages. The script | |
| * feeds all messages, oldest-first, to Claude. | |
| * 3. Detects whether the thread is a LinkedIn InMail notification (any | |
| * message from @linkedin.com) or a regular email, and tags the | |
| * input so the reply wording fits the channel. For email, the | |
| * draft offers your LinkedIn URL inline. For InMail, the draft | |
| * invites a connection request without the URL: the recruiter is | |
| * already on LinkedIn with your profile a click away, so the URL | |
| * would just be redundant ceremony. | |
| * | |
| * Why we care about the channel: when you reply to a LinkedIn InMail | |
| * notification via Gmail, LinkedIn intercepts the reply and posts it | |
| * into the original InMail thread as if you'd typed it inside | |
| * LinkedIn. The recruiter sees your message in LinkedIn's UI, not | |
| * as a regular email. So the reply needs to read naturally inside | |
| * LinkedIn's interface. | |
| * 4. Asks Claude (Sonnet 4.6) to decide whether the thread is recruiter | |
| * outreach. If yes, Claude drafts a warm, polite, grammatical reply | |
| * that thanks the sender, declines clearly, and leaves the door | |
| * open by offering to connect on LinkedIn. | |
| * 5. Saves the reply as a DRAFT in your Gmail, with HTML formatting so | |
| * Gmail doesn't hard-wrap the lines at ~78 characters (the plain- | |
| * text default, which looks dated). Exactly one draft per thread, | |
| * ever (see HOW WE AVOID DUPLICATE DRAFTS below). | |
| * 6. You review each draft in Gmail -> Drafts and click Send, edit, or | |
| * discard yourself. | |
| * | |
| * HUMAN-IN-THE-LOOP, BY DESIGN | |
| * This script NEVER sends email. It only creates drafts. You are the | |
| * final gatekeeper: every reply requires you to open Gmail, read the | |
| * draft, and click Send yourself. This is deliberate. A misclassified | |
| * thread or an awkwardly-worded draft, sent automatically, could burn | |
| * a relationship with someone you might want as a contact in five | |
| * years. The five seconds you spend reviewing each draft is cheap | |
| * insurance against that. If you want the script to also send, that | |
| * would require modifying the code; the default is review-first. | |
| * | |
| * SETUP (one-time, ~5 minutes): | |
| * 1. Go to https://script.google.com -> New project | |
| * 2. Paste this file in as Code.gs | |
| * 3. Project Settings (gear icon) -> Script Properties -> Add: | |
| * ANTHROPIC_API_KEY = sk-ant-... (required) | |
| * LINKEDIN_URL = https://www.linkedin.com/in/your-handle (required) | |
| * MY_NAME = first name to sign drafts with (required) | |
| * MY_PRONOUN_POSS = his|her|their (optional; defaults to "their") | |
| * MY_ROLE = your role (optional; defaults to "software engineer") | |
| * 4. Run `setup()` once from the editor. Approve the OAuth scopes when | |
| * prompted (Gmail read/modify + external HTTP for the Anthropic API). | |
| * 5. Done. Trigger is now installed and running every 5 minutes. To verify, | |
| * open the Triggers panel (clock icon in sidebar); you should see one | |
| * time-driven trigger pointing at processInbox. | |
| * | |
| * HOW WE AVOID DUPLICATE DRAFTS: | |
| * Every thread the script touches gets one of two labels in your Gmail: | |
| * - `recruiter-autodraft/processed` (a draft was created) | |
| * - `recruiter-autodraft/skipped` (not a recruiter, or low confidence) | |
| * The Gmail query excludes both labels, so a thread is processed AT MOST | |
| * once. On top of that, a timestamp bookmark (the `LAST_RUN_AT` Script | |
| * Property) limits each run to threads received after the previous run | |
| * finished, so the script isn't repeatedly scanning your whole inbox. | |
| * Both safeguards together make duplicate drafting effectively impossible. | |
| * | |
| * BACKFILL (one-shot, manual): | |
| * For older recruiter emails (up to 1.5 years back) that you never replied | |
| * to, run `backfillInbox()` once from the Apps Script editor. It uses the | |
| * same labeling system as the 5-minute loop, so: | |
| * - Re-running backfillInbox() is safe; already-touched threads are skipped. | |
| * - The 5-min cron and backfill never duplicate each other's work. | |
| * - Backfill does NOT touch the LAST_RUN_AT timestamp. | |
| * If you hit the 6-minute Apps Script execution limit mid-backfill, just | |
| * run backfillInbox() again. It picks up wherever it left off via labels. | |
| * | |
| * MAINTENANCE: | |
| * - To pause: Triggers panel (clock icon) -> delete the trigger. | |
| * - To tune behavior: edit SYSTEM_PROMPT or CONFIDENCE_THRESHOLD below. | |
| * - To tune identity: edit Script Properties (MY_NAME, LINKEDIN_URL, etc.). | |
| * - To tune voice: the EXAMPLE_REPLY_JSON constant contains a one-shot | |
| * gold-standard reply. The model strongly mimics its | |
| * tone. Rewrite the `draft` field to match how YOU | |
| * would actually reply; otherwise all drafts will | |
| * sound like the example author's voice. | |
| * - Logs: Executions panel (list icon) shows every run. | |
| * | |
| * MODEL CHOICE: | |
| * Sonnet 4.6 is used because the SELF-CHECK phase requires reliable | |
| * multi-step constraint satisfaction, which Haiku 4.5 (3x cheaper, 2-3x | |
| * faster) is weaker at. To experiment with Haiku, swap MODEL below to | |
| * 'claude-haiku-4-5-20251001' and watch the first 20-30 drafts for | |
| * regressions in tone or rule-following. | |
| */ | |
| // ---------- Config ---------- | |
| const CONFIDENCE_THRESHOLD = 0.85; | |
| const PROCESSED_LABEL = 'recruiter-autodraft/processed'; | |
| const SKIPPED_LABEL = 'recruiter-autodraft/skipped'; | |
| const FIRST_RUN_LOOKBACK_SECONDS = 60 * 60; // 1 hour, only used on the first ever run when no bookmark exists | |
| const CURSOR_PROPERTY = 'LAST_RUN_AT'; // Script Property name; stores the unix seconds of the last successful run | |
| const MODEL = 'claude-sonnet-4-6'; | |
| const MAX_BODY_CHARS = 8000; | |
| const MAX_THREADS_PER_RUN = 50; | |
| // Backfill-specific config. Backfill is a manual, one-shot operation; it does | |
| // NOT touch the steady-state timestamp bookmark. Safe to re-run because the | |
| // labels prevent re-processing, same as the 5-minute loop. | |
| const BACKFILL_LOOKBACK_DAYS = 548; // ~1.5 years | |
| const BACKFILL_PAGE_SIZE = 50; | |
| const BACKFILL_MAX_THREADS = 500; // safety cap; raise if you actually have more | |
| const BACKFILL_TIME_BUDGET_MS = 5 * 60 * 1000; // leave 1 min headroom under the 6-min ceiling | |
| // Pre-filter query used by backfill to avoid burning LLM calls on every email | |
| // from the past 1.5 years. Captures the common recruiter/headhunter/InMail | |
| // signals; misses are acceptable at this volume. Each term is tuned to be | |
| // specific enough not to pull in unrelated mail (e.g., 'subject:role' was | |
| // removed because it matches "Roleplaying tonight?" from friends). | |
| const BACKFILL_PREFILTER_TERMS = [ | |
| 'from:linkedin.com', | |
| 'subject:opportunity', | |
| 'subject:position', | |
| 'subject:reaching out', | |
| 'subject:recruiter', | |
| '"reaching out"', | |
| '"opportunity at"', | |
| '"your background"', | |
| ].join(' OR '); | |
| // Result codes from handleThread. Use the constants, not the literals, so a | |
| // typo at a call site becomes a ReferenceError instead of silent misbehavior. | |
| const THREAD_RESULT = Object.freeze({ | |
| DRAFTED: 'drafted', | |
| SKIPPED: 'skipped', | |
| }); | |
| // SYSTEM_PROMPT uses {{NAME}}, {{LINKEDIN_URL}}, {{PRONOUN_POSS}}, and | |
| // {{ROLE}} placeholders that get replaced at config-load time. Do NOT | |
| // hardcode any personal values here. | |
| const SYSTEM_PROMPT = `You are an assistant that processes incoming emails for {{NAME}}, a {{ROLE}} who is NOT job searching. | |
| The input you receive will be a Gmail THREAD, which may contain one or more messages (oldest first). The LATEST message is what {{NAME}} is replying to, but earlier messages in the thread often contain crucial context (the original pitch, the role details, the company). READ THE ENTIRE THREAD before drafting. If earlier messages were cut for length, work with what you have. | |
| The input starts with a CHANNEL TAG, either [Channel: Email] or [Channel: LinkedIn InMail]. This affects the wording of the LinkedIn offer in the reply (see DRAFT section below). It does NOT affect classification or any other part of the reply. | |
| Your job has two parts: | |
| 1. CLASSIFY: Decide if the email is an outreach from someone trying to recruit {{NAME}} into a job. This covers THREE distinct roles, all of which qualify: | |
| - IN-HOUSE RECRUITER / SOURCER: works directly for the hiring company. Email is FROM a @company.com address pitching that company's role. | |
| - HEADHUNTER / AGENCY RECRUITER: works for a third-party recruiting firm, retained by a company to fill a role. Email mentions "my client," "the company I'm representing," "I'm working with [X] on this," or comes from a recruiting-agency domain. | |
| - LINKEDIN INMAIL: a LinkedIn notification forwarded to email. You can detect these by the [Channel: LinkedIn InMail] tag at the start of the input (set by the script based on the sender domain). | |
| The distinction between in-house and headhunter doesn't change the reply, but DO note which kind in the "reason" field so the log is informative. | |
| It does NOT include: | |
| - Newsletters, marketing, job board digests | |
| - Emails from people {{NAME}} already knows pitching jobs casually | |
| - Investor/board outreach to {{NAME}}'s current company | |
| - Internal emails from {{NAME}}'s current company | |
| TONE (read this BEFORE writing the draft; it overrides everything else): | |
| The reply MUST sound like a real person who genuinely appreciates being thought of, NOT like an AI-generated message and NOT like a brusque dismissal. Recruiters and headhunters spent real effort reaching out; the reply should be warm and thankful. We are NOT trying to disqualify {{NAME}} from future opportunities. We are politely declining THIS one while leaving the door wide open for the future. | |
| Aim for: friendly, polite, courteous, humble, and genuinely warm. Think "thoughtful note to someone who took the time to reach out" rather than "professional business correspondence" or "quick clipped text." The reply should leave the sender feeling glad they reached out, not embarrassed. | |
| HARD RULES on what to AVOID: | |
| Two failure modes to avoid simultaneously: | |
| (A) Sounding AI-generated. Telltale signs: | |
| - Em dashes (—) | |
| - Exclamation points | |
| - Long polished sentences | |
| - Balanced parallel structures ("happy at X and not exploring Y") | |
| - Corporate phrasing ("respectfully decline", "at this juncture", "moving forward") | |
| - Stacking multiple AI-isms in one reply (one of "thanks so much" or "I'd love to" on its own is fine and human; combining them with "I hope this finds you well" and "appreciate you reaching out" reads AI) | |
| (B) Sounding brusque or dismissive. Telltale signs: | |
| - Parentheticals that explain WHY you're declining ("I'll pass (5 days in office wouldn't work)", "not interested (already at a better-paying job)"). These come across as either humble-bragging or as creating a wall. We do NOT include reasons for declining beyond "happy at current role." Never give specifics that could disqualify {{NAME}} from future outreach. | |
| - Clipped, busy-sounding tone ("Thanks, I'll pass.", "Not looking, sorry.") | |
| - Skipping the thank-you | |
| - Failing to leave a door open ("don't bother in the future", "not interested in opportunities like this") | |
| - Anything that signals "please stop emailing me" | |
| Both failure modes are equally bad. A warm-but-AI reply gets flagged as AI. A human-sounding-but-cold reply burns the relationship. The target is warm AND human. | |
| OTHER RULES: | |
| - USE CORRECT GRAMMAR. No comma splices (two independent clauses joined by just a comma, e.g., "Thanks for reaching out, that's really kind of you"). Use a period, semicolon, "and", or rephrase. Comma splices are both ungrammatical AND a mild AI tell. | |
| - NO mentioning salary or specific role details beyond what the sender mentioned. | |
| - NO inventing facts about the company. | |
| - NO over-explaining why {{NAME}} is declining beyond "happy at current role." | |
| 2. DRAFT (only if classified as recruiter, headhunter, or sourcer outreach): | |
| - 3-5 sentences total, following the TONE rules above | |
| - Address the sender by first name if available | |
| - Open with a genuine thank-you for the outreach. Reference the SPECIFIC role and/or company they mentioned (only what's actually in the email; never invent details). | |
| - Decline clearly: {{NAME}} is happy at {{PRONOUN_POSS}} current role and not exploring opportunities right now. Do NOT add reasons or qualifiers. | |
| - Leave the door open for the future. Make it clear {{NAME}} would be glad to stay in touch. | |
| - Offer to connect on LinkedIn. The wording depends on the channel: | |
| - If [Channel: Email]: include this URL inline: {{LINKEDIN_URL}} | |
| Example: "Feel free to connect with me on LinkedIn: {{LINKEDIN_URL}}" | |
| - If [Channel: LinkedIn InMail]: do NOT include the URL. The sender | |
| is already on LinkedIn with {{NAME}}'s profile a click away; | |
| including the URL would be redundant ceremony. | |
| Example: "Feel free to shoot me a LinkedIn connection invite." | |
| - Sign off "Best, {{NAME}}" | |
| 3. SELF-CHECK (mandatory before outputting JSON): | |
| Before you return your final answer, do this silently. The draft must pass BOTH checks below. | |
| a. Draft the reply following the rules above. | |
| b. AI-tells AND grammar check. Re-read the draft as the recruiter or headhunter receiving it, skimming through 50 outreach replies at their desk. They have seen hundreds of AI-generated replies and can spot them in two seconds. Look for: | |
| - Em dashes (—) | |
| - Exclamation points | |
| - Comma splices (two independent clauses joined by just a comma, e.g., "Thanks for reaching out, that's really kind of you"). These are ungrammatical AND read as AI. | |
| - Long polished sentences (over ~20 words) | |
| - Balanced parallel structures ("happy at X and not exploring Y") | |
| - Corporate or formal phrasing ("respectfully decline", "at this juncture", "moving forward") | |
| - Multiple AI-isms stacked together (one of "thanks so much" / "I'd love to" / "appreciate you reaching out" is fine; two or more in the same short reply reads AI) | |
| - Any other grammar errors (subject-verb agreement, wrong pronoun, run-on sentences) | |
| c. Warmth check. Re-read the draft as the same recruiter, but this time asking: does this reply make me glad I reached out, or does it make me feel brushed off? Look for: | |
| - Missing thank-you. Did the draft start with genuine appreciation? | |
| - Clipped or busy-sounding tone ("Thanks, I'll pass.") | |
| - Parentheticals that give reasons for declining ("(5 days in office wouldn't work)", "(already at a great job)"). These create distance and risk disqualifying {{NAME}} from future outreach. | |
| - Closing the door. Phrases like "not interested in opportunities like this" or anything that signals "don't email me again." | |
| - Missing the future-stay-in-touch invitation. | |
| d. If the draft fails either check, do NOT patch. Patching is a trap: you fix one symptom but the surrounding prose stays wrong. Instead, restart the draft from scratch with both targets held in mind together: WARM and HUMAN. The new draft should feel different, not just have one phrase swapped. | |
| e. Only after the rewritten draft passes both checks, output the JSON. | |
| Do NOT mention the self-check in your output. Do NOT show your work. Output ONLY the final JSON. | |
| Return ONLY valid JSON, no markdown fences, no prose: | |
| { | |
| "is_recruiter": boolean, | |
| "confidence": number between 0 and 1, | |
| "reason": "one short sentence on why", | |
| "draft": "the reply text, or empty string if not a recruiter" | |
| }`; | |
| // One-shot example. Sent to the model as a prior user/assistant exchange | |
| // before the actual email, so the model has a concrete reference for tone, | |
| // length, and JSON shape. {{NAME}} and {{LINKEDIN_URL}} are templated in | |
| // loadConfig the same way SYSTEM_PROMPT is. | |
| // | |
| // IMPORTANT: the example reply below is written to demonstrate the target | |
| // tone (warm, thankful, leaves door open for future, no brusqueness, no | |
| // disqualifying parentheticals). The model strongly mimics whatever voice | |
| // this example uses. If your natural way of writing this kind of reply | |
| // differs, rewrite the `draft` field in EXAMPLE_REPLY_JSON to match how | |
| // YOU would actually reply, while preserving the same SHAPE: thank-you, | |
| // decline, door-leaving offer, LinkedIn URL, signoff. | |
| // | |
| // What to preserve when customizing: | |
| // - The JSON shape (is_recruiter, confidence, reason, draft fields) | |
| // - The {{NAME}} and {{LINKEDIN_URL}} placeholders | |
| // - The "Best,\n{{NAME}}" signoff (the URL-fallback regex depends on this) | |
| // - confidence: 0.95 (not 1.0; a 1.0 example would train every future | |
| // call to also output 1.0, defeating CONFIDENCE_THRESHOLD) | |
| // - 3-5 sentences total | |
| // - The four required moves: thank → decline → door open → LinkedIn | |
| // - No em dashes, no exclamation points, no parentheticals giving reasons | |
| // for declining | |
| // | |
| // Note: the example below shows the [Channel: Email] case. For LinkedIn | |
| // InMail threads, the prompt instructs the model to use different wording | |
| // ("Feel free to shoot me a LinkedIn connection invite") and omit the URL. | |
| // No separate InMail example is provided; the prompt's instructions are | |
| // strong enough on their own. | |
| // | |
| // The example EMAIL (from "Sarah") is fine as-is for anyone using this | |
| // script. It's a representative recruiter pitch and intentionally contains | |
| // an em dash so the model learns to produce em-dash-free replies even when | |
| // the input has them. | |
| const EXAMPLE_EMAIL = `[Channel: Email] | |
| Subject: Senior Backend Engineer @ Mercato (Series C, $80M raised) | |
| Messages in this thread (oldest first, 1 total): | |
| From: Sarah Chen <sarah.chen@northwindtalent.com> | |
| Date: 2026-05-20T14:32:00.000Z | |
| Hi {{NAME}}, | |
| I'm a recruiter at Northwind, working with Mercato (mercato.com) on their Senior Backend Engineer role. They're building checkout infrastructure for ~2,000 indie e-commerce brands and just closed an $80M Series C led by Lightspeed. | |
| I came across your background — your work on AI document evaluation caught my eye, and the team thinks your distributed systems experience would translate well to what they're building. | |
| Comp is $230-280K base + 0.1-0.3% equity. Fully remote (US/Canada). | |
| Open to a 20-min intro chat next week? | |
| Best, | |
| Sarah`; | |
| const EXAMPLE_REPLY_JSON = JSON.stringify({ | |
| is_recruiter: true, | |
| confidence: 0.95, | |
| reason: 'Headhunter at Northwind (third-party agency) pitching a Senior Backend Engineer role at their client Mercato with specific comp details.', | |
| draft: `Hi Sarah,\n\nThanks so much for thinking of me for the Mercato role. That's really kind of you to reach out. I'm happy at my current company and not exploring new opportunities right now, but I'd genuinely love to stay in touch for the future. Feel free to connect with me on LinkedIn: {{LINKEDIN_URL}}\n\nBest,\n{{NAME}}`, | |
| }); | |
| // ---------- Entry points ---------- | |
| /** Run once manually to install the trigger. */ | |
| function setup() { | |
| // Safe to re-run: remove any existing triggers for this function first | |
| // so we never end up with duplicates. | |
| ScriptApp.getProjectTriggers() | |
| .filter(t => t.getHandlerFunction() === 'processInbox') | |
| .forEach(t => ScriptApp.deleteTrigger(t)); | |
| ScriptApp.newTrigger('processInbox') | |
| .timeBased() | |
| .everyMinutes(5) | |
| .create(); | |
| // Labels are created lazily on first run; no need to pre-create here. | |
| console.log('Setup complete. Trigger installed.'); | |
| } | |
| /** Main loop. Called by the trigger. */ | |
| function processInbox() { | |
| const config = loadConfig(); | |
| const props = PropertiesService.getScriptProperties(); | |
| // Timestamp bookmark: only process mail received after the previous run. | |
| // On first run, fall back to a 1-hour lookback so we don't blast through | |
| // weeks of inbox history. Use Number.isFinite (not `||`) so an explicit 0 | |
| // is respected and only true junk (null, NaN, "abc") triggers the fallback. | |
| const nowSec = Math.floor(Date.now() / 1000); | |
| const storedCursor = props.getProperty(CURSOR_PROPERTY); | |
| const parsedCursor = storedCursor === null ? NaN : parseInt(storedCursor, 10); | |
| const cursorSec = Number.isFinite(parsedCursor) | |
| ? parsedCursor | |
| : nowSec - FIRST_RUN_LOOKBACK_SECONDS; | |
| // Gmail's `after:` operator takes a unix timestamp in seconds. | |
| // We deliberately drop `is:unread`: if you read a recruiter email on your | |
| // phone before the cron fires, you still want a draft. | |
| const query = `in:inbox after:${cursorSec} ` + | |
| `-label:${PROCESSED_LABEL} -label:${SKIPPED_LABEL}`; | |
| const threads = GmailApp.search(query, 0, MAX_THREADS_PER_RUN); | |
| console.log(`Bookmark=${cursorSec} (${new Date(cursorSec * 1000).toISOString()}), ` + | |
| `found ${threads.length} candidate thread(s).`); | |
| const processedLabel = getOrCreateLabel(PROCESSED_LABEL); | |
| const skippedLabel = getOrCreateLabel(SKIPPED_LABEL); | |
| let anyThreadErrored = false; | |
| for (const thread of threads) { | |
| try { | |
| const result = handleThread(thread, config); | |
| thread.addLabel(result === THREAD_RESULT.DRAFTED ? processedLabel : skippedLabel); | |
| } catch (err) { | |
| anyThreadErrored = true; | |
| console.error(`Thread ${thread.getId()} failed: ${err.message}`); | |
| // Don't label on error. Let it retry next run via the bookmark | |
| // staying put (see logic below). | |
| } | |
| } | |
| // Only advance the timestamp bookmark if: | |
| // (a) every thread succeeded, AND | |
| // (b) we didn't hit the page limit (which would mean unprocessed mail still | |
| // exists in this time window; advancing past it would strand those | |
| // threads forever since they'd be newer than what the next run sees). | |
| // If either condition fails, leave the bookmark where it was so the next | |
| // run re-examines the same window. Successful threads in this run are | |
| // labeled and will be excluded by the query. | |
| const hitPageLimit = threads.length >= MAX_THREADS_PER_RUN; | |
| if (!anyThreadErrored && !hitPageLimit) { | |
| props.setProperty(CURSOR_PROPERTY, String(nowSec)); | |
| } else { | |
| const reason = hitPageLimit ? 'hit page limit' : 'thread error'; | |
| console.warn(`Bookmark not advanced (${reason}); next run will re-examine this window.`); | |
| } | |
| } | |
| /** | |
| * One-shot backfill. Run manually from the Apps Script editor. | |
| * | |
| * Looks back BACKFILL_LOOKBACK_DAYS (~1.5 years) for threads that match | |
| * a recruiter pre-filter and that you never replied to. Drafts one reply | |
| * per qualifying thread, labels everything it touches, and exits when: | |
| * (a) it runs out of threads, | |
| * (b) BACKFILL_MAX_THREADS is reached, or | |
| * (c) BACKFILL_TIME_BUDGET_MS is exhausted. | |
| * | |
| * Safe to re-run: labels prevent re-processing. Does NOT touch the | |
| * steady-state LAST_RUN_AT timestamp bookmark. | |
| */ | |
| function backfillInbox() { | |
| const config = loadConfig(); | |
| const startMs = Date.now(); | |
| const query = `in:inbox older_than:1h newer_than:${BACKFILL_LOOKBACK_DAYS}d ` + | |
| `-label:${PROCESSED_LABEL} -label:${SKIPPED_LABEL} ` + | |
| `(${BACKFILL_PREFILTER_TERMS})`; | |
| console.log(`Backfill query: ${query}`); | |
| const processedLabel = getOrCreateLabel(PROCESSED_LABEL); | |
| const skippedLabel = getOrCreateLabel(SKIPPED_LABEL); | |
| let totalSeen = 0; | |
| let totalDrafted = 0; | |
| let totalSkipped = 0; | |
| let totalErrored = 0; | |
| // IDs of threads that errored this run. They'd otherwise show up on every | |
| // subsequent page (since the label-exclusion query won't filter them) and | |
| // create an infinite retry loop within a single run. | |
| const erroredIdsThisRun = new Set(); | |
| while (totalSeen < BACKFILL_MAX_THREADS) { | |
| if (Date.now() - startMs > BACKFILL_TIME_BUDGET_MS) { | |
| console.warn(`Time budget exhausted. Re-run backfillInbox() to continue.`); | |
| break; | |
| } | |
| // Always start:0 because labeled threads are excluded server-side, so the | |
| // first page is always the next chunk of unlabeled threads. | |
| const threads = GmailApp.search(query, 0, BACKFILL_PAGE_SIZE) | |
| .filter(t => !erroredIdsThisRun.has(t.getId())); | |
| if (!threads.length) { | |
| console.log('No more processable threads in this run.'); | |
| break; | |
| } | |
| for (const thread of threads) { | |
| totalSeen++; | |
| try { | |
| const result = handleThread(thread, config, { requireNeverReplied: true }); | |
| thread.addLabel(result === THREAD_RESULT.DRAFTED ? processedLabel : skippedLabel); | |
| if (result === THREAD_RESULT.DRAFTED) totalDrafted++; | |
| else totalSkipped++; | |
| } catch (err) { | |
| totalErrored++; | |
| erroredIdsThisRun.add(thread.getId()); | |
| console.error(`Thread ${thread.getId()} failed: ${err.message}`); | |
| } | |
| } | |
| } | |
| console.log( | |
| `Backfill done. seen=${totalSeen} drafted=${totalDrafted} ` + | |
| `skipped=${totalSkipped} errored=${totalErrored} elapsed=${Date.now() - startMs}ms` | |
| ); | |
| } | |
| // ---------- Core logic ---------- | |
| const handleThread = (thread, config, options = {}) => { | |
| const { requireNeverReplied = false } = options; | |
| const messages = thread.getMessages(); | |
| const latest = messages[messages.length - 1]; | |
| const sender = latest.getFrom(); | |
| const senderLower = sender.toLowerCase(); | |
| const myEmailLower = config.myEmail.toLowerCase(); | |
| // Always skip if you sent the most recent message (active conversation, | |
| // they're waiting on something else, not a cold pitch awaiting decline). | |
| if (senderLower.includes(myEmailLower)) { | |
| console.log(`Skip: outbound message in thread ${thread.getId()}`); | |
| return THREAD_RESULT.SKIPPED; | |
| } | |
| // Backfill mode: also skip if you EVER replied in this thread. A months-old | |
| // conversation where you already responded shouldn't get a fresh decline. | |
| if (requireNeverReplied) { | |
| const youEverReplied = messages.some(m => m.getFrom().toLowerCase().includes(myEmailLower)); | |
| if (youEverReplied) { | |
| console.log(`Skip: already replied at least once in thread ${thread.getId()}`); | |
| return THREAD_RESULT.SKIPPED; | |
| } | |
| } | |
| // Detect whether the LATEST INBOUND message (the one being replied to) is | |
| // a LinkedIn InMail notification. If yes, the reply will be gatewayed back | |
| // into LinkedIn by LinkedIn's email-reply system, and the recruiter will | |
| // read it inside LinkedIn's UI, so the LinkedIn URL would be redundant. | |
| // | |
| // Check the latest inbound message (not "any message in the thread"): | |
| // if the conversation started on LinkedIn but the recruiter later switched | |
| // to regular email (e.g., once they had your address), the latest message | |
| // would be a real email and the reply should include the URL normally. | |
| // `latest` is already the most recent message; if it's from you, we'd | |
| // have returned above. So `latest` here is the latest inbound message. | |
| const isLinkedInInMail = senderLower.includes('@linkedin.com'); | |
| // Build the LLM input from the ENTIRE thread, not just the latest message. | |
| // Threads where you replied early but the recruiter followed up weeks | |
| // later often have substantive context (role details, company info) only | |
| // in earlier messages. The model needs the full picture to classify | |
| // correctly and to write a reply that acknowledges the right specifics. | |
| const subject = thread.getFirstMessageSubject(); | |
| const llmInput = buildThreadInput(messages, subject, isLinkedInInMail); | |
| const result = callClaude(llmInput, config); | |
| console.log(`Thread "${subject}": is_recruiter=${result.is_recruiter}, ` + | |
| `confidence=${result.confidence}, channel=${isLinkedInInMail ? 'linkedin_inmail' : 'email'}, ` + | |
| `reason="${result.reason}"`); | |
| if (!result.is_recruiter || result.confidence < CONFIDENCE_THRESHOLD) { | |
| return THREAD_RESULT.SKIPPED; | |
| } | |
| // Guard against the model returning null/undefined/empty draft despite | |
| // classifying as recruiter. Without this, the .includes() call below crashes. | |
| if (!result.draft || typeof result.draft !== 'string') { | |
| console.warn(`Recruiter classified but no draft returned for thread ${thread.getId()}; skipping.`); | |
| return THREAD_RESULT.SKIPPED; | |
| } | |
| // URL-injection fallback for the email case only. For LinkedIn InMails, | |
| // the reply intentionally doesn't include the URL, so this fallback is | |
| // skipped. For email replies, if the model omitted the URL, inject it | |
| // before the signoff. The signoff regex uses the configured name. | |
| let draft = result.draft; | |
| if (!isLinkedInInMail && !draft.includes(config.linkedinUrl)) { | |
| const signoffPattern = new RegExp(`\\n*Best,\\s*\\n?${escapeRegex(config.myName)}`, 'i'); | |
| draft = draft.replace( | |
| signoffPattern, | |
| `\n\nYou can find me on LinkedIn here if you'd like to stay connected: ${config.linkedinUrl}\n\nBest,\n${config.myName}`, | |
| ); | |
| } | |
| // Save as HTML so Gmail renders it as a normal modern email instead of | |
| // hard-wrapping every ~78 characters (the plain-text default). Both | |
| // versions are sent so clients without HTML support still see the text. | |
| latest.createDraftReply(draft, { htmlBody: plainTextToHtml(draft) }); | |
| console.log(`Draft created for thread ${thread.getId()}`); | |
| return THREAD_RESULT.DRAFTED; | |
| }; | |
| const callClaude = (userContent, config) => { | |
| const response = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', { | |
| method: 'post', | |
| contentType: 'application/json', | |
| headers: { | |
| 'x-api-key': config.anthropicKey, | |
| 'anthropic-version': '2023-06-01', | |
| }, | |
| payload: JSON.stringify({ | |
| model: MODEL, | |
| max_tokens: 2048, | |
| system: config.systemPrompt, | |
| messages: [ | |
| // One-shot example: prior user/assistant turn shows the model the | |
| // expected tone, length, and exact JSON shape. The actual email | |
| // follows as the next user turn. | |
| { role: 'user', content: config.exampleEmail }, | |
| { role: 'assistant', content: config.exampleReplyJson }, | |
| { role: 'user', content: userContent }, | |
| ], | |
| }), | |
| muteHttpExceptions: true, | |
| }); | |
| const code = response.getResponseCode(); | |
| const text = response.getContentText(); | |
| if (code !== 200) throw new Error(`Anthropic API ${code}: ${text.slice(0, 200)}`); | |
| const body = JSON.parse(text); | |
| const firstBlock = body.content?.[0]; | |
| if (!firstBlock?.text) throw new Error(`Anthropic returned no text block: ${text.slice(0, 200)}`); | |
| // Strip accidental markdown fences if the model adds them | |
| const cleaned = firstBlock.text.trim() | |
| .replace(/^```(?:json)?\s*/i, '') | |
| .replace(/```\s*$/i, ''); | |
| return JSON.parse(cleaned); | |
| }; | |
| // ---------- Helpers ---------- | |
| const loadConfig = () => { | |
| const props = PropertiesService.getScriptProperties(); | |
| const anthropicKey = props.getProperty('ANTHROPIC_API_KEY'); | |
| const linkedinUrl = props.getProperty('LINKEDIN_URL'); | |
| const myName = props.getProperty('MY_NAME'); | |
| // Optional Script Properties with sensible defaults. 'their' is the | |
| // singular-they possessive (works for anyone without assuming gender). | |
| // 'software engineer' is the most common profile but can be overridden | |
| // via Script Properties. | |
| const myPronounPoss = props.getProperty('MY_PRONOUN_POSS') || 'their'; | |
| const myRole = props.getProperty('MY_ROLE') || 'software engineer'; | |
| if (!anthropicKey) throw new Error('Missing ANTHROPIC_API_KEY in Script Properties'); | |
| if (!linkedinUrl) throw new Error('Missing LINKEDIN_URL in Script Properties'); | |
| if (!myName) throw new Error('Missing MY_NAME in Script Properties'); | |
| // Session.getActiveUser().getEmail() returns '' if the script lacks the | |
| // userinfo.email scope or the user hasn't authorized it. An empty myEmail | |
| // would make ''.toLowerCase().includes('') always true, causing every | |
| // thread to be silently skipped as 'outbound'. Fail loudly instead. | |
| const myEmail = Session.getActiveUser().getEmail(); | |
| if (!myEmail) { | |
| throw new Error( | |
| 'Could not determine your email address. The script may be missing ' + | |
| 'the userinfo.email scope, or you need to re-authorize. Try running ' + | |
| 'setup() again and approving all requested scopes.' | |
| ); | |
| } | |
| // Build templated strings once per run, not once per API call. | |
| // Use replaceAll (V8) since there are multiple {{NAME}} occurrences. | |
| const template = (s) => s | |
| .replaceAll('{{NAME}}', myName) | |
| .replaceAll('{{LINKEDIN_URL}}', linkedinUrl) | |
| .replaceAll('{{PRONOUN_POSS}}', myPronounPoss) | |
| .replaceAll('{{ROLE}}', myRole); | |
| const systemPrompt = template(SYSTEM_PROMPT); | |
| const exampleEmail = template(EXAMPLE_EMAIL); | |
| const exampleReplyJson = template(EXAMPLE_REPLY_JSON); | |
| return { | |
| anthropicKey, | |
| linkedinUrl, | |
| myName, | |
| myEmail, | |
| systemPrompt, | |
| exampleEmail, | |
| exampleReplyJson, | |
| }; | |
| }; | |
| const getOrCreateLabel = (name) => | |
| GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name); | |
| // Escape a string for safe use inside a RegExp. | |
| // Source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions | |
| const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| // Build a single string representing the full thread for the LLM. Each message | |
| // is prefixed with its From/Date so the model can see who said what and when. | |
| // Messages are ordered oldest-first to mirror how a human reads an email thread. | |
| // The channel flag tells the model whether the reply will be sent via regular | |
| // email or as a LinkedIn InMail reply, which affects whether to include the | |
| // LinkedIn URL in the draft. | |
| // | |
| // Budget enforcement: we cap the total at MAX_BODY_CHARS. If the thread is | |
| // longer, we keep the LATEST messages (most relevant to the reply) and drop | |
| // older ones from the front, with a notice that earlier content was cut. | |
| const buildThreadInput = (messages, subject, isLinkedInInMail) => { | |
| const formatted = messages.map(m => { | |
| const from = m.getFrom(); | |
| const date = m.getDate().toISOString(); | |
| const body = m.getPlainBody(); | |
| return `From: ${from}\nDate: ${date}\n\n${body}`; | |
| }); | |
| // Join with a delimiter so the model can clearly see message boundaries. | |
| const sep = '\n\n----- next message in thread -----\n\n'; | |
| const joined = formatted.join(sep); | |
| const channelTag = isLinkedInInMail | |
| ? '[Channel: LinkedIn InMail]' | |
| : '[Channel: Email]'; | |
| // If the thread fits, return it with a subject header. | |
| if (joined.length <= MAX_BODY_CHARS) { | |
| return `${channelTag}\nSubject: ${subject}\nMessages in this thread (oldest first, ${messages.length} total):\n\n${joined}`; | |
| } | |
| // Thread is too long. Drop the OLDEST messages until it fits, since the | |
| // most recent message is what we're actually replying to. | |
| const kept = [...formatted]; | |
| let dropped = 0; | |
| while (kept.length > 1 && kept.join(sep).length > MAX_BODY_CHARS) { | |
| kept.shift(); | |
| dropped++; | |
| } | |
| // If even the latest message alone is too long, truncate it. | |
| let trimmed = kept.join(sep); | |
| if (trimmed.length > MAX_BODY_CHARS) { | |
| trimmed = trimmed.slice(0, MAX_BODY_CHARS); | |
| } | |
| const cutNotice = dropped > 0 | |
| ? `[${dropped} earlier message(s) in this thread cut for length.]\n\n` | |
| : ''; | |
| return `${channelTag}\nSubject: ${subject}\nMessages in this thread (${messages.length} total):\n\n${cutNotice}${trimmed}`; | |
| }; | |
| // Convert plain-text draft into minimal HTML so Gmail doesn't hard-wrap lines | |
| // at ~78 chars (its plain-text default, which looks dated and stubby on modern | |
| // displays). Steps: | |
| // 1. Escape HTML special characters so any '<' or '&' in the draft becomes | |
| // visible text instead of a broken tag. Defensive: protects against the | |
| // unlikely case where a recruiter's name or company contains such chars. | |
| // 2. Linkify bare URLs so they become real clickable <a> tags. | |
| // 3. Convert paragraphs (double newlines) to <p> blocks; single newlines | |
| // become <br>. This preserves the signoff layout where "Best,\nBryan" | |
| // should render on two consecutive lines, not in two separate paragraphs. | |
| const plainTextToHtml = (text) => { | |
| const escape = (s) => s | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| const linkify = (s) => s.replace( | |
| /(https?:\/\/[^\s<]+)/g, | |
| (url) => `<a href="${url}">${url}</a>`, | |
| ); | |
| const paragraphs = escape(text).split(/\n\n+/); | |
| return paragraphs | |
| .map(p => `<p>${linkify(p).replace(/\n/g, '<br>')}</p>`) | |
| .join(''); | |
| }; |
