// Sending email through your own mailbox provider (SMTP). // // Deliberately through the existing outgoing mail server instead of a sending // service: the domain sits there anyway, so SPF and DKIM are right by // themselves, and no further provider comes into play that would get to see the // mails. // // The credentials sit encrypted in `settings` (AES-256-GCM, like the OpenRouter // key and the delivery target password) and **never** in the repository. import nodemailer from 'nodemailer'; import { one } from './db.ts'; import { decrypt } from './crypto.ts'; export interface MailCfg { host: string; port: number; secure: boolean; user: string; password: string; from: string; } export interface MailSource { cfg: MailCfg; source: 'database' | 'environment' } /** * SMTP credentials — from the settings first, otherwise from the environment. * * The same order of precedence as for the OpenRouter key: whatever someone * enters in the admin area wins. The environment variables are the starting * value, so that sending works right after a fresh setup without anyone having * to type credentials in — and so that the password sits **only** there and in * the encrypted column, never in the repository. */ export async function loadMailSource(): Promise { const s = await one( `SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`); if (s?.smtp_host && s?.smtp_user && s?.smtp_password) { try { return { source: 'database', cfg: { host: s.smtp_host, port: Number(s.smtp_port) || 465, secure: s.smtp_secure !== false, user: s.smtp_user, password: decrypt(s.smtp_password), from: s.smtp_from || s.smtp_user, } }; } catch { // Decryption failed (after a change of the ENCRYPTION_KEY, for example) — // then the environment is better than nothing. console.error('[mail] Stored password not readable, falling back to the environment.'); } } const e = process.env; if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) { return { source: 'environment', cfg: { host: e.SMTP_HOST, port: Number(e.SMTP_PORT) || 465, secure: e.SMTP_SECURE !== 'false', user: e.SMTP_USER, password: e.SMTP_PASSWORD, from: e.SMTP_FROM || e.SMTP_USER, } }; } return null; } /** Only the credentials, without where they came from. */ export async function loadMailConfig(): Promise { return (await loadMailSource())?.cfg ?? null; } export const mailReady = async (): Promise => !!(await loadMailConfig()); export interface MailInput { to: string; subject: string; /** Body text. Is sent as the plain-text version as well. */ text: string; /** Optional HTML body; without one the text is simply framed. */ html?: string; } export const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** * A plain, readable HTML frame. No external images, no tracking pixels, no * fonts from someone else's servers — that would be blocked in many mailboxes * anyway and would be impolite towards the recipient. */ export function mailLayout(title: string, paragraphs: string[], button?: { text: string; url: string }): string { const p = paragraphs.map((a) => `

${a}

`).join(''); const btn = button ? `

${esc(button.text)}

If the button does not work:
${esc(button.url)}

` : ''; return `
Klarbild

${esc(title)}

${p}${btn}
This message comes from your own Klarbild installation.
`; } /** * Sends a mail. Throws if no credentials are configured — the callers decide * whether that is an error or only a notice. */ export async function sendMail(m: MailInput): Promise { const cfg = await loadMailConfig(); if (!cfg) throw new Error('No email credentials configured.'); const tx = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.password }, // No endless hanging when the server does not answer. connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000, }); try { await tx.sendMail({ from: cfg.from, to: m.to, subject: m.subject, text: m.text, html: m.html || mailLayout(m.subject, m.text.split('\n\n').map(esc)), }); } finally { tx.close(); } } /** Like `sendMail`, but reports errors only into the log. For notifications. */ export async function trySendMail(m: MailInput): Promise { try { await sendMail(m); return true; } catch (e: any) { console.error('[mail] sending failed:', e?.message || e); return false; } } /** Check the connection without sending anything — for the test button in the admin area. */ export async function testMail(): Promise<{ ok: boolean; message: string }> { const cfg = await loadMailConfig(); if (!cfg) return { ok: false, message: 'No email credentials configured.' }; const tx = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.password }, connectionTimeout: 15000, greetingTimeout: 10000, }); try { await tx.verify(); return { ok: true, message: `Connection to ${cfg.host}:${cfg.port} succeeded.` }; } catch (e: any) { // Provider messages can contain the user name — keep it short. const raw = String(e?.message || e); const short = /auth/i.test(raw) ? 'Sign-in refused — check the user name or the password.' : /timeout|ETIMEDOUT|ECONNREFUSED/i.test(raw) ? 'Server not reachable — check the host and the port.' : /certificate|self.signed/i.test(raw) ? 'Certificate error — check the encryption (port 465 = SSL, 587 = STARTTLS).' : 'Connection failed.'; return { ok: false, message: short }; } finally { tx.close(); } } /** * A rough plausibility check of an address. * * Control characters are checked against the **raw** value, not the trimmed * one: in JavaScript `$` without the `m` flag also matches in front of a * trailing line break, and `trim()` would take it away anyway. The two together * would have let `"a@b.de\n"` through — and that is exactly how every header * injection starts. */ export const isEmail = (v: string): boolean => { const raw = String(v ?? ''); if (/[\r\n\t\0]/.test(raw)) return false; const s = raw.trim(); return !!s && s.length <= 254 && /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s); };