mailer.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import net from 'node:net';
  2. import tls from 'node:tls';
  3. import crypto from 'node:crypto';
  4. import { signDkim } from './dkim.js';
  5. export function parseAddressList(value) {
  6. return String(value || '')
  7. .split(',')
  8. .map((item) => item.trim())
  9. .filter(Boolean)
  10. .map(extractAddress)
  11. .filter(Boolean);
  12. }
  13. export function extractAddress(value) {
  14. const match = String(value || '').match(/<([^<>@\s]+@[^<>@\s]+)>/);
  15. const address = match ? match[1] : String(value || '').trim();
  16. if (!/^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$/.test(address)) return '';
  17. return address.toLowerCase();
  18. }
  19. export function domainFromAddress(value) {
  20. const address = extractAddress(value);
  21. return address.split('@')[1] || '';
  22. }
  23. export function buildMessage({ from, to, subject, text, html, baseUrl }) {
  24. const recipients = Array.isArray(to) ? to : parseAddressList(to);
  25. if (!recipients.length) throw new Error('At least one recipient is required.');
  26. const messageIdHost = domainFromAddress(from) || 'localhost';
  27. const messageId = `<${crypto.randomUUID()}@${messageIdHost}>`;
  28. const commonHeaders = [
  29. ['From', sanitizeHeader(from)],
  30. ['To', recipients.join(', ')],
  31. ['Subject', encodeHeader(subject || '(no subject)')],
  32. ['Date', new Date().toUTCString()],
  33. ['Message-ID', messageId],
  34. ['MIME-Version', '1.0'],
  35. ['X-MailHub', baseUrl || 'mailhub']
  36. ];
  37. if (html) {
  38. const boundary = `mailhub-${crypto.randomBytes(12).toString('hex')}`;
  39. const headers = [
  40. ...commonHeaders,
  41. ['Content-Type', `multipart/alternative; boundary="${boundary}"`]
  42. ];
  43. const body = [
  44. `--${boundary}`,
  45. 'Content-Type: text/plain; charset=UTF-8',
  46. 'Content-Transfer-Encoding: base64',
  47. '',
  48. encodeBase64Body(text || stripHtml(html)),
  49. `--${boundary}`,
  50. 'Content-Type: text/html; charset=UTF-8',
  51. 'Content-Transfer-Encoding: base64',
  52. '',
  53. encodeBase64Body(html),
  54. `--${boundary}--`,
  55. ''
  56. ].join('\r\n');
  57. return `${formatHeaders(headers)}\r\n\r\n${body}`;
  58. }
  59. const headers = [
  60. ...commonHeaders,
  61. ['Content-Type', 'text/plain; charset=UTF-8'],
  62. ['Content-Transfer-Encoding', 'base64']
  63. ];
  64. return `${formatHeaders(headers)}\r\n\r\n${encodeBase64Body(text || '')}\r\n`;
  65. }
  66. export function signMessageForDomain(rawMessage, domain) {
  67. if (!domain?.dkimPrivate || !domain?.selector) return rawMessage;
  68. return signDkim(rawMessage, {
  69. domain: domain.domain,
  70. selector: domain.selector,
  71. privateKey: domain.dkimPrivate
  72. });
  73. }
  74. export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
  75. if (!host) throw new Error('SMTP_HOST is not configured.');
  76. const client = await SmtpClient.connect({ host, port, secure });
  77. try {
  78. await client.expect([220]);
  79. let response = await client.command(`EHLO ${helo || 'mailhub.local'}`, [250, 502, 500]);
  80. if (![250].includes(response.code)) {
  81. await client.command(`HELO ${helo || 'mailhub.local'}`, [250]);
  82. }
  83. if (username || password) {
  84. const auth = Buffer.from(`\u0000${username || ''}\u0000${password || ''}`).toString('base64');
  85. await client.command(`AUTH PLAIN ${auth}`, [235]);
  86. }
  87. await client.command(`MAIL FROM:<${extractAddress(mailFrom)}>`, [250]);
  88. for (const recipient of recipients) {
  89. await client.command(`RCPT TO:<${recipient}>`, [250, 251]);
  90. }
  91. await client.command('DATA', [354]);
  92. await client.writeData(dotStuff(rawMessage));
  93. const dataResponse = await client.expect([250]);
  94. await client.command('QUIT', [221]).catch(() => null);
  95. return dataResponse;
  96. } finally {
  97. client.close();
  98. }
  99. }
  100. function sanitizeHeader(value) {
  101. return String(value || '').replace(/[\r\n]+/g, ' ').trim();
  102. }
  103. function encodeHeader(value) {
  104. const clean = sanitizeHeader(value);
  105. if (/^[\x20-\x7e]*$/.test(clean)) return clean;
  106. return `=?UTF-8?B?${Buffer.from(clean).toString('base64')}?=`;
  107. }
  108. function formatHeaders(headers) {
  109. return headers
  110. .filter(([, value]) => value !== undefined && value !== null && value !== '')
  111. .map(([name, value]) => `${name}: ${value}`)
  112. .join('\r\n');
  113. }
  114. function normalizeBody(value) {
  115. return String(value || '').replace(/\r?\n/g, '\r\n');
  116. }
  117. function encodeBase64Body(value) {
  118. const encoded = Buffer.from(normalizeBody(value), 'utf8').toString('base64');
  119. return encoded.replace(/.{1,76}/g, '$&\r\n').trimEnd();
  120. }
  121. function stripHtml(value) {
  122. return String(value || '')
  123. .replace(/<style[\s\S]*?<\/style>/gi, '')
  124. .replace(/<script[\s\S]*?<\/script>/gi, '')
  125. .replace(/<[^>]+>/g, ' ')
  126. .replace(/\s+/g, ' ')
  127. .trim();
  128. }
  129. function dotStuff(rawMessage) {
  130. const normalized = rawMessage.replace(/\r?\n/g, '\r\n');
  131. return `${normalized.replace(/^\./gm, '..')}\r\n.`;
  132. }
  133. class SmtpClient {
  134. static connect({ host, port = 25, secure = false }) {
  135. return new Promise((resolve, reject) => {
  136. const socket = secure
  137. ? tls.connect({ host, port: Number(port), servername: host })
  138. : net.createConnection({ host, port: Number(port) });
  139. const client = new SmtpClient(socket);
  140. socket.once('connect', () => resolve(client));
  141. socket.once('secureConnect', () => resolve(client));
  142. socket.once('error', reject);
  143. setTimeout(() => reject(new Error('SMTP connection timeout.')), 15000).unref();
  144. });
  145. }
  146. constructor(socket) {
  147. this.socket = socket;
  148. this.buffer = '';
  149. this.pending = [];
  150. this.currentLines = [];
  151. socket.setEncoding('utf8');
  152. socket.on('data', (chunk) => this.onData(chunk));
  153. socket.on('error', (error) => this.rejectPending(error));
  154. socket.on('close', () => this.rejectPending(new Error('SMTP connection closed.')));
  155. }
  156. command(command, expectedCodes) {
  157. this.socket.write(`${command}\r\n`);
  158. return this.expect(expectedCodes);
  159. }
  160. writeData(data) {
  161. this.socket.write(`${data}\r\n`);
  162. return Promise.resolve();
  163. }
  164. expect(expectedCodes) {
  165. return new Promise((resolve, reject) => {
  166. this.pending.push({ expectedCodes, resolve, reject });
  167. this.flushResponses();
  168. });
  169. }
  170. close() {
  171. this.socket.destroy();
  172. }
  173. onData(chunk) {
  174. this.buffer += chunk;
  175. let index;
  176. while ((index = this.buffer.indexOf('\n')) !== -1) {
  177. const rawLine = this.buffer.slice(0, index).replace(/\r$/, '');
  178. this.buffer = this.buffer.slice(index + 1);
  179. this.currentLines.push(rawLine);
  180. if (/^\d{3} /.test(rawLine)) {
  181. this.flushResponses();
  182. }
  183. }
  184. }
  185. flushResponses() {
  186. while (this.pending.length && this.currentLines.length) {
  187. const lastLine = this.currentLines[this.currentLines.length - 1];
  188. if (!/^\d{3} /.test(lastLine)) return;
  189. const responseLines = this.currentLines.splice(0);
  190. const code = Number(lastLine.slice(0, 3));
  191. const response = {
  192. code,
  193. message: responseLines.join('\n')
  194. };
  195. const pending = this.pending.shift();
  196. if (pending.expectedCodes.includes(code)) {
  197. pending.resolve(response);
  198. } else {
  199. pending.reject(new Error(`Unexpected SMTP response ${response.message}`));
  200. }
  201. }
  202. }
  203. rejectPending(error) {
  204. while (this.pending.length) {
  205. this.pending.shift().reject(error);
  206. }
  207. }
  208. }