Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500. Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
3.2 KiB
TypeScript
122 lines
3.2 KiB
TypeScript
/**
|
|
* Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset.
|
|
* Its two callers, the welcome email at sign-up and the password reset link,
|
|
* check {@link isEmailEnabled} first and don't wait for delivery.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
|
|
|
|
import { SMTPS_URL } from "../config.ts";
|
|
|
|
/**
|
|
* One address, a comma-separated list of them, or an array, as nodemailer
|
|
* takes them.
|
|
*/
|
|
export type EmailRecipient = string | string[];
|
|
|
|
/**
|
|
* Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or
|
|
* HTML body; the callers send the same Markdown as both, rendered for `html`.
|
|
*/
|
|
export interface EmailMessage {
|
|
from: string;
|
|
to: EmailRecipient;
|
|
subject: string;
|
|
text?: string;
|
|
html?: string;
|
|
cc?: EmailRecipient;
|
|
bcc?: EmailRecipient;
|
|
replyTo?: string;
|
|
}
|
|
|
|
/**
|
|
* Returned by {@link sendEmail} once the SMTP server has taken the message.
|
|
* No caller reads it yet.
|
|
*/
|
|
export interface EmailSendResult {
|
|
messageId: string;
|
|
accepted: string[];
|
|
rejected: string[];
|
|
}
|
|
|
|
let transporter: Transporter | null = null;
|
|
|
|
/**
|
|
* Checks whether an SMTP server is configured. Without one, sign-up sends no
|
|
* welcome email and a password reset request does nothing.
|
|
*/
|
|
export function isEmailEnabled(): boolean {
|
|
return SMTPS_URL.length > 0;
|
|
}
|
|
|
|
/** Flattens the recipient forms nodemailer takes and returns into addresses. */
|
|
function normalizeRecipients(
|
|
recipient: string | readonly (string | { address?: string })[] | undefined,
|
|
): string[] {
|
|
if (!recipient) return [];
|
|
if (typeof recipient === "string") {
|
|
return recipient.split(",").map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
return recipient.flatMap((entry) =>
|
|
typeof entry === "string" ? entry.trim() : (entry.address?.trim() ?? "")
|
|
).filter(Boolean);
|
|
}
|
|
|
|
function getTransporter(): Transporter {
|
|
if (!isEmailEnabled()) {
|
|
throw new Error(
|
|
"GERBEUR_SMTPS_URL environment variable is required to send emails.",
|
|
);
|
|
}
|
|
|
|
if (!transporter) {
|
|
transporter = nodemailer.createTransport(SMTPS_URL);
|
|
}
|
|
|
|
return transporter;
|
|
}
|
|
|
|
/**
|
|
* Sends `message` and resolves once the SMTP server has accepted it. The
|
|
* transport is created on the first send.
|
|
*
|
|
* @throws {Error} When email is disabled, the message lacks a recipient,
|
|
* subject or body, or the server refuses it.
|
|
*/
|
|
export async function sendEmail(
|
|
message: EmailMessage,
|
|
): Promise<EmailSendResult> {
|
|
if (normalizeRecipients(message.to).length === 0) {
|
|
throw new Error("Email recipient is required.");
|
|
}
|
|
|
|
if (!message.subject.trim()) {
|
|
throw new Error("Email subject is required.");
|
|
}
|
|
|
|
if (!message.text?.trim() && !message.html?.trim()) {
|
|
throw new Error("Email body is required (text and/or html).");
|
|
}
|
|
|
|
const options: SendMailOptions = {
|
|
from: message.from,
|
|
to: message.to,
|
|
cc: message.cc,
|
|
bcc: message.bcc,
|
|
subject: message.subject,
|
|
text: message.text,
|
|
html: message.html,
|
|
replyTo: message.replyTo,
|
|
};
|
|
|
|
const info = await getTransporter().sendMail(options);
|
|
|
|
return {
|
|
messageId: info.messageId,
|
|
accepted: normalizeRecipients(info.accepted),
|
|
rejected: normalizeRecipients(info.rejected),
|
|
};
|
|
}
|