CI / check (push) Has been cancelled
A self-hosted print studio: images at exact physical sizes, sheet layout with crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery, share links, mail, Telegram — is an optional module that is off by default. This is the neutral public twin of a private tool. Compared to that original: - optional modules behind KLARBILD_MODULES; the default install is print-only - English interface with German as a full second language (1,038 strings); the English sentence is the translation key - no built-in accounts — first boot creates one admin from ADMIN_USERNAME / ADMIN_PASSWORD, or generates a password and logs it once - delivery and backup targets are generic SFTP/FTPS endpoints, off by default - English routes, English identifiers, English comments throughout - two compose files: print-only by default, everything in the full one - AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY 124 tests green, type check clean, verified end to end against a live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
179 lines
7.4 KiB
TypeScript
179 lines
7.4 KiB
TypeScript
// 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<MailSource | null> {
|
|
const s = await one<any>(
|
|
`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<MailCfg | null> {
|
|
return (await loadMailSource())?.cfg ?? null;
|
|
}
|
|
|
|
export const mailReady = async (): Promise<boolean> => !!(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, '>').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) => `<p style="margin:0 0 14px;line-height:1.6">${a}</p>`).join('');
|
|
const btn = button
|
|
? `<p style="margin:22px 0"><a href="${esc(button.url)}" style="display:inline-block;background:#1B3BE0;color:#fff;
|
|
text-decoration:none;padding:12px 20px;border-radius:4px;font-weight:600">${esc(button.text)}</a></p>
|
|
<p style="margin:0 0 14px;font-size:13px;color:#6A685D">If the button does not work:<br>
|
|
<a href="${esc(button.url)}" style="color:#1B3BE0;word-break:break-all">${esc(button.url)}</a></p>`
|
|
: '';
|
|
return `<!doctype html><html lang="en"><body style="margin:0;background:#EAEAE3;padding:24px;
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#16150F;font-size:15px">
|
|
<div style="max-width:560px;margin:0 auto;background:#FBFBF7;border:1px solid #D7D6CC;border-radius:4px;padding:28px">
|
|
<div style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#6A685D;margin-bottom:18px">Klarbild</div>
|
|
<h1 style="font-size:20px;margin:0 0 16px;line-height:1.3">${esc(title)}</h1>
|
|
${p}${btn}
|
|
</div>
|
|
<div style="max-width:560px;margin:14px auto 0;font-size:12px;color:#6A685D;text-align:center">
|
|
This message comes from your own Klarbild installation.
|
|
</div></body></html>`;
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<boolean> {
|
|
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);
|
|
};
|