← 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
M .env.exampleA lib/digest.jsM lib/scheduler.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →