submission.js 13 KB

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