mailer.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  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 }) {
  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. ['X-MailHub', baseUrl || 'mailhub']
  37. ];
  38. if (html) {
  39. const boundary = `mailhub-${crypto.randomBytes(12).toString('hex')}`;
  40. const headers = [
  41. ...commonHeaders,
  42. ['Content-Type', `multipart/alternative; boundary="${boundary}"`]
  43. ];
  44. const body = [
  45. `--${boundary}`,
  46. 'Content-Type: text/plain; charset=UTF-8',
  47. 'Content-Transfer-Encoding: base64',
  48. '',
  49. encodeBase64Body(text || stripHtml(html)),
  50. `--${boundary}`,
  51. 'Content-Type: text/html; charset=UTF-8',
  52. 'Content-Transfer-Encoding: base64',
  53. '',
  54. encodeBase64Body(html),
  55. `--${boundary}--`,
  56. ''
  57. ].join('\r\n');
  58. return `${formatHeaders(headers)}\r\n\r\n${body}`;
  59. }
  60. const headers = [
  61. ...commonHeaders,
  62. ['Content-Type', 'text/plain; charset=UTF-8'],
  63. ['Content-Transfer-Encoding', 'base64']
  64. ];
  65. return `${formatHeaders(headers)}\r\n\r\n${encodeBase64Body(text || '')}\r\n`;
  66. }
  67. export function signMessageForDomain(rawMessage, domain) {
  68. if (!domain?.dkimPrivate || !domain?.selector) return rawMessage;
  69. return signDkim(rawMessage, {
  70. domain: domain.domain,
  71. selector: domain.selector,
  72. privateKey: domain.dkimPrivate
  73. });
  74. }
  75. export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
  76. const deliveryLog = [];
  77. const addLog = (entry) => deliveryLog.push({ at: new Date().toISOString(), ...entry });
  78. if (!host) {
  79. const error = new Error('SMTP_HOST is not configured.');
  80. error.deliveryLog = deliveryLog;
  81. throw error;
  82. }
  83. let client;
  84. try {
  85. addLog({
  86. phase: 'connect',
  87. direction: 'system',
  88. message: `Connecting to ${host}:${Number(port || 25)}${secure ? ' with TLS' : ''}`
  89. });
  90. client = await SmtpClient.connect({ host, port, secure });
  91. addLog({
  92. phase: 'connect',
  93. direction: 'system',
  94. message: `Connected to ${host}:${Number(port || 25)}`,
  95. ok: true
  96. });
  97. await expectResponse(client, [220], 'connect', addLog);
  98. let response = await runCommand(client, `EHLO ${helo || 'mailhub.local'}`, [250, 502, 500], 'smtp', addLog);
  99. if (![250].includes(response.code)) {
  100. await runCommand(client, `HELO ${helo || 'mailhub.local'}`, [250], 'smtp', addLog);
  101. }
  102. if (username || password) {
  103. const auth = Buffer.from(`\u0000${username || ''}\u0000${password || ''}`).toString('base64');
  104. await runCommand(client, `AUTH PLAIN ${auth}`, [235], 'auth', addLog);
  105. }
  106. await runCommand(client, `MAIL FROM:<${extractAddress(mailFrom)}>`, [250], 'envelope', addLog);
  107. for (const recipient of recipients) {
  108. await runCommand(client, `RCPT TO:<${recipient}>`, [250, 251], 'envelope', addLog);
  109. }
  110. await runCommand(client, 'DATA', [354], 'data', addLog);
  111. addLog({
  112. phase: 'data',
  113. direction: 'client',
  114. message: 'Message content transmitted',
  115. messageBytes: Buffer.byteLength(rawMessage || '', 'utf8'),
  116. ok: true
  117. });
  118. await client.writeData(dotStuff(rawMessage));
  119. const dataResponse = await expectResponse(client, [250], 'queue', addLog);
  120. await runCommand(client, 'QUIT', [221], 'quit', addLog).catch((error) => {
  121. addLog({
  122. phase: 'quit',
  123. direction: 'system',
  124. message: error.message,
  125. ok: false
  126. });
  127. });
  128. return {
  129. ...dataResponse,
  130. queueId: extractQueueIdFromSmtpResponse(dataResponse.message),
  131. deliveryLog
  132. };
  133. } catch (error) {
  134. addLog({
  135. phase: 'error',
  136. direction: 'system',
  137. message: error.message,
  138. ok: false
  139. });
  140. error.deliveryLog = deliveryLog;
  141. throw error;
  142. } finally {
  143. client?.close();
  144. }
  145. }
  146. async function runCommand(client, command, expectedCodes, phase, addLog) {
  147. addLog({
  148. phase,
  149. direction: 'client',
  150. command: sanitizeSmtpCommand(command)
  151. });
  152. const response = await client.command(command, expectedCodes);
  153. addLog({
  154. phase,
  155. direction: 'server',
  156. code: response.code,
  157. response: response.message,
  158. ok: true
  159. });
  160. return response;
  161. }
  162. async function expectResponse(client, expectedCodes, phase, addLog) {
  163. const response = await client.expect(expectedCodes);
  164. addLog({
  165. phase,
  166. direction: 'server',
  167. code: response.code,
  168. response: response.message,
  169. ok: true
  170. });
  171. return response;
  172. }
  173. function sanitizeSmtpCommand(command) {
  174. return String(command || '').replace(/^AUTH\s+(\S+)(?:\s+.*)?$/i, 'AUTH $1 <redacted>');
  175. }
  176. function sanitizeHeader(value) {
  177. return String(value || '').replace(/[\r\n]+/g, ' ').trim();
  178. }
  179. function encodeHeader(value) {
  180. const clean = sanitizeHeader(value);
  181. if (/^[\x20-\x7e]*$/.test(clean)) return clean;
  182. return `=?UTF-8?B?${Buffer.from(clean).toString('base64')}?=`;
  183. }
  184. function formatHeaders(headers) {
  185. return headers
  186. .filter(([, value]) => value !== undefined && value !== null && value !== '')
  187. .map(([name, value]) => `${name}: ${value}`)
  188. .join('\r\n');
  189. }
  190. function normalizeBody(value) {
  191. return String(value || '').replace(/\r?\n/g, '\r\n');
  192. }
  193. function encodeBase64Body(value) {
  194. const encoded = Buffer.from(normalizeBody(value), 'utf8').toString('base64');
  195. return encoded.replace(/.{1,76}/g, '$&\r\n').trimEnd();
  196. }
  197. function stripHtml(value) {
  198. return String(value || '')
  199. .replace(/<style[\s\S]*?<\/style>/gi, '')
  200. .replace(/<script[\s\S]*?<\/script>/gi, '')
  201. .replace(/<[^>]+>/g, ' ')
  202. .replace(/\s+/g, ' ')
  203. .trim();
  204. }
  205. function dotStuff(rawMessage) {
  206. const normalized = rawMessage.replace(/\r?\n/g, '\r\n');
  207. return `${normalized.replace(/^\./gm, '..')}\r\n.`;
  208. }
  209. class SmtpClient {
  210. static connect({ host, port = 25, secure = false }) {
  211. return new Promise((resolve, reject) => {
  212. const socket = secure
  213. ? tls.connect({ host, port: Number(port), servername: host })
  214. : net.createConnection({ host, port: Number(port) });
  215. const client = new SmtpClient(socket);
  216. socket.once('connect', () => resolve(client));
  217. socket.once('secureConnect', () => resolve(client));
  218. socket.once('error', reject);
  219. setTimeout(() => reject(new Error('SMTP connection timeout.')), 15000).unref();
  220. });
  221. }
  222. constructor(socket) {
  223. this.socket = socket;
  224. this.buffer = '';
  225. this.pending = [];
  226. this.currentLines = [];
  227. socket.setEncoding('utf8');
  228. socket.on('data', (chunk) => this.onData(chunk));
  229. socket.on('error', (error) => this.rejectPending(error));
  230. socket.on('close', () => this.rejectPending(new Error('SMTP connection closed.')));
  231. }
  232. command(command, expectedCodes) {
  233. this.socket.write(`${command}\r\n`);
  234. return this.expect(expectedCodes);
  235. }
  236. writeData(data) {
  237. this.socket.write(`${data}\r\n`);
  238. return Promise.resolve();
  239. }
  240. expect(expectedCodes) {
  241. return new Promise((resolve, reject) => {
  242. this.pending.push({ expectedCodes, resolve, reject });
  243. this.flushResponses();
  244. });
  245. }
  246. close() {
  247. this.socket.destroy();
  248. }
  249. onData(chunk) {
  250. this.buffer += chunk;
  251. let index;
  252. while ((index = this.buffer.indexOf('\n')) !== -1) {
  253. const rawLine = this.buffer.slice(0, index).replace(/\r$/, '');
  254. this.buffer = this.buffer.slice(index + 1);
  255. this.currentLines.push(rawLine);
  256. if (/^\d{3} /.test(rawLine)) {
  257. this.flushResponses();
  258. }
  259. }
  260. }
  261. flushResponses() {
  262. while (this.pending.length && this.currentLines.length) {
  263. const lastLine = this.currentLines[this.currentLines.length - 1];
  264. if (!/^\d{3} /.test(lastLine)) return;
  265. const responseLines = this.currentLines.splice(0);
  266. const code = Number(lastLine.slice(0, 3));
  267. const response = {
  268. code,
  269. message: responseLines.join('\n')
  270. };
  271. const pending = this.pending.shift();
  272. if (pending.expectedCodes.includes(code)) {
  273. pending.resolve(response);
  274. } else {
  275. pending.reject(new Error(`Unexpected SMTP response ${response.message}`));
  276. }
  277. }
  278. }
  279. rejectPending(error) {
  280. while (this.pending.length) {
  281. this.pending.shift().reject(error);
  282. }
  283. }
  284. }