server-admin-api.test.js 75 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { mkdtempSync, readdirSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import process from 'node:process';
  7. import { test } from 'node:test';
  8. import net from 'node:net';
  9. test('admin API routes respond once and keep the server alive', async () => {
  10. const port = await freePort();
  11. const child = spawn(process.execPath, ['src/server.js'], {
  12. cwd: process.cwd(),
  13. env: {
  14. ...process.env,
  15. PORT: String(port),
  16. DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
  17. ADMIN_PASSWORD: 'password123',
  18. SUBMISSION_ENABLED: 'false',
  19. IMAP_ENABLED: 'false',
  20. POP3_ENABLED: 'false'
  21. },
  22. stdio: ['ignore', 'pipe', 'pipe']
  23. });
  24. try {
  25. await waitForOutput(child, 'MailHub listening');
  26. const baseUrl = `http://127.0.0.1:${port}`;
  27. const login = await fetch(`${baseUrl}/api/login`, {
  28. method: 'POST',
  29. headers: { 'Content-Type': 'application/json' },
  30. body: JSON.stringify({ username: 'admin', password: 'password123' })
  31. });
  32. assert.equal(login.status, 200);
  33. const cookie = login.headers.get('set-cookie')?.split(';')[0] || '';
  34. assert.ok(cookie);
  35. const settings = await fetch(`${baseUrl}/api/admin/settings`, {
  36. headers: { Cookie: cookie }
  37. });
  38. assert.equal(settings.status, 200);
  39. const settingsPayload = await settings.json();
  40. assert.equal(settingsPayload.settings.mailHostname, 'mailhub.local');
  41. assert.equal(settingsPayload.settings.systemChecks.ptr.key, 'ptr');
  42. const exited = await waitForExit(child, 300);
  43. assert.equal(exited, false);
  44. } finally {
  45. child.kill('SIGTERM');
  46. await waitForExit(child, 1000);
  47. }
  48. });
  49. test('built auth assets are served before authentication', async () => {
  50. const assetName = readdirSync(path.join(process.cwd(), 'public', 'assets')).find((name) => /\.(js|css)$/.test(name));
  51. assert.ok(assetName, 'expected at least one built frontend asset');
  52. const port = await freePort();
  53. const child = spawn(process.execPath, ['src/server.js'], {
  54. cwd: process.cwd(),
  55. env: {
  56. ...process.env,
  57. PORT: String(port),
  58. DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')),
  59. ADMIN_PASSWORD: 'password123',
  60. SUBMISSION_ENABLED: 'false',
  61. IMAP_ENABLED: 'false',
  62. POP3_ENABLED: 'false'
  63. },
  64. stdio: ['ignore', 'pipe', 'pipe']
  65. });
  66. try {
  67. await waitForOutput(child, 'MailHub listening');
  68. const baseUrl = `http://127.0.0.1:${port}`;
  69. const login = await fetch(`${baseUrl}/login`);
  70. assert.equal(login.status, 200);
  71. const asset = await fetch(`${baseUrl}/assets/${assetName}`, { redirect: 'manual' });
  72. assert.equal(asset.status, 200);
  73. assert.notEqual(asset.headers.get('location'), '/login');
  74. } finally {
  75. child.kill('SIGTERM');
  76. await waitForExit(child, 1000);
  77. }
  78. });
  79. test('auth pages preserve query messages instead of redirecting them away', async () => {
  80. const { child, baseUrl } = await startTestServer();
  81. try {
  82. for (const pathname of ['/login?error=hello', '/reset-password?token=abc123']) {
  83. const response = await fetch(`${baseUrl}${pathname}`, { redirect: 'manual' });
  84. assert.equal(response.status, 200);
  85. assert.equal(response.headers.get('location'), null);
  86. assert.match(await response.text(), /auth-root/);
  87. }
  88. } finally {
  89. child.kill('SIGTERM');
  90. await waitForExit(child, 1000);
  91. }
  92. });
  93. test('users can manage multiple smtp login credentials', async () => {
  94. const { child, baseUrl } = await startTestServer();
  95. try {
  96. const cookie = await login(baseUrl, 'admin', 'password123');
  97. const first = await createSmtpCredential(baseUrl, cookie, {
  98. username: 'admin-smtp-main',
  99. password: 'main-secret'
  100. });
  101. const second = await createSmtpCredential(baseUrl, cookie, {
  102. username: 'admin-smtp-app',
  103. password: 'app-secret'
  104. });
  105. assert.equal(first.username, 'admin-smtp-main');
  106. assert.equal(first.passwordSet, true);
  107. assert.equal('password' in first, false);
  108. assert.equal(second.username, 'admin-smtp-app');
  109. assert.equal(second.passwordSet, true);
  110. assert.equal('password' in second, false);
  111. const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } });
  112. assert.equal(list.status, 200);
  113. const listPayload = await list.json();
  114. assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']);
  115. assert.equal(listPayload.credentials[0].passwordSet, true);
  116. assert.equal('password' in listPayload.credentials[0], false);
  117. const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, {
  118. method: 'PATCH',
  119. headers: {
  120. 'Content-Type': 'application/json',
  121. Cookie: cookie
  122. },
  123. body: JSON.stringify({ username: 'admin-smtp-app-renamed' })
  124. });
  125. assert.equal(update.status, 200);
  126. const updatedCredential = (await update.json()).credential;
  127. assert.equal(updatedCredential.passwordSet, true);
  128. assert.equal('password' in updatedCredential, false);
  129. const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, {
  130. method: 'DELETE',
  131. headers: { Cookie: cookie }
  132. });
  133. assert.equal(deleted.status, 200);
  134. assert.equal((await deleted.json()).deleted, true);
  135. } finally {
  136. child.kill('SIGTERM');
  137. await waitForExit(child, 1000);
  138. }
  139. });
  140. test('users can manage inbound mailboxes and read inbound messages', async () => {
  141. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  142. try {
  143. const cookie = await login(baseUrl, 'admin', 'password123');
  144. await createSendingDomain(baseUrl, cookie, { domain: 'inbound-api.example' });
  145. const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  146. method: 'POST',
  147. headers: {
  148. 'Content-Type': 'application/json',
  149. Cookie: cookie
  150. },
  151. body: JSON.stringify({
  152. address: 'Support@inbound-api.example',
  153. displayName: 'Support',
  154. password: 'mailbox-pass-123',
  155. forwardTo: 'archive@example.net',
  156. keepForwarded: true
  157. })
  158. });
  159. assert.equal(createMailbox.status, 201);
  160. const createMailboxBody = await createMailbox.json();
  161. const mailbox = createMailboxBody.mailbox;
  162. assert.equal(mailbox.address, 'support@inbound-api.example');
  163. assert.equal(mailbox.displayName, 'Support');
  164. assert.equal(mailbox.passwordSet, true);
  165. assert.deepEqual(mailbox.forwardTo, ['archive@example.net']);
  166. assert.equal(mailbox.unreadCount, 0);
  167. assert.equal(createMailboxBody.clientConfig.username, 'support@inbound-api.example');
  168. assert.equal(createMailboxBody.clientConfig.password, 'mailbox-pass-123');
  169. assert.equal(createMailboxBody.clientConfig.outgoing.authMethod, 'Normal password');
  170. const mailboxes = await fetch(`${baseUrl}/api/inbound-mailboxes`, { headers: { Cookie: cookie } });
  171. assert.equal(mailboxes.status, 200);
  172. const mailboxesBody = await mailboxes.json();
  173. assert.deepEqual(mailboxesBody.mailboxes.map((entry) => entry.address), ['support@inbound-api.example']);
  174. const messageId = seedInboundMessage(dataDir, sessionSecret, 'support@inbound-api.example');
  175. const messages = await fetch(`${baseUrl}/api/inbound-messages?mailboxId=${mailbox.id}`, { headers: { Cookie: cookie } });
  176. assert.equal(messages.status, 200);
  177. const messagesBody = await messages.json();
  178. assert.equal(messagesBody.messages.length, 1);
  179. assert.equal(messagesBody.messages[0].id, messageId);
  180. assert.equal(messagesBody.messages[0].subject, 'Inbound API message');
  181. assert.equal(messagesBody.messages[0].textBody, undefined);
  182. const detail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } });
  183. assert.equal(detail.status, 200);
  184. const detailBody = await detail.json();
  185. assert.equal(detailBody.message.textBody, 'Hello from inbound API.');
  186. assert.equal(detailBody.message.rawMessage.includes('Inbound API message'), true);
  187. const markRead = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, {
  188. method: 'PATCH',
  189. headers: {
  190. 'Content-Type': 'application/json',
  191. Cookie: cookie
  192. },
  193. body: JSON.stringify({ read: true })
  194. });
  195. assert.equal(markRead.status, 200);
  196. assert.equal((await markRead.json()).message.read, true);
  197. } finally {
  198. child.kill('SIGTERM');
  199. await waitForExit(child, 1000);
  200. }
  201. });
  202. test('scoped API tokens create persistent and temporary mailboxes', async () => {
  203. const { child, baseUrl } = await startTestServer();
  204. try {
  205. const cookie = await login(baseUrl, 'admin', 'password123');
  206. await createSendingDomain(baseUrl, cookie, { domain: 'mailbox-token-api.example' });
  207. const sendOnlyResponse = await fetch(`${baseUrl}/api/api-tokens`, {
  208. method: 'POST',
  209. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  210. body: JSON.stringify({ name: 'send only', scopes: ['send'] })
  211. });
  212. assert.equal(sendOnlyResponse.status, 201);
  213. const sendOnly = (await sendOnlyResponse.json()).token;
  214. const denied = await fetch(`${baseUrl}/api/mailboxes`, {
  215. method: 'POST',
  216. headers: {
  217. Authorization: `Bearer ${sendOnly.token}`,
  218. 'Content-Type': 'application/json'
  219. },
  220. body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
  221. });
  222. assert.equal(denied.status, 403);
  223. const tokenResponse = await fetch(`${baseUrl}/api/api-tokens`, {
  224. method: 'POST',
  225. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  226. body: JSON.stringify({ name: 'mailboxes', scopes: ['mailboxes:read', 'mailboxes:write'] })
  227. });
  228. assert.equal(tokenResponse.status, 201);
  229. const mailboxToken = (await tokenResponse.json()).token;
  230. const temporary = await fetch(`${baseUrl}/api/mailboxes`, {
  231. method: 'POST',
  232. headers: {
  233. Authorization: `Bearer ${mailboxToken.token}`,
  234. 'Content-Type': 'application/json'
  235. },
  236. body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
  237. });
  238. assert.equal(temporary.status, 201);
  239. const temporaryBody = await temporary.json();
  240. assert.match(temporaryBody.mailbox.address, /^tmp-[a-f0-9]+@mailbox-token-api\.example$/);
  241. assert.equal(temporaryBody.mailbox.temporary, true);
  242. assert.ok(temporaryBody.mailbox.expiresAt);
  243. assert.equal(temporaryBody.password.length >= 8, true);
  244. assert.equal(temporaryBody.clientConfig.incoming.protocol, 'IMAP');
  245. const permanent = await fetch(`${baseUrl}/api/mailboxes`, {
  246. method: 'POST',
  247. headers: {
  248. Authorization: `Bearer ${mailboxToken.token}`,
  249. 'Content-Type': 'application/json'
  250. },
  251. body: JSON.stringify({
  252. mode: 'permanent',
  253. address: 'support@mailbox-token-api.example',
  254. password: 'mailbox-pass-123'
  255. })
  256. });
  257. assert.equal(permanent.status, 201);
  258. const permanentBody = await permanent.json();
  259. assert.equal(permanentBody.mailbox.temporary, false);
  260. assert.equal(permanentBody.mailbox.expiresAt, null);
  261. assert.equal(permanentBody.clientConfig.outgoing.protocol, 'SMTP');
  262. const listed = await fetch(`${baseUrl}/api/mailboxes`, {
  263. headers: { Authorization: `Bearer ${mailboxToken.token}` }
  264. });
  265. assert.equal(listed.status, 200);
  266. assert.equal((await listed.json()).mailboxes.length, 2);
  267. const sendDenied = await fetch(`${baseUrl}/api/send`, {
  268. method: 'POST',
  269. headers: {
  270. Authorization: `Bearer ${mailboxToken.token}`,
  271. 'Content-Type': 'application/json'
  272. },
  273. body: JSON.stringify({ from: 'noreply@mailbox-token-api.example', to: 'user@example.com', text: 'blocked' })
  274. });
  275. assert.equal(sendDenied.status, 403);
  276. const revoke = await fetch(`${baseUrl}/api/api-tokens/${mailboxToken.id}`, {
  277. method: 'DELETE',
  278. headers: { Cookie: cookie }
  279. });
  280. assert.equal(revoke.status, 200);
  281. assert.equal((await revoke.json()).token.status, 'revoked');
  282. const rejectedAfterRevoke = await fetch(`${baseUrl}/api/mailboxes`, {
  283. headers: { Authorization: `Bearer ${mailboxToken.token}` }
  284. });
  285. assert.equal(rejectedAfterRevoke.status, 401);
  286. } finally {
  287. child.kill('SIGTERM');
  288. await waitForExit(child, 1000);
  289. }
  290. });
  291. test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
  292. const relayServer = await startFakeSmtpServer();
  293. const { child, baseUrl } = await startTestServer();
  294. try {
  295. const cookie = await login(baseUrl, 'admin', 'password123');
  296. const domainResponse = await fetch(`${baseUrl}/api/domains`, {
  297. method: 'POST',
  298. headers: {
  299. 'Content-Type': 'application/json',
  300. Cookie: cookie
  301. },
  302. body: JSON.stringify({
  303. domain: 'relay.example',
  304. selector: 'mh',
  305. senderHost: 'mail.relay.example',
  306. sendingIp: '127.0.0.1'
  307. })
  308. });
  309. assert.equal(domainResponse.status, 201);
  310. const createRelay = await fetch(`${baseUrl}/api/smtp-relays`, {
  311. method: 'POST',
  312. headers: {
  313. 'Content-Type': 'application/json',
  314. Cookie: cookie
  315. },
  316. body: JSON.stringify({
  317. name: 'Primary outbound',
  318. host: '127.0.0.1',
  319. port: relayServer.port,
  320. secure: false,
  321. username: 'relay-user',
  322. password: 'relay-password',
  323. helo: 'helo.relay.example',
  324. isDefault: true
  325. })
  326. });
  327. assert.equal(createRelay.status, 201);
  328. const created = await createRelay.json();
  329. assert.equal(created.relay.passwordSet, true);
  330. assert.equal('password' in created.relay, false);
  331. const list = await fetch(`${baseUrl}/api/smtp-relays`, { headers: { Cookie: cookie } });
  332. assert.equal(list.status, 200);
  333. const listed = await list.json();
  334. assert.equal(listed.relays.length, 1);
  335. assert.equal('password' in listed.relays[0], false);
  336. const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
  337. assert.equal(detail.status, 200);
  338. const detailBody = await detail.json();
  339. assert.equal(detailBody.relay.passwordSet, true);
  340. assert.equal('password' in detailBody.relay, false);
  341. assert.equal('passwordSecret' in detailBody.relay, false);
  342. const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, {
  343. method: 'PATCH',
  344. headers: {
  345. 'Content-Type': 'application/json',
  346. Cookie: cookie
  347. },
  348. body: JSON.stringify({
  349. name: 'Missing relay',
  350. host: '127.0.0.1',
  351. port: relayServer.port,
  352. secure: false,
  353. username: 'missing-user',
  354. password: 'missing-password'
  355. })
  356. });
  357. assert.equal(missingPatch.status, 404);
  358. const updateWithoutPassword = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, {
  359. method: 'PATCH',
  360. headers: {
  361. 'Content-Type': 'application/json',
  362. Cookie: cookie
  363. },
  364. body: JSON.stringify({
  365. name: 'Primary outbound renamed',
  366. host: '127.0.0.1',
  367. port: relayServer.port,
  368. secure: false,
  369. username: 'relay-user'
  370. })
  371. });
  372. assert.equal(updateWithoutPassword.status, 200);
  373. const updatedRelay = (await updateWithoutPassword.json()).relay;
  374. assert.equal(updatedRelay.isDefault, true);
  375. assert.equal(updatedRelay.passwordSet, true);
  376. assert.equal('password' in updatedRelay, false);
  377. const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
  378. const detailAfterPatchBody = await detailAfterPatch.json();
  379. assert.equal(detailAfterPatchBody.relay.passwordSet, true);
  380. assert.equal('password' in detailAfterPatchBody.relay, false);
  381. const invalidRelaySend = await fetch(`${baseUrl}/api/send`, {
  382. method: 'POST',
  383. headers: {
  384. 'Content-Type': 'application/json',
  385. Cookie: cookie
  386. },
  387. body: JSON.stringify({
  388. from: 'noreply@relay.example',
  389. to: 'user@example.com',
  390. subject: 'Invalid relay',
  391. text: 'hello',
  392. smtpRelayId: 999999
  393. })
  394. });
  395. assert.equal(invalidRelaySend.status, 400);
  396. const send = await fetch(`${baseUrl}/api/send`, {
  397. method: 'POST',
  398. headers: {
  399. 'Content-Type': 'application/json',
  400. Cookie: cookie
  401. },
  402. body: JSON.stringify({
  403. from: 'noreply@relay.example',
  404. to: 'user@example.com',
  405. subject: 'Relay send',
  406. text: 'hello',
  407. smtpRelayId: created.relay.id
  408. })
  409. });
  410. assert.equal(send.status, 202);
  411. assert.equal((await send.json()).smtpRelayId, created.relay.id);
  412. await waitForCondition(() => relayServer.messages.length === 1);
  413. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  414. assert.ok(authCommand);
  415. assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), '\0relay-user\0relay-password');
  416. const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } });
  417. assert.equal(events.status, 200);
  418. const eventsBody = await events.json();
  419. assert.equal(eventsBody.events[0].smtpRelayId, created.relay.id);
  420. const eventDetail = await fetch(`${baseUrl}/api/events/${eventsBody.events[0].id}`, {
  421. headers: { Cookie: cookie }
  422. });
  423. assert.equal(eventDetail.status, 200);
  424. const eventDetailBody = await eventDetail.json();
  425. assert.equal(eventDetailBody.event.id, eventsBody.events[0].id);
  426. assert.equal(eventDetailBody.event.smtpRelayId, created.relay.id);
  427. assert.equal(Array.isArray(eventDetailBody.event.webhookDeliveries), true);
  428. } finally {
  429. child.kill('SIGTERM');
  430. await waitForExit(child, 1000);
  431. await relayServer.close();
  432. }
  433. });
  434. test('smtp relay selection prefers request relay then domain relay then user default relay', async () => {
  435. const requestRelayServer = await startFakeSmtpServer();
  436. const domainRelayServer = await startFakeSmtpServer();
  437. const defaultRelayServer = await startFakeSmtpServer();
  438. const { child, baseUrl } = await startTestServer();
  439. try {
  440. const cookie = await login(baseUrl, 'admin', 'password123');
  441. const settings = await fetch(`${baseUrl}/api/admin/settings`, {
  442. method: 'PATCH',
  443. headers: {
  444. 'Content-Type': 'application/json',
  445. Cookie: cookie
  446. },
  447. body: JSON.stringify({
  448. appBaseUrl: baseUrl,
  449. engagementTrackingEnabled: true
  450. })
  451. });
  452. assert.equal(settings.status, 200);
  453. const defaultRelay = await createSmtpRelay(baseUrl, cookie, {
  454. name: 'Default relay',
  455. host: '127.0.0.1',
  456. port: defaultRelayServer.port,
  457. username: 'default-user',
  458. password: 'default-password',
  459. isDefault: true
  460. });
  461. const domainRelay = await createSmtpRelay(baseUrl, cookie, {
  462. name: 'Domain relay',
  463. host: '127.0.0.1',
  464. port: domainRelayServer.port,
  465. username: 'domain-user',
  466. password: 'domain-password'
  467. });
  468. const requestRelay = await createSmtpRelay(baseUrl, cookie, {
  469. name: 'Request relay',
  470. host: '127.0.0.1',
  471. port: requestRelayServer.port,
  472. username: 'request-user',
  473. password: 'request-password'
  474. });
  475. const domain = await createSendingDomain(baseUrl, cookie, {
  476. domain: 'relay-order.example',
  477. smtpRelayId: domainRelay.id
  478. });
  479. assert.equal(domain.smtpRelayId, domainRelay.id);
  480. const domainSend = await sendApiMail(baseUrl, cookie, {
  481. from: 'noreply@relay-order.example',
  482. to: 'domain@example.com',
  483. subject: 'Domain relay'
  484. });
  485. assert.equal(domainSend.smtpRelayId, domainRelay.id);
  486. await waitForCondition(() => domainRelayServer.messages.length === 1);
  487. assertRelayAuth(domainRelayServer, 'domain-user', 'domain-password');
  488. const requestSend = await sendApiMail(baseUrl, cookie, {
  489. from: 'noreply@relay-order.example',
  490. to: 'request@example.com',
  491. subject: 'Request relay',
  492. smtpRelayId: requestRelay.id
  493. });
  494. assert.equal(requestSend.smtpRelayId, requestRelay.id);
  495. await waitForCondition(() => requestRelayServer.messages.length === 1);
  496. assertRelayAuth(requestRelayServer, 'request-user', 'request-password');
  497. const testSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
  498. method: 'POST',
  499. headers: {
  500. 'Content-Type': 'application/json',
  501. Cookie: cookie
  502. },
  503. body: JSON.stringify({
  504. to: 'test-send@example.com',
  505. subject: 'Selected relay test send',
  506. text: 'Open the HTML version to verify tracking.',
  507. html: '<html><body><p>MailHub tracking test.</p><a href="https://example.net/tracked">Tracked link</a></body></html>',
  508. smtpRelayId: requestRelay.id
  509. })
  510. });
  511. assert.equal(testSend.status, 202);
  512. const testSendBody = await testSend.json();
  513. assert.equal(testSendBody.smtpRelayId, requestRelay.id);
  514. assert.deepEqual(testSendBody.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
  515. await waitForCondition(() => requestRelayServer.messages.length === 2);
  516. const testHtml = decodeHtmlPart(requestRelayServer.messages[1]);
  517. assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
  518. assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
  519. assert.equal(testHtml.includes('https://example.net/tracked'), false);
  520. const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, {
  521. method: 'POST',
  522. headers: {
  523. 'Content-Type': 'application/json',
  524. Cookie: cookie
  525. },
  526. body: JSON.stringify({
  527. to: 'invalid-test-send@example.com',
  528. smtpRelayId: 999999
  529. })
  530. });
  531. assert.equal(invalidTestSend.status, 400);
  532. const defaultDomain = await createSendingDomain(baseUrl, cookie, {
  533. domain: 'default-relay.example'
  534. });
  535. assert.equal(defaultDomain.smtpRelayId, null);
  536. const defaultSend = await sendApiMail(baseUrl, cookie, {
  537. from: 'noreply@default-relay.example',
  538. to: 'default@example.com',
  539. subject: 'Default relay'
  540. });
  541. assert.equal(defaultSend.smtpRelayId, defaultRelay.id);
  542. await waitForCondition(() => defaultRelayServer.messages.length === 1);
  543. assertRelayAuth(defaultRelayServer, 'default-user', 'default-password');
  544. } finally {
  545. child.kill('SIGTERM');
  546. await waitForExit(child, 1000);
  547. await requestRelayServer.close();
  548. await domainRelayServer.close();
  549. await defaultRelayServer.close();
  550. }
  551. });
  552. test('admin users can list audit logs', async () => {
  553. const { child, baseUrl } = await startTestServer();
  554. try {
  555. const cookie = await login(baseUrl, 'admin', 'password123');
  556. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  557. headers: { Cookie: cookie }
  558. });
  559. assert.equal(response.status, 200);
  560. assert.deepEqual(await response.json(), { logs: [] });
  561. } finally {
  562. child.kill('SIGTERM');
  563. await waitForExit(child, 1000);
  564. }
  565. });
  566. test('admin users can list resource inventory', async () => {
  567. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  568. try {
  569. seedUsers(dataDir, sessionSecret, [{
  570. username: 'alice',
  571. email: 'alice@example.com',
  572. password: 'password123',
  573. status: 'active'
  574. }]);
  575. const adminCookie = await login(baseUrl, 'admin', 'password123');
  576. const userCookie = await login(baseUrl, 'alice', 'password123');
  577. const forbidden = await fetch(`${baseUrl}/api/admin/resources`, {
  578. headers: { Cookie: userCookie }
  579. });
  580. assert.equal(forbidden.status, 403);
  581. const response = await fetch(`${baseUrl}/api/admin/resources`, {
  582. headers: { Cookie: adminCookie }
  583. });
  584. assert.equal(response.status, 200);
  585. const body = await response.json();
  586. assert.ok(Array.isArray(body.inventory.users));
  587. assert.ok(Array.isArray(body.inventory.warnings));
  588. assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice'));
  589. } finally {
  590. child.kill('SIGTERM');
  591. await waitForExit(child, 1000);
  592. }
  593. });
  594. test('admin users can transfer individual resources', async () => {
  595. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  596. try {
  597. const seeded = seedTransferResources(dataDir, sessionSecret);
  598. const adminCookie = await login(baseUrl, 'admin', 'password123');
  599. const aliceCookie = await login(baseUrl, 'alice', 'password123');
  600. const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  601. method: 'POST',
  602. headers: {
  603. 'Content-Type': 'application/json',
  604. Cookie: aliceCookie
  605. },
  606. body: JSON.stringify({ targetUserId: seeded.bobId })
  607. });
  608. assert.equal(forbidden.status, 403);
  609. const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, {
  610. method: 'POST',
  611. headers: {
  612. 'Content-Type': 'application/json',
  613. Cookie: adminCookie
  614. },
  615. body: JSON.stringify({
  616. targetUserId: seeded.bobId,
  617. dnsCredentialMode: 'with_dns_credential'
  618. })
  619. });
  620. assert.equal(domain.status, 200);
  621. const domainBody = await domain.json();
  622. assert.equal(domainBody.domain.userId, seeded.bobId);
  623. assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId);
  624. const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, {
  625. method: 'POST',
  626. headers: {
  627. 'Content-Type': 'application/json',
  628. Cookie: adminCookie
  629. },
  630. body: JSON.stringify({ targetUserId: seeded.bobId })
  631. });
  632. assert.equal(dns.status, 200);
  633. assert.equal((await dns.json()).credential.userId, seeded.bobId);
  634. const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, {
  635. method: 'POST',
  636. headers: {
  637. 'Content-Type': 'application/json',
  638. Cookie: adminCookie
  639. },
  640. body: JSON.stringify({
  641. targetUserId: seeded.bobId,
  642. tokenIds: [seeded.apiTokenId]
  643. })
  644. });
  645. assert.equal(tokens.status, 200);
  646. const tokensBody = await tokens.json();
  647. assert.equal(tokensBody.tokens.length, 1);
  648. assert.equal(tokensBody.tokens[0].userId, seeded.bobId);
  649. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, {
  650. headers: { Cookie: adminCookie }
  651. });
  652. const actions = (await audit.json()).logs.map((entry) => entry.action);
  653. assert.ok(actions.includes('admin.transfer_domain'));
  654. assert.ok(actions.includes('admin.transfer_dns_credential'));
  655. assert.ok(actions.includes('admin.transfer_api_tokens'));
  656. } finally {
  657. child.kill('SIGTERM');
  658. await waitForExit(child, 1000);
  659. }
  660. });
  661. test('admin users can preview and execute user merge', async () => {
  662. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  663. try {
  664. const seeded = seedMergeResources(dataDir, sessionSecret);
  665. const adminCookie = await login(baseUrl, 'admin', 'password123');
  666. const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, {
  667. method: 'POST',
  668. headers: {
  669. 'Content-Type': 'application/json',
  670. Cookie: adminCookie
  671. },
  672. body: JSON.stringify({
  673. sourceUserId: seeded.sourceId,
  674. targetUserId: seeded.targetId
  675. })
  676. });
  677. assert.equal(previewResponse.status, 200);
  678. const preview = (await previewResponse.json()).preview;
  679. assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget');
  680. assert.equal(preview.counts.domains, 1);
  681. const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  682. method: 'POST',
  683. headers: {
  684. 'Content-Type': 'application/json',
  685. Cookie: adminCookie
  686. },
  687. body: JSON.stringify({
  688. sourceUserId: seeded.sourceId,
  689. targetUserId: seeded.targetId,
  690. confirmation: 'wrong'
  691. })
  692. });
  693. assert.equal(invalid.status, 400);
  694. const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, {
  695. method: 'POST',
  696. headers: {
  697. 'Content-Type': 'application/json',
  698. Cookie: adminCookie
  699. },
  700. body: JSON.stringify({
  701. sourceUserId: seeded.sourceId,
  702. targetUserId: seeded.targetId,
  703. confirmation: preview.confirmationText
  704. })
  705. });
  706. assert.equal(execute.status, 200);
  707. const result = (await execute.json()).result;
  708. assert.equal(result.counts.domains, 1);
  709. assert.equal(result.sourceUser.status, 'disabled');
  710. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, {
  711. headers: { Cookie: adminCookie }
  712. });
  713. assert.equal(audit.status, 200);
  714. assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId);
  715. } finally {
  716. child.kill('SIGTERM');
  717. await waitForExit(child, 1000);
  718. }
  719. });
  720. test('admin users can manage system email settings without exposing password', async () => {
  721. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  722. try {
  723. seedUsers(dataDir, sessionSecret, [{
  724. username: 'alice',
  725. email: 'alice@example.com',
  726. password: 'password123',
  727. status: 'active'
  728. }]);
  729. const adminCookie = await login(baseUrl, 'admin', 'password123');
  730. const userCookie = await login(baseUrl, 'alice', 'password123');
  731. const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, {
  732. headers: { Cookie: userCookie }
  733. });
  734. assert.equal(forbidden.status, 403);
  735. const empty = await fetch(`${baseUrl}/api/admin/system-email`, {
  736. headers: { Cookie: adminCookie }
  737. });
  738. assert.equal(empty.status, 200);
  739. assert.equal((await empty.json()).settings.passwordSet, false);
  740. const saved = await fetch(`${baseUrl}/api/admin/system-email`, {
  741. method: 'PATCH',
  742. headers: {
  743. 'Content-Type': 'application/json',
  744. Cookie: adminCookie
  745. },
  746. body: JSON.stringify({
  747. host: 'smtp.example.com',
  748. port: 587,
  749. secure: false,
  750. username: 'mailer@example.com',
  751. password: 'smtp-password-123',
  752. helo: 'mail.example.com',
  753. fromEmail: 'notify@example.com',
  754. fromName: 'MailHub Notify',
  755. testRecipient: 'admin@example.com'
  756. })
  757. });
  758. assert.equal(saved.status, 200);
  759. const savedBody = await saved.json();
  760. assert.equal(savedBody.settings.host, 'smtp.example.com');
  761. assert.equal(savedBody.settings.port, 587);
  762. assert.equal(savedBody.settings.secure, false);
  763. assert.equal(savedBody.settings.passwordSet, true);
  764. assert.equal('password' in savedBody.settings, false);
  765. assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false);
  766. const preserved = await fetch(`${baseUrl}/api/admin/system-email`, {
  767. method: 'PATCH',
  768. headers: {
  769. 'Content-Type': 'application/json',
  770. Cookie: adminCookie
  771. },
  772. body: JSON.stringify({
  773. host: 'smtp2.example.com',
  774. password: ''
  775. })
  776. });
  777. assert.equal(preserved.status, 200);
  778. const preservedBody = await preserved.json();
  779. assert.equal(preservedBody.settings.host, 'smtp2.example.com');
  780. assert.equal(preservedBody.settings.passwordSet, true);
  781. assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false);
  782. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, {
  783. headers: { Cookie: adminCookie }
  784. });
  785. assert.equal(audit.status, 200);
  786. const [entry] = (await audit.json()).logs;
  787. assert.equal(entry.action, 'admin.update_system_email');
  788. assert.equal(entry.targetType, 'system_email');
  789. assert.equal(entry.summary.host, 'smtp2.example.com');
  790. assert.equal(entry.summary.password, undefined);
  791. assert.equal(entry.summary.passwordSet, true);
  792. } finally {
  793. child.kill('SIGTERM');
  794. await waitForExit(child, 1000);
  795. }
  796. });
  797. test('registration and verification resend use configured system email', async () => {
  798. const smtp = await startFakeSmtpServer();
  799. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  800. try {
  801. seedUsers(dataDir, sessionSecret, [
  802. { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' },
  803. { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' }
  804. ]);
  805. const adminCookie = await login(baseUrl, 'admin', 'password123');
  806. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  807. const register = await fetch(`${baseUrl}/api/register`, {
  808. method: 'POST',
  809. headers: { 'Content-Type': 'application/json' },
  810. body: JSON.stringify({
  811. username: 'mailuser',
  812. email: 'mailuser@example.com',
  813. password: 'password123'
  814. })
  815. });
  816. assert.equal(register.status, 201);
  817. const registerBody = await register.json();
  818. assert.equal(registerBody.user.status, 'pending_email');
  819. assert.equal(registerBody.verificationEmailSent, true);
  820. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
  821. const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  822. method: 'POST',
  823. headers: { 'Content-Type': 'application/json' },
  824. body: JSON.stringify({ email: 'publicpending@example.com' })
  825. });
  826. assert.equal(publicResend.status, 202);
  827. const publicResendBody = await publicResend.json();
  828. assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。');
  829. assert.equal('verificationEmailSent' in publicResendBody, false);
  830. assert.equal('result' in publicResendBody, false);
  831. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1);
  832. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  833. headers: { Cookie: adminCookie }
  834. });
  835. const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending');
  836. assert.ok(adminPending);
  837. const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, {
  838. method: 'POST',
  839. headers: { Cookie: adminCookie }
  840. });
  841. assert.equal(adminResend.status, 202);
  842. assert.equal((await adminResend.json()).verificationEmailSent, true);
  843. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1);
  844. assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:<notify@example.com>'));
  845. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<mailuser@example.com>'));
  846. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<publicpending@example.com>'));
  847. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<adminpending@example.com>'));
  848. assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false);
  849. } finally {
  850. child.kill('SIGTERM');
  851. await waitForExit(child, 1000);
  852. await smtp.close();
  853. }
  854. });
  855. test('public verification resend is generic and does not create tokens without mail config', async () => {
  856. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  857. try {
  858. seedUsers(dataDir, sessionSecret, [{
  859. username: 'pendingnomail',
  860. email: 'pendingnomail@example.com',
  861. password: 'password123',
  862. status: 'pending_email'
  863. }]);
  864. const response = await fetch(`${baseUrl}/api/auth/resend-verification`, {
  865. method: 'POST',
  866. headers: { 'Content-Type': 'application/json' },
  867. body: JSON.stringify({ email: 'pendingnomail@example.com' })
  868. });
  869. assert.equal(response.status, 202);
  870. assert.deepEqual(await response.json(), {
  871. message: '如果账号需要验证,我们会发送验证邮件。'
  872. });
  873. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0);
  874. } finally {
  875. child.kill('SIGTERM');
  876. await waitForExit(child, 1000);
  877. }
  878. });
  879. test('registration reports pending email when system email is not configured', async () => {
  880. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  881. try {
  882. const register = await fetch(`${baseUrl}/api/register`, {
  883. method: 'POST',
  884. headers: { 'Content-Type': 'application/json' },
  885. body: JSON.stringify({
  886. username: 'nomailuser',
  887. email: 'nomailuser@example.com',
  888. password: 'password123'
  889. })
  890. });
  891. assert.equal(register.status, 201);
  892. const body = await register.json();
  893. assert.equal(body.user.status, 'pending_email');
  894. assert.equal(body.verificationEmailSent, false);
  895. assert.match(body.message, /验证邮件暂未发送/);
  896. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1);
  897. } finally {
  898. child.kill('SIGTERM');
  899. await waitForExit(child, 1000);
  900. }
  901. });
  902. test('admin users can send system email test messages', async () => {
  903. const smtp = await startFakeSmtpServer();
  904. const { child, baseUrl } = await startTestServer();
  905. try {
  906. const adminCookie = await login(baseUrl, 'admin', 'password123');
  907. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  908. const response = await fetch(`${baseUrl}/api/admin/system-email/test`, {
  909. method: 'POST',
  910. headers: {
  911. 'Content-Type': 'application/json',
  912. Cookie: adminCookie
  913. },
  914. body: JSON.stringify({ to: 'operator@example.com' })
  915. });
  916. assert.equal(response.status, 202);
  917. const body = await response.json();
  918. assert.equal(body.result.ok, true);
  919. assert.equal(body.result.queueId, 'SYS123');
  920. assert.equal(JSON.stringify(body).includes('smtp-password-123'), false);
  921. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<operator@example.com>'));
  922. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, {
  923. headers: { Cookie: adminCookie }
  924. });
  925. assert.equal(audit.status, 200);
  926. const [entry] = (await audit.json()).logs;
  927. assert.equal(entry.targetType, 'system_email');
  928. assert.equal(entry.summary.to, 'operator@example.com');
  929. assert.equal(entry.summary.ok, true);
  930. } finally {
  931. child.kill('SIGTERM');
  932. await waitForExit(child, 1000);
  933. await smtp.close();
  934. }
  935. });
  936. test('public forgot password is generic and sends reset email when configured', async () => {
  937. const smtp = await startFakeSmtpServer({ responseDelayMs: 700 });
  938. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  939. try {
  940. seedUsers(dataDir, sessionSecret, [{
  941. username: 'resetme',
  942. email: 'resetme@example.com',
  943. password: 'password123',
  944. status: 'active'
  945. }]);
  946. const adminCookie = await login(baseUrl, 'admin', 'password123');
  947. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  948. const startedAt = Date.now();
  949. const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  950. method: 'POST',
  951. headers: { 'Content-Type': 'application/json' },
  952. body: JSON.stringify({ email: 'resetme@example.com' })
  953. });
  954. const elapsedMs = Date.now() - startedAt;
  955. assert.equal(existing.status, 202);
  956. assert.equal(elapsedMs < 500, true);
  957. assert.deepEqual(await existing.json(), {
  958. message: '如果邮箱存在,我们会发送密码重置邮件。'
  959. });
  960. await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1);
  961. const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, {
  962. method: 'POST',
  963. headers: { 'Content-Type': 'application/json' },
  964. body: JSON.stringify({ email: 'missing@example.com' })
  965. });
  966. assert.equal(missing.status, 202);
  967. assert.deepEqual(await missing.json(), {
  968. message: '如果邮箱存在,我们会发送密码重置邮件。'
  969. });
  970. await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:<resetme@example.com>'));
  971. } finally {
  972. child.kill('SIGTERM');
  973. await waitForExit(child, 1000);
  974. await smtp.close();
  975. }
  976. });
  977. test('public reset password consumes token and updates password', async () => {
  978. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  979. try {
  980. seedUsers(dataDir, sessionSecret, [{
  981. username: 'tokenreset',
  982. email: 'tokenreset@example.com',
  983. password: 'password123',
  984. status: 'active'
  985. }]);
  986. const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset');
  987. const response = await fetch(`${baseUrl}/api/auth/reset-password`, {
  988. method: 'POST',
  989. headers: { 'Content-Type': 'application/json' },
  990. body: JSON.stringify({
  991. token,
  992. password: 'new-password-123'
  993. })
  994. });
  995. assert.equal(response.status, 200);
  996. assert.deepEqual(await response.json(), {
  997. message: '密码已重置,请使用新密码登录。'
  998. });
  999. const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123');
  1000. assert.equal(oldLogin.status, 401);
  1001. const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123');
  1002. assert.equal(newLogin.status, 200);
  1003. const reused = await fetch(`${baseUrl}/api/auth/reset-password`, {
  1004. method: 'POST',
  1005. headers: { 'Content-Type': 'application/json' },
  1006. body: JSON.stringify({
  1007. token,
  1008. password: 'another-password-123'
  1009. })
  1010. });
  1011. assert.equal(reused.status, 400);
  1012. } finally {
  1013. child.kill('SIGTERM');
  1014. await waitForExit(child, 1000);
  1015. }
  1016. });
  1017. test('admin users can trigger password reset email and set temporary password', async () => {
  1018. const smtp = await startFakeSmtpServer();
  1019. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1020. try {
  1021. seedUsers(dataDir, sessionSecret, [
  1022. { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' },
  1023. { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' }
  1024. ]);
  1025. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1026. const memberCookie = await login(baseUrl, 'member2', 'password123');
  1027. await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port);
  1028. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1029. headers: { Cookie: adminCookie }
  1030. });
  1031. const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser');
  1032. assert.ok(target);
  1033. const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  1034. method: 'POST',
  1035. headers: { Cookie: memberCookie }
  1036. });
  1037. assert.equal(forbiddenReset.status, 403);
  1038. const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, {
  1039. method: 'POST',
  1040. headers: { Cookie: adminCookie }
  1041. });
  1042. assert.equal(reset.status, 202);
  1043. assert.equal((await reset.json()).result.ok, true);
  1044. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1);
  1045. assert.ok(smtp.commands.some((command) => command === 'RCPT TO:<targetuser@example.com>'));
  1046. const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  1047. method: 'POST',
  1048. headers: {
  1049. 'Content-Type': 'application/json',
  1050. Cookie: memberCookie
  1051. },
  1052. body: JSON.stringify({ password: 'temporary-123' })
  1053. });
  1054. assert.equal(forbiddenTemporary.status, 403);
  1055. const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, {
  1056. method: 'POST',
  1057. headers: {
  1058. 'Content-Type': 'application/json',
  1059. Cookie: adminCookie
  1060. },
  1061. body: JSON.stringify({ password: 'temporary-123' })
  1062. });
  1063. assert.equal(temporary.status, 200);
  1064. assert.equal((await temporary.json()).user.id, target.id);
  1065. assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0);
  1066. const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123');
  1067. assert.equal(oldLogin.status, 401);
  1068. const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123');
  1069. assert.equal(tempLogin.status, 200);
  1070. const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, {
  1071. headers: { Cookie: adminCookie }
  1072. });
  1073. assert.equal(audit.status, 200);
  1074. const logs = (await audit.json()).logs;
  1075. assert.ok(logs.some((entry) => entry.action === 'admin.password_reset'));
  1076. const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password');
  1077. assert.ok(temporaryLog);
  1078. assert.equal(temporaryLog.summary.username, 'targetuser');
  1079. assert.equal(temporaryLog.summary.password, undefined);
  1080. assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false);
  1081. } finally {
  1082. child.kill('SIGTERM');
  1083. await waitForExit(child, 1000);
  1084. await smtp.close();
  1085. }
  1086. });
  1087. test('non-admin users cannot list audit logs', async () => {
  1088. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1089. try {
  1090. seedUsers(dataDir, sessionSecret, [{
  1091. username: 'alice',
  1092. email: 'alice@example.com',
  1093. password: 'password123',
  1094. status: 'active'
  1095. }]);
  1096. const cookie = await login(baseUrl, 'alice', 'password123');
  1097. const response = await fetch(`${baseUrl}/api/admin/audit-logs`, {
  1098. headers: { Cookie: cookie }
  1099. });
  1100. assert.equal(response.status, 403);
  1101. } finally {
  1102. child.kill('SIGTERM');
  1103. await waitForExit(child, 1000);
  1104. }
  1105. });
  1106. test('admin user patch rejects invalid status with a bad request', async () => {
  1107. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1108. try {
  1109. seedUsers(dataDir, sessionSecret, [{
  1110. username: 'badstatus',
  1111. email: 'badstatus@example.com',
  1112. password: 'password123',
  1113. status: 'active'
  1114. }]);
  1115. const cookie = await login(baseUrl, 'admin', 'password123');
  1116. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1117. headers: { Cookie: cookie }
  1118. });
  1119. assert.equal(usersResponse.status, 200);
  1120. const usersBody = await usersResponse.json();
  1121. const target = usersBody.users.find((user) => user.username === 'badstatus');
  1122. assert.ok(target);
  1123. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  1124. method: 'PATCH',
  1125. headers: {
  1126. 'Content-Type': 'application/json',
  1127. Cookie: cookie
  1128. },
  1129. body: JSON.stringify({ status: 'archived' })
  1130. });
  1131. assert.equal(response.status, 400);
  1132. assert.equal((await response.json()).error, '用户状态不正确。');
  1133. const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, {
  1134. method: 'PATCH',
  1135. headers: {
  1136. 'Content-Type': 'application/json',
  1137. Cookie: cookie
  1138. },
  1139. body: JSON.stringify({ password: 'short' })
  1140. });
  1141. assert.equal(shortPassword.status, 400);
  1142. assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。');
  1143. } finally {
  1144. child.kill('SIGTERM');
  1145. await waitForExit(child, 1000);
  1146. }
  1147. });
  1148. test('self registration creates a pending email user and verification token without a session', async () => {
  1149. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1150. try {
  1151. const register = await fetch(`${baseUrl}/api/register`, {
  1152. method: 'POST',
  1153. headers: { 'Content-Type': 'application/json' },
  1154. body: JSON.stringify({
  1155. username: 'newuser',
  1156. email: 'newuser@example.com',
  1157. password: 'password123'
  1158. })
  1159. });
  1160. assert.equal(register.status, 201);
  1161. assert.equal(sessionCookieFrom(register), '');
  1162. const text = await register.text();
  1163. assert.doesNotMatch(text, /token/i);
  1164. const body = JSON.parse(text);
  1165. assert.equal('token' in body, false);
  1166. assert.equal('token' in body.user, false);
  1167. assert.equal('tokenHash' in body.user, false);
  1168. assert.equal(body.user.status, 'pending_email');
  1169. assert.match(body.message, /验证邮箱/);
  1170. assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1);
  1171. const login = await loginResponse(baseUrl, 'newuser', 'password123');
  1172. assert.equal(login.status, 403);
  1173. assert.equal(sessionCookieFrom(login), '');
  1174. assert.equal((await login.json()).error, '请先验证邮箱。');
  1175. } finally {
  1176. child.kill('SIGTERM');
  1177. await waitForExit(child, 1000);
  1178. }
  1179. });
  1180. test('email verification route consumes token and moves user to admin review', async () => {
  1181. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1182. try {
  1183. const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
  1184. username: 'verifyme',
  1185. email: 'verifyme@example.com',
  1186. password: 'password123',
  1187. status: 'pending_email'
  1188. });
  1189. const missing = await fetch(`${baseUrl}/api/auth/verify-email`);
  1190. assert.equal(missing.status, 400);
  1191. assert.equal(sessionCookieFrom(missing), '');
  1192. const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`);
  1193. assert.equal(invalid.status, 400);
  1194. assert.equal(sessionCookieFrom(invalid), '');
  1195. const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  1196. assert.equal(response.status, 200);
  1197. assert.equal(sessionCookieFrom(response), '');
  1198. const body = await response.json();
  1199. assert.equal(body.user.id, created.user.id);
  1200. assert.equal(body.user.status, 'pending_review');
  1201. assert.match(body.message, /管理员审核/);
  1202. const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
  1203. assert.equal(reused.status, 400);
  1204. assert.equal(sessionCookieFrom(reused), '');
  1205. const login = await loginResponse(baseUrl, 'verifyme', 'password123');
  1206. assert.equal(login.status, 403);
  1207. assert.equal(sessionCookieFrom(login), '');
  1208. assert.equal((await login.json()).error, '账号正在等待管理员审核。');
  1209. } finally {
  1210. child.kill('SIGTERM');
  1211. await waitForExit(child, 1000);
  1212. }
  1213. });
  1214. test('admin users can approve pending review users with an audit log', async () => {
  1215. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1216. try {
  1217. seedUsers(dataDir, sessionSecret, [
  1218. { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' },
  1219. { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' },
  1220. { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' },
  1221. { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' }
  1222. ]);
  1223. const adminCookie = await login(baseUrl, 'admin', 'password123');
  1224. const usersResponse = await fetch(`${baseUrl}/api/admin/users`, {
  1225. headers: { Cookie: adminCookie }
  1226. });
  1227. assert.equal(usersResponse.status, 200);
  1228. const users = (await usersResponse.json()).users;
  1229. const target = users.find((user) => user.username === 'reviewme');
  1230. const pendingEmail = users.find((user) => user.username === 'emailonly');
  1231. const disabled = users.find((user) => user.username === 'disabledreview');
  1232. assert.ok(target);
  1233. assert.ok(pendingEmail);
  1234. assert.ok(disabled);
  1235. const memberCookie = await login(baseUrl, 'member', 'password123');
  1236. const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  1237. method: 'POST',
  1238. headers: { Cookie: memberCookie }
  1239. });
  1240. assert.equal(nonAdmin.status, 403);
  1241. const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, {
  1242. method: 'POST',
  1243. headers: { Cookie: adminCookie }
  1244. });
  1245. assert.equal(missing.status, 404);
  1246. const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, {
  1247. method: 'POST',
  1248. headers: { Cookie: adminCookie }
  1249. });
  1250. assert.equal(pendingEmailResponse.status, 400);
  1251. assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/);
  1252. const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, {
  1253. method: 'POST',
  1254. headers: { Cookie: adminCookie }
  1255. });
  1256. assert.equal(disabledResponse.status, 400);
  1257. assert.match((await disabledResponse.json()).error, /等待审核|只能审批/);
  1258. const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, {
  1259. method: 'POST',
  1260. headers: { Cookie: adminCookie }
  1261. });
  1262. assert.equal(response.status, 200);
  1263. const body = await response.json();
  1264. assert.equal(body.user.id, target.id);
  1265. assert.equal(body.user.status, 'active');
  1266. const approvedCookie = await login(baseUrl, 'reviewme', 'password123');
  1267. assert.ok(approvedCookie);
  1268. const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, {
  1269. headers: { Cookie: adminCookie }
  1270. });
  1271. assert.equal(auditResponse.status, 200);
  1272. const [entry] = (await auditResponse.json()).logs;
  1273. assert.equal(entry.action, 'admin.approve_user');
  1274. assert.equal(entry.targetType, 'user');
  1275. assert.equal(entry.targetId, String(target.id));
  1276. assert.equal(entry.targetUserId, target.id);
  1277. assert.equal(entry.summary.username, 'reviewme');
  1278. assert.equal(entry.summary.status, 'active');
  1279. assert.equal(entry.summary.password, undefined);
  1280. assert.equal(entry.summary.token, undefined);
  1281. } finally {
  1282. child.kill('SIGTERM');
  1283. await waitForExit(child, 1000);
  1284. }
  1285. });
  1286. test('login returns account status restrictions only after password verification', async () => {
  1287. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1288. try {
  1289. seedUsers(dataDir, sessionSecret, [
  1290. { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' },
  1291. { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' },
  1292. { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' },
  1293. { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' }
  1294. ]);
  1295. await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。');
  1296. await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。');
  1297. await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。');
  1298. const active = await loginResponse(baseUrl, 'activeuser', 'password123');
  1299. assert.equal(active.status, 200);
  1300. assert.ok(sessionCookieFrom(active));
  1301. } finally {
  1302. child.kill('SIGTERM');
  1303. await waitForExit(child, 1000);
  1304. }
  1305. });
  1306. test('admin audit log actor filter rejects non-decimal user ids', async () => {
  1307. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1308. try {
  1309. seedAuditLogs(dataDir, sessionSecret);
  1310. const cookie = await login(baseUrl, 'admin', 'password123');
  1311. assert.deepEqual(
  1312. await auditLogActions(baseUrl, cookie, 'actorUserId=1'),
  1313. ['audit.actor-one']
  1314. );
  1315. assert.deepEqual(
  1316. await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'),
  1317. ['audit.actor-one-hundred', 'audit.actor-one']
  1318. );
  1319. } finally {
  1320. child.kill('SIGTERM');
  1321. await waitForExit(child, 1000);
  1322. }
  1323. });
  1324. test('admin audit log date filter ignores invalid dates', async () => {
  1325. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  1326. try {
  1327. seedAuditLogs(dataDir, sessionSecret);
  1328. const cookie = await login(baseUrl, 'admin', 'password123');
  1329. assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []);
  1330. assert.deepEqual(
  1331. await auditLogActions(baseUrl, cookie, 'from=2026-02-31'),
  1332. ['audit.actor-one-hundred', 'audit.actor-one']
  1333. );
  1334. } finally {
  1335. child.kill('SIGTERM');
  1336. await waitForExit(child, 1000);
  1337. }
  1338. });
  1339. async function startTestServer() {
  1340. const port = await freePort();
  1341. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-'));
  1342. const sessionSecret = 'test-session-secret';
  1343. const child = spawn(process.execPath, ['src/server.js'], {
  1344. cwd: process.cwd(),
  1345. env: {
  1346. ...process.env,
  1347. PORT: String(port),
  1348. DATA_DIR: dataDir,
  1349. ADMIN_PASSWORD: 'password123',
  1350. SESSION_SECRET: sessionSecret,
  1351. DNS_AUTO_CHECK_ENABLED: 'false',
  1352. SUBMISSION_ENABLED: 'false',
  1353. IMAP_ENABLED: 'false',
  1354. POP3_ENABLED: 'false'
  1355. },
  1356. stdio: ['ignore', 'pipe', 'pipe']
  1357. });
  1358. await waitForOutput(child, 'MailHub listening');
  1359. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  1360. }
  1361. async function login(baseUrl, username, password) {
  1362. const response = await loginResponse(baseUrl, username, password);
  1363. assert.equal(response.status, 200);
  1364. const cookie = sessionCookieFrom(response);
  1365. assert.ok(cookie);
  1366. return cookie;
  1367. }
  1368. function loginResponse(baseUrl, username, password) {
  1369. return fetch(`${baseUrl}/api/login`, {
  1370. method: 'POST',
  1371. headers: { 'Content-Type': 'application/json' },
  1372. body: JSON.stringify({ username, password })
  1373. });
  1374. }
  1375. async function assertLoginDeniedByStatus(baseUrl, username, message) {
  1376. const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password');
  1377. assert.equal(wrongPassword.status, 401);
  1378. assert.equal((await wrongPassword.json()).error, '账号或密码不正确。');
  1379. assert.equal(sessionCookieFrom(wrongPassword), '');
  1380. const correctPassword = await loginResponse(baseUrl, username, 'password123');
  1381. assert.equal(correctPassword.status, 403);
  1382. assert.equal((await correctPassword.json()).error, message);
  1383. assert.equal(sessionCookieFrom(correctPassword), '');
  1384. }
  1385. function sessionCookieFrom(response) {
  1386. return response.headers.get('set-cookie')?.split(';')[0] || '';
  1387. }
  1388. function seedUsers(dataDir, sessionSecret, users) {
  1389. const script = `
  1390. import { initDatabase, createUser } from './src/db.js';
  1391. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1392. for (const user of JSON.parse(process.env.SEED_USERS)) {
  1393. createUser(user);
  1394. }
  1395. `;
  1396. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1397. cwd: process.cwd(),
  1398. env: {
  1399. ...process.env,
  1400. DATA_DIR: dataDir,
  1401. SESSION_SECRET: sessionSecret,
  1402. SEED_USERS: JSON.stringify(users)
  1403. },
  1404. encoding: 'utf8'
  1405. });
  1406. assert.equal(result.status, 0, result.stderr || result.stdout);
  1407. }
  1408. function seedInboundMessage(dataDir, sessionSecret, address) {
  1409. const script = `
  1410. import {
  1411. createInboundMessage,
  1412. getInboundMailboxByAddress,
  1413. initDatabase
  1414. } from './src/db.js';
  1415. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1416. const mailbox = getInboundMailboxByAddress(process.env.INBOUND_ADDRESS);
  1417. const message = createInboundMessage(mailbox, {
  1418. sender: 'alice@example.net',
  1419. recipients: [process.env.INBOUND_ADDRESS],
  1420. subject: 'Inbound API message',
  1421. messageId: '<inbound-api@example.net>',
  1422. rawMessage: [
  1423. 'From: Alice <alice@example.net>',
  1424. 'To: Support <' + process.env.INBOUND_ADDRESS + '>',
  1425. 'Subject: Inbound API message',
  1426. '',
  1427. 'Hello from inbound API.'
  1428. ].join('\\r\\n'),
  1429. textBody: 'Hello from inbound API.'
  1430. });
  1431. console.log(String(message.id));
  1432. `;
  1433. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1434. cwd: process.cwd(),
  1435. env: {
  1436. ...process.env,
  1437. DATA_DIR: dataDir,
  1438. SESSION_SECRET: sessionSecret,
  1439. INBOUND_ADDRESS: address
  1440. },
  1441. encoding: 'utf8'
  1442. });
  1443. assert.equal(result.status, 0, result.stderr || result.stdout);
  1444. return Number(result.stdout.trim());
  1445. }
  1446. function seedTransferResources(dataDir, sessionSecret) {
  1447. const script = `
  1448. import {
  1449. initDatabase,
  1450. createApiToken,
  1451. createDomain,
  1452. createUser,
  1453. saveDnsCredential
  1454. } from './src/db.js';
  1455. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1456. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' });
  1457. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' });
  1458. const credential = saveDnsCredential(alice.id, {
  1459. name: 'Alice DNS',
  1460. provider: 'cloudflare',
  1461. zoneName: 'alice.example',
  1462. credentials: { apiToken: 'secret-token' }
  1463. });
  1464. const standaloneCredential = saveDnsCredential(alice.id, {
  1465. name: 'Standalone DNS',
  1466. provider: 'cloudflare',
  1467. zoneName: 'standalone.example',
  1468. credentials: { apiToken: 'standalone-secret-token' }
  1469. });
  1470. const domain = createDomain(alice.id, {
  1471. dnsCredentialId: credential.id,
  1472. domain: 'alice.example',
  1473. selector: 'mh202607',
  1474. verificationToken: 'token',
  1475. dkimPublic: 'public',
  1476. dkimPrivate: 'private',
  1477. senderHost: 'mail.alice.example',
  1478. sendingIp: '127.0.0.1',
  1479. spfExtra: '',
  1480. dmarcPolicy: 'none',
  1481. dmarcRua: ''
  1482. });
  1483. const apiToken = createApiToken(alice.id, 'primary');
  1484. console.log(JSON.stringify({
  1485. aliceId: alice.id,
  1486. bobId: bob.id,
  1487. domainId: domain.id,
  1488. credentialId: credential.id,
  1489. standaloneCredentialId: standaloneCredential.id,
  1490. apiTokenId: apiToken.id
  1491. }));
  1492. `;
  1493. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1494. cwd: process.cwd(),
  1495. env: {
  1496. ...process.env,
  1497. DATA_DIR: dataDir,
  1498. SESSION_SECRET: sessionSecret
  1499. },
  1500. encoding: 'utf8'
  1501. });
  1502. assert.equal(result.status, 0, result.stderr || result.stdout);
  1503. return JSON.parse(result.stdout);
  1504. }
  1505. function seedMergeResources(dataDir, sessionSecret) {
  1506. const script = `
  1507. import {
  1508. initDatabase,
  1509. createApiToken,
  1510. createDomain,
  1511. createUser,
  1512. logSendEvent,
  1513. saveDnsCredential
  1514. } from './src/db.js';
  1515. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1516. const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' });
  1517. const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' });
  1518. const credential = saveDnsCredential(source.id, {
  1519. name: 'Merge DNS',
  1520. provider: 'cloudflare',
  1521. zoneName: 'merge.example',
  1522. credentials: { apiToken: 'merge-secret-token' }
  1523. });
  1524. const domain = createDomain(source.id, {
  1525. dnsCredentialId: credential.id,
  1526. domain: 'merge.example',
  1527. selector: 'mh202607',
  1528. verificationToken: 'token',
  1529. dkimPublic: 'public',
  1530. dkimPrivate: 'private',
  1531. senderHost: 'mail.merge.example',
  1532. sendingIp: '127.0.0.1',
  1533. spfExtra: '',
  1534. dmarcPolicy: 'none',
  1535. dmarcRua: ''
  1536. });
  1537. createApiToken(source.id, 'primary');
  1538. logSendEvent({
  1539. userId: source.id,
  1540. domainId: domain.id,
  1541. sender: 'noreply@merge.example',
  1542. recipients: ['a@example.com'],
  1543. subject: 'Queued',
  1544. status: 'queued'
  1545. });
  1546. console.log(JSON.stringify({ sourceId: source.id, targetId: target.id }));
  1547. `;
  1548. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1549. cwd: process.cwd(),
  1550. env: {
  1551. ...process.env,
  1552. DATA_DIR: dataDir,
  1553. SESSION_SECRET: sessionSecret
  1554. },
  1555. encoding: 'utf8'
  1556. });
  1557. assert.equal(result.status, 0, result.stderr || result.stdout);
  1558. return JSON.parse(result.stdout);
  1559. }
  1560. function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) {
  1561. const script = `
  1562. import { initDatabase, createUser, createAccountToken } from './src/db.js';
  1563. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1564. const user = createUser(JSON.parse(process.env.SEED_USER));
  1565. const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 });
  1566. console.log(JSON.stringify({ user, token: token.token }));
  1567. `;
  1568. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1569. cwd: process.cwd(),
  1570. env: {
  1571. ...process.env,
  1572. DATA_DIR: dataDir,
  1573. SESSION_SECRET: sessionSecret,
  1574. SEED_USER: JSON.stringify(user)
  1575. },
  1576. encoding: 'utf8'
  1577. });
  1578. assert.equal(result.status, 0, result.stderr || result.stdout);
  1579. return JSON.parse(result.stdout);
  1580. }
  1581. function createPasswordResetToken(dataDir, sessionSecret, username) {
  1582. const script = `
  1583. import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js';
  1584. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1585. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1586. const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 });
  1587. console.log(token.token);
  1588. `;
  1589. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1590. cwd: process.cwd(),
  1591. env: {
  1592. ...process.env,
  1593. DATA_DIR: dataDir,
  1594. SESSION_SECRET: sessionSecret,
  1595. TOKEN_USERNAME: username
  1596. },
  1597. encoding: 'utf8'
  1598. });
  1599. assert.equal(result.status, 0, result.stderr || result.stdout);
  1600. return result.stdout.trim();
  1601. }
  1602. function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1603. return countAccountTokens(dataDir, sessionSecret, username, purpose, false);
  1604. }
  1605. function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) {
  1606. return countAccountTokens(dataDir, sessionSecret, username, purpose, true);
  1607. }
  1608. function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) {
  1609. const script = `
  1610. import path from 'node:path';
  1611. import { DatabaseSync } from 'node:sqlite';
  1612. import { initDatabase, getUserByLogin } from './src/db.js';
  1613. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1614. const user = getUserByLogin(process.env.TOKEN_USERNAME);
  1615. const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1616. database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1617. const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : '';
  1618. const row = user
  1619. ? database
  1620. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter)
  1621. .get(user.id, process.env.TOKEN_PURPOSE)
  1622. : { count: 0 };
  1623. console.log(String(row.count));
  1624. `;
  1625. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1626. cwd: process.cwd(),
  1627. env: {
  1628. ...process.env,
  1629. DATA_DIR: dataDir,
  1630. SESSION_SECRET: sessionSecret,
  1631. TOKEN_USERNAME: username,
  1632. TOKEN_PURPOSE: purpose,
  1633. TOKEN_UNUSED_ONLY: String(unusedOnly)
  1634. },
  1635. encoding: 'utf8'
  1636. });
  1637. assert.equal(result.status, 0, result.stderr || result.stdout);
  1638. return Number(result.stdout.trim());
  1639. }
  1640. function seedAuditLogs(dataDir, sessionSecret) {
  1641. const script = `
  1642. import path from 'node:path';
  1643. import { DatabaseSync } from 'node:sqlite';
  1644. import { initDatabase, logAudit } from './src/db.js';
  1645. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  1646. const actorOneId = logAudit({
  1647. actorUserId: 1,
  1648. action: 'audit.actor-one',
  1649. targetType: 'system',
  1650. summary: { label: 'actor-one' }
  1651. });
  1652. const actorOneHundredId = logAudit({
  1653. actorUserId: 100,
  1654. action: 'audit.actor-one-hundred',
  1655. targetType: 'system',
  1656. summary: { label: 'actor-one-hundred' }
  1657. });
  1658. const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
  1659. db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  1660. const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?');
  1661. update.run('2026-02-01T00:00:00.000Z', actorOneId);
  1662. update.run('2026-02-02T00:00:00.000Z', actorOneHundredId);
  1663. `;
  1664. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  1665. cwd: process.cwd(),
  1666. env: {
  1667. ...process.env,
  1668. DATA_DIR: dataDir,
  1669. SESSION_SECRET: sessionSecret
  1670. },
  1671. encoding: 'utf8'
  1672. });
  1673. assert.equal(result.status, 0, result.stderr || result.stdout);
  1674. }
  1675. async function auditLogActions(baseUrl, cookie, query) {
  1676. const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, {
  1677. headers: { Cookie: cookie }
  1678. });
  1679. assert.equal(response.status, 200);
  1680. const body = await response.json();
  1681. return body.logs.map((log) => log.action);
  1682. }
  1683. async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) {
  1684. const response = await fetch(`${baseUrl}/api/admin/system-email`, {
  1685. method: 'PATCH',
  1686. headers: {
  1687. 'Content-Type': 'application/json',
  1688. Cookie: cookie
  1689. },
  1690. body: JSON.stringify({
  1691. host: '127.0.0.1',
  1692. port: smtpPort,
  1693. secure: false,
  1694. username: 'mailer@example.com',
  1695. password: 'smtp-password-123',
  1696. helo: 'mail.example.com',
  1697. fromEmail: 'notify@example.com',
  1698. fromName: 'MailHub Notify',
  1699. testRecipient: 'admin@example.com'
  1700. })
  1701. });
  1702. assert.equal(response.status, 200);
  1703. }
  1704. async function createSendingDomain(baseUrl, cookie, data = {}) {
  1705. const domain = data.domain || 'send.example';
  1706. const response = await fetch(`${baseUrl}/api/domains`, {
  1707. method: 'POST',
  1708. headers: {
  1709. 'Content-Type': 'application/json',
  1710. Cookie: cookie
  1711. },
  1712. body: JSON.stringify({
  1713. domain,
  1714. selector: data.selector || 'mh',
  1715. senderHost: data.senderHost || `mail.${domain}`,
  1716. sendingIp: data.sendingIp || '127.0.0.1',
  1717. smtpRelayId: data.smtpRelayId
  1718. })
  1719. });
  1720. assert.equal(response.status, 201);
  1721. return (await response.json()).domain;
  1722. }
  1723. async function createSmtpRelay(baseUrl, cookie, data = {}) {
  1724. const response = await fetch(`${baseUrl}/api/smtp-relays`, {
  1725. method: 'POST',
  1726. headers: {
  1727. 'Content-Type': 'application/json',
  1728. Cookie: cookie
  1729. },
  1730. body: JSON.stringify({
  1731. name: data.name || 'Relay',
  1732. host: data.host || '127.0.0.1',
  1733. port: data.port,
  1734. secure: data.secure || false,
  1735. username: data.username || '',
  1736. password: data.password || '',
  1737. helo: data.helo || '',
  1738. isDefault: data.isDefault || false
  1739. })
  1740. });
  1741. assert.equal(response.status, 201);
  1742. return (await response.json()).relay;
  1743. }
  1744. async function createSmtpCredential(baseUrl, cookie, data = {}) {
  1745. const response = await fetch(`${baseUrl}/api/smtp-credentials`, {
  1746. method: 'POST',
  1747. headers: {
  1748. 'Content-Type': 'application/json',
  1749. Cookie: cookie
  1750. },
  1751. body: JSON.stringify(data)
  1752. });
  1753. assert.equal(response.status, 201);
  1754. return (await response.json()).credential;
  1755. }
  1756. async function sendApiMail(baseUrl, cookie, data) {
  1757. const response = await fetch(`${baseUrl}/api/send`, {
  1758. method: 'POST',
  1759. headers: {
  1760. 'Content-Type': 'application/json',
  1761. Cookie: cookie
  1762. },
  1763. body: JSON.stringify({
  1764. from: data.from,
  1765. to: data.to,
  1766. subject: data.subject,
  1767. text: data.text || 'hello',
  1768. smtpRelayId: data.smtpRelayId
  1769. })
  1770. });
  1771. assert.equal(response.status, 202);
  1772. return response.json();
  1773. }
  1774. function assertRelayAuth(relayServer, username, password) {
  1775. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  1776. assert.ok(authCommand);
  1777. assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`);
  1778. }
  1779. function decodeHtmlPart(rawMessage) {
  1780. const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
  1781. assert.ok(match, 'expected an HTML MIME part');
  1782. return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
  1783. }
  1784. function escapeRegExp(value) {
  1785. return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1786. }
  1787. function startFakeSmtpServer({ responseDelayMs = 0 } = {}) {
  1788. const commands = [];
  1789. const messages = [];
  1790. const server = net.createServer((socket) => {
  1791. socket.setEncoding('utf8');
  1792. writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs);
  1793. let buffer = '';
  1794. let dataMode = false;
  1795. let messageLines = [];
  1796. socket.on('data', (chunk) => {
  1797. buffer += chunk;
  1798. let index;
  1799. while ((index = buffer.indexOf('\n')) !== -1) {
  1800. const line = buffer.slice(0, index).replace(/\r$/, '');
  1801. buffer = buffer.slice(index + 1);
  1802. if (dataMode) {
  1803. if (line === '.') {
  1804. dataMode = false;
  1805. messages.push(messageLines.join('\n'));
  1806. messageLines = [];
  1807. writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs);
  1808. } else {
  1809. messageLines.push(line);
  1810. }
  1811. continue;
  1812. }
  1813. commands.push(line);
  1814. if (line.startsWith('EHLO')) {
  1815. writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs);
  1816. } else if (line.startsWith('AUTH PLAIN')) {
  1817. writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs);
  1818. } else if (line.startsWith('MAIL FROM')) {
  1819. writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs);
  1820. } else if (line.startsWith('RCPT TO')) {
  1821. writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs);
  1822. } else if (line === 'DATA') {
  1823. dataMode = true;
  1824. writeSmtpResponse(socket, '354 end with dot', responseDelayMs);
  1825. } else if (line === 'QUIT') {
  1826. writeSmtpResponse(socket, '221 bye', responseDelayMs);
  1827. socket.end();
  1828. }
  1829. }
  1830. });
  1831. });
  1832. return new Promise((resolve, reject) => {
  1833. server.once('error', reject);
  1834. server.listen(0, '127.0.0.1', () => {
  1835. server.off('error', reject);
  1836. resolve({
  1837. port: server.address().port,
  1838. commands,
  1839. messages,
  1840. close: () => new Promise((closeResolve) => server.close(closeResolve))
  1841. });
  1842. });
  1843. });
  1844. }
  1845. function writeSmtpResponse(socket, response, delayMs) {
  1846. const write = () => socket.write(`${response}\r\n`);
  1847. if (delayMs > 0) setTimeout(write, delayMs);
  1848. else write();
  1849. }
  1850. async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) {
  1851. const startedAt = Date.now();
  1852. while (Date.now() - startedAt < timeoutMs) {
  1853. if (predicate()) return;
  1854. await new Promise((resolve) => setTimeout(resolve, intervalMs));
  1855. }
  1856. assert.fail('Timed out waiting for condition.');
  1857. }
  1858. function freePort() {
  1859. return new Promise((resolve, reject) => {
  1860. const server = net.createServer();
  1861. server.listen(0, '127.0.0.1', () => {
  1862. const address = server.address();
  1863. server.close(() => {
  1864. if (address && typeof address === 'object') resolve(address.port);
  1865. else reject(new Error('Unable to allocate a test port.'));
  1866. });
  1867. });
  1868. });
  1869. }
  1870. function waitForOutput(child, text) {
  1871. return new Promise((resolve, reject) => {
  1872. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
  1873. const chunks = [];
  1874. const onData = (chunk) => {
  1875. chunks.push(String(chunk));
  1876. if (chunks.join('').includes(text)) {
  1877. clearTimeout(timeout);
  1878. child.stdout.off('data', onData);
  1879. child.stderr.off('data', onData);
  1880. resolve();
  1881. }
  1882. };
  1883. child.stdout.on('data', onData);
  1884. child.stderr.on('data', onData);
  1885. child.once('exit', (code) => {
  1886. clearTimeout(timeout);
  1887. reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
  1888. });
  1889. });
  1890. }
  1891. function waitForExit(child, timeoutMs) {
  1892. if (child.exitCode !== null) return Promise.resolve(true);
  1893. return new Promise((resolve) => {
  1894. const timeout = setTimeout(() => {
  1895. child.off('exit', onExit);
  1896. resolve(false);
  1897. }, timeoutMs);
  1898. const onExit = () => {
  1899. clearTimeout(timeout);
  1900. resolve(true);
  1901. };
  1902. child.once('exit', onExit);
  1903. });
  1904. }