metatroncubeswdev e905236f41 Email: accept SMTP_FROM as "Name <address>"
The whole string was used as the address, so the mail server saw
"Quorla support"@domain and rejected the sender.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:38:42 -04:00

111 lines
6.2 KiB
JavaScript

// Notifications: an in-app list for the team (bell in the sidebar) plus email when SMTP is set up.
// Team emails go to people who keep email on in Settings; client emails (posts waiting for their approval)
// go to the reviewer saved on the client's approval link, sent in the agency's name.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import nodemailer from "nodemailer";
import { DATA_DIR } from "./config.js";
import { agencyName } from "./workspace.js";
import * as users from "./users.js";
const FILE = path.join(DATA_DIR, "notifications.json");
const PREFS = path.join(DATA_DIR, "notify-prefs.json");
const KEEP = 500;
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); };
// ---------- email ----------
let transport = null;
export function emailConfigured() { return Boolean(process.env.SMTP_HOST); }
function mailer() {
if (!emailConfigured()) return null;
transport ||= nodemailer.createTransport({
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: Number(process.env.SMTP_PORT) === 465,
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || "" } : undefined,
connectionTimeout: 15000, greetingTimeout: 15000, socketTimeout: 30000, // fail with a reason instead of hanging for minutes
});
return transport;
}
// SMTP_FROM may be "support@x.com" or "Quorla <support@x.com>"; nodemailer needs the bare address here.
const fromAddress = () => { const f = process.env.SMTP_FROM || process.env.SMTP_USER || ""; return (f.match(/<([^>]+)>/)?.[1] || f).trim(); };
export const appUrl = (p = "") => `${(process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : "http://localhost:4310")).replace(/\/+$/, "")}${p}`;
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
function html({ heading, lines, button, footer }) {
return `<!doctype html><html><body style="margin:0;background:#f5f6f8;font-family:Inter,Segoe UI,Arial,sans-serif;color:#1f2024">
<div style="max-width:560px;margin:0 auto;padding:28px 16px"><div style="background:#fff;border:1px solid #e4e7ec;border-radius:12px;padding:24px">
<h1 style="font-size:19px;margin:0 0 12px;color:#242142">${esc(heading)}</h1>
${lines.map((l) => `<p style="font-size:14.5px;line-height:1.55;margin:0 0 10px">${esc(l)}</p>`).join("")}
${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="display:inline-block;background:#242142;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:8px">${esc(button.label)}</a></p>` : ""}
</div><p style="font-size:12px;color:#6b6e78;text-align:center;margin-top:14px">${esc(footer || agencyName())}</p></div></body></html>`;
}
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
export async function sendEmail({ to, subject, heading, lines, button, footer, fromName, replyTo, attachments }) {
const m = mailer();
if (!m) return { sent: false, reason: "Email isn't set up (SMTP_HOST in .env)." };
if (!to) return { sent: false, reason: "No recipient." };
try {
await m.sendMail({
from: { name: fromName || agencyName(), address: fromAddress() }, to, subject, replyTo,
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
html: html({ heading, lines, button, footer }),
attachments,
});
return { sent: true };
} catch (e) {
console.error("Email failed:", e.message);
return { sent: false, reason: e.message };
}
}
// ---------- preferences ----------
const DEFAULT_PREFS = { email: true, digest: true };
export function prefsFor(userId) { return { ...DEFAULT_PREFS, ...(readJson(PREFS, {})[userId] || {}) }; }
export function setPrefs(userId, patch) {
const all = readJson(PREFS, {});
all[userId] = { ...prefsFor(userId), ...Object.fromEntries(Object.entries(patch).filter(([k]) => k in DEFAULT_PREFS).map(([k, v]) => [k, Boolean(v)])) };
writeJson(PREFS, all);
return all[userId];
}
// ---------- in-app list ----------
const activeUsers = () => users.listUsers().filter((u) => u.active);
function recipients(to) {
const all = activeUsers();
if (to === "admins") return all.filter((u) => u.role === "admin");
if (to === "all") return all;
const want = new Set((to || []).map((x) => String(x).toLowerCase()));
return all.filter((u) => (want.has("admins") && u.role === "admin") || want.has(u.id) || want.has(String(u.email).toLowerCase()) || want.has(String(u.name || "").toLowerCase()));
}
/**
* Record a notification for some team members and email those who want email.
* to: "admins" | "all" | [user ids / emails / names]. level: info | ok | warn | bad.
*/
export async function notify({ type, title, body = "", link = "", client = null, to = "admins", level = "info", email = true }) {
const people = recipients(to);
if (!people.length) return;
const n = { id: crypto.randomUUID().slice(0, 10), at: new Date().toISOString(), type, title, body, link, client, level, for: people.map((u) => u.id), readBy: [] };
const list = readJson(FILE, []);
list.push(n);
writeJson(FILE, list.slice(-KEEP));
if (!email || !emailConfigured()) return;
for (const u of people) {
if (!prefsFor(u.id).email || !u.email) continue;
await sendEmail({ to: u.email, subject: title, heading: title, lines: body ? [body] : [], button: link ? { label: "Open in Quorla", url: appUrl(link) } : null, fromName: "Quorla" });
}
}
export function listFor(userId, { limit = 50 } = {}) {
const mine = readJson(FILE, []).filter((n) => n.for.includes(userId)).reverse();
return { unread: mine.filter((n) => !n.readBy.includes(userId)).length, items: mine.slice(0, limit).map(({ for: _f, readBy, ...n }) => ({ ...n, read: readBy.includes(userId) })) };
}
export function markRead(userId, ids) {
const list = readJson(FILE, []);
for (const n of list) if (n.for.includes(userId) && (!ids || ids.includes(n.id)) && !n.readBy.includes(userId)) n.readBy.push(userId);
writeJson(FILE, list);
}