2026-05-03 19:51:57 -05:00
const nodemailer = require ( 'nodemailer' ) ;
const { getDb , getSetting } = require ( '../db/database' ) ;
2026-06-03 21:43:54 -05:00
const { decryptSecret , encryptSecret } = require ( './encryptionService' ) ;
2026-06-07 01:05:48 -05:00
const { accountingActiveSql } = require ( './paymentAccountingService' ) ;
2026-05-03 19:51:57 -05:00
const {
markNotificationError ,
markNotificationSuccess ,
markNotificationTestSuccess ,
} = require ( './statusRuntime' ) ;
2026-06-10 19:42:51 -05:00
const { localDateString } = require ( '../utils/dates' ) ;
2026-06-11 20:12:31 -05:00
const { fromCents } = require ( '../utils/money' ) ;
2026-05-03 19:51:57 -05:00
2026-06-03 21:43:54 -05:00
// ── 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' ,
) ;
}
2026-06-04 03:38:32 -05:00
module . exports . _push = { sendNtfy , sendGotify , sendDiscord , sendTelegram , sendTestPush , sendPushToUser , encryptSecret } ;
2026-06-03 21:43:54 -05:00
2026-05-03 19:51:57 -05:00
// ── SMTP transport ────────────────────────────────────────────────────────────
2026-05-31 15:06:10 -05:00
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
}
}
2026-05-03 19:51:57 -05:00
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' ) ;
2026-05-31 15:06:10 -05:00
const password = getSmtpPassword ( ) ;
2026-05-03 19:51:57 -05:00
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 ] ;
2026-06-11 20:12:31 -05:00
const amount = '$' + ( fromCents ( bill . expected _amount ) || 0 ) . toFixed ( 2 ) ;
2026-05-03 19:51:57 -05:00
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 ( ) ;
2026-06-03 21:43:54 -05:00
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 ;
2026-05-03 19:51:57 -05:00
const now = new Date ( ) ;
const year = now . getFullYear ( ) ;
const month = now . getMonth ( ) + 1 ;
2026-06-10 19:42:51 -05:00
const today = localDateString ( now ) ;
2026-05-03 19:51:57 -05:00
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-06-03 21:43:54 -05:00
"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)"
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 = [ ] ;
2026-06-04 21:00:59 -05:00
// Batch-fetch all payments for active bills this cycle to avoid N+1 queries.
// Bills use different cycle ranges per bill, so we use a broad month window
// and the per-bill cycle check happens in memory below.
const billIds = bills . map ( b => b . id ) ;
const monthStart = ` ${ year } - ${ String ( month ) . padStart ( 2 , '0' ) } -01 ` ;
2026-06-10 19:42:51 -05:00
const monthEnd = localDateString ( new Date ( year , month , 0 ) ) ;
2026-06-04 21:00:59 -05:00
const paidMap = new Map ( ) ;
if ( billIds . length > 0 ) {
const placeholders = billIds . map ( ( ) => '?' ) . join ( ',' ) ;
const paidRows = db . prepare ( `
SELECT bill _id , SUM ( amount ) AS paid _sum
FROM payments
WHERE bill _id IN ( $ { placeholders } )
AND paid _date BETWEEN ? AND ?
AND deleted _at IS NULL
2026-06-07 01:05:48 -05:00
AND $ { accountingActiveSql ( ) }
2026-06-04 21:00:59 -05:00
GROUP BY bill _id
` ).all(...billIds, monthStart, monthEnd);
for ( const row of paidRows ) paidMap . set ( row . bill _id , row . paid _sum ) ;
}
// Batch-fetch all notifications already sent today to avoid N× M per-bill-per-recipient queries.
const sentRows = db . prepare ( `
SELECT bill _id , user _id , type FROM notifications
WHERE year = ? AND month = ? AND sent _date = ?
` ).all(year, month, today);
const sentSet = new Set ( sentRows . map ( n => ` ${ n . bill _id } : ${ n . user _id } : ${ n . type } ` ) ) ;
2026-05-03 19:51:57 -05:00
for ( const bill of bills ) {
2026-05-16 20:26:09 -05:00
const dueDate = resolveDueDate ( bill , year , month ) ;
if ( ! dueDate ) continue ;
2026-06-04 21:00:59 -05:00
const totalPaid = paidMap . get ( bill . id ) ? ? 0 ;
2026-05-03 19:51:57 -05:00
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 ;
2026-06-04 21:00:59 -05:00
if ( sentSet . has ( ` ${ bill . id } : ${ recipient . id } : ${ type } ` ) ) continue ;
2026-05-03 19:51:57 -05:00
const meta = TYPE _META [ type ] ;
const subject = meta . subject ( bill ) ;
2026-06-03 21:43:54 -05:00
const urgency = meta . urgency ;
2026-06-11 20:12:31 -05:00
const amount = '$' + ( fromCents ( bill . expected _amount ) || 0 ) . toFixed ( 2 ) ;
2026-06-03 21:43:54 -05:00
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 } ` ) ;
}
}
2026-05-03 19:51:57 -05:00
2026-06-03 21:43:54 -05:00
// 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 } ` ) ;
}
2026-05-03 19:51:57 -05:00
}
2026-06-03 21:43:54 -05:00
2026-06-04 21:00:59 -05:00
if ( sent ) {
recordNotification ( db , bill . id , recipient . id , year , month , type , today ) ;
sentSet . add ( ` ${ bill . id } : ${ recipient . id } : ${ type } ` ) ;
}
2026-05-03 19:51:57 -05:00
}
}
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 ;
2026-06-10 19:42:51 -05:00
const today = localDateString ( now ) ;
2026-05-30 14:33:55 -05:00
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 } ;