← back to Ventura Corridor
iter 91+92: related-features carousel on /magazine/:id + morning feature-of-the-day email — reader page now appends a 'More in <category>' 3-column section above the footer with random-pick same-category siblings (excludes self), each clickable to its own reader page; src/jobs/morning_feature_email.ts pulls FOTD via same DOY-deterministic SQL, builds Georgia-serif HTML email (kicker date · 36px italic headline · subhead · 200+ char editorial · pull quote · biz meta footer · admin links), POSTs to George Gmail localhost:9850 (account=info, isHtml=true) with subject 'The Corridor · <headline>'; com.steve.ventura-corridor-morning-email launchd loaded fires daily 8:07 AM PT; npm run magazine:morning-email --dry-run for preview; --dry-run flag prints HTML to stdout
bdb5ece82cf64666fec74f09816bbec635ebc487 · 2026-05-06 17:07:01 -0700 · SteveStudio2
Files touched
M package.jsonA src/jobs/morning_feature_email.tsM src/server/index.ts
Diff
commit bdb5ece82cf64666fec74f09816bbec635ebc487
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 17:07:01 2026 -0700
iter 91+92: related-features carousel on /magazine/:id + morning feature-of-the-day email — reader page now appends a 'More in <category>' 3-column section above the footer with random-pick same-category siblings (excludes self), each clickable to its own reader page; src/jobs/morning_feature_email.ts pulls FOTD via same DOY-deterministic SQL, builds Georgia-serif HTML email (kicker date · 36px italic headline · subhead · 200+ char editorial · pull quote · biz meta footer · admin links), POSTs to George Gmail localhost:9850 (account=info, isHtml=true) with subject 'The Corridor · <headline>'; com.steve.ventura-corridor-morning-email launchd loaded fires daily 8:07 AM PT; npm run magazine:morning-email --dry-run for preview; --dry-run flag prints HTML to stdout
---
package.json | 3 +-
src/jobs/morning_feature_email.ts | 63 +++++++++++++++++++++++++++++++++++++++
src/server/index.ts | 19 ++++++++++++
3 files changed, 84 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 9dd1d93..2fb10cb 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"magazine:gen": "tsx src/jobs/generate_features.ts",
"magazine:photos": "tsx src/jobs/find_photos.ts",
"magazine:watchdog": "tsx src/jobs/magazine_watchdog.ts",
- "magazine:normalize": "tsx src/jobs/normalize_categories.ts"
+ "magazine:normalize": "tsx src/jobs/normalize_categories.ts",
+ "magazine:morning-email": "tsx src/jobs/morning_feature_email.ts"
},
"dependencies": {
"dotenv": "^16.4.5",
diff --git a/src/jobs/morning_feature_email.ts b/src/jobs/morning_feature_email.ts
new file mode 100644
index 0000000..192500b
--- /dev/null
+++ b/src/jobs/morning_feature_email.ts
@@ -0,0 +1,63 @@
+/**
+ * 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];
+
+ 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>
+<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); });
diff --git a/src/server/index.ts b/src/server/index.ts
index 29677f2..431c939 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -2077,6 +2077,16 @@ app.get('/magazine/:id', async (req, res) => {
const f = r.rows[0];
// Bump views
await query(`UPDATE magazine_features SET views = views + 1 WHERE id = $1`, [id]);
+ // Pull 3 related features from same category
+ const rel = await query(
+ `SELECT mf.id, mf.headline, mf.subhead, b.name AS biz_name
+ FROM magazine_features mf
+ JOIN businesses b ON b.id = mf.business_id
+ WHERE mf.category_tag = $1 AND mf.id <> $2
+ ORDER BY random()
+ LIMIT 3`,
+ [f.category_tag, id]
+ );
const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
@@ -2132,6 +2142,15 @@ footer{margin-top:48px;padding:24px 0;text-align:center;font-size:10px;letter-sp
<dt>Generated</dt><dd class="mono">${new Date(f.generated_at).toLocaleString('en-US', { month:'short', day:'numeric', year:'numeric'})} via ${esc(f.model)}</dd>
<dt>Views</dt><dd class="mono">${f.views || 0}</dd>
</dl>
+ ${rel.rows.length > 0 ? `<section style="margin-top:64px;padding-top:32px;border-top:1px solid var(--rule)">
+ <div style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:16px">More in ${esc(f.category_tag || 'this category')}</div>
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:18px">
+ ${rel.rows.map((r: any) => `<a href="/magazine/${r.id}" style="display:block;padding:14px;border:1px solid var(--rule);text-decoration:none;color:var(--ink)">
+ <h4 style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:18px;margin:0 0 6px;line-height:1.2">${esc(r.headline || r.biz_name)}</h4>
+ <div style="font-family:'Inter',sans-serif;font-size:11px;color:var(--ink-mute);letter-spacing:.05em">${esc(r.biz_name)}</div>
+ </a>`).join('')}
+ </div>
+ </section>` : ''}
<footer>The Corridor · Volume I · 2026 · all loopback / not for redistribution</footer>
</article>
</body></html>`);
← 082198f iter 89+90: per-vertical /issue nav strip + bulk-review-draf
·
back to Ventura Corridor
·
iter 93: sponsor-candidate-first generation order — generate 21beb48 →