const nodemailer = require('nodemailer'); const { getDb, getSetting } = require('../db/database'); const { markNotificationError, markNotificationSuccess, markNotificationTestSuccess, } = require('./statusRuntime'); // ── SMTP transport ──────────────────────────────────────────────────────────── function createTransport() { const host = getSetting('notify_smtp_host'); const port = parseInt(getSetting('notify_smtp_port') || '587', 10); const encryption = getSetting('notify_smtp_encryption') || 'starttls'; const username = getSetting('notify_smtp_username'); const password = getSetting('notify_smtp_password'); const selfSigned = getSetting('notify_smtp_self_signed') === 'true'; if (!host) throw new Error('SMTP host is not configured'); return nodemailer.createTransport({ host, port, secure: encryption === 'ssl', auth: username ? { user: username, pass: password } : undefined, tls: { rejectUnauthorized: !selfSigned, ...(encryption === 'none' ? { ignoreTLS: true } : {}), }, }); } function senderAddress() { const name = getSetting('notify_sender_name') || 'Bill Tracker'; const address = getSetting('notify_sender_address'); if (!address) throw new Error('Sender address is not configured'); return `"${name}" <${address}>`; } // ── Email templates ─────────────────────────────────────────────────────────── const TYPE_META = { due_3d: { subject: (b) => `Reminder: ${b.name} due in 3 days`, urgency: 'upcoming' }, due_1d: { subject: (b) => `Reminder: ${b.name} due tomorrow`, urgency: 'soon' }, due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' }, overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' }, }; const URGENCY_COLOR = { upcoming: '#4f46e5', soon: '#d97706', today: '#dc7308', overdue: '#dc2626', }; function buildEmailHtml(bill, type, dueDate) { const meta = TYPE_META[type]; const color = URGENCY_COLOR[meta.urgency]; const amount = '$' + Number(bill.expected_amount || 0).toFixed(2); const fmt = (d) => { if (!d) return '—'; const [y, m, day] = d.split('-'); return `${parseInt(m)}/${parseInt(day)}/${y}`; }; const messages = { due_3d: `${bill.name} is due in 3 days.`, due_1d: `${bill.name} is due tomorrow.`, due_today: `${bill.name} is due today.`, overdue: `${bill.name} was due on ${fmt(dueDate)} and has not been marked as paid.`, }; return `
Bill Tracker

${messages[type]}

Bill ${esc(bill.name)}
Due Date ${fmt(dueDate)}
Amount ${amount}

Sent by Bill Tracker · ${new Date().toLocaleDateString()}

`; } function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } // ── Send ────────────────────────────────────────────────────────────────────── async function sendEmail(to, subject, html) { const transport = createTransport(); await transport.sendMail({ from: senderAddress(), to, subject, html }); } async function sendTestEmail(to) { const html = `

Bill Tracker — Test Email

Your SMTP configuration is working correctly.

Sent at ${new Date().toISOString()}

`; try { await sendEmail(to, 'Bill Tracker — Test Email', html); markNotificationTestSuccess(); } catch (err) { markNotificationError(err); throw err; } } // ── Notification tracking ───────────────────────────────────────────────────── function hasNotification(db, billId, userId, year, month, type, date) { return !!db.prepare(` SELECT 1 FROM notifications WHERE bill_id=? AND user_id=? AND year=? AND month=? AND type=? AND sent_date=? `).get(billId, userId, year, month, type, date); } function recordNotification(db, billId, userId, year, month, type, date) { try { db.prepare(` INSERT INTO notifications (bill_id, user_id, year, month, type, sent_date) VALUES (?, ?, ?, ?, ?, ?) `).run(billId, userId, year, month, type, date); } catch { // Unique constraint: already recorded, ignore } } // ── Main notification runner (called by daily worker) ───────────────────────── async function runNotifications() { const db = getDb(); if (getSetting('notify_smtp_enabled') !== 'true') return; if (!getSetting('notify_smtp_host')) return; if (!getSetting('notify_sender_address')) return; const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const today = now.toISOString().slice(0, 10); const { getCycleRange, resolveDueDate } = require('./statusService'); const { start, end } = getCycleRange(year, month); const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all(); const allowUserConfig = getSetting('notify_allow_user_config') === 'true'; const globalRecipient = getSetting('notify_global_recipient'); // Gather recipient list const recipients = []; if (allowUserConfig) { const users = db.prepare( "SELECT * FROM users WHERE role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''" ).all(); recipients.push(...users); } else if (globalRecipient) { // Treat global recipient as a synthetic user-like object recipients.push({ id: 0, notification_email: globalRecipient, notify_3d: 1, notify_1d: 1, notify_due: 1, notify_overdue: 1, }); } if (!recipients.length) return; const errors = []; for (const bill of bills) { const payments = db.prepare( 'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL' ).all(bill.id, start, end); const totalPaid = payments.reduce((s, p) => s + p.amount, 0); const isPaid = totalPaid >= bill.expected_amount; if (isPaid) continue; const dueDate = resolveDueDate(bill, year, month); const due = new Date(dueDate + 'T00:00:00'); const diffDays = Math.floor((due - now) / 86400000); // Determine which type applies today let type = null; if (diffDays === 3) type = 'due_3d'; else if (diffDays === 1) type = 'due_1d'; else if (diffDays === 0) type = 'due_today'; else if (diffDays < 0) type = 'overdue'; if (!type) continue; for (const recipient of recipients) { // Check recipient's preferences if (type === 'due_3d' && !recipient.notify_3d) continue; if (type === 'due_1d' && !recipient.notify_1d) continue; if (type === 'due_today' && !recipient.notify_due) continue; if (type === 'overdue' && !recipient.notify_overdue) continue; if (hasNotification(db, bill.id, recipient.id, year, month, type, today)) continue; const meta = TYPE_META[type]; const subject = meta.subject(bill); const html = buildEmailHtml(bill, type, dueDate); try { await sendEmail(recipient.notification_email, subject, html); recordNotification(db, bill.id, recipient.id, year, month, type, today); } catch (err) { errors.push(`${recipient.notification_email}/${bill.name}: ${err.message}`); } } } if (errors.length) { markNotificationError(new Error(errors.join('; '))); console.error('[notifications] Send errors:', errors); } else { markNotificationSuccess(); console.log(`[notifications] Run complete for ${today}`); } } module.exports = { runNotifications, sendTestEmail, createTransport };