Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so only one enterprise application is needed. The app additionally gets the SMTP.SendAsApp permission; Exchange still limits it to the mailbox granted via Add-MailboxPermission. Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT, SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The transport is fixed to smtp.office365.com:587 with required STARTTLS. SMTP_USER (sending mailbox) and SMTP_FROM remain. - lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail - diagnosis drops basic-auth-only causes; hints name the ENTRA_* values and warn that an expired secret breaks sign-in and mail together - admin e-mail test, .env.example, docker-compose, k8s manifests, preflight and README updated Verified against Entra ID with fake values: token error classified, client secret never exposed, missing ENTRA_CLIENT_SECRET reported. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
213 lines
8.6 KiB
TypeScript
213 lines
8.6 KiB
TypeScript
import nodemailer, { type Transporter } from "nodemailer";
|
|
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
|
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
|
|
import { createMs365TokenProvider, type Ms365TokenProvider } from "@/lib/ms365-oauth";
|
|
import { readEntraAppCredentials, type EntraAppCredentials } from "@/lib/entra";
|
|
|
|
export interface MailAttachment {
|
|
filename: string;
|
|
content: Buffer;
|
|
contentType: string;
|
|
}
|
|
|
|
interface SendMailInput {
|
|
to: string;
|
|
subject: string;
|
|
html: string;
|
|
text: string;
|
|
/**
|
|
* Optionale Anhaenge, aktuell die Rechnung zur Bestellbestaetigung.
|
|
* Bewusst als Buffer und nicht als Pfad: der Aufrufer hat das Dokument
|
|
* ohnehin schon im Speicher, und so haengt der Mailversand nicht daran,
|
|
* dass das Ablageverzeichnis gerade erreichbar ist.
|
|
*/
|
|
attachments?: MailAttachment[];
|
|
}
|
|
|
|
/**
|
|
* Versand ausschliesslich ueber Microsoft 365 mit OAuth 2.0 (SMTP XOAUTH2,
|
|
* Client-Credentials-Flow). Als App dient dieselbe App-Registrierung wie fuer
|
|
* "Mit Microsoft anmelden" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID,
|
|
* ENTRA_CLIENT_SECRET) — eine Unternehmensanwendung statt zwei. Eine
|
|
* Anmeldung per Passwort (Basic Auth) gibt es bewusst nicht mehr: kein
|
|
* Benutzerpasswort im Cluster, kein Konflikt mit MFA/Sicherheitsstandards,
|
|
* nicht betroffen vom Ende der Basic Auth bei Exchange Online.
|
|
*
|
|
* SMTP_USER ist das Postfach, mit dem gesendet wird; Exchange erlaubt der App
|
|
* nur die Postfaecher, die ihr per Add-MailboxPermission freigegeben wurden.
|
|
*/
|
|
interface SmtpConfig {
|
|
user: string;
|
|
app: EntraAppCredentials;
|
|
}
|
|
|
|
const MS365_SMTP_HOST = "smtp.office365.com";
|
|
/** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */
|
|
const MS365_SMTP_PORT = 587;
|
|
const TEST_TIMEOUT_MS = 15_000;
|
|
|
|
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
|
|
|
|
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
|
|
function maskEmail(email: string): string {
|
|
const [local, domain] = email.split("@");
|
|
if (!domain) return "***";
|
|
return `${local.slice(0, 2)}***@${domain}`;
|
|
}
|
|
|
|
/** Namen der Variablen, die fuer den Versand noch fehlen — leer, wenn alles da ist. */
|
|
function missingSmtpSettings(): string[] {
|
|
const missing: string[] = [];
|
|
if (!process.env.SMTP_USER?.trim()) missing.push("SMTP_USER");
|
|
if (!process.env.ENTRA_TENANT_ID?.trim()) missing.push("ENTRA_TENANT_ID");
|
|
if (!process.env.ENTRA_CLIENT_ID?.trim()) missing.push("ENTRA_CLIENT_ID");
|
|
if (!process.env.ENTRA_CLIENT_SECRET?.trim()) missing.push("ENTRA_CLIENT_SECRET");
|
|
return missing;
|
|
}
|
|
|
|
function readSmtpConfig(): SmtpConfig | null {
|
|
const user = process.env.SMTP_USER?.trim() ?? "";
|
|
const app = readEntraAppCredentials();
|
|
return user && app ? { user, app } : null;
|
|
}
|
|
|
|
function getFromAddress(): string {
|
|
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
|
}
|
|
|
|
// Ein Token-Provider pro Prozess: das Token (ca. 60 Minuten gueltig) wird
|
|
// ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt.
|
|
let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null;
|
|
|
|
function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider {
|
|
const key = `${app.tenantId}|${app.clientId}|${app.clientSecret}`;
|
|
if (!tokenProvider || tokenProvider.key !== key) {
|
|
tokenProvider = { key, provider: createMs365TokenProvider(app) };
|
|
}
|
|
return tokenProvider.provider;
|
|
}
|
|
|
|
function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter {
|
|
const options: SMTPTransport.Options = {
|
|
host: MS365_SMTP_HOST,
|
|
port: MS365_SMTP_PORT,
|
|
secure: false,
|
|
// Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token
|
|
// gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne
|
|
// STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen.
|
|
requireTLS: true,
|
|
auth: {
|
|
type: "OAuth2",
|
|
user: config.user,
|
|
// nodemailer fragt hier nach einem Token; renew=true nach einer
|
|
// Ablehnung durch den Server → frisches Token statt des gecachten.
|
|
// expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen mit
|
|
// Ablaufreserve uebernimmt der Token-Provider.
|
|
provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => {
|
|
getTokenProvider(config.app)
|
|
.getToken(renew)
|
|
.then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0));
|
|
},
|
|
},
|
|
...extra,
|
|
};
|
|
return nodemailer.createTransport(options);
|
|
}
|
|
|
|
let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null;
|
|
// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in
|
|
// Produktion fehlendes Setup im Server-Log sichtbar, statt dass
|
|
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
|
|
let warnedAboutMissingSmtpInProduction = false;
|
|
|
|
function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | null {
|
|
const config = readSmtpConfig();
|
|
if (!config) {
|
|
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
|
|
warnedAboutMissingSmtpInProduction = true;
|
|
console.warn(
|
|
`[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) };
|
|
return cachedTransport;
|
|
}
|
|
|
|
/**
|
|
* Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die
|
|
* Mail NICHT gesendet, sondern nur geloggt — damit lokale Entwicklung und
|
|
* Deployments ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen
|
|
* Fehler: ein Mail-Ausfall darf eine Bestellung oder Registrierung nicht
|
|
* verhindern.
|
|
*
|
|
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
|
|
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
|
|
* sichtbarer Rueckmeldung ("Link erneut gesendet", "Kundschaft informiert")
|
|
* duerfen Erfolg nur melden, wenn hier true zurueckkam — sonst wartet die
|
|
* Kundschaft auf eine Mail, die nie kommt.
|
|
*/
|
|
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
|
const active = getTransport();
|
|
if (!active) {
|
|
console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await active.transport.sendMail({
|
|
from: getFromAddress(),
|
|
to: input.to,
|
|
subject: input.subject,
|
|
html: input.html,
|
|
text: input.text,
|
|
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
|
});
|
|
return true;
|
|
} catch (error: unknown) {
|
|
const diagnosis = explainSmtpError(error, { secrets: [active.config.app.clientSecret] });
|
|
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
interface SmtpTestBase {
|
|
host: string;
|
|
user: string;
|
|
from: string;
|
|
}
|
|
|
|
export type SmtpTestResult =
|
|
| ({ ok: true } & SmtpTestBase)
|
|
| { ok: false; configured: false; missing: string[] }
|
|
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase);
|
|
|
|
/**
|
|
* Prueft den Mailversand im laufenden Betrieb: Token, Verbindung, Anmeldung
|
|
* (verify) und danach ein echter Versand an `to` — erst der Versand zeigt,
|
|
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut
|
|
* bewusst einen frischen Transport statt des zwischengespeicherten. Das
|
|
* Client-Secret erscheint nie im Ergebnis.
|
|
*/
|
|
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
|
|
const config = readSmtpConfig();
|
|
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() };
|
|
const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() };
|
|
const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
|
|
try {
|
|
await transport.verify();
|
|
await transport.sendMail({
|
|
from: base.from,
|
|
to,
|
|
subject: "Wabenhain: Test des E-Mail-Versands",
|
|
text: `Diese Test-Mail bestaetigt, dass der Shop ueber Microsoft 365 (OAuth 2.0) als ${config.user} mit dem Absender ${base.from} senden kann.`,
|
|
});
|
|
return { ok: true, ...base };
|
|
} catch (error: unknown) {
|
|
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) };
|
|
} finally {
|
|
transport.close();
|
|
}
|
|
}
|