|
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
import { env } from '$env/dynamic/private';
|
|
|
|
|
import { getDatabase } from '$lib/server/database';
|
|
|
|
|
import nodemailer from 'nodemailer';
|
|
|
|
|
|
|
|
|
|
function mailTransport() {
|
|
|
|
|
if (!env.SMTP_HOST || !env.SMTP_FROM) return null;
|
|
|
|
|
return nodemailer.createTransport({
|
|
|
|
|
host: env.SMTP_HOST,
|
|
|
|
|
port: Number(env.SMTP_PORT || 587),
|
|
|
|
|
secure: env.SMTP_SECURE === 'true',
|
|
|
|
|
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
|
|
|
|
|
requireTLS: env.SMTP_SECURE !== 'true',
|
|
|
|
|
disableFileAccess: true,
|
|
|
|
|
disableUrlAccess: true
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const escapeHtml = (value: unknown) => String(value).replace(/[&<>"']/g, (character) => ({
|
|
|
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
|
|
|
})[character] ?? character);
|
|
|
|
|
|
|
|
|
|
async function deliverSystemMessages() {
|
|
|
|
|
const database = await getDatabase();
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
|
|
|
|
FROM automated_system_message_deliveries delivery
|
|
|
|
|
JOIN automated_system_messages message ON message.id=delivery.message_id
|
|
|
|
|
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
|
|
|
|
ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
|
|
|
|
|
const transport = mailTransport();
|
|
|
|
|
const origin = (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
|
|
|
|
let delivered = 0;
|
|
|
|
|
for (const message of due) {
|
|
|
|
|
const notificationId = randomUUID();
|
|
|
|
|
await (await database.prepare(`INSERT INTO notifications
|
|
|
|
|
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
|
|
|
|
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
|
|
|
|
|
notificationId, message.user_id, message.source, message.title, message.body,
|
|
|
|
|
message.link ?? null, message.actions_json ?? null, now
|
|
|
|
|
);
|
|
|
|
|
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
|
|
|
|
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
|
|
|
|
.run(now, notificationId, message.delivery_id);
|
|
|
|
|
const preference = await (await database.prepare(
|
|
|
|
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
|
|
|
|
)).get(message.user_id);
|
|
|
|
|
if (transport && preference?.email_frequency === 'immediate') {
|
|
|
|
|
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
|
|
|
|
|
if (typeof identity?.email === 'string') {
|
|
|
|
|
try {
|
|
|
|
|
await transport.sendMail({
|
|
|
|
|
from: env.SMTP_FROM,
|
|
|
|
|
to: identity.email,
|
|
|
|
|
subject: `SHD.build · ${message.title}`,
|
|
|
|
|
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
|
|
|
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
|
|
|
|
|
});
|
|
|
|
|
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Unable to email automated notification ${notificationId}`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delivered++;
|
|
|
|
|
}
|
|
|
|
|
return delivered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deliverOversightMessages() {
|
|
|
|
|
const database = await getDatabase();
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue
|
|
|
|
|
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now);
|
|
|
|
|
const groups = new Map<string, Record<string, unknown>[]>();
|
|
|
|
|
for (const item of due) {
|
|
|
|
|
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
|
|
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
|
|
|
groups.get(key)?.push(item);
|
|
|
|
|
}
|
|
|
|
|
const transport = mailTransport();
|
|
|
|
|
const origin = (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
|
|
|
|
let delivered = 0;
|
|
|
|
|
for (const items of groups.values()) {
|
|
|
|
|
const first = items[0];
|
|
|
|
|
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
|
|
|
|
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
|
|
|
|
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n');
|
|
|
|
|
const body = `${details}${items.length > 10 ? `\n• ${items.length - 10} additional review items` : ''}`;
|
|
|
|
|
const notificationId = randomUUID();
|
|
|
|
|
await (await database.prepare(`INSERT INTO notifications
|
|
|
|
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
|
|
|
|
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
|
|
|
|
|
notificationId, first.user_id, title, body, first.link, now
|
|
|
|
|
);
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
await (await database.prepare(
|
|
|
|
|
'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL'
|
|
|
|
|
)).run(now, item.id);
|
|
|
|
|
}
|
|
|
|
|
const preference = await (await database.prepare(
|
|
|
|
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
|
|
|
|
)).get(first.user_id);
|
|
|
|
|
if (transport && preference?.email_frequency === 'immediate') {
|
|
|
|
|
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
|
|
|
|
|
if (typeof identity?.email === 'string') {
|
|
|
|
|
try {
|
|
|
|
|
await transport.sendMail({
|
|
|
|
|
from: env.SMTP_FROM,
|
|
|
|
|
to: identity.email,
|
|
|
|
|
subject: `SHD.build · ${title}`,
|
|
|
|
|
text: `${body}\n\n${origin}${first.link}`,
|
|
|
|
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${escapeHtml(title)}</h1><p style="white-space:pre-line;color:#a5aaab">${escapeHtml(body)}</p><a style="color:#f6a821" href="${escapeHtml(`${origin}${first.link}`)}">OPEN OVERSIGHT QUEUE</a></div>`
|
|
|
|
|
});
|
|
|
|
|
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Unable to email Oversight digest ${notificationId}`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delivered += items.length;
|
|
|
|
|
}
|
|
|
|
|
return delivered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deliverDailyDigests() {
|
|
|
|
|
const transport = mailTransport();
|
|
|
|
|
if (!transport) return 0;
|
|
|
|
|
const database = await getDatabase();
|
|
|
|
|
const origin = (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
|
|
|
|
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
|
|
|
|
FROM notifications n
|
|
|
|
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
|
|
|
|
JOIN user_emails e ON e.user_id=n.user_id
|
|
|
|
|
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all();
|
|
|
|
|
let sent = 0;
|
|
|
|
|
for (const recipient of recipients) {
|
|
|
|
|
const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
|
|
|
|
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
|
|
|
|
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`
|
|
|
|
|
)).all(recipient.user_id);
|
|
|
|
|
if (!items.length) continue;
|
|
|
|
|
const rows = items.map((item) => `<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escapeHtml(item.title)}</strong><p style="color:#a5aaab">${escapeHtml(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${item.link}`)}">VIEW INTELLIGENCE</a>` : ''}</div>`).join('');
|
|
|
|
|
await transport.sendMail({
|
|
|
|
|
from: env.SMTP_FROM,
|
|
|
|
|
to: String(recipient.email),
|
|
|
|
|
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
|
|
|
|
|
text: items.map((item) => `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`).join('\n\n'),
|
|
|
|
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`
|
|
|
|
|
});
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
await (await database.prepare(
|
|
|
|
|
'UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL'
|
|
|
|
|
)).run(now, item.id);
|
|
|
|
|
}
|
|
|
|
|
sent++;
|
|
|
|
|
}
|
|
|
|
|
return sent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let running: Promise<{ system: number; oversight: number; digests: number }> | null = null;
|
|
|
|
|
export function runScheduledNotificationTasks() {
|
|
|
|
|
if (running) return running;
|
|
|
|
|
running = (async () => {
|
|
|
|
|
const system = await deliverSystemMessages();
|
|
|
|
|
const oversight = await deliverOversightMessages();
|
|
|
|
|
const digests = await deliverDailyDigests();
|
|
|
|
|
return { system, oversight, digests };
|
|
|
|
|
})()
|
|
|
|
|
.finally(() => { running = null; });
|
|
|
|
|
return running;
|
|
|
|
|
}
|