Files
Primora/apps/auth/src/lib/mail.ts
T
Tomas DvorakandDevin 92b1810c07 fix(auth): surface sign-up failures, trust serving origin, unbreakable mail probe
Self-hosted sign-up bounced back to the bootstrap screen for three
compounding reasons:

- better-auth's client resolves { data, error } on HTTP failure; the UI
  ignored res.error and showed "Account created — signing you in", hiding
  the real 403/429/500.
- trustedOrigins was a static localhost-only list. Browsing the instance
  via LAN IP or custom port failed every auth POST once cookies existed.
  Origins are now derived from the request's public host (forwarded by
  nginx), so any origin serving the app works.
- hasMailTransport threw when core.settings was unreadable (fresh volume,
  backend still migrating), so sign-up 500'd and the startup backfill
  could crash-loop the service. Both now degrade to "no transport".

Verified live: LAN-style origin sign-up succeeds, missing core.settings
logs a warn instead of exiting, and the frontend shows real errors.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:23:01 +02:00

85 lines
2.7 KiB
TypeScript

import nodemailer from "nodemailer";
import { Resend } from "resend";
import { env } from "./env.js";
import { getStringSetting } from "./settings.js";
// Mail config resolves per send: in-app setting → env var. This keeps
// SMTP/Resend editable from the dashboard without restarting the service.
async function resolveMailConfig() {
const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
if (resendApiKey) {
return { from, resendApiKey } as const;
}
const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
return {
from,
smtp: host
? {
host,
port: Number(portRaw) || 1025,
secure: secureRaw === "true",
auth: user ? { user, pass } : undefined,
}
: undefined,
} as const;
}
// True when a mail transport exists (Resend key or SMTP host). Verification
// and reset mails only make sense when mail can actually leave the box.
// Mail is optional — a failed settings read must degrade to "no transport",
// never break sign-up.
export async function hasMailTransport(): Promise<boolean> {
try {
const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
return "smtp" in cfg && Boolean(cfg.smtp);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
return false;
}
}
export async function sendTransactionalEmail(input: {
to: string;
subject: string;
text: string;
html: string;
}) {
const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) {
const resend = new Resend(cfg.resendApiKey);
await resend.emails.send({
from: cfg.from,
to: [input.to],
subject: input.subject,
text: input.text,
html: input.html,
});
return;
}
if (!("smtp" in cfg) || !cfg.smtp) {
// Self-hosted instances may run without mail — auth still works, the
// email simply never goes out.
console.warn(
JSON.stringify({ level: "warn", msg: "mail_skipped_no_transport", to: input.to, subject: input.subject }),
);
return;
}
await nodemailer.createTransport(cfg.smtp).sendMail({
from: cfg.from,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
}