Files
NextChair/manager/src/lib/test-email.ts
T
Stefan Lazic 17eee5b932
CI / ci (push) Canceled after 0s
Updates 1
The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
2026-10-17 22:29:00 +01:00

126 lines
4.6 KiB
TypeScript

import nodemailer from "nodemailer";
import type { EmailEncryptionMode } from "./global-settings";
/**
* A small, standalone nodemailer call — Manager and the main app are
* separate deployable processes/images that share no code today (see
* nextchair-manager's own package.json description); duplicating this
* ~30-line transport setup is simpler than inventing a cross-package
* shared module for one function, and mirrors the main app's own
* src/services/notifications/providers/brevo-email-provider.ts closely
* enough that a future shared package (if the two apps ever grow more
* code in common) would be a mechanical extraction, not a rewrite.
*/
export interface TestEmailConfig {
host: string;
port: number;
username: string;
password: string;
encryption: EmailEncryptionMode;
fromAddress: string;
fromName: string;
replyTo: string;
recipient: string;
}
export interface TestEmailResult {
ok: boolean;
message: string;
}
/**
* Never rethrows the raw SMTP error to the caller — nodemailer/SMTP error
* messages can echo back the configured username or other connection
* details, and this result is ultimately shown in the browser. Categorized
* instead: auth failure, connection failure, or a generic fallback.
*/
export async function sendTestSmtpEmail(config: TestEmailConfig): Promise<TestEmailResult> {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.encryption === "tls",
ignoreTLS: config.encryption === "none",
auth: { user: config.username, pass: config.password },
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
try {
await transporter.verify();
await transporter.sendMail({
from: config.fromName ? `"${config.fromName}" <${config.fromAddress}>` : config.fromAddress,
to: config.recipient,
replyTo: config.replyTo || undefined,
subject: "NextChair test email",
text: "This is a test email from your NextChair Manager Settings page — if you received this, your SMTP configuration is working.",
html: "<p>This is a test email from your NextChair Manager Settings page — if you received this, your SMTP configuration is working.</p>",
});
return { ok: true, message: `Test email sent to ${config.recipient}.` };
} catch (error) {
return { ok: false, message: categorizeSmtpError(error) };
} finally {
transporter.close();
}
}
export interface PlainEmailConfig extends Omit<TestEmailConfig, "recipient"> {
recipient: string;
subject: string;
text: string;
}
/**
* Same transport + error-categorising contract as sendTestSmtpEmail, for
* the operator alert emails (health / backup failures). Plain text only;
* never throws and never echoes raw SMTP errors.
*/
export async function sendPlainSmtpEmail(config: PlainEmailConfig): Promise<TestEmailResult> {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.encryption === "tls",
ignoreTLS: config.encryption === "none",
auth: { user: config.username, pass: config.password },
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
try {
await transporter.sendMail({
from: config.fromName ? `"${config.fromName}" <${config.fromAddress}>` : config.fromAddress,
to: config.recipient,
replyTo: config.replyTo || undefined,
subject: config.subject,
text: config.text,
});
return { ok: true, message: `Sent to ${config.recipient}.` };
} catch (error) {
return { ok: false, message: categorizeSmtpError(error) };
} finally {
transporter.close();
}
}
function categorizeSmtpError(error: unknown): string {
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : "";
const responseCode =
typeof error === "object" && error !== null && "responseCode" in error
? Number((error as { responseCode?: unknown }).responseCode)
: undefined;
if (code === "EAUTH" || responseCode === 535) {
return "Authentication failed — check the SMTP username and password.";
}
if (code === "ECONNECTION" || code === "ESOCKET" || code === "ECONNREFUSED") {
return "Couldn't connect to the SMTP host — check the host, port, and encryption mode.";
}
if (code === "ETIMEDOUT") {
return "Connection to the SMTP host timed out.";
}
if (code === "EENVELOPE") {
return "The mail server rejected the sender or recipient address.";
}
return "Couldn't send the test email — check your SMTP settings and try again.";
}