web: PRO needs a proven address — email verification gates the studio

A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
This commit is contained in:
2026-09-20 07:39:03 +07:00
parent efe578f61c
commit 52b672deec
16 changed files with 633 additions and 74 deletions
+58
View File
@@ -0,0 +1,58 @@
import nodemailer, { type Transporter } from 'nodemailer';
// The API sends exactly one kind of mail: the link that proves an address. The
// relay is declared in the deployment's .env, because a mail server is
// infrastructure, not a constant.
//
// With no SMTP_HOST there is nothing to connect to, so the link is written to
// the log instead. That keeps a dev box — or this repo's own test suite — able
// to finish a signup without a mail server, and whoever reads the log is
// already the person running the database.
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim();
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_USER = (process.env.SMTP_USER ?? '').trim();
const SMTP_PASS = process.env.SMTP_PASS ?? '';
const SMTP_FROM = (process.env.SMTP_FROM ?? '').trim() || SMTP_USER || 'no-reply@recipescam.local';
// 465 is TLS from the first byte; 587 starts in the clear and upgrades. Only a
// port the operator actually chose should be second-guessed.
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465;
export const mailConfigured = SMTP_HOST !== '';
const transporter: Transporter | null = mailConfigured
? nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
// A relay that never answers must not hold a request open.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
})
: null;
// Both languages, because the account's language is not known before it exists.
const body = (url: string) =>
[
'RecipesCam — xác thực địa chỉ email / verify your email address',
'',
url,
'',
'Liên kết hết hạn sau 24 giờ. Nếu bạn không đăng ký, hãy bỏ qua thư này.',
'The link expires in 24 hours. If you did not sign up, ignore this mail.',
].join('\r\n');
// Fire and forget: the account already exists, so a relay that is slow, out of
// quota or misconfigured may not fail the signup that asked for it. The owner
// can ask for another link from the studio; the operator sees the error here.
export function sendVerificationMail(to: string, url: string, log: (msg: string) => void): void {
if (!transporter) {
log(`[verify] SMTP not configured — verification link for ${to}: ${url}`);
return;
}
transporter
.sendMail({ from: SMTP_FROM, to, subject: 'RecipesCam — verify your email', text: body(url) })
.then(() => log(`[verify] link sent to ${to}`))
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
}