Relay transactional email through Salesforce instead of SMTP (#9)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #9 Co-authored-by: Patrick Müller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Müller <patrick@mueller-patrick.tech>
This commit was merged in pull request #9.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import logger from '../middleware/logger';
|
||||
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client';
|
||||
|
||||
// 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<boolean> => {
|
||||
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<EmailSendResponse>('/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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user