← back to AbramsOS
lib/digest.js
79 lines
// 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);
// Suppress zero-deadline days — no "nothing due" noise in the inbox.
if (d.counts.total === 0) {
return { sent: false, skipped: 'no deadlines — empty day suppressed', ...d.counts };
}
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 };