3ea9e630ed
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:
- "type": "module", module nodenext, target ES2024, .js suffixes on all
relative imports, require('mariadb'|'cors') replaced by imports, and
export= packages (winston, app-root-path, bcrypt) consumed via default
imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
hoisted module mocks with static imports and resetModules + require,
which Jest's ESM mode does not support; vitest keeps them nearly
verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
via vitest-sonar-reporter, so sonar-project.properties is unchanged.
vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
connector option and was silently ignored.
tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
108 lines
3.9 KiB
TypeScript
108 lines
3.9 KiB
TypeScript
import logger from '../middleware/logger.js';
|
|
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client.js';
|
|
|
|
// 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;
|
|
};
|
|
}
|