submission.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import net from 'node:net';
  2. import tls from 'node:tls';
  3. import { readFileSync } from 'node:fs';
  4. import { getDomainByName, logSendEvent, verifySmtpCredential } from './db.js';
  5. import {
  6. domainFromAddress,
  7. extractAddress,
  8. sendViaSmtp,
  9. signMessageForDomain
  10. } from './mailer.js';
  11. const implicitTlsDetectTimeoutMs = 300;
  12. export function startSubmissionServer(config) {
  13. if (!config.enabled) return null;
  14. const tlsMaterial = loadTlsMaterial(config);
  15. const servers = [];
  16. for (const listener of config.listeners) {
  17. const listenerConfig = {
  18. ...config,
  19. port: listener.port,
  20. protocol: listener.protocol,
  21. secureContext: tlsMaterial?.secureContext || null,
  22. tlsActive: listener.protocol === 'smtps',
  23. startTlsAvailable: listener.protocol === 'smtp' && Boolean(tlsMaterial?.secureContext)
  24. };
  25. const server = listener.protocol === 'smtps'
  26. ? tls.createServer({ key: tlsMaterial?.key, cert: tlsMaterial?.cert }, (socket) => new SubmissionSession(socket, listenerConfig))
  27. : net.createServer((socket) => acceptSubmissionSocket(socket, listenerConfig));
  28. server.listen(listener.port, '0.0.0.0', () => {
  29. console.log(`MailHub SMTP ${listener.protocol} listening on 0.0.0.0:${listener.port}`);
  30. });
  31. servers.push(server);
  32. }
  33. return servers;
  34. }
  35. function loadTlsMaterial(config) {
  36. if (!config.tlsKeyPath || !config.tlsCertPath) {
  37. console.warn('SMTP TLS certificate paths are not configured; STARTTLS/SMTPS will be unavailable.');
  38. return null;
  39. }
  40. try {
  41. const key = readFileSync(config.tlsKeyPath);
  42. const cert = readFileSync(config.tlsCertPath);
  43. return tls.createSecureContext({
  44. key,
  45. cert
  46. }) && {
  47. key,
  48. cert,
  49. secureContext: tls.createSecureContext({ key, cert })
  50. };
  51. } catch (error) {
  52. console.warn(`Unable to load SMTP TLS certificate: ${error.message}`);
  53. return null;
  54. }
  55. }
  56. function acceptSubmissionSocket(socket, listenerConfig) {
  57. if (listenerConfig.protocol !== 'smtp' || !listenerConfig.secureContext) {
  58. new SubmissionSession(socket, listenerConfig);
  59. return;
  60. }
  61. let settled = false;
  62. const timer = setTimeout(() => startPlainSession(), implicitTlsDetectTimeoutMs);
  63. function cleanup() {
  64. clearTimeout(timer);
  65. socket.off('data', onData);
  66. socket.off('error', onError);
  67. }
  68. function onError() {
  69. cleanup();
  70. }
  71. function onData(chunk) {
  72. socket.pause();
  73. if (isTlsClientHello(chunk)) {
  74. startImplicitTlsSession(chunk);
  75. } else {
  76. startPlainSession(chunk);
  77. }
  78. }
  79. function startPlainSession(firstChunk) {
  80. if (settled) return;
  81. settled = true;
  82. cleanup();
  83. const session = new SubmissionSession(socket, listenerConfig);
  84. if (firstChunk?.length) session.onData(firstChunk);
  85. socket.resume();
  86. }
  87. function startImplicitTlsSession(firstChunk) {
  88. if (settled) return;
  89. settled = true;
  90. cleanup();
  91. socket.unshift(firstChunk);
  92. const secureSocket = new tls.TLSSocket(socket, {
  93. isServer: true,
  94. secureContext: listenerConfig.secureContext
  95. });
  96. const secureConfig = {
  97. ...listenerConfig,
  98. tlsActive: true,
  99. startTlsAvailable: false
  100. };
  101. let sessionStarted = false;
  102. const startSession = () => {
  103. if (sessionStarted) return;
  104. sessionStarted = true;
  105. secureSocket.off('secure', startSession);
  106. secureSocket.off('secureConnect', startSession);
  107. new SubmissionSession(secureSocket, secureConfig);
  108. secureSocket.resume();
  109. };
  110. secureSocket.once('secure', startSession);
  111. secureSocket.once('secureConnect', startSession);
  112. secureSocket.on('error', () => null);
  113. secureSocket.resume();
  114. }
  115. socket.once('data', onData);
  116. socket.once('error', onError);
  117. }
  118. function isTlsClientHello(chunk) {
  119. return chunk?.length >= 3 && chunk[0] === 0x16 && chunk[1] === 0x03;
  120. }
  121. export function parseSubmissionListeners(value) {
  122. return String(value || '25:smtp,587:smtp,465:smtps,2525:smtp')
  123. .split(',')
  124. .map((item) => item.trim())
  125. .filter(Boolean)
  126. .map((item) => {
  127. const [portRaw, protocolRaw = 'smtp'] = item.split(':');
  128. const port = Number(portRaw);
  129. const protocol = protocolRaw.toLowerCase() === 'smtps' ? 'smtps' : 'smtp';
  130. if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
  131. return { port, protocol };
  132. })
  133. .filter(Boolean);
  134. }
  135. export function publicSubmissionListeners(listeners) {
  136. return listeners.map((listener) => ({
  137. port: listener.port,
  138. protocol: listener.protocol === 'smtps' ? 'SMTPS' : 'SMTP + STARTTLS'
  139. }));
  140. }
  141. class SubmissionSession {
  142. constructor(socket, config) {
  143. this.socket = socket;
  144. this.config = config;
  145. this.buffer = '';
  146. this.dataMode = false;
  147. this.dataLines = [];
  148. this.authState = '';
  149. this.authUser = '';
  150. this.user = null;
  151. this.authenticated = false;
  152. this.mailFrom = '';
  153. this.recipients = [];
  154. this.remoteAddress = socket.remoteAddress || '';
  155. this.onDataBound = (chunk) => this.onData(chunk);
  156. this.queue = Promise.resolve();
  157. socket.setEncoding('utf8');
  158. socket.on('data', this.onDataBound);
  159. socket.on('error', () => null);
  160. this.write(220, `${config.hostname} MailHub SMTP ready`);
  161. }
  162. onData(chunk) {
  163. this.buffer += chunk;
  164. let index;
  165. while ((index = this.buffer.indexOf('\n')) !== -1) {
  166. const line = this.buffer.slice(0, index).replace(/\r$/, '');
  167. this.buffer = this.buffer.slice(index + 1);
  168. this.queue = this.queue
  169. .then(() => this.onLine(line))
  170. .catch((error) => {
  171. console.error(error);
  172. this.write(451, 'Temporary local error');
  173. });
  174. }
  175. }
  176. async onLine(line) {
  177. if (this.dataMode) {
  178. if (line === '.') return await this.finishData();
  179. this.dataLines.push(line.startsWith('..') ? line.slice(1) : line);
  180. return;
  181. }
  182. if (this.authState) return this.continueAuth(line);
  183. const [rawCommand, ...args] = line.split(' ');
  184. const command = rawCommand.toUpperCase();
  185. const argument = args.join(' ').trim();
  186. if (command === 'EHLO' || command === 'HELO') return this.ehlo();
  187. if (command === 'NOOP') return this.write(250, 'OK');
  188. if (command === 'RSET') return this.resetEnvelope();
  189. if (command === 'QUIT') {
  190. this.write(221, 'Bye');
  191. return this.socket.end();
  192. }
  193. if (command === 'AUTH') return this.auth(argument);
  194. if (command === 'STARTTLS') return this.startTls();
  195. if (command === 'MAIL') return this.mail(argument);
  196. if (command === 'RCPT') return this.rcpt(argument);
  197. if (command === 'DATA') return this.data();
  198. return this.write(502, 'Command not implemented');
  199. }
  200. ehlo() {
  201. this.socket.write(`250-${this.config.hostname}\r\n`);
  202. this.socket.write('250-SIZE 52428800\r\n');
  203. this.socket.write('250-8BITMIME\r\n');
  204. if (this.config.startTlsAvailable && !this.config.tlsActive) {
  205. this.socket.write('250-STARTTLS\r\n');
  206. }
  207. if (this.canAuthenticate()) {
  208. this.socket.write('250-AUTH PLAIN LOGIN\r\n');
  209. }
  210. this.socket.write('250 SMTPUTF8\r\n');
  211. }
  212. startTls() {
  213. if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write(454, 'TLS is not available');
  214. if (this.config.tlsActive) return this.write(503, 'TLS is already active');
  215. this.write(220, 'Ready to start TLS');
  216. this.socket.removeListener('data', this.onDataBound);
  217. const secureSocket = new tls.TLSSocket(this.socket, {
  218. isServer: true,
  219. secureContext: this.config.secureContext
  220. });
  221. this.socket = secureSocket;
  222. this.buffer = '';
  223. this.authenticated = false;
  224. this.user = null;
  225. this.authState = '';
  226. this.config = {
  227. ...this.config,
  228. tlsActive: true,
  229. startTlsAvailable: false
  230. };
  231. secureSocket.setEncoding('utf8');
  232. secureSocket.on('data', this.onDataBound);
  233. secureSocket.on('error', () => null);
  234. }
  235. auth(argument) {
  236. if (!this.canAuthenticate()) return this.write(538, 'Encryption required for authentication');
  237. const [methodRaw, response] = argument.split(/\s+/, 2);
  238. const method = String(methodRaw || '').toUpperCase();
  239. if (method === 'PLAIN') {
  240. if (!response) {
  241. this.authState = 'plain';
  242. return this.write(334, '');
  243. }
  244. return this.finishPlainAuth(response);
  245. }
  246. if (method === 'LOGIN') {
  247. this.authState = 'login-username';
  248. return this.write(334, Buffer.from('Username:').toString('base64'));
  249. }
  250. return this.write(504, 'Unsupported authentication method');
  251. }
  252. continueAuth(line) {
  253. if (this.authState === 'plain') return this.finishPlainAuth(line);
  254. if (this.authState === 'login-username') {
  255. this.authUser = decodeBase64(line);
  256. this.authState = 'login-password';
  257. return this.write(334, Buffer.from('Password:').toString('base64'));
  258. }
  259. if (this.authState === 'login-password') {
  260. const password = decodeBase64(line);
  261. this.authState = '';
  262. return this.finishAuth(this.authUser, password);
  263. }
  264. }
  265. finishPlainAuth(response) {
  266. const decoded = decodeBase64(response);
  267. const parts = decoded.split('\u0000');
  268. const user = parts[1] || parts[0] || '';
  269. const password = parts[2] || parts[1] || '';
  270. this.authState = '';
  271. return this.finishAuth(user, password);
  272. }
  273. finishAuth(user, password) {
  274. const auth = verifySmtpCredential(user, password);
  275. if (auth?.user) {
  276. this.user = auth.user;
  277. this.authenticated = true;
  278. return this.write(235, 'Authentication successful');
  279. }
  280. this.user = null;
  281. this.authenticated = false;
  282. return this.write(535, 'Authentication failed');
  283. }
  284. mail(argument) {
  285. if (!this.authenticated) return this.write(530, 'Authentication required');
  286. const address = extractPathAddress(argument);
  287. if (!address) return this.write(501, 'Invalid MAIL FROM');
  288. this.mailFrom = address;
  289. this.recipients = [];
  290. return this.write(250, 'Sender OK');
  291. }
  292. rcpt(argument) {
  293. if (!this.authenticated) return this.write(530, 'Authentication required');
  294. if (!this.mailFrom) return this.write(503, 'MAIL FROM required first');
  295. const address = extractPathAddress(argument);
  296. if (!address) return this.write(501, 'Invalid RCPT TO');
  297. if (this.recipients.length >= 100) return this.write(452, 'Too many recipients');
  298. this.recipients.push(address);
  299. return this.write(250, 'Recipient OK');
  300. }
  301. data() {
  302. if (!this.authenticated) return this.write(530, 'Authentication required');
  303. if (!this.mailFrom || !this.recipients.length) return this.write(503, 'Need MAIL FROM and RCPT TO first');
  304. this.dataMode = true;
  305. this.dataLines = [];
  306. return this.write(354, 'End data with <CR><LF>.<CR><LF>');
  307. }
  308. async finishData() {
  309. this.dataMode = false;
  310. const rawMessage = `${this.dataLines.join('\r\n')}\r\n`;
  311. const headerFrom = extractHeader(rawMessage, 'from');
  312. const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
  313. const sender = extractAddress(headerFrom) || this.mailFrom;
  314. const domainName = domainFromAddress(sender || this.mailFrom);
  315. const domain = getDomainByName(domainName, { userId: this.user?.id, includePrivate: true });
  316. if (!domain) {
  317. logSendEvent({
  318. userId: this.user?.id || null,
  319. domainId: null,
  320. sender: sender || this.mailFrom,
  321. recipients: this.recipients,
  322. subject,
  323. status: 'failed',
  324. detail: `Sender domain ${domainName || '(unknown)'} is not configured`
  325. });
  326. return this.write(550, 'Sender domain is not configured in MailHub');
  327. }
  328. try {
  329. const signed = signMessageForDomain(rawMessage, domain);
  330. const smtpResult = await sendViaSmtp({
  331. host: this.config.relayHost,
  332. port: this.config.relayPort,
  333. secure: this.config.relaySecure,
  334. username: this.config.relayUsername,
  335. password: this.config.relayPassword,
  336. helo: this.config.relayHelo,
  337. mailFrom: this.mailFrom,
  338. recipients: this.recipients,
  339. rawMessage: signed
  340. });
  341. logSendEvent({
  342. userId: this.user.id,
  343. domainId: domain.id,
  344. sender: sender || this.mailFrom,
  345. recipients: this.recipients,
  346. subject,
  347. status: 'queued',
  348. detail: `submission ${this.remoteAddress}; ${smtpResult.message}`,
  349. queueId: smtpResult.queueId,
  350. deliveryLog: smtpResult.deliveryLog
  351. });
  352. this.resetEnvelope(false);
  353. return this.write(250, 'Message queued');
  354. } catch (error) {
  355. logSendEvent({
  356. userId: this.user.id,
  357. domainId: domain.id,
  358. sender: sender || this.mailFrom,
  359. recipients: this.recipients,
  360. subject,
  361. status: 'failed',
  362. detail: `submission ${this.remoteAddress}; ${error.message}`,
  363. deliveryLog: deliveryLogFromError(error)
  364. });
  365. return this.write(451, 'Temporary local delivery error');
  366. }
  367. }
  368. resetEnvelope(reply = true) {
  369. this.mailFrom = '';
  370. this.recipients = [];
  371. this.dataMode = false;
  372. this.dataLines = [];
  373. this.user = this.authenticated ? this.user : null;
  374. if (reply) this.write(250, 'OK');
  375. }
  376. write(code, message) {
  377. this.socket.write(`${code} ${message}\r\n`);
  378. }
  379. canAuthenticate() {
  380. return this.config.tlsActive || this.config.allowInsecureAuth;
  381. }
  382. }
  383. function extractPathAddress(argument) {
  384. const match = String(argument || '').match(/FROM:\s*<([^>]+)>|TO:\s*<([^>]+)>/i);
  385. const raw = match ? (match[1] || match[2]) : argument;
  386. return extractAddress(raw);
  387. }
  388. function extractHeader(rawMessage, name) {
  389. const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
  390. const lines = head.split(/\r?\n/);
  391. const headers = [];
  392. for (const line of lines) {
  393. if (/^[\t ]/.test(line) && headers.length) {
  394. headers[headers.length - 1].value += ` ${line.trim()}`;
  395. continue;
  396. }
  397. const index = line.indexOf(':');
  398. if (index === -1) continue;
  399. headers.push({
  400. name: line.slice(0, index).toLowerCase(),
  401. value: line.slice(index + 1).trim()
  402. });
  403. }
  404. return headers.reverse().find((header) => header.name === name.toLowerCase())?.value || '';
  405. }
  406. function decodeHeader(value) {
  407. return String(value || '').replace(/=\?UTF-8\?B\?([^?]+)\?=/gi, (_, encoded) => {
  408. try {
  409. return Buffer.from(encoded, 'base64').toString('utf8');
  410. } catch {
  411. return _;
  412. }
  413. });
  414. }
  415. function decodeBase64(value) {
  416. try {
  417. return Buffer.from(String(value || ''), 'base64').toString('utf8');
  418. } catch {
  419. return '';
  420. }
  421. }
  422. function deliveryLogFromError(error) {
  423. if (Array.isArray(error?.deliveryLog)) return error.deliveryLog;
  424. return [{
  425. at: new Date().toISOString(),
  426. phase: 'error',
  427. direction: 'system',
  428. message: error?.message || 'Unknown SMTP delivery error',
  429. ok: false
  430. }];
  431. }