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:
@@ -1,42 +0,0 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
export namespace MailService {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST,
|
||||
pool: true,
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USERNAME,
|
||||
pass: process.env.EMAIL_PASSWORD
|
||||
},
|
||||
tls: {rejectUnauthorized: false}
|
||||
});
|
||||
|
||||
export interface MailAttachment {
|
||||
filename: string;
|
||||
content: string | Buffer;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface SendMailOptions {
|
||||
html?: string;
|
||||
attachments?: MailAttachment[];
|
||||
}
|
||||
|
||||
// Builds a fresh options object per call rather than mutating a shared
|
||||
// module-level one - the transporter is pooled, so overlapping sendMail
|
||||
// calls (e.g. two guests redeeming at once) previously risked one
|
||||
// call's recipient/subject/body being overwritten by another's before
|
||||
// transporter.sendMail() read it.
|
||||
export const sendMail = async (recipientAddress: string, subject: string, body: string, options?: SendMailOptions) => {
|
||||
await transporter.sendMail({
|
||||
from: 'noreply@nachklang.art',
|
||||
to: recipientAddress,
|
||||
subject: subject,
|
||||
text: body,
|
||||
html: options?.html,
|
||||
attachments: options?.attachments
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Shared server-to-server access to the one Nachklang Salesforce org. Both the
|
||||
// newsletter-signup sync (feedback module) and the transactional-email relay
|
||||
// (common.mail) authenticate the same way - OAuth 2.0 client credentials
|
||||
// against the nk_Nachklang_API_Integration external client app - so the token
|
||||
// cache and the retry-once-on-401 live here rather than being duplicated.
|
||||
//
|
||||
// Salesforce's client-credentials token response does not reliably include
|
||||
// expires_in, so the cache lifetime is a conservative guess rather than a
|
||||
// value read from the response - a 401 on the next call just triggers a fresh
|
||||
// fetch (see salesforceApexRestPost).
|
||||
|
||||
const TOKEN_CACHE_MS = 15 * 60 * 1000;
|
||||
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
|
||||
|
||||
export const salesforceEnabled = (): boolean => process.env.SALESFORCE_ENABLED === 'true';
|
||||
|
||||
const readConfig = (): {instanceUrl: string; clientId: string; clientSecret: string} => {
|
||||
const instanceUrl = process.env.SALESFORCE_API_URL;
|
||||
const clientId = process.env.SALESFORCE_CLIENT_ID;
|
||||
const clientSecret = process.env.SALESFORCE_CLIENT_SECRET;
|
||||
if (!instanceUrl || !clientId || !clientSecret) {
|
||||
throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.');
|
||||
}
|
||||
return {instanceUrl, clientId, clientSecret};
|
||||
};
|
||||
|
||||
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
|
||||
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
|
||||
return cachedToken.accessToken;
|
||||
}
|
||||
|
||||
const {instanceUrl, clientId, clientSecret} = readConfig();
|
||||
const res = await axios.post(
|
||||
`${instanceUrl}/services/oauth2/token`,
|
||||
new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(),
|
||||
{headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000}
|
||||
);
|
||||
cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()};
|
||||
return cachedToken.accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* POSTs a JSON body to an Apex REST path (e.g. '/services/apexrest/newsletter/signup')
|
||||
* and returns the parsed response body. Retries once with a forced token
|
||||
* refresh on a 401 - the server-side token may have expired even though our
|
||||
* conservative local TTL has not. All other errors propagate to the caller.
|
||||
*/
|
||||
export const salesforceApexRestPost = async <T>(path: string, body: unknown): Promise<T> => {
|
||||
const {instanceUrl} = readConfig();
|
||||
const url = `${instanceUrl}${path}`;
|
||||
|
||||
try {
|
||||
const token = await getAccessToken(false);
|
||||
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
const token = await getAccessToken(true);
|
||||
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
|
||||
return res.data;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user