← back to Ventura Corridor

src/jobs/morning_feature_email.ts

86 lines

/**
 * Daily 8 AM email — feature of the day to Steve.
 * Pulls /api/magazine/feature-of-the-day and emails it via George Gmail.
 *
 *   $ npx tsx src/jobs/morning_feature_email.ts             # send
 *   $ npx tsx src/jobs/morning_feature_email.ts --dry-run   # preview
 */
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';

const TO = process.env.MORNING_TO || 'steveabramsdesigns@gmail.com';
const GEORGE = process.env.GEORGE_URL || 'http://localhost:9850/api/send';
const AUTH = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
const DRY = process.argv.includes('--dry-run');

const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));

async function main() {
  const r = await query(`
    SELECT mf.*, b.name AS biz_name, b.address AS biz_address, b.city
    FROM magazine_features mf
    JOIN businesses b ON b.id = mf.business_id
    WHERE mf.editorial IS NOT NULL AND length(mf.editorial) > 200
    ORDER BY (mf.id * (EXTRACT(DOY FROM CURRENT_DATE)::int + 1)) % (SELECT GREATEST(count(*), 1) FROM magazine_features)
    LIMIT 1
  `);
  if (r.rowCount === 0) { console.log('[morning_email] no candidate feature'); await pool.end(); return; }
  const f = r.rows[0];

  // Draft pick of the night — strongest unpublished draft from last 24h, scored.
  const dp = await query(`
    SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.category_tag, length(mf.editorial) AS editorial_len,
           b.name AS biz_name,
           (length(mf.editorial) +
            CASE WHEN mf.pull_quote IS NOT NULL THEN 100 ELSE 0 END +
            CASE WHEN mf.subhead    IS NOT NULL THEN  50 ELSE 0 END +
            COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) * 30) AS score
    FROM magazine_features mf
    JOIN businesses b ON b.id = mf.business_id
    LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
    WHERE mf.status = 'draft' AND mf.generated_at > NOW() - INTERVAL '24 hours' AND length(mf.editorial) > 240
    ORDER BY score DESC LIMIT 1
  `);
  const draftPick = dp.rows[0] || null;

  const html = `<div style="font-family:Georgia,serif;max-width:580px;color:#1a1a1a;background:#faf6ee;padding:32px">
<div style="font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-family:Helvetica,sans-serif;font-weight:500">The Corridor · ${new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric'})}</div>
<h1 style="font-family:Georgia,serif;font-style:italic;font-weight:500;font-size:36px;margin:14px 0 8px;line-height:1.05">${esc(f.headline || f.biz_name)}</h1>
<div style="font-family:Georgia,serif;font-style:italic;color:#6e6356;font-size:15px;margin-bottom:20px">${esc(f.subhead || '')}</div>
<p style="font-size:15px;line-height:1.65">${esc(f.editorial || '')}</p>
${f.pull_quote ? `<div style="font-style:italic;font-size:18px;color:#6a3a1a;border-left:3px solid #8a6d3b;padding:6px 16px;margin:18px 0">"${esc(f.pull_quote)}"</div>` : ''}
<div style="margin-top:28px;padding-top:14px;border-top:1px solid #d8cdb8;font-size:11px;color:#6e6356;letter-spacing:.04em">
  <strong>${esc(f.biz_name)}</strong> · ${esc(f.biz_address || '')} · ${esc(f.city || '')}<br>
  <span style="color:#8a6d3b">category: ${esc(f.category_tag || '')}</span>
</div>
${draftPick ? `<div style="margin-top:30px;padding:18px 20px;background:rgba(184,153,104,0.10);border:1px solid #d8cdb8;border-left:3px solid #8a6d3b">
  <div style="font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-family:Helvetica,sans-serif;font-weight:500;margin-bottom:8px">Suggested next publish</div>
  <div style="font-family:Georgia,serif;font-style:italic;font-size:20px;line-height:1.15;margin-bottom:6px"><a href="http://127.0.0.1:9780/magazine/${draftPick.id}" style="color:#1a1815;text-decoration:none;border-bottom:1px solid #8a6d3b">${esc(draftPick.headline || draftPick.biz_name)}</a></div>
  ${draftPick.subhead ? `<div style="font-family:Georgia,serif;font-style:italic;color:#6e6356;font-size:13px;margin-bottom:8px">${esc(draftPick.subhead)}</div>` : ''}
  <div style="font-size:11px;color:#888;font-family:Helvetica,sans-serif;letter-spacing:.05em">${esc(draftPick.biz_name)} · ${esc(draftPick.category_tag || '')} · ${draftPick.editorial_len}c · score ${draftPick.score}</div>
</div>` : ''}
<div style="margin-top:18px;font-size:10px;color:#888;font-family:Helvetica,sans-serif;letter-spacing:.05em">
  Read more issues at <a href="http://127.0.0.1:9780/issue" style="color:#8a6d3b">/issue</a> · admin <a href="http://127.0.0.1:9780/magazine.html" style="color:#8a6d3b">/magazine</a>
</div>
</div>`;

  if (DRY) { process.stdout.write(html); await pool.end(); return; }

  const resp = await fetch(GEORGE, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: AUTH },
    body: JSON.stringify({
      to: TO,
      subject: `The Corridor · ${f.headline || f.biz_name}`,
      body: html,
      isHtml: true,
      account: 'info'
    })
  });
  const out = await resp.json().catch(() => ({}));
  console.log(`[morning_email] ${resp.status} → ${TO} · messageId=${out.messageId || 'none'}`);
  await pool.end();
}

main().catch(e => { console.error(e); process.exit(1); });