Relay transactional email through Salesforce instead of SMTP
Our SMTP host's IP reputation gets its mail blocked by allowlist-based receivers (t-online.de). Route voucher confirmations, account activation and password-reset mail through the Salesforce org's MTA + DKIM instead, via a new EmailSendResource Apex REST endpoint. - common/salesforce.client.ts: shared client-credentials access (token cache + retry-once-on-401), extracted from the newsletter sync so it is no longer duplicated - common.mail.nodemailer.ts -> common.mail.ts: posts to the org endpoint, never throws on a delivery failure (logs + returns a boolean), retries once on a transient failure, base64-encodes attachments with a 3 MB cap - drop the nodemailer dependency - fixes activation/reset email failures that previously threw after the transaction had already committed - tickets.confirmation-email.ts: shared confirmation-email builder used by both the public redeem path and a new admin resend action - redemptions gain a confirmation_email_status column (migration 003) so the admin UI can flag a failed send; POST /tickets/admin/redemptions/:id/resend-confirmation rebuilds and resends Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
import logger from '../../../middleware/logger';
|
||||
import {salesforceApexRestPost} from '../../../common/salesforce.client';
|
||||
|
||||
// Newsletter opt-ins sync to Salesforce, which already runs a full
|
||||
// double-opt-in subscription flow (Person Account for existing constituents,
|
||||
@@ -9,7 +9,8 @@ import logger from '../../../middleware/logger';
|
||||
// This is the one file that knows that contract exists; submissions.service.ts
|
||||
// only ever calls syncNewsletterSignup(signupId) after its own transaction
|
||||
// commits, fire-and-forget, so a Salesforce outage can never delay or fail a
|
||||
// visitor's feedback submission.
|
||||
// visitor's feedback submission. The OAuth token cache and retry-once-on-401
|
||||
// live in common/salesforce.client.ts, shared with the transactional-email relay.
|
||||
|
||||
interface SalesforceSuccessResponse {
|
||||
status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED';
|
||||
@@ -26,54 +27,8 @@ interface NewsletterSignupRow {
|
||||
event_name: string;
|
||||
}
|
||||
|
||||
// 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 the retry-once logic in postSignup).
|
||||
const TOKEN_CACHE_MS = 15 * 60 * 1000;
|
||||
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
|
||||
|
||||
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
|
||||
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
|
||||
return cachedToken.accessToken;
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const postSignup = async (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> => {
|
||||
const instanceUrl = process.env.SALESFORCE_API_URL;
|
||||
const url = `${instanceUrl}/services/apexrest/newsletter/signup`;
|
||||
|
||||
try {
|
||||
const token = await getAccessToken(false);
|
||||
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
// The cached token may have expired server-side even though our
|
||||
// conservative local TTL hasn't - retry once with a forced refresh
|
||||
// before treating this as a real failure.
|
||||
if (err?.response?.status === 401) {
|
||||
const token = await getAccessToken(true);
|
||||
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
|
||||
return res.data;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const postSignup = (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> =>
|
||||
salesforceApexRestPost<SalesforceSuccessResponse>('/services/apexrest/newsletter/signup', payload);
|
||||
|
||||
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
|
||||
Reference in New Issue
Block a user