mailer.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import net from 'node:net';
  2. import tls from 'node:tls';
  3. import crypto from 'node:crypto';
  4. import { extractQueueIdFromSmtpResponse } from './delivery-tracker.js';
  5. import { signDkim } from './dkim.js';
  6. export function parseAddressList(value) {
  7. return String(value || '')
  8. .split(',')
  9. .map((item) => item.trim())
  10. .filter(Boolean)
  11. .map(extractAddress)
  12. .filter(Boolean);
  13. }
  14. export function extractAddress(value) {
  15. const match = String(value || '').match(/<([^<>@\s]+@[^<>@\s]+)>/);
  16. const address = match ? match[1] : String(value || '').trim();
  17. if (!/^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$/.test(address)) return '';
  18. return address.toLowerCase();
  19. }
  20. export function domainFromAddress(value) {
  21. const address = extractAddress(value);
  22. return address.split('@')[1] || '';
  23. }
  24. export function buildMessage({ from, to, subject, text, html, baseUrl, headers: extraHeaders = [] }) {
  25. const recipients = Array.isArray(to) ? to : parseAddressList(to);
  26. if (!recipients.length) throw new Error('At least one recipient is required.');
  27. const messageIdHost = domainFromAddress(from) || 'localhost';
  28. const messageId = `<${crypto.randomUUID()}@${messageIdHost}>`;
  29. const commonHeaders = [
  30. ['From', sanitizeHeader(from)],
  31. ['To', recipients.join(', ')],
  32. ['Subject', encodeHeader(subject || '(no subject)')],
  33. ['Date', new Date().toUTCString()],
  34. ['Message-ID', messageId],
  35. ['MIME-Version', '1.0'],
  36. ...normalizeExtraHeaders(extraHeaders),
  37. ['X-MailHub', baseUrl || 'mailhub']
  38. ];
  39. if (html) {
  40. const boundary = `mailhub-${crypto.randomBytes(12).toString('hex')}`;
  41. const headers = [
  42. ...commonHeaders,
  43. ['Content-Type', `multipart/alternative; boundary="${boundary}"`]
  44. ];
  45. const body = [
  46. `--${boundary}`,
  47. 'Content-Type: text/plain; charset=UTF-8',
  48. 'Content-Transfer-Encoding: base64',
  49. '',
  50. encodeBase64Body(text || stripHtml(html)),
  51. `--${boundary}`,
  52. 'Content-Type: text/html; charset=UTF-8',
  53. 'Content-Transfer-Encoding: base64',
  54. '',
  55. encodeBase64Body(html),
  56. `--${boundary}--`,
  57. ''
  58. ].join('\r\n');
  59. return `${formatHeaders(headers)}\r\n\r\n${body}`;
  60. }
  61. const headers = [
  62. ...commonHeaders,
  63. ['Content-Type', 'text/plain; charset=UTF-8'],
  64. ['Content-Transfer-Encoding', 'base64']
  65. ];
  66. return `${formatHeaders(headers)}\r\n\r\n${encodeBase64Body(text || '')}\r\n`;
  67. }
  68. export function signMessageForDomain(rawMessage, domain) {
  69. if (!domain?.dkimPrivate || !domain?.selector) return rawMessage;
  70. return signDkim(rawMessage, {
  71. domain: domain.domain,
  72. selector: domain.selector,
  73. privateKey: domain.dkimPrivate,
  74. identity: `@${domain.domain}`
  75. });
  76. }
  77. export function buildDeliverabilityHeaders({
  78. from = '',
  79. listUnsubscribeMailto = '',
  80. listUnsubscribeUrl = '',
  81. listUnsubscribePostEnabled = false,
  82. feedbackId = '',
  83. reportAbuseTo = '',
  84. csaComplaintsTo = '',
  85. context = {}
  86. } = {}) {
  87. const headers = [];
  88. const unsubscribeLinks = [];
  89. const mailto = normalizeMailtoListUnsubscribe(listUnsubscribeMailto);
  90. const url = normalizeHttpListUnsubscribe(renderDeliverabilityTemplate(listUnsubscribeUrl, context));
  91. if (mailto) unsubscribeLinks.push(`<${mailto}>`);
  92. if (url) unsubscribeLinks.push(`<${url}>`);
  93. if (unsubscribeLinks.length) {
  94. headers.push(['List-Unsubscribe', unsubscribeLinks.join(', ')]);
  95. if (listUnsubscribePostEnabled && isHttpsUrl(url)) {
  96. headers.push(['List-Unsubscribe-Post', 'List-Unsubscribe=One-Click']);
  97. }
  98. }
  99. const normalizedFeedbackId = normalizeFeedbackId(feedbackId);
  100. if (normalizedFeedbackId) headers.push(['Feedback-Id', normalizedFeedbackId]);
  101. const abuse = sanitizeHeader(reportAbuseTo);
  102. if (abuse) headers.push(['X-Report-Abuse-To', abuse]);
  103. const complaints = sanitizeHeader(csaComplaintsTo);
  104. if (complaints) headers.push(['X-CSA-Complaints', complaints]);
  105. const sender = extractAddress(from);
  106. if (sender) headers.push(['X-Sender', sender]);
  107. return headers;
  108. }
  109. export function addHeadersToRawMessage(rawMessage, headers = []) {
  110. const extraHeaders = normalizeExtraHeaders(headers);
  111. if (!extraHeaders.length) return rawMessage;
  112. const normalized = normalizeMimeMessage(rawMessage);
  113. const separator = normalized.indexOf('\r\n\r\n');
  114. if (separator === -1) return `${formatHeaders(extraHeaders)}\r\n\r\n${normalized}`;
  115. const headerBlock = normalized.slice(0, separator);
  116. const body = normalized.slice(separator + 4);
  117. const existingNames = new Set(parseHeaderNames(headerBlock));
  118. const missingHeaders = extraHeaders.filter(([name]) => !existingNames.has(name.toLowerCase()));
  119. if (!missingHeaders.length) return normalized;
  120. return `${headerBlock}\r\n${formatHeaders(missingHeaders)}\r\n\r\n${body}`;
  121. }
  122. export function createFeedbackId({ userId, domainId, eventId, secret, product = 'MailHub' } = {}) {
  123. const key = String(secret || 'mailhub-feedback');
  124. const safeProduct = normalizeFeedbackId(product) || 'MailHub';
  125. return [
  126. 'mh',
  127. feedbackPart('user', userId, key),
  128. feedbackPart('domain', domainId, key),
  129. `${feedbackPart('event', eventId, key)}:${safeProduct}`
  130. ].join('.');
  131. }
  132. export function resolveEnvelopeSender(settings = {}, fallback = '') {
  133. if (!settings.bounceEnvelopeEnabled) return fallback;
  134. return extractAddress(settings.bounceAddress) || fallback;
  135. }
  136. export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
  137. const deliveryLog = [];
  138. const addLog = (entry) => deliveryLog.push({ at: new Date().toISOString(), ...entry });
  139. if (!host) {
  140. const error = new Error('SMTP_HOST is not configured.');
  141. error.deliveryLog = deliveryLog;
  142. throw error;
  143. }
  144. let client;
  145. try {
  146. addLog({
  147. phase: 'connect',
  148. direction: 'system',
  149. message: `Connecting to ${host}:${Number(port || 25)}${secure ? ' with TLS' : ''}`
  150. });
  151. client = await SmtpClient.connect({ host, port, secure });
  152. addLog({
  153. phase: 'connect',
  154. direction: 'system',
  155. message: `Connected to ${host}:${Number(port || 25)}`,
  156. ok: true
  157. });
  158. await expectResponse(client, [220], 'connect', addLog);
  159. let response = await runCommand(client, `EHLO ${helo || 'mailhub.local'}`, [250, 502, 500], 'smtp', addLog);
  160. if (![250].includes(response.code)) {
  161. await runCommand(client, `HELO ${helo || 'mailhub.local'}`, [250], 'smtp', addLog);
  162. }
  163. if (username || password) {
  164. const auth = Buffer.from(`\u0000${username || ''}\u0000${password || ''}`).toString('base64');
  165. await runCommand(client, `AUTH PLAIN ${auth}`, [235], 'auth', addLog);
  166. }
  167. await runCommand(client, `MAIL FROM:<${extractAddress(mailFrom)}>`, [250], 'envelope', addLog);
  168. for (const recipient of recipients) {
  169. await runCommand(client, `RCPT TO:<${recipient}>`, [250, 251], 'envelope', addLog);
  170. }
  171. await runCommand(client, 'DATA', [354], 'data', addLog);
  172. addLog({
  173. phase: 'data',
  174. direction: 'client',
  175. message: 'Message content transmitted',
  176. messageBytes: Buffer.isBuffer(rawMessage)
  177. ? rawMessage.length
  178. : Buffer.byteLength(rawMessage || '', 'utf8'),
  179. ok: true
  180. });
  181. await client.writeData(dotStuff(rawMessage));
  182. const dataResponse = await expectResponse(client, [250], 'queue', addLog);
  183. await runCommand(client, 'QUIT', [221], 'quit', addLog).catch((error) => {
  184. addLog({
  185. phase: 'quit',
  186. direction: 'system',
  187. message: error.message,
  188. ok: false
  189. });
  190. });
  191. return {
  192. ...dataResponse,
  193. queueId: extractQueueIdFromSmtpResponse(dataResponse.message),
  194. deliveryLog
  195. };
  196. } catch (error) {
  197. addLog({
  198. phase: 'error',
  199. direction: 'system',
  200. message: error.message,
  201. ok: false
  202. });
  203. error.deliveryLog = deliveryLog;
  204. throw error;
  205. } finally {
  206. client?.close();
  207. }
  208. }
  209. async function runCommand(client, command, expectedCodes, phase, addLog) {
  210. addLog({
  211. phase,
  212. direction: 'client',
  213. command: sanitizeSmtpCommand(command)
  214. });
  215. const response = await client.command(command, expectedCodes);
  216. addLog({
  217. phase,
  218. direction: 'server',
  219. code: response.code,
  220. response: response.message,
  221. ok: true
  222. });
  223. return response;
  224. }
  225. async function expectResponse(client, expectedCodes, phase, addLog) {
  226. const response = await client.expect(expectedCodes);
  227. addLog({
  228. phase,
  229. direction: 'server',
  230. code: response.code,
  231. response: response.message,
  232. ok: true
  233. });
  234. return response;
  235. }
  236. function sanitizeSmtpCommand(command) {
  237. return String(command || '').replace(/^AUTH\s+(\S+)(?:\s+.*)?$/i, 'AUTH $1 <redacted>');
  238. }
  239. function sanitizeHeader(value) {
  240. return String(value || '').replace(/[\r\n]+/g, ' ').trim();
  241. }
  242. function encodeHeader(value) {
  243. const clean = sanitizeHeader(value);
  244. if (/^[\x20-\x7e]*$/.test(clean)) return clean;
  245. return `=?UTF-8?B?${Buffer.from(clean).toString('base64')}?=`;
  246. }
  247. function formatHeaders(headers) {
  248. return headers
  249. .filter(([, value]) => value !== undefined && value !== null && value !== '')
  250. .map(([name, value]) => formatHeader(name, value))
  251. .join('\r\n');
  252. }
  253. function formatHeader(name, value) {
  254. const line = `${name}: ${value}`;
  255. if (line.length <= 78) return line;
  256. return foldStructuredHeader(name, value);
  257. }
  258. function foldStructuredHeader(name, value) {
  259. const prefix = `${name}: `;
  260. const lines = [];
  261. let current = prefix;
  262. for (const token of splitHeaderTokens(value)) {
  263. if (current.length > prefix.length && current.length + token.length > 78) {
  264. lines.push(current.trimEnd());
  265. current = ` ${token.trimStart()}`;
  266. continue;
  267. }
  268. current += token;
  269. }
  270. lines.push(current.trimEnd());
  271. return lines.join('\r\n');
  272. }
  273. function splitHeaderTokens(value) {
  274. return String(value).split(/((?:,\s+)|\s+)/).filter(Boolean);
  275. }
  276. function normalizeExtraHeaders(headers) {
  277. return (headers || [])
  278. .filter((header) => Array.isArray(header) && isHeaderName(header[0]))
  279. .map(([name, value]) => [String(name), sanitizeHeader(value)])
  280. .filter(([, value]) => value);
  281. }
  282. function isHeaderName(value) {
  283. return /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/.test(String(value || ''));
  284. }
  285. function normalizeMailtoListUnsubscribe(value) {
  286. const clean = sanitizeHeader(value);
  287. if (!clean) return '';
  288. if (/^mailto:/i.test(clean)) return clean;
  289. const address = extractAddress(clean);
  290. return address ? `mailto:${address}` : '';
  291. }
  292. function normalizeHttpListUnsubscribe(value) {
  293. const clean = sanitizeHeader(value);
  294. if (!clean) return '';
  295. try {
  296. const url = new URL(clean);
  297. if (!['http:', 'https:'].includes(url.protocol)) return '';
  298. return url.toString();
  299. } catch {
  300. return '';
  301. }
  302. }
  303. function isHttpsUrl(value) {
  304. try {
  305. return new URL(value).protocol === 'https:';
  306. } catch {
  307. return false;
  308. }
  309. }
  310. function normalizeMimeMessage(value) {
  311. return String(value || '').replace(/\r?\n/g, '\r\n');
  312. }
  313. function parseHeaderNames(headerBlock) {
  314. const names = [];
  315. for (const line of String(headerBlock || '').split('\r\n')) {
  316. if (/^[\t ]/.test(line)) continue;
  317. const index = line.indexOf(':');
  318. if (index === -1) continue;
  319. names.push(line.slice(0, index).toLowerCase());
  320. }
  321. return names;
  322. }
  323. function renderDeliverabilityTemplate(value, context = {}) {
  324. return String(value || '').replace(/\{(eventId|recipient|sender|domain|userId)\}/g, (_match, key) => {
  325. const replacement = context[key] ?? '';
  326. return encodeURIComponent(String(replacement));
  327. });
  328. }
  329. function normalizeFeedbackId(value) {
  330. const clean = sanitizeHeader(value);
  331. if (!/^[A-Za-z0-9._:-]{1,128}$/.test(clean)) return '';
  332. return clean;
  333. }
  334. function feedbackPart(name, value, secret) {
  335. return crypto
  336. .createHmac('sha256', secret)
  337. .update(`${name}:${String(value ?? '')}`)
  338. .digest('hex')
  339. .slice(0, 12);
  340. }
  341. function normalizeBody(value) {
  342. return String(value || '').replace(/\r?\n/g, '\r\n');
  343. }
  344. function encodeBase64Body(value) {
  345. const encoded = Buffer.from(normalizeBody(value), 'utf8').toString('base64');
  346. return encoded.replace(/.{1,76}/g, '$&\r\n').trimEnd();
  347. }
  348. function stripHtml(value) {
  349. return String(value || '')
  350. .replace(/<style[\s\S]*?<\/style>/gi, '')
  351. .replace(/<script[\s\S]*?<\/script>/gi, '')
  352. .replace(/<[^>]+>/g, ' ')
  353. .replace(/\s+/g, ' ')
  354. .trim();
  355. }
  356. function dotStuff(rawMessage) {
  357. const binary = Buffer.isBuffer(rawMessage) || rawMessage instanceof Uint8Array;
  358. const source = binary ? Buffer.from(rawMessage).toString('latin1') : String(rawMessage || '');
  359. const normalized = source.replace(/\r?\n/g, '\r\n');
  360. const stuffed = `${normalized.replace(/^\./gm, '..')}\r\n.`;
  361. return binary ? Buffer.from(stuffed, 'latin1') : stuffed;
  362. }
  363. class SmtpClient {
  364. static connect({ host, port = 25, secure = false }) {
  365. return new Promise((resolve, reject) => {
  366. const socket = secure
  367. ? tls.connect({ host, port: Number(port), servername: host })
  368. : net.createConnection({ host, port: Number(port) });
  369. const client = new SmtpClient(socket);
  370. socket.once('connect', () => resolve(client));
  371. socket.once('secureConnect', () => resolve(client));
  372. socket.once('error', reject);
  373. setTimeout(() => reject(new Error('SMTP connection timeout.')), 15000).unref();
  374. });
  375. }
  376. constructor(socket) {
  377. this.socket = socket;
  378. this.buffer = '';
  379. this.pending = [];
  380. this.currentLines = [];
  381. socket.setEncoding('utf8');
  382. socket.on('data', (chunk) => this.onData(chunk));
  383. socket.on('error', (error) => this.rejectPending(error));
  384. socket.on('close', () => this.rejectPending(new Error('SMTP connection closed.')));
  385. }
  386. command(command, expectedCodes) {
  387. this.socket.write(`${command}\r\n`);
  388. return this.expect(expectedCodes);
  389. }
  390. writeData(data) {
  391. if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
  392. this.socket.write(data);
  393. this.socket.write('\r\n');
  394. } else {
  395. this.socket.write(`${data}\r\n`);
  396. }
  397. return Promise.resolve();
  398. }
  399. expect(expectedCodes) {
  400. return new Promise((resolve, reject) => {
  401. this.pending.push({ expectedCodes, resolve, reject });
  402. this.flushResponses();
  403. });
  404. }
  405. close() {
  406. this.socket.destroy();
  407. }
  408. onData(chunk) {
  409. this.buffer += chunk;
  410. let index;
  411. while ((index = this.buffer.indexOf('\n')) !== -1) {
  412. const rawLine = this.buffer.slice(0, index).replace(/\r$/, '');
  413. this.buffer = this.buffer.slice(index + 1);
  414. this.currentLines.push(rawLine);
  415. if (/^\d{3} /.test(rawLine)) {
  416. this.flushResponses();
  417. }
  418. }
  419. }
  420. flushResponses() {
  421. while (this.pending.length && this.currentLines.length) {
  422. const lastLine = this.currentLines[this.currentLines.length - 1];
  423. if (!/^\d{3} /.test(lastLine)) return;
  424. const responseLines = this.currentLines.splice(0);
  425. const code = Number(lastLine.slice(0, 3));
  426. const response = {
  427. code,
  428. message: responseLines.join('\n')
  429. };
  430. const pending = this.pending.shift();
  431. if (pending.expectedCodes.includes(code)) {
  432. pending.resolve(response);
  433. } else {
  434. pending.reject(new Error(`Unexpected SMTP response ${response.message}`));
  435. }
  436. }
  437. }
  438. rejectPending(error) {
  439. while (this.pending.length) {
  440. this.pending.shift().reject(error);
  441. }
  442. }
  443. }