#!/usr/bin/env node /** * contact-extract.mjs — discover a recruiter/interviewer contact from a pasted * reply, and save it to data/contacts.tsv for a later re-engagement follow-up * (#4361). * * Problem this closes: once a role goes to Rejected, whatever recruiter or * interviewer identity passed through an interview invite or rejection email * is effectively gone. There is no path from "I had a good interview with * this person months ago" to "here is their email, drafted into a follow-up" * when a new, different opening appears at the same company later. * * This is the CORE (local-only, no Gmail dependency) layer of #4361's * two-layer design. It reuses the exact input shape paste-reply.mjs already * established (#1802) — paste an email's Subject/From/body, or read it from a * file — so anyone who doesn't want to grant any tool mailbox access can * still use it. The opt-in Gmail-plugin layer (#1583) is future work and can * feed this same pipeline once it exists, because both produce the identical * {subject, from, body} shape this script already consumes. * * What it does, in order: * 1. Parse the pasted email into {subject, from, body} (via * paste-reply.mjs's parseFileInput/collectInteractive — no duplicated * parsing logic). * 2. Parse the From header into {name, email} (parseFromHeader). * 3. Match the email to a tracker row using reply-matcher.mjs's own * matchCandidates() — the same matcher reply-watch.mjs already trusts — * so this never re-implements company/role matching. * 4. Classify the reply (classifyReply) and infer a contact `type` * (recruiter / hiring-manager / interviewer) from the email text. * 5. Confirm with the user (or --yes for non-interactive use) before * creating or updating one row in data/contacts.tsv. * * This script NEVER writes to data/applications.md, NEVER sends anything, and * NEVER classifies or updates tracker status — same boundary as * paste-reply.mjs and reply-watch.mjs. A tracker row is required to attach the * contact to (via auto-match or an explicit --company/--tracker override); * without one, nothing is written and the script exits 0 with an explanation. * * Usage: * node contact-extract.mjs --file email.txt * node contact-extract.mjs --file email.txt --yes * node contact-extract.mjs --file email.txt --company "Acme Inc" --tracker 42 * node contact-extract.mjs --file email.txt --type interviewer * node contact-extract.mjs # interactive, same prompts as paste-reply.mjs * node contact-extract.mjs --help * * --file format is identical to paste-reply.mjs's: * Subject: * From: * * * * The contacts path is always data/contacts.tsv under the resolved career-ops * data root, matching contacts.mjs. */ import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import { resolveTrackerPath, writeFileAtomic } from './tracker-utils.mjs'; import { withPipelineLock } from './pipeline-lock.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { localToday } from './lib/local-today.mjs'; import { validateFlags, hasFlag, flagValue } from './lib/cli-flags.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { parseFileInput, collectInteractive } from './paste-reply.mjs'; import { matchCandidates, classifyReply } from './reply-matcher.mjs'; import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs'; import { parseFollowups } from './followup-cadence.mjs'; import { escapeFormulaCell, unescapeFormulaCell } from './lib/tsv-formula-escape.mjs'; const DATA_ROOT = getCareerOpsRoot(); const CONTACTS_PATH = path.join(DATA_ROOT, 'data', 'contacts.tsv'); const APPS_FILE = resolveTrackerPath(DATA_ROOT); const FOLLOWUPS_FILE = path.join(DATA_ROOT, 'data', 'follow-ups.md'); // Kept in sync by hand with contacts.mjs's own VALID_TYPES — both are small, // stable enums describing the same TSV column, and contacts.mjs does not // export its copy. internal-referral (#4691) added to both at the same time. export const VALID_TYPES = new Set(['recruiter', 'hiring-manager', 'peer', 'interviewer', 'internal-referral', 'other']); const KNOWN_FLAGS = ['--file', '--yes', '--company', '--tracker', '--type', '--help', '-h']; const USAGE = `Usage: node contact-extract.mjs --file read subject/from/body from a file node contact-extract.mjs interactive: prompts for subject, from, body node contact-extract.mjs --file --yes skip the confirm-before-write prompt node contact-extract.mjs --file --company "Acme Inc" --tracker 42 attach to a specific tracker row when auto-matching finds none or is ambiguous node contact-extract.mjs --file --type interviewer override the inferred contact type node contact-extract.mjs --help`; /** * Parse an email "From:" header into {name, email}. Handles the three shapes * real mail clients produce: `"Name" `, `Name `, and a bare * address with no display name. A value that is neither is treated as a bare * name with no email (still useful — a contact can be saved without one, just * not looked up for a follow-up draft later). * * Exported for direct unit testing. */ export function parseFromHeader(from) { if (!from || !from.trim()) return { name: '', email: null }; const angleMatch = from.match(/^\s*"?([^"<]*?)"?\s*<([^<>]+)>\s*$/); if (angleMatch) { const email = angleMatch[2].trim(); return { name: angleMatch[1].trim(), email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email.toLowerCase() : null, }; } const trimmed = from.trim(); if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { return { name: '', email: trimmed.toLowerCase() }; } return { name: trimmed, email: null }; } /** * Infer a contacts.tsv `type` value from the reply text. Defaults to * 'recruiter' — the common case for an interview-invite or rejection sender — * and only overrides that when the body itself names a more specific role. * Checked in this order because a rejection sent BY a hiring manager who * calls themselves that in the signature is a stronger, more specific signal * than a generic mention of "the interview panel" elsewhere in the same email. * * Exported for direct unit testing. */ export function inferContactType(text) { const t = (text || '').toLowerCase(); if (/\bhiring manager\b/.test(t)) return 'hiring-manager'; if (/\b(interview panel|interview team|panelist|panellist|interviewer)\b/.test(t)) return 'interviewer'; return 'recruiter'; } /** One TSV cell must never contain a tab or newline — either would shift or * split the row. Exported for direct unit testing. */ export function sanitizeCell(value) { const cell = String(value ?? '').replace(/[\t\r\n]+/g, ' ').trim(); return escapeFormulaCell(cell); } /** * Create or update one contact row in data/contacts.tsv. Existing contacts are * matched by case-insensitive name + company, the same identity used by the * contacto workflow. Empty incoming optional fields preserve saved details. * * Exported for direct unit testing. Returns the file's total data-row count * after the write. */ export async function appendContact(contact, contactsPath = CONTACTS_PATH) { if (!sanitizeCell(contact.name)) throw new Error('Contact name is required'); return withPipelineLock(contactsPath, () => { const header = '# name\tcompany\ttype\ttitle\tphone\temail\tlinkedin\ttracker\tnotes'; let lines = [header]; if (fs.existsSync(contactsPath)) { lines = fs.readFileSync(contactsPath, 'utf-8').split(/\r?\n/); if (lines.at(-1) === '') lines.pop(); if (!lines.length) lines = [header]; } else { fs.mkdirSync(path.dirname(contactsPath), { recursive: true }); } const identityPart = (value, stored = false) => unescapeFormulaCell(stored ? value : sanitizeCell(value)) .normalize('NFC').replace(/\s+/g, ' ').toLowerCase(); const key = (name, company) => `${identityPart(name)}\0${identityPart(company)}`; const storedKey = (name, company) => `${identityPart(name, true)}\0${identityPart(company, true)}`; const incomingKey = key(contact.name, contact.company); const existingIndex = lines.findLastIndex((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) return false; const cells = line.split('\t'); return storedKey(cells[0], cells[1]) === incomingKey; }); const previous = existingIndex >= 0 ? lines[existingIndex].split('\t').map(unescapeFormulaCell) : []; const values = [ contact.name, contact.company, contact.type || previous[2] || '', contact.title || previous[3] || '', contact.phone || previous[4] || '', contact.email || previous[5] || '', contact.linkedin || previous[6] || '', contact.tracker || previous[7] || '-', contact.notes || previous.slice(8).join(' ') || '', ].map(sanitizeCell); const row = values.join('\t'); if (existingIndex >= 0) lines[existingIndex] = row; else lines.push(row); writeFileAtomic(contactsPath, `${lines.join('\n')}\n`); return lines.filter((line) => line.trim() && !line.trim().startsWith('#')).length; }); } function loadTrackerApps(appsFile = APPS_FILE) { if (!fs.existsSync(appsFile)) return []; const content = fs.readFileSync(appsFile, 'utf-8'); const lines = content.split('\n'); const colmap = resolveColumns(lines); const apps = []; for (const line of lines) { const row = parseTrackerRow(line, colmap); if (row) apps.push(row); } return apps; } function loadFollowups(followupsFile = FOLLOWUPS_FILE) { if (!fs.existsSync(followupsFile)) return []; return parseFollowups(fs.readFileSync(followupsFile, 'utf-8')); } function askYesNo(query) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { let answered = false; // EOF on stdin (piped input that ends without a line) fires 'close' // without ever invoking the question callback below — decline rather // than hang forever waiting for an answer that will never arrive. rl.on('close', () => { if (!answered) resolve(false); }); rl.question(query, (ans) => { answered = true; rl.close(); resolve(/^y(es)?$/i.test(ans.trim())); }); }); } function printHelp() { console.log(`contact-extract.mjs — discover a recruiter/interviewer contact from a pasted reply and save it to data/contacts.tsv (#4361) ${USAGE} Never touches data/applications.md, never sends anything, never classifies or updates tracker status. Requires a tracker row to attach the contact to (via auto-match against data/applications.md, or an explicit --company/--tracker override); without one, nothing is written.`); } async function main() { const args = process.argv.slice(2); validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: ['--file', '--company', '--tracker', '--type'], requireOperand: true, }); if (args.includes('--help') || args.includes('-h')) { printHelp(); return; } const filePath = flagValue(args, '--file'); let input; if (filePath !== undefined) { if (!fs.existsSync(filePath)) { console.error(`Error: file not found: ${filePath}`); process.exitCode = 1; return; } input = parseFileInput(fs.readFileSync(filePath, 'utf-8')); } else { input = await collectInteractive(); } if (!input.subject && !input.body) { console.error('Error: no subject or body text found — nothing to extract.'); process.exitCode = 1; return; } const { name, email } = parseFromHeader(input.from); const candidate = { message_id: `extract-${Date.now()}`, from: input.from || '', subject: input.subject || '', body_snippet: input.body || '', signal: null, }; const companyOverride = flagValue(args, '--company'); const trackerOverride = flagValue(args, '--tracker'); const apps = loadTrackerApps(); let company; let trackerNum; let candidateApps = apps; // Company and tracker# must always describe the SAME row. When --tracker // is given, its row is the sole source of truth for company — resolved // here, atomically, rather than letting `company` drift in from a separate // matchCandidates() hit further down (which searches ALL rows off the // email body and has no idea a --tracker override already pinned one). if (trackerOverride !== undefined) { const trackerNumber = Number(trackerOverride.trim()); const trackerRow = /^\d+$/.test(trackerOverride.trim()) && Number.isSafeInteger(trackerNumber) ? apps.find((app) => app.num === trackerNumber) : undefined; if (!trackerRow) { console.error(`Error: --tracker must name an existing tracker row; got "${trackerOverride}"`); process.exitCode = 1; return; } if (companyOverride && companyOverride.trim().toLowerCase() !== trackerRow.company.trim().toLowerCase()) { console.error(`Error: --company "${companyOverride}" does not match tracker #${trackerNumber}'s company "${trackerRow.company}"`); process.exitCode = 1; return; } trackerNum = String(trackerNumber); company = trackerRow.company; } else if (companyOverride) { const normalizedCompany = companyOverride.trim().toLowerCase(); const companyApps = apps.filter((app) => app.company.trim().toLowerCase() === normalizedCompany); if (!companyApps.length) { console.error(`Error: --company must name a company in the tracker; got "${companyOverride}"`); process.exitCode = 1; return; } candidateApps = companyApps; const [match] = matchCandidates([candidate], companyApps, loadFollowups()); const matchedRow = match?.application_num != null ? companyApps.find((app) => app.num === match.application_num) : companyApps.length === 1 ? companyApps[0] : undefined; if (matchedRow) { company = matchedRow.company; trackerNum = String(matchedRow.num); } } if (!company || !trackerNum) { const followups = loadFollowups(); const [match] = matchCandidates([candidate], candidateApps, followups); if (match && match.application_num != null) { company = company || match.company_hint; trackerNum = trackerNum || String(match.application_num); } } if (!company || !trackerNum) { console.log('Could not match this reply to a single tracker row, and no --company/--tracker override was given.'); console.log('Nothing was written to data/contacts.tsv. Re-run with --company "" --tracker to attach it manually.'); return; } const typeOverride = flagValue(args, '--type'); if (typeOverride && !VALID_TYPES.has(typeOverride)) { console.error(`Error: --type must be one of ${[...VALID_TYPES].join(', ')}; got "${typeOverride}"`); process.exitCode = 1; return; } const contactType = typeOverride || inferContactType(`${candidate.subject} ${candidate.body_snippet}`); const replyType = classifyReply(candidate).type; // The local day: this lands in the user's contacts.tsv, and the UTC day is // tomorrow all evening west of Greenwich (#3070). const today = localToday(); const contact = { name, company, type: contactType, email, tracker: trackerNum, notes: `extracted from pasted reply, ${today}; reply type: ${replyType}`, }; console.log('\nDiscovered contact:'); console.log(` name: ${contact.name || '(none)'}`); console.log(` email: ${contact.email || '(none)'}`); console.log(` company: ${contact.company}`); console.log(` type: ${contact.type}`); console.log(` tracker#: ${contact.tracker}`); if (!contact.name) { console.log('\nNo contact name could be parsed from the From header — add a display name before saving. Nothing was written.'); return; } const skipConfirm = hasFlag(args, '--yes'); if (!skipConfirm) { const proceed = await askYesNo('\nSave this contact to data/contacts.tsv? [y/N] '); if (!proceed) { console.log('Not saved.'); return; } } const total = await appendContact(contact); console.log(`\nSaved. data/contacts.tsv now has ${total} contact row(s).`); } if (isMainModule(import.meta.url)) { main().catch((err) => { console.error('Fatal:', err); process.exit(1); }); }