const nodemailer = require('nodemailer'); const { getDb, getSetting } = require('../db/database'); const { decryptSecret, encryptSecret } = require('./encryptionService'); const { markNotificationError, markNotificationSuccess, markNotificationTestSuccess, } = require('./statusRuntime'); // ── Push notification channels ──────────────────────────────────────────────── const PUSH_PRIORITY = { upcoming: 'default', soon: 'high', today: 'high', overdue: 'urgent' }; const PUSH_TAGS = { upcoming: ['bell'], soon: ['warning'], today: ['rotating_light'], overdue: ['rotating_light','red_circle'] }; const DISCORD_COLOR = { upcoming: 0x4f46e5, soon: 0xd97706, today: 0xdc7308, overdue: 0xdc2626 }; function safeDecrypt(stored) { if (!stored) return ''; try { return decryptSecret(stored); } catch { return stored; } } async function sendNtfy(url, token, title, body, urgency = 'soon') { const headers = { 'Title': title, 'Priority': PUSH_PRIORITY[urgency] || 'default', 'Tags': (PUSH_TAGS[urgency] || ['bell']).join(','), 'Content-Type': 'text/plain', }; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(url, { method: 'POST', headers, body }); if (!res.ok) throw new Error(`ntfy returned ${res.status}`); } async function sendGotify(url, token, title, body, urgency = 'soon') { const priority = { upcoming: 4, soon: 6, today: 7, overdue: 9 }[urgency] ?? 5; const endpoint = url.replace(/\/$/, '') + '/message?token=' + encodeURIComponent(token); const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, message: body, priority }), }); if (!res.ok) throw new Error(`Gotify returned ${res.status}`); } async function sendDiscord(webhookUrl, title, body, urgency = 'soon') { const color = DISCORD_COLOR[urgency] ?? 0x4f46e5; const res = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ embeds: [{ title, description: body, color, footer: { text: 'Bill Tracker' }, timestamp: new Date().toISOString() }], }), }); if (!res.ok) throw new Error(`Discord returned ${res.status}`); } async function sendTelegram(botToken, chatId, title, body) { const text = `*${title}*\n${body}`; const url = `https://api.telegram.org/bot${botToken}/sendMessage`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown' }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(`Telegram error: ${err.description || res.status}`); } } // Dispatch push to whichever channel this user has configured. async function sendPushToUser(user, title, body, urgency) { if (!user.notify_push_enabled || !user.push_channel || !user.push_url) return; const url = safeDecrypt(user.push_url); const token = safeDecrypt(user.push_token); switch (user.push_channel) { case 'ntfy': return sendNtfy(url, token, title, body, urgency); case 'gotify': return sendGotify(url, token, title, body, urgency); case 'discord': return sendDiscord(url, title, body, urgency); case 'telegram': return sendTelegram(token, user.push_chat_id, title, body); default: throw new Error(`Unknown push channel: ${user.push_channel}`); } } async function sendTestPush(user) { await sendPushToUser( user, 'Bill Tracker — Test Notification', 'Your push notification channel is working correctly.', 'upcoming', ); } module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret }; // ── SMTP transport ──────────────────────────────────────────────────────────── function getSmtpPassword() { const stored = getSetting('notify_smtp_password'); if (!stored) return ''; try { return decryptSecret(stored); } catch { return stored; // legacy plaintext — works until re-saved via admin UI } } 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 = getSmtpPassword(); 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 `
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(); const emailReady = getSetting('notify_smtp_enabled') === 'true' && !!getSetting('notify_smtp_host') && !!getSetting('notify_sender_address'); // Check whether any user has push notifications enabled const pushReady = !!db.prepare( "SELECT 1 FROM users WHERE notify_push_enabled=1 AND push_channel IS NOT NULL LIMIT 1" ).get(); // Nothing to do if neither email nor push is configured if (!emailReady && !pushReady) 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'); // Fetch all active bills. In global-notification mode, the single global recipient // legitimately receives every bill. In per-user mode, each recipient must only // see their own bills — the ownership filter is applied in the loop below. const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').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 active = 1 AND role='user' AND notifications_enabled=1 AND (notification_email IS NOT NULL AND notification_email != '' OR notify_push_enabled=1)" ).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 dueDate = resolveDueDate(bill, year, month); if (!dueDate) continue; const range = getCycleRange(year, month, bill); const payments = db.prepare( 'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL' ).all(bill.id, range.start, range.end); const totalPaid = payments.reduce((s, p) => s + p.amount, 0); const isPaid = totalPaid >= bill.expected_amount; if (isPaid) continue; const due = new Date(dueDate + 'T00:00:00'); // Compare calendar days, not timestamps, to avoid same-day bugs // (e.g., due today at midnight vs now at 3pm would give -0.625 days → floors to -1) const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()); const diffDays = Math.round((dueDay - todayDate) / 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; // Defensive: warn if a bill somehow has no owner if (!bill.user_id) { console.warn(`[notifications] Bill id=${bill.id} name="${bill.name}" has no user_id — skipping`); continue; } for (const recipient of recipients) { // In per-user mode, only send bills belonging to this recipient if (allowUserConfig && bill.user_id !== recipient.id) continue; // 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 urgency = meta.urgency; const amount = '$' + Number(bill.expected_amount || 0).toFixed(2); const pushBody = `${subject} · ${amount}`; let sent = false; // Email if (recipient.notification_email) { const html = buildEmailHtml(bill, type, dueDate); try { await sendEmail(recipient.notification_email, subject, html); sent = true; } catch (err) { errors.push(`email/${recipient.notification_email}/${bill.name}: ${err.message}`); } } // Push (ntfy / Gotify / Discord / Telegram) if (recipient.notify_push_enabled && recipient.push_channel && recipient.push_url) { try { await sendPushToUser(recipient, subject, pushBody, urgency); sent = true; } catch (err) { errors.push(`push/${recipient.push_channel}/${bill.name}: ${err.message}`); } } if (sent) recordNotification(db, bill.id, recipient.id, year, month, type, today); } } if (errors.length) { markNotificationError(new Error(errors.join('; '))); console.error('[notifications] Send errors:', errors); } else { markNotificationSuccess(); console.log(`[notifications] Run complete for ${today}`); } } // ── Drift / price-change digest email ──────────────────────────────────────── function fmtAmt(n) { return '$' + Number(n || 0).toFixed(2); } function buildDriftDigestHtml(bills) { const color = '#d97706'; // amber-600 const rows = bills.map(b => { const sign = b.direction === 'up' ? '+' : ''; const arrow = b.direction === 'up' ? '▲' : '▼'; const arrowColor = b.direction === 'up' ? '#d97706' : '#0d9488'; return `