[object Object]

← back to AbramsOS

feat(digest): opt-in daily deadline digest email via George

8cf03dfc984b63caabb21bd431af0fefc50fcd56 · 2026-07-07 07:45:29 -0700 · Steve

- lib/digest.js builds an HTML digest (overdue / this week / coming up) and self-sends to Steve's own inbox via George /api/send (internal recipient, no external-send token)
- scheduler cron 08:00 America/Los_Angeles is DOUBLE-GATED: off unless DIGEST_ENABLED=1 AND GEORGE_BASIC_AUTH set; committed code is dormant and never auto-sends on its own
- .env.example documents all digest keys (default DIGEST_ENABLED=0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8cf03dfc984b63caabb21bd431af0fefc50fcd56
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 07:45:29 2026 -0700

    feat(digest): opt-in daily deadline digest email via George
    
    - lib/digest.js builds an HTML digest (overdue / this week / coming up) and self-sends to Steve's own inbox via George /api/send (internal recipient, no external-send token)
    - scheduler cron 08:00 America/Los_Angeles is DOUBLE-GATED: off unless DIGEST_ENABLED=1 AND GEORGE_BASIC_AUTH set; committed code is dormant and never auto-sends on its own
    - .env.example documents all digest keys (default DIGEST_ENABLED=0)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .env.example     |  8 ++++++
 lib/digest.js    | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/scheduler.js | 20 ++++++++++++++-
 3 files changed, 101 insertions(+), 1 deletion(-)

diff --git a/.env.example b/.env.example
index 24c7fdd..bf5d114 100644
--- a/.env.example
+++ b/.env.example
@@ -34,3 +34,11 @@ INFO_EMAIL=info@abramsos.agentabrams.com
 PLAID_ENV=sandbox
 PLAID_CLIENT_ID=
 PLAID_SECRET=
+
+# Daily deadline digest via George (self-send). Set GEORGE_BASIC_AUTH to enable the 8AM auto-email.
+GEORGE_URL=http://127.0.0.1:9850
+GEORGE_BASIC_AUTH=
+DIGEST_TO=steveabramsdesigns@gmail.com
+DIGEST_ACCOUNT=steve-office
+DIGEST_DAYS=21
+DIGEST_ENABLED=0   # set to 1 to arm the daily 8AM digest cron
diff --git a/lib/digest.js b/lib/digest.js
new file mode 100644
index 0000000..36c3046
--- /dev/null
+++ b/lib/digest.js
@@ -0,0 +1,74 @@
+// Daily deadline digest — builds an email from upcoming deadlines and sends it
+// via George (self-send to Steve's own inbox, which George treats as internal,
+// so no external-send approval token is needed). Safe no-op if unconfigured.
+
+const db = require('./db');
+const engine = require('./reminder-engine');
+
+const GEORGE_URL = (process.env.GEORGE_URL || '').replace(/\/$/, '');
+const GEORGE_BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || '';   // base64 of "admin:pass"
+const DIGEST_TO = process.env.DIGEST_TO || '';
+const DIGEST_ACCOUNT = process.env.DIGEST_ACCOUNT || 'steve-office'; // steve-personal token expires weekly; office is stable
+const DIGEST_DAYS = parseInt(process.env.DIGEST_DAYS || '21', 10);
+const APP_URL = process.env.BASE_URL || 'http://localhost:9774';
+
+function esc(s) {
+  return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+}
+
+async function buildDigest(userId) {
+  await engine.generateForUser(userId);
+  const rows = await engine.upcomingForUser(userId, { days: DIGEST_DAYS });
+  const now = Date.now();
+  const items = rows.map((r) => ({ ...r, days: Math.round((new Date(r.due_at).getTime() - now) / 86400000) }));
+
+  const overdue = items.filter((i) => i.days < 0);
+  const thisWeek = items.filter((i) => i.days >= 0 && i.days <= 7);
+  const later = items.filter((i) => i.days > 7);
+
+  const subject = `AbramsOS · ${items.length} deadline${items.length === 1 ? '' : 's'}` +
+    (overdue.length ? ` (${overdue.length} overdue)` : '') +
+    ` · ${new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
+
+  const section = (title, list, color) => {
+    if (!list.length) return '';
+    const li = list.map((i) => {
+      const when = i.days < 0 ? `${Math.abs(i.days)}d overdue` : i.days === 0 ? 'today' : `in ${i.days}d`;
+      return `<li style="margin:6px 0"><b>${esc(when)}</b> — ${esc(i.title)}${i.body ? `<br><span style="color:#667">${esc(i.body)}</span>` : ''}</li>`;
+    }).join('');
+    return `<h3 style="color:${color};margin:18px 0 6px">${esc(title)}</h3><ul style="padding-left:18px;margin:0">${li}</ul>`;
+  };
+
+  const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:620px;color:#111">
+    <h2 style="margin:0 0 4px">Your AbramsOS deadlines</h2>
+    <p style="color:#667;margin:0 0 8px">${new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })} · next ${DIGEST_DAYS} days</p>
+    ${items.length ? '' : '<p>Nothing due — you\'re clear. ✅</p>'}
+    ${section('⚠️ Overdue', overdue, '#c0392b')}
+    ${section('This week', thisWeek, '#b8860b')}
+    ${section('Coming up', later, '#2c3e50')}
+    <p style="margin-top:22px"><a href="${APP_URL}/deadlines" style="color:#0369a1">Open Deadlines →</a></p>
+  </div>`;
+
+  const text = [`Your AbramsOS deadlines — next ${DIGEST_DAYS} days`, '']
+    .concat(items.length ? items.map((i) => `- ${i.days < 0 ? Math.abs(i.days) + 'd overdue' : 'in ' + i.days + 'd'}: ${i.title}`) : ['Nothing due — clear.'])
+    .concat(['', `${APP_URL}/deadlines`]).join('\n');
+
+  return { subject, html, text, counts: { total: items.length, overdue: overdue.length, thisWeek: thisWeek.length } };
+}
+
+async function sendDigest(userId) {
+  const d = await buildDigest(userId);
+  if (!GEORGE_URL || !GEORGE_BASIC_AUTH || !DIGEST_TO) {
+    return { sent: false, skipped: 'george-not-configured (set GEORGE_URL, GEORGE_BASIC_AUTH, DIGEST_TO in .env)', ...d.counts };
+  }
+  const res = await fetch(`${GEORGE_URL}/api/send`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + GEORGE_BASIC_AUTH },
+    body: JSON.stringify({ account: DIGEST_ACCOUNT, to: DIGEST_TO, subject: d.subject, body: d.html }),
+  });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok || json.error) throw new Error(`George send failed (${res.status}): ${json.error || 'unknown'}`);
+  return { sent: true, messageId: json.messageId, ...d.counts };
+}
+
+module.exports = { buildDigest, sendDigest };
diff --git a/lib/scheduler.js b/lib/scheduler.js
index b2857a4..943b335 100644
--- a/lib/scheduler.js
+++ b/lib/scheduler.js
@@ -8,6 +8,7 @@ const db = require('./db');
 const reminders = require('./reminder-engine');
 const cpsc = require('./cpsc-fetcher');
 const matcher = require('./recall-matcher');
+const digest = require('./digest');
 const { id } = require('./ids');
 
 const LOG_FILE = path.join(__dirname, '..', 'logs', 'scheduler.log');
@@ -72,6 +73,18 @@ async function refreshCpscRecalls(days = 7) {
   }
 }
 
+async function sendDailyDigest() {
+  try {
+    const users = await db.query(`SELECT id FROM user_account`);
+    for (const u of users.rows) {
+      const r = await digest.sendDigest(u.id);
+      logLine(`digest[${u.id}]: ${r.sent ? `sent (${r.total} deadlines, ${r.overdue} overdue) msg=${r.messageId}` : `skipped — ${r.skipped}`}`);
+    }
+  } catch (err) {
+    logLine(`digest FAILED: ${err.message}`);
+  }
+}
+
 let started = false;
 function start() {
   if (started) return;
@@ -82,9 +95,14 @@ function start() {
   cron.schedule('11 */4 * * *', regenerateRemindersForAll);
   // Every 24 hours at 03:17, refresh CPSC recalls (last 7 days)
   cron.schedule('17 3 * * *', () => refreshCpscRecalls(7));
+  // Daily 08:00 — email Steve his deadline digest. OFF by default: opt in with
+  // DIGEST_ENABLED=1 (and set GEORGE_BASIC_AUTH). Double-gated so the committed
+  // code never auto-sends until Steve explicitly turns it on.
+  const digestOn = process.env.DIGEST_ENABLED === '1';
+  if (digestOn) cron.schedule('0 8 * * *', sendDailyDigest, { timezone: 'America/Los_Angeles' });
 
   started = true;
-  logLine('scheduler started · reminders every 4h · cpsc daily 03:17');
+  logLine(`scheduler started · reminders every 4h · cpsc daily 03:17 · digest ${digestOn ? 'daily 08:00' : 'DISABLED (set DIGEST_ENABLED=1)'}`);
 }
 
 module.exports = { start, regenerateRemindersForAll, refreshCpscRecalls };

← 61b2dca feat(warranties): manual Warranties page + Deadlines coverag  ·  back to AbramsOS  ·  feat(digest): suppress zero-deadline days 3a31291 →