import logger from '../middleware/logger.js'; import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client.js'; // Transactional email for the ticketing/calendar flows (voucher redemption // confirmations, account activation links, password-reset tokens) is relayed // through the Nachklang Salesforce org rather than sent over our own SMTP host: // that host's IP reputation gets it blocked by allowlist-based receivers // (notably t-online.de). Salesforce's MTA plus the org's DKIM signature for // nachklang.art get the mail delivered. The org endpoint is EmailSendResource // (POST /services/apexrest/email/send); the From address is fixed server-side // there and is never sent from here. // // sendMail never throws on a delivery problem. Every caller has already // committed its own work (a registration, a password-reset token, a // redemption) by the time mail goes out, so a mail failure must not surface as // a user-facing error. It returns whether the mail was accepted so the one // caller that shows failures to staff (the voucher confirmation) can record it. export namespace MailService { export interface MailAttachment { filename: string; content: string | Buffer; contentType?: string; } export interface SendMailOptions { html?: string; attachments?: MailAttachment[]; } interface EmailSendResponse { status: 'SENT'; } // Practical ceiling, well under Apex REST's 6 MB request-body limit once // base64 inflation (~33%) is accounted for. The only attachment today is a // ~1 KB .ics file. const MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024; const isRetriable = (err: any): boolean => { const status = err?.response?.status; if (status !== undefined) { return status >= 500; } // No response at all - network error or timeout. return true; }; /** * Relays one email through the Salesforce org. Retries once on a transient * failure (5xx / network / timeout), then logs and returns false rather * than throwing. Returns false immediately (without a callout) when the * Salesforce integration is disabled. */ export const sendMail = async ( recipientAddress: string, subject: string, body: string, options?: SendMailOptions ): Promise => { if (!salesforceEnabled()) { logger.info('MailService: SALESFORCE_ENABLED is false, would have sent', {recipientAddress, subject}); return false; } let attachments: {filename: string; contentType?: string; contentBase64: string}[]; try { attachments = (options?.attachments ?? []).map(attachment => { const buffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content, 'utf-8'); if (buffer.byteLength > MAX_ATTACHMENT_BYTES) { throw new Error(`attachment ${attachment.filename} is ${buffer.byteLength} bytes, over the ${MAX_ATTACHMENT_BYTES} limit`); } return {filename: attachment.filename, contentType: attachment.contentType, contentBase64: buffer.toString('base64')}; }); } catch (err: any) { logger.error('MailService: could not prepare attachments', {recipientAddress, subject, detail: err?.message}); return false; } const payload = { to: recipientAddress, subject, textBody: body, htmlBody: options?.html ?? null, attachments }; for (let attempt = 1; attempt <= 2; attempt++) { try { await salesforceApexRestPost('/services/apexrest/email/send', payload); return true; } catch (err: any) { const status = err?.response?.status; const detail = err?.response?.data?.errorCode || err?.response?.data?.message || err?.message || 'unknown error'; if (attempt === 1 && isRetriable(err)) { logger.warn('MailService: send failed, retrying once', {recipientAddress, subject, status, detail}); continue; } logger.error('MailService: send failed', {recipientAddress, subject, status, detail}); return false; } } return false; }; }