258 lines
9.9 KiB
JavaScript
258 lines
9.9 KiB
JavaScript
|
|
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</span>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
<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</td>
|
||
|
|
<td style="padding:10px 14px;border-bottom:1px solid #e2e4eb;">${esc(bill.name)}</td>
|
||
|
|
</tr>
|
||
|
|
<tr>
|
||
|
|
<td style="padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;">Due Date</td>
|
||
|
|
<td style="padding:10px 14px;border-bottom:1px solid #e2e4eb;">${fmt(dueDate)}</td>
|
||
|
|
</tr>
|
||
|
|
<tr style="background:#f5f6fa;">
|
||
|
|
<td style="padding:10px 14px;color:#6b7280;font-weight:600;">Amount</td>
|
||
|
|
<td style="padding:10px 14px;font-weight:700;">${amount}</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
<p style="margin:24px 0 0;font-size:12px;color:#9ca3af;">
|
||
|
|
Sent by Bill Tracker · ${new Date().toLocaleDateString()}
|
||
|
|
</p>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
</td></tr>
|
||
|
|
</table>
|
||
|
|
</body>
|
||
|
|
</html>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
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</h2>
|
||
|
|
<p>Your SMTP configuration is working correctly.</p>
|
||
|
|
<p style="color:#6b7280;font-size:12px;">Sent at ${new Date().toISOString()}</p>
|
||
|
|
</body></html>`;
|
||
|
|
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 };
|