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
+115
View File
@@ -0,0 +1,115 @@
// common.mail relays one email through the Salesforce org (see
// src/common/common.mail.ts). These tests mock the shared Salesforce client so
// no network is touched, and check: the payload shape, base64 attachment
// encoding, the attachment size cap, the retry-once-on-transient-failure
// behaviour, and that a delivery failure is swallowed (returns false, never
// throws).
jest.mock('../../src/common/salesforce.client');
jest.mock('../../src/middleware/logger', () => ({
__esModule: true,
default: {info: jest.fn(), warn: jest.fn(), error: jest.fn()}
}));
import {MailService} from '../../src/common/common.mail';
import {salesforceApexRestPost, salesforceEnabled} from '../../src/common/salesforce.client';
const mockPost = salesforceApexRestPost as jest.Mock;
const mockEnabled = salesforceEnabled as jest.Mock;
const httpError = (status: number, body?: any): any => {
const err: any = new Error('request failed with ' + status);
err.response = {status, data: body};
return err;
};
beforeEach(() => {
jest.clearAllMocks();
mockEnabled.mockReturnValue(true);
mockPost.mockResolvedValue({status: 'SENT'});
});
describe('MailService.sendMail', () => {
it('returns false without a callout when Salesforce is disabled', async () => {
mockEnabled.mockReturnValue(false);
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
it('posts the email to the Apex REST endpoint and returns true on success', async () => {
const result = await MailService.sendMail('guest@example.com', 'Bestätigung', 'Hallo', {html: '<p>Hallo</p>'});
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledWith('/services/apexrest/email/send', {
to: 'guest@example.com',
subject: 'Bestätigung',
textBody: 'Hallo',
htmlBody: '<p>Hallo</p>',
attachments: []
});
});
it('sends htmlBody as null when no HTML is given', async () => {
await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(mockPost).toHaveBeenCalledWith('/services/apexrest/email/send', expect.objectContaining({htmlBody: null}));
});
it('base64-encodes attachments', async () => {
await MailService.sendMail('guest@example.com', 'Hi', 'Hallo', {
attachments: [{filename: 'konzert.ics', content: 'BEGIN:VCALENDAR', contentType: 'text/calendar'}]
});
expect(mockPost).toHaveBeenCalledWith(
'/services/apexrest/email/send',
expect.objectContaining({
attachments: [{
filename: 'konzert.ics',
contentType: 'text/calendar',
contentBase64: Buffer.from('BEGIN:VCALENDAR', 'utf-8').toString('base64')
}]
})
);
});
it('rejects an attachment over the size cap without sending', async () => {
const huge = Buffer.alloc(3 * 1024 * 1024 + 1);
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo', {
attachments: [{filename: 'big.pdf', content: huge}]
});
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
it('retries once on a 5xx and returns false when the retry also fails', async () => {
mockPost.mockRejectedValue(httpError(503));
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).toHaveBeenCalledTimes(2);
});
it('retries once on a network error (no response) then succeeds', async () => {
mockPost.mockRejectedValueOnce(new Error('socket hang up')).mockResolvedValueOnce({status: 'SENT'});
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledTimes(2);
});
it('does not retry on a 4xx (e.g. the 429 limit response) and returns false', async () => {
mockPost.mockRejectedValue(httpError(429, {errorCode: 'LIMIT_REACHED'}));
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).toHaveBeenCalledTimes(1);
});
});
+124
View File
@@ -0,0 +1,124 @@
// salesforce.client caches the OAuth token at module scope, so every test
// resets the module registry for a clean cache and re-requires axios + the
// module under test after the reset (same approach as
// test/feedback/salesforce.service.test.ts).
export {}; // isolate module scope from other script-style test files
jest.mock('axios');
const freshImports = () => {
const axios = require('axios');
const {salesforceApexRestPost, salesforceEnabled} = require('../../src/common/salesforce.client');
return {axios, salesforceApexRestPost, salesforceEnabled};
};
const ORIGINAL_ENV = {...process.env};
const ENABLED_ENV = {
...ORIGINAL_ENV,
SALESFORCE_ENABLED: 'true',
SALESFORCE_API_URL: 'https://example.my.salesforce.com',
SALESFORCE_CLIENT_ID: 'client-id',
SALESFORCE_CLIENT_SECRET: 'client-secret'
};
beforeEach(() => {
jest.resetModules();
process.env = {...ENABLED_ENV};
});
afterAll(() => {
process.env = {...ORIGINAL_ENV};
});
describe('salesforceEnabled', () => {
it('is true only when SALESFORCE_ENABLED === "true"', () => {
process.env.SALESFORCE_ENABLED = 'true';
expect(freshImports().salesforceEnabled()).toBe(true);
jest.resetModules();
process.env.SALESFORCE_ENABLED = 'false';
expect(freshImports().salesforceEnabled()).toBe(false);
});
});
describe('salesforceApexRestPost', () => {
it('fetches a token, posts to the given Apex REST path, and returns the response body', async () => {
const {axios, salesforceApexRestPost} = freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {ok: true}});
});
const result = await salesforceApexRestPost('/services/apexrest/email/send', {to: 'x@example.com'});
expect(result).toEqual({ok: true});
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/oauth2/token',
expect.any(String),
expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}})
);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/apexrest/email/send',
{to: 'x@example.com'},
expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}})
);
});
it('reuses the cached token across calls instead of fetching twice', async () => {
const {axios, salesforceApexRestPost} = freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {}});
});
await salesforceApexRestPost('/services/apexrest/email/send', {});
await salesforceApexRestPost('/services/apexrest/email/send', {});
const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token'));
expect(tokenCalls).toHaveLength(1);
});
it('retries once with a fresh token on a 401, then succeeds', async () => {
const {axios, salesforceApexRestPost} = freshImports();
let tokenFetches = 0;
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) {
tokenFetches += 1;
return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}});
}
if (tokenFetches === 1) {
const err: any = new Error('Unauthorized');
err.response = {status: 401};
return Promise.reject(err);
}
return Promise.resolve({data: {ok: true}});
});
const result = await salesforceApexRestPost('/services/apexrest/email/send', {});
expect(result).toEqual({ok: true});
expect(tokenFetches).toBe(2);
});
it('does not retry on a non-401 error and rethrows it', async () => {
const {axios, salesforceApexRestPost} = freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
const err: any = new Error('Server error');
err.response = {status: 500};
return Promise.reject(err);
});
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('Server error');
const endpointCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/email/send'));
expect(endpointCalls).toHaveLength(1);
});
it('throws a clear error when client credentials are not configured', async () => {
process.env.SALESFORCE_CLIENT_ID = '';
const {salesforceApexRestPost} = freshImports();
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('SALESFORCE_CLIENT_ID');
});
});