Relay transactional email through Salesforce instead of SMTP (#9)
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:
2026-08-31 16:17:59 +00:00
committed by Patrick Müller
parent 3c4f3331d8
commit 449edd6c68
16 changed files with 819 additions and 141 deletions
+66
View File
@@ -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;
}
};