2026-05-03 19:51:57 -05:00
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 : ` <strong> ${ bill . name } </strong> is due in 3 days. ` ,
due _1d : ` <strong> ${ bill . name } </strong> is due <strong>tomorrow</strong>. ` ,
due _today : ` <strong> ${ bill . name } </strong> is due <strong>today</strong>. ` ,
overdue : ` <strong> ${ bill . name } </strong> was due on ${ fmt ( dueDate ) } and has not been marked as paid. ` ,
} ;
return ` <!DOCTYPE html>
< html >
< body style = "margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;" >
< table width = "100%" cellpadding = "0" cellspacing = "0" style = "padding:32px 16px;" >
< tr > < td align = "center" >
< table width = "540" cellpadding = "0" cellspacing = "0" style = "background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;" >
< tr >
< td style = "background:${color};padding:16px 24px;" >
< span style = "color:white;font-size:18px;font-weight:700;" > Bill Tracker < / s p a n >
< / t d >
< / t r >
< tr >
< td style = "padding:28px 24px;" >
< p style = "margin:0 0 20px;font-size:15px;color:#1a1d2e;line-height:1.6;" >
$ { messages [ type ] }
< / p >
< table width = "100%" cellpadding = "0" cellspacing = "0"
style = "border:1px solid #e2e4eb;border-radius:6px;overflow:hidden;font-size:14px;" >
< tr style = "background:#f5f6fa;" >
< td style = "padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;" > Bill < / t d >
< td style = "padding:10px 14px;border-bottom:1px solid #e2e4eb;" > $ { esc ( bill . name ) } < / t d >
< / t r >
< tr >
< td style = "padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;" > Due Date < / t d >
< td style = "padding:10px 14px;border-bottom:1px solid #e2e4eb;" > $ { fmt ( dueDate ) } < / t d >
< / t r >
< tr style = "background:#f5f6fa;" >
< td style = "padding:10px 14px;color:#6b7280;font-weight:600;" > Amount < / t d >
< td style = "padding:10px 14px;font-weight:700;" > $ { amount } < / t d >
< / t r >
< / t a b l e >
< p style = "margin:24px 0 0;font-size:12px;color:#9ca3af;" >
Sent by Bill Tracker & middot ; $ { new Date ( ) . toLocaleDateString ( ) }
< / p >
< / t d >
< / t r >
< / t a b l e >
< / t d > < / t r >
< / t a b l e >
< / b o d y >
< / h t m l > ` ;
}
function esc ( s ) {
return String ( s || '' ) . replace ( /&/g , '&' ) . 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 = ` <!DOCTYPE html>
< html > < body style = "font-family:sans-serif;padding:32px;" >
< h2 style = "color:#4f46e5;" > Bill Tracker — Test Email < / h 2 >
< p > Your SMTP configuration is working correctly . < / p >
< p style = "color:#6b7280;font-size:12px;" > Sent at $ { new Date ( ) . toISOString ( ) } < / p >
< / b o d y > < / h t m l > ` ;
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' ) ;
fix: notification privacy leak — per-user bills no longer sent to all recipients (v0.23.2)
CRITICAL security fix: In per-user notification mode, the notification runner
was fetching ALL active bills globally and sending each bill's details to
every opted-in recipient regardless of ownership. This meant User A's bill
names, amounts, and due dates could be emailed to User B.
Fix: Added ownership filter in the recipient loop:
if (allowUserConfig && bill.user_id !== recipient.id) continue;
Also added a defensive guard for bills with no user_id (orphaned bills),
which are now skipped with a console.warn instead of being broadcast.
Global notification mode (single admin recipient) is unaffected.
Security audit: Private_Hudson confirmed the fix is airtight. All other
routes (bills, payments, tracker, analytics, export, calendar, summary,
categories) properly scope data by user_id.
Version bump: 0.23.1 → 0.23.2 (security patch)
2026-05-10 12:34:53 -05:00
// 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.
2026-05-16 10:34:32 -05:00
const bills = db . prepare ( 'SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL' ) . all ( ) ;
2026-05-03 19:51:57 -05:00
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 (
2026-05-04 23:34:24 -05:00
"SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''"
2026-05-03 19:51:57 -05:00
) . 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 ) {
2026-05-16 20:26:09 -05:00
const dueDate = resolveDueDate ( bill , year , month ) ;
if ( ! dueDate ) continue ;
const range = getCycleRange ( year , month , bill ) ;
2026-05-03 19:51:57 -05:00
const payments = db . prepare (
'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL'
2026-05-16 20:26:09 -05:00
) . all ( bill . id , range . start , range . end ) ;
2026-05-03 19:51:57 -05:00
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' ) ;
2026-05-10 15:25:47 -05: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 ) ;
2026-05-03 19:51:57 -05:00
// 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 ;
fix: notification privacy leak — per-user bills no longer sent to all recipients (v0.23.2)
CRITICAL security fix: In per-user notification mode, the notification runner
was fetching ALL active bills globally and sending each bill's details to
every opted-in recipient regardless of ownership. This meant User A's bill
names, amounts, and due dates could be emailed to User B.
Fix: Added ownership filter in the recipient loop:
if (allowUserConfig && bill.user_id !== recipient.id) continue;
Also added a defensive guard for bills with no user_id (orphaned bills),
which are now skipped with a console.warn instead of being broadcast.
Global notification mode (single admin recipient) is unaffected.
Security audit: Private_Hudson confirmed the fix is airtight. All other
routes (bills, payments, tracker, analytics, export, calendar, summary,
categories) properly scope data by user_id.
Version bump: 0.23.1 → 0.23.2 (security patch)
2026-05-10 12:34:53 -05:00
// 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 ;
}
2026-05-03 19:51:57 -05:00
for ( const recipient of recipients ) {
fix: notification privacy leak — per-user bills no longer sent to all recipients (v0.23.2)
CRITICAL security fix: In per-user notification mode, the notification runner
was fetching ALL active bills globally and sending each bill's details to
every opted-in recipient regardless of ownership. This meant User A's bill
names, amounts, and due dates could be emailed to User B.
Fix: Added ownership filter in the recipient loop:
if (allowUserConfig && bill.user_id !== recipient.id) continue;
Also added a defensive guard for bills with no user_id (orphaned bills),
which are now skipped with a console.warn instead of being broadcast.
Global notification mode (single admin recipient) is unaffected.
Security audit: Private_Hudson confirmed the fix is airtight. All other
routes (bills, payments, tracker, analytics, export, calendar, summary,
categories) properly scope data by user_id.
Version bump: 0.23.1 → 0.23.2 (security patch)
2026-05-10 12:34:53 -05:00
// In per-user mode, only send bills belonging to this recipient
if ( allowUserConfig && bill . user _id !== recipient . id ) continue ;
2026-05-03 19:51:57 -05:00
// 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 } ` ) ;
}
}
2026-05-30 14:33:55 -05:00
// ── 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 `
< tr >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;" > $ { esc ( b . name ) } < / t d >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;text-decoration:line-through;color:#9ca3af;" > $ { fmtAmt ( b . expected _amount ) } < / t d >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;font-weight:600;" > $ { fmtAmt ( b . recent _amount ) } < / t d >
< td style = "padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px;color:${arrowColor};font-weight:700;" > $ { arrow } $ { sign } $ { b . drift _pct } % < / t d >
< / t r > ` ;
} ) . join ( '' ) ;
return ` <!DOCTYPE html>
< html >
< body style = "margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;" >
< table width = "100%" cellpadding = "0" cellspacing = "0" style = "padding:32px 16px;" >
< tr > < td align = "center" >
< table width = "540" cellpadding = "0" cellspacing = "0" style = "background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;" >
< tr >
< td style = "background:${color};padding:16px 24px;" >
< p style = "margin:0;color:#fff;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;" > Bill Tracker < / p >
< h1 style = "margin:4px 0 0;color:#fff;font-size:20px;font-weight:700;" > Price Changes Detected < / h 1 >
< / t d >
< / t r >
< tr >
< td style = "padding:20px 24px 8px;" >
< p style = "margin:0;color:#374151;font-size:14px;" >
The following bills have been paid at amounts different from their expected amounts for the past $ { bills [ 0 ] ? . months _sampled ? ? 2 } + months .
< / p >
< / t d >
< / t r >
< tr >
< td style = "padding:8px 24px 20px;" >
< table width = "100%" cellpadding = "0" cellspacing = "0" style = "border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;" >
< thead >
< tr style = "background:#fef9c3;" >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Bill < / t h >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Was < / t h >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Now ~ < / t h >
< th style = "padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;" > Change < / t h >
< / t r >
< / t h e a d >
< tbody > $ { rows } < / t b o d y >
< / t a b l e >
< / t d >
< / t r >
< tr >
< td style = "padding:0 24px 24px;" >
< p style = "margin:0;color:#6b7280;font-size:13px;" >
You can update the expected amounts or dismiss these alerts in Bill Tracker .
< / p >
< / t d >
< / t r >
< / t a b l e >
< / t d > < / t r >
< / t a b l e >
< / b o d y >
< / h t m l > ` ;
}
async function runDriftNotifications ( ) {
if ( getSetting ( 'notify_smtp_enabled' ) !== 'true' ) return ;
if ( ! getSetting ( 'notify_smtp_host' ) ) return ;
if ( ! getSetting ( 'notify_sender_address' ) ) return ;
const db = getDb ( ) ;
const { getDriftReport } = require ( './driftService' ) ;
const now = new Date ( ) ;
const year = now . getFullYear ( ) ;
const month = now . getMonth ( ) + 1 ;
const today = now . toISOString ( ) . slice ( 0 , 10 ) ;
const allowUserConfig = getSetting ( 'notify_allow_user_config' ) === 'true' ;
const globalRecipient = getSetting ( 'notify_global_recipient' ) ;
const recipients = [ ] ;
if ( allowUserConfig ) {
const users = db . prepare (
"SELECT * FROM users WHERE active=1 AND role='user' AND notifications_enabled=1 AND notify_amount_change=1 AND notification_email IS NOT NULL AND notification_email != ''"
) . all ( ) ;
recipients . push ( ... users ) ;
} else if ( globalRecipient ) {
recipients . push ( { id : 0 , notification _email : globalRecipient , notify _amount _change : 1 } ) ;
}
for ( const recipient of recipients ) {
try {
const report = getDriftReport ( recipient . id , now ) ;
const newBills = ( report . bills || [ ] ) . filter ( b =>
! hasNotification ( db , b . id , recipient . id , year , month , 'amount_change' , today )
) ;
if ( ! newBills . length ) continue ;
await sendEmail (
recipient . notification _email ,
'Price Change Alert: Your bill amounts have changed' ,
buildDriftDigestHtml ( newBills )
) ;
for ( const b of newBills ) {
recordNotification ( db , b . id , recipient . id , year , month , 'amount_change' , today ) ;
}
} catch ( err ) {
console . error ( '[drift notifications] Error for recipient' , recipient . notification _email , ':' , err . message ) ;
}
}
}
module . exports = { runNotifications , runDriftNotifications , sendTestEmail , createTransport } ;