320let sent = 0, skipped = 0;
321/* A database blip must cost one message, not the whole night.
322 *
323 * thunga@ died at 06:07 on EADDRNOTAVAIL — a transient failure opening a
324 * connection to Supabase — with 100 messages still to send. Every Gmail call in
325 * this file already retries, because the network between here and Google was
326 * expected to wobble. The network between here and the DATABASE was not, so a
327 * single failed query threw out of the loop and took the process with it.
328 *
329 * The pilot this was written for made seven queries over 22 minutes. This run
330 * makes four per message for eight hours. At that length "it usually works" is
331 * not a property you can rely on, and the supervisor restarting the process is
332 * a coarse recovery: it loses whatever was in flight and waits up to fifteen
333 * minutes to notice. Catching here is the cheap, precise version. */
334for (const d of approved) {
335 try {
336 if (!okAddr.has(d.to_email.toLowerCase())) {
337 await sql.query(
338 `update outreach_drafts set status = 'rejected',
339 why_this_company = why_this_company || ' | SKIPPED AT SEND: address no longer in v_sendable'
340 where id = $1`, [d.id]);
341 console.log(` SKIP ${d.company} — ${d.to_email} left v_sendable since approval`);
342 skipped++;
343 continue;
344 }
345
346 if (!REALLY) {
347 console.log(` would send ${d.company.padEnd(40)} -> ${d.to_email} "${d.subject}"`);
348 continue;
349 }
350
351 /* CLAIM IT. Three senders run at once and a keeper restarts any that dies;
352 pgrep is a race, so a twin can exist for an instant. "select approved, then
353 send" is read-then-write with a gap, and in that gap two processes send the
354 same mail to the same partner. Moving approved -> sending in ONE statement
355 closes it: whoever gets the row owns it, the other gets zero rows and walks
356 on. This is the never-mail-twice rule made structural. */
357 const claim = await sql.query(
358 `update outreach_drafts set status = 'sending'
359 where id = $1 and status = 'approved' returning id`, [d.id]);
360 if (!claim.rowCount) {
361 console.log(` claimed by another sender, skipping ${d.company}`);
362 continue;
363 }
364
365 /* Belt and braces: has this exact address already had this touch, under any
366 draft row? The unique index would refuse it at the end anyway, but finding
367 out BEFORE transmitting is the difference between a blocked write and a
368 delivered duplicate. */
369 const dup = await sql.query(
370 `select 1 from outreach_drafts
371 where lower(to_email) = lower($1) and campaign = $2 and touch = $3
372 and status = 'sent' limit 1`, [d.to_email, d.campaign, d.touch]);
373 if (dup.rowCount) {
374 await sql.query(
375 `update outreach_drafts set status = 'rejected',
376 why_this_company = why_this_company || ' | ALREADY SENT to this address for this touch'
377 where id = $1`, [d.id]);
378 console.log(` ALREADY SENT to ${d.to_email} — refusing to send twice`);
379 skipped++;
380 continue;
381 }
382
383 const cc = TEAM.filter((m) => m !== SENDER);
384 const raw = mime(d.to_email, d.subject, d.body, sigHtml ? htmlBody(d.body) : null,
385 d.campaign, d.touch, cc, ATTACH, INLINE);
386 // A transient ETIMEDOUT on one call must not kill the run (it did, 10 Aug —
387 // 2 of 49 sent, process dead). Retry the network; skip the draft on
388 // persistent failure — it stays approved and the next run picks it up.
389 let res = null;
390 for (let attempt = 1; attempt <= 3; attempt++) {
391 try {
392 // 60s hard timeout: a dead-air connection hung the 10 Aug night run for
393 // 8 hours. A send that times out is treated as NOT sent; reconcile
394 // against the mailbox's real Sent folder before ever assuming otherwise.
395 res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
396 method: 'POST',
397 headers: { authorization: `Bearer ${await token()}`, 'content-type': 'application/json' },
398 body: JSON.stringify({ raw: b64url(raw) }),
399 signal: AbortSignal.timeout(60000),
400 });
401 break;
402 } catch (e) {
403 console.error(` network error for ${d.company} (attempt ${attempt}/3): ${e.cause?.code || e.message}`);
404 // THE CONVEYOR LESSON (10 Aug): a send whose response never arrives may
405 // still have TRANSMITTED. Before any retry, ask the mailbox itself.
406 try {
407 const chk = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages?' +
408 new URLSearchParams({ q: `in:sent to:${d.to_email} newer_than:1d`, maxResults: '1' }),
409 { headers: { authorization: `Bearer ${await token()}` }, signal: AbortSignal.timeout(30000) });
410 if (chk.ok && ((await chk.json()).resultSizeEstimate || 0) > 0) {
411 console.error(` ${d.company}: found in Sent despite the error — marking sent, NOT retrying`);
412 res = { ok: true, alreadyInSent: true };
413 break;
414 }
415 } catch { /* check itself failed — fall through to retry/backoff */ }
416 if (attempt < 3) await new Promise((r) => setTimeout(r, 15000 * attempt));
417 }
418 }
419 /* Release the claim on a failure we KNOW did not transmit, so the next run
420 picks it up. Both paths below have already asked the mailbox's own Sent
421 folder and been told the message is not there. A claim held by a dead
422 process would otherwise park that partner permanently in 'sending'. */
423 if (!res) {
424 await sql.query(`update outreach_drafts set status = 'approved' where id = $1 and status = 'sending'`, [d.id]);
425 console.error(` GIVING UP on ${d.company} this run — released, next run retries`);
426 continue;
427 }
428 if (!res.ok) {
429 const txt = await res.text();
430 await sql.query(`update outreach_drafts set status = 'approved' where id = $1 and status = 'sending'`, [d.id]);
431 console.error(` FAILED ${d.company}: ${res.status} ${txt} — released, not marked sent`);
432 // A 429 or 403 is Google telling us to slow down or stop. Backing off here
433 // rather than hammering is what keeps the account alive.
434 if (res.status === 429 || res.status === 403) {
435 console.error(` RATE LIMITED on ${SENDER} — pausing 10 minutes`);
436 await new Promise((r) => setTimeout(r, 600000));
437 }
438 continue;
439 }
440
441 await sql.query('begin');
442 try {
443 await sql.query(`update outreach_drafts set status = 'sent', sent_at = now() where id = $1 and status = 'sending'`, [d.id]);
444 await sql.query(
445 `insert into agent_events (agent_id, kind, note, actor_email)
446 values ($1, 'email', $2, $3)`,
447 [d.agent_id, `Touch ${d.touch} sent (${d.campaign}) via Gmail pilot`, SENDER]);
448 await sql.query(
449 `insert into agent_state (agent_id, status, first_emailed_at, last_emailed_at)
450 values ($1, 'emailed', now(), now())