← 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); });