453 order by d.decided_at asc
454 limit $1`, [room, CAMPAIGN_FILTER, SENDER]);
455console.log(`approved and unsent for ${SENDER}: ${approved.length}`);
456if (!approved.length) { await sql.end(); process.exit(0); }
457
458const { rows: sendable } = await sql.query('select lower(email) email from v_sendable');
459const okAddr = new Set(sendable.map((s) => s.email));
460
461let sent = 0, skipped = 0;
462/* A database blip must cost one message, not the whole night.
463 *
464 * thunga@ died at 06:07 on EADDRNOTAVAIL — a transient failure opening a
465 * connection to Supabase — with 100 messages still to send. Every Gmail call in
466 * this file already retries, because the network between here and Google was
467 * expected to wobble. The network between here and the DATABASE was not, so a
468 * single failed query threw out of the loop and took the process with it.
469 *
470 * The pilot this was written for made seven queries over 22 minutes. This run
471 * makes four per message for eight hours. At that length "it usually works" is
472 * not a property you can rely on, and the supervisor restarting the process is
473 * a coarse recovery: it loses whatever was in flight and waits up to fifteen
474 * minutes to notice. Catching here is the cheap, precise version. */
475const { existsSync } = await import('node:fs');
476const stopped = () => STOP_FILE && existsSync(STOP_FILE);
477/* Sleep in 10-second steps so a stop file ends the wait, not just the next send. */
478const humanWait = async (ms) => { const end = Date.now() + ms; while (Date.now() < end) { if (stopped()) return; await new Promise((r) => setTimeout(r, Math.min(10000, end - Date.now()))); } };
479let nextBreakAt = 12 + Math.floor(Math.random() * 7);
480for (const d of approved) {
481 try {
482 if (stopped()) { console.log(`STOP FILE ${STOP_FILE} present - ending the run before ${d.to_email}`); break; }
483 if (HUMAN) {
484 /* Re-prove THIS address now, not at start: the loaded set is minutes or hours old. */
485 const { rows: [live] } = await sql.query(
486 `select exists (select 1 from v_sendable where lower(email) = lower($1)) ok,
487 exists (select 1 from agent_state where agent_id = $2 and status::text = 'do_not_contact') dnc`, [d.to_email, d.agent_id]);
488 if (!live.ok || live.dnc) okAddr.delete(d.to_email.toLowerCase());
489 }
490 if (!okAddr.has(d.to_email.toLowerCase())) {
491 await sql.query(
492 `update outreach_drafts set status = 'rejected',
493 why_this_company = why_this_company || ' | SKIPPED AT SEND: address no longer in v_sendable'
494 where id = $1`, [d.id]);
495 console.log(` SKIP ${d.company} — ${d.to_email} left v_sendable since approval`);
496 skipped++;
497 continue;
498 }
499
500 if (!REALLY) {
501 console.log(` would send ${d.company.padEnd(40)} -> ${d.to_email} "${d.subject}"`);
502 continue;
503 }
504
505 /* CLAIM IT. Three senders run at once and a keeper restarts any that dies;
506 pgrep is a race, so a twin can exist for an instant. "select approved, then
507 send" is read-then-write with a gap, and in that gap two processes send the
508 same mail to the same partner. Moving approved -> sending in ONE statement
509 closes it: whoever gets the row owns it, the other gets zero rows and walks
510 on. This is the never-mail-twice rule made structural. */
511 const claim = await sql.query(
512 `update outreach_drafts set status = 'sending'
513 where id = $1 and status = 'approved' returning id`, [d.id]);
514 if (!claim.rowCount) {
515 console.log(` claimed by another sender, skipping ${d.company}`);
516 continue;
517 }
518
519 /* Belt and braces: has this exact address already had this touch, under any
520 draft row? The unique index would refuse it at the end anyway, but finding
521 out BEFORE transmitting is the difference between a blocked write and a
522 delivered duplicate. */
523 const dup = await sql.query(
524 `select 1 from outreach_drafts
525 where lower(to_email) = lower($1) and campaign = $2 and touch = $3
526 and status = 'sent' limit 1`, [d.to_email, d.campaign, d.touch]);
527 if (dup.rowCount) {
528 await sql.query(
529 `update outreach_drafts set status = 'rejected',
530 why_this_company = why_this_company || ' | ALREADY SENT to this address for this touch'
531 where id = $1`, [d.id]);
532 console.log(` ALREADY SENT to ${d.to_email} — refusing to send twice`);
533 skipped++;
534 continue;
535 }
536
537 const cc = CC_LIST;
538 // The cross-system double-send guard. The ALREADY SENT check above asks OUR
539 // database; this asks the shared ledger, so a mail a Claude session sent to
540 // this same person an hour ago — which the database knows nothing about —
541 // still stops the campaign writing to them again. Releases the claim first
542 // so the row is not parked in 'sending'. See scripts/mail/mail-guard.mjs.
543 try { await guard({ to: d.to_email, cc, subject: d.subject }); }
544 catch (e) {
545 if (!(e instanceof GuardBlocked)) throw e;
546 await sql.query(`update outreach_drafts set status = 'approved' where id = $1 and status = 'sending'`, [d.id]);
547 console.error(`\n ${d.company}: ${e.message}`);
548 skipped++;
549 continue;
550 }
551 const raw = HUMAN && !CARD
552 ? mime(d.to_email, d.subject, d.body, null, d.campaign, d.touch, cc, null, null)
553 : mime(d.to_email, d.subject, d.body, sigHtml ? htmlBody(d.body) : null,
554 d.campaign, d.touch, cc, ATTACH, INLINE);
555 // A transient ETIMEDOUT on one call must not kill the run (it did, 10 Aug —
556 // 2 of 49 sent, process dead). Retry the network; skip the draft on
557 // persistent failure — it stays approved and the next run picks it up.
558 let res = null;
559 for (let attempt = 1; attempt <= 3; attempt++) {
560 try {
561 // 60s hard timeout: a dead-air connection hung the 10 Aug night run for
562 // 8 hours. A send that times out is treated as NOT sent; reconcile
563 // against the mailbox's real Sent folder before ever assuming otherwise.
564 res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
565 method: 'POST',
566 headers: { authorization: `Bearer ${await token()}`, 'content-type': 'application/json' },
567 body: JSON.stringify({ raw: b64url(raw) }),
568 signal: AbortSignal.timeout(60000),
569 });
570 break;
571 } catch (e) {
572 console.error(` network error for ${d.company} (attempt ${attempt}/3): ${e.cause?.code || e.message}`);
573 // THE CONVEYOR LESSON (10 Aug): a send whose response never arrives may
574 // still have TRANSMITTED. Before any retry, ask the mailbox itself.
575 try {
576 const chk = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages?' +
577 new URLSearchParams({ q: `in:sent to:${d.to_email} newer_than:1d`, maxResults: '1' }),
578 { headers: { authorization: `Bearer ${await token()}` }, signal: AbortSignal.timeout(30000) });
579 if (chk.ok && ((await chk.json()).resultSizeEstimate || 0) > 0) {
580 console.error(` ${d.company}: found in Sent despite the error — marking sent, NOT retrying`);
581 res = { ok: true, alreadyInSent: true };
582 break;