db.test.js 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734
  1. import assert from 'node:assert/strict';
  2. import { createHash } from 'node:crypto';
  3. import { mkdtempSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import { DatabaseSync } from 'node:sqlite';
  8. import {
  9. authenticateUser,
  10. claimLegacyData,
  11. consumeAccountToken,
  12. createApiToken,
  13. createAccountToken,
  14. createSendEvent,
  15. createTrackingLink,
  16. createDomain,
  17. createInboundMailbox,
  18. createInboundMessage,
  19. createUser,
  20. createUserWithAccountToken,
  21. createWebhook,
  22. deleteSmtpCredential,
  23. deleteSmtpRelay,
  24. getDnsCredential,
  25. getDomain,
  26. getInboundMessage,
  27. getSendEvent,
  28. getSendAnalytics,
  29. getSmtpRelay,
  30. getSmtpCredential,
  31. getAdminResourceInventory,
  32. getSystemEmailSettings,
  33. getUser,
  34. initDatabase,
  35. listAuditLogs,
  36. listDomains,
  37. listInboundMailboxes,
  38. listInboundMessages,
  39. listSendEvents,
  40. listSmtpCredentials,
  41. listSmtpRelays,
  42. listUsersWithResourceCounts,
  43. invalidateAccountTokens,
  44. logAudit,
  45. logSendEvent,
  46. approveUser,
  47. markUserEmailVerified,
  48. previewUserMerge,
  49. pruneTrackingEvents,
  50. recordTrackingEvent,
  51. saveDnsCredential,
  52. saveSmtpRelay,
  53. saveSmtpCredential,
  54. saveSystemEmailSettings,
  55. seedAdminUser,
  56. transferApiTokens,
  57. transferDnsCredential,
  58. transferDomain,
  59. updateUser,
  60. updateUserStatus,
  61. executeUserMerge,
  62. finalizeSendEvent,
  63. findSendEventByTrackingToken,
  64. findTrackingLinkByToken,
  65. verifyApiToken,
  66. verifySmtpCredential
  67. } from '../src/db.js';
  68. import {
  69. createTrackingToken,
  70. encryptTrackingTarget,
  71. hashTrackingToken,
  72. trackingTargetFingerprint
  73. } from '../src/tracking.js';
  74. test('migrates legacy data to the seeded admin user', () => {
  75. const dataDir = tempDataDir();
  76. const dbPath = path.join(dataDir, 'mailhub.sqlite');
  77. const legacy = new DatabaseSync(dbPath);
  78. legacy.exec(`
  79. CREATE TABLE domains (
  80. id INTEGER PRIMARY KEY AUTOINCREMENT,
  81. domain TEXT NOT NULL UNIQUE,
  82. selector TEXT NOT NULL,
  83. verification_token TEXT NOT NULL,
  84. dkim_public TEXT NOT NULL,
  85. dkim_private TEXT NOT NULL,
  86. sender_host TEXT NOT NULL,
  87. sending_ip TEXT NOT NULL,
  88. spf_extra TEXT NOT NULL DEFAULT '',
  89. dmarc_policy TEXT NOT NULL DEFAULT 'none',
  90. dmarc_rua TEXT NOT NULL DEFAULT '',
  91. status_json TEXT NOT NULL DEFAULT '{}',
  92. created_at TEXT NOT NULL,
  93. updated_at TEXT NOT NULL
  94. );
  95. CREATE TABLE send_events (
  96. id INTEGER PRIMARY KEY AUTOINCREMENT,
  97. domain_id INTEGER,
  98. sender TEXT NOT NULL,
  99. recipients TEXT NOT NULL,
  100. subject TEXT NOT NULL,
  101. status TEXT NOT NULL,
  102. detail TEXT NOT NULL DEFAULT '',
  103. created_at TEXT NOT NULL
  104. );
  105. CREATE TABLE smtp_credentials (
  106. id INTEGER PRIMARY KEY CHECK (id = 1),
  107. username TEXT NOT NULL,
  108. password_hash TEXT NOT NULL,
  109. password_secret TEXT NOT NULL DEFAULT '',
  110. created_at TEXT NOT NULL,
  111. updated_at TEXT NOT NULL
  112. );
  113. `);
  114. legacy
  115. .prepare(`
  116. INSERT INTO domains (
  117. domain, selector, verification_token, dkim_public, dkim_private,
  118. sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua, created_at, updated_at
  119. ) VALUES ('legacy.example', 'mh', 'tok', 'pub', 'priv', 'mail.legacy.example', '127.0.0.1', '', 'none', '', 'now', 'now')
  120. `)
  121. .run();
  122. legacy
  123. .prepare(`
  124. INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
  125. VALUES (1, 'noreply@legacy.example', '["user@example.com"]', 'hi', 'queued', '', 'now')
  126. `)
  127. .run();
  128. legacy
  129. .prepare(`
  130. INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
  131. VALUES (1, 'legacy-smtp', 'scrypt$salt$hash', '', 'now', 'now')
  132. `)
  133. .run();
  134. legacy.close();
  135. initDatabase(dataDir, 'test-secret');
  136. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  137. claimLegacyData(admin.id);
  138. assert.equal(listDomains(admin.id).length, 1);
  139. assert.equal(listSendEvents(admin.id).length, 1);
  140. assert.equal(getSmtpCredential(admin.id).username, 'legacy-smtp');
  141. });
  142. test('migrates legacy send events with engagement tracking disabled', () => {
  143. const dataDir = tempDataDir();
  144. const database = initDatabase(dataDir, 'test-secret');
  145. const columns = database.prepare('PRAGMA table_info(send_events)').all().map((column) => column.name);
  146. assert.equal(columns.includes('tracking_token_hash'), true);
  147. assert.equal(columns.includes('tracking_opens'), true);
  148. assert.equal(columns.includes('tracking_clicks'), true);
  149. const tables = database
  150. .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('tracking_links', 'tracking_events') ORDER BY name")
  151. .all()
  152. .map((row) => row.name);
  153. assert.deepEqual(tables, ['tracking_events', 'tracking_links']);
  154. });
  155. test('creates and finalizes one tracked send event with a unique open token', () => {
  156. initDatabase(tempDataDir(), 'test-secret');
  157. const user = createUser({ username: 'tracked', email: 'tracked@example.com', password: 'password123' });
  158. const domain = createDomain(user.id, domainFixture('tracked.example'));
  159. const token = createTrackingToken();
  160. const id = createSendEvent({
  161. userId: user.id,
  162. domainId: domain.id,
  163. sender: 'sender@tracked.example',
  164. recipients: ['reader@example.net'],
  165. subject: 'Tracked',
  166. trackingToken: token,
  167. trackingOpens: true,
  168. trackingClicks: true
  169. });
  170. const submitting = getSendEvent(user.id, id);
  171. assert.equal(submitting.status, 'submitting');
  172. assert.equal(submitting.tracking.enabled, true);
  173. assert.equal(submitting.tracking.opens, true);
  174. assert.equal(submitting.tracking.clicks, true);
  175. assert.equal(findSendEventByTrackingToken(token).id, id);
  176. const finalized = finalizeSendEvent(id, user.id, {
  177. status: 'queued',
  178. detail: 'queued as TRACK123',
  179. queueId: 'TRACK123',
  180. deliveryLog: [{ phase: 'queue', ok: true }]
  181. });
  182. assert.equal(finalized.status, 'queued');
  183. assert.equal(finalized.queueId, 'TRACK123');
  184. assert.throws(() => createSendEvent({
  185. userId: user.id,
  186. domainId: domain.id,
  187. sender: 'sender@tracked.example',
  188. recipients: ['second@example.net'],
  189. subject: 'Duplicate token',
  190. trackingToken: token,
  191. trackingOpens: true
  192. }), /unique/i);
  193. });
  194. test('stores encrypted opaque tracking links and isolates creation by owner', () => {
  195. initDatabase(tempDataDir(), 'test-secret');
  196. const alice = createUser({ username: 'track-alice', email: 'track-alice@example.com', password: 'password123' });
  197. const bob = createUser({ username: 'track-bob', email: 'track-bob@example.com', password: 'password123' });
  198. const domain = createDomain(alice.id, domainFixture('links.example'));
  199. const eventId = createSendEvent({
  200. userId: alice.id,
  201. domainId: domain.id,
  202. sender: 'sender@links.example',
  203. recipients: ['reader@example.net'],
  204. subject: 'Links',
  205. trackingClicks: true
  206. });
  207. const token = createTrackingToken();
  208. const target = 'https://example.net/reset?token=private';
  209. const linkId = createTrackingLink(alice.id, eventId, {
  210. token,
  211. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  212. targetFingerprint: hashTrackingToken(`destination:${target}`),
  213. targetOrigin: 'https://example.net'
  214. });
  215. const link = findTrackingLinkByToken(token);
  216. assert.equal(link.id, linkId);
  217. assert.equal(link.sendEventId, eventId);
  218. assert.equal(link.userId, alice.id);
  219. assert.equal(link.targetCiphertext.includes('private'), false);
  220. assert.equal(link.targetOrigin, 'https://example.net');
  221. const detail = getSendEvent(alice.id, eventId, { trackingSecret: 'tracking-secret' });
  222. assert.equal(detail.tracking.links.length, 1);
  223. assert.equal(detail.tracking.links[0].target, target);
  224. assert.throws(() => createTrackingLink(bob.id, eventId, {
  225. token: createTrackingToken(),
  226. targetCiphertext: encryptTrackingTarget('https://example.net/other', 'tracking-secret'),
  227. targetFingerprint: 'other',
  228. targetOrigin: 'https://example.net'
  229. }), /not found/i);
  230. });
  231. test('records deduplicated engagement events and summarizes message detail', () => {
  232. initDatabase(tempDataDir(), 'test-secret');
  233. const user = createUser({ username: 'events', email: 'events@example.com', password: 'password123' });
  234. const domain = createDomain(user.id, domainFixture('events.example'));
  235. const eventId = createSendEvent({
  236. userId: user.id,
  237. domainId: domain.id,
  238. sender: 'sender@events.example',
  239. recipients: ['reader@example.net'],
  240. subject: 'Engagement',
  241. trackingOpens: true,
  242. trackingClicks: true
  243. });
  244. const trackingLinkId = createTrackingLink(user.id, eventId, {
  245. token: createTrackingToken(),
  246. targetCiphertext: encryptTrackingTarget('https://example.net/click', 'tracking-secret'),
  247. targetFingerprint: trackingTargetFingerprint('https://example.net/click', 'tracking-secret'),
  248. targetOrigin: 'https://example.net'
  249. });
  250. const scanner = recordTrackingEvent({
  251. sendEventId: eventId,
  252. eventType: 'open',
  253. source: 'scanner',
  254. occurredAt: '2026-07-09T12:00:00.000Z',
  255. userAgent: 'Scanner',
  256. ipHash: 'scanner-ip',
  257. replayKey: 'scanner-open'
  258. });
  259. assert.equal(scanner.recorded, true);
  260. assert.equal(scanner.firstQualifying, false);
  261. const direct = recordTrackingEvent({
  262. sendEventId: eventId,
  263. eventType: 'open',
  264. source: 'direct',
  265. occurredAt: '2026-07-09T12:01:00.000Z',
  266. userAgent: 'Mozilla/5.0',
  267. ipHash: 'direct-ip',
  268. replayKey: 'direct-open'
  269. });
  270. assert.equal(direct.recorded, true);
  271. assert.equal(direct.firstQualifying, true);
  272. recordTrackingEvent({
  273. sendEventId: eventId,
  274. trackingLinkId,
  275. eventType: 'click',
  276. source: 'direct',
  277. occurredAt: '2026-07-09T11:59:00.000Z',
  278. userAgent: 'Mozilla/5.0',
  279. ipHash: 'click-ip',
  280. replayKey: 'direct-click'
  281. });
  282. const duplicate = recordTrackingEvent({
  283. sendEventId: eventId,
  284. eventType: 'open',
  285. source: 'direct',
  286. occurredAt: '2026-07-09T12:01:30.000Z',
  287. userAgent: 'Mozilla/5.0',
  288. ipHash: 'direct-ip',
  289. replayKey: 'direct-open'
  290. });
  291. assert.equal(duplicate.recorded, false);
  292. assert.equal(duplicate.duplicate, true);
  293. const detail = getSendEvent(user.id, eventId);
  294. assert.equal(detail.tracking.summary.totalOpens, 1);
  295. assert.equal(detail.tracking.summary.totalClicks, 1);
  296. assert.equal(detail.tracking.summary.uniqueOpen, true);
  297. assert.equal(detail.tracking.summary.scannerEvents, 1);
  298. assert.equal(detail.tracking.summary.firstOpenedAt, '2026-07-09T11:59:00.000Z');
  299. assert.equal(detail.tracking.summary.lastOpenedAt, '2026-07-09T12:01:00.000Z');
  300. assert.equal(detail.tracking.events.length, 3);
  301. assert.equal(detail.tracking.eventCount, 3);
  302. assert.equal(detail.tracking.eventsTruncated, false);
  303. assert.equal(detail.tracking.events.some((event) => Object.hasOwn(event, 'ipHash')), false);
  304. const listed = listSendEvents(user.id)[0];
  305. assert.equal(listed.tracking.summary.totalOpens, 1);
  306. assert.equal(listed.tracking.summary.uniqueOpen, true);
  307. });
  308. test('bounds tracking detail events while preserving the complete SQL summary', () => {
  309. const database = initDatabase(tempDataDir(), 'test-secret');
  310. const user = createUser({ username: 'bounded-events', email: 'bounded-events@example.com', password: 'password123' });
  311. const domain = createDomain(user.id, domainFixture('bounded-events.example'));
  312. const eventId = createSendEvent({
  313. userId: user.id,
  314. domainId: domain.id,
  315. sender: 'sender@bounded-events.example',
  316. recipients: ['reader@example.net'],
  317. subject: 'Bounded events',
  318. trackingOpens: true
  319. });
  320. const start = Date.parse('2026-07-09T00:00:00.000Z');
  321. for (let index = 0; index < 505; index += 1) {
  322. recordTrackingEvent({
  323. sendEventId: eventId,
  324. eventType: 'open',
  325. source: 'direct',
  326. occurredAt: new Date(start + index * 1000).toISOString(),
  327. userAgent: 'Mozilla/5.0',
  328. ipHash: `bounded-${index}`,
  329. replayKey: `bounded-${index}`
  330. });
  331. }
  332. for (let index = 0; index < 205; index += 1) {
  333. const target = `https://example.net/link/${index}`;
  334. createTrackingLink(user.id, eventId, {
  335. token: createTrackingToken(),
  336. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  337. targetFingerprint: trackingTargetFingerprint(target, 'tracking-secret'),
  338. targetOrigin: 'https://example.net'
  339. });
  340. }
  341. const detail = getSendEvent(user.id, eventId, { trackingSecret: 'tracking-secret' });
  342. assert.equal(detail.tracking.summary.totalOpens, 505);
  343. assert.equal(detail.tracking.eventCount, 505);
  344. assert.equal(detail.tracking.events.length, 500);
  345. assert.equal(detail.tracking.eventsTruncated, true);
  346. assert.equal(detail.tracking.linkCount, 205);
  347. assert.equal(detail.tracking.links.length, 200);
  348. assert.equal(detail.tracking.linksTruncated, true);
  349. assert.equal(detail.tracking.events[0].occurredAt, '2026-07-09T00:00:05.000Z');
  350. assert.equal(detail.tracking.events.at(-1).occurredAt, '2026-07-09T00:08:24.000Z');
  351. const listed = listSendEvents(user.id)[0];
  352. assert.equal(listed.tracking.summary.totalOpens, 505);
  353. assert.equal(Object.hasOwn(listed.tracking, 'events'), false);
  354. const eventIndexes = database.prepare("PRAGMA index_list('tracking_events')").all().map((row) => row.name);
  355. const sendEventIndexes = database.prepare("PRAGMA index_list('send_events')").all().map((row) => row.name);
  356. assert.equal(eventIndexes.includes('idx_tracking_events_link_time'), true);
  357. assert.equal(sendEventIndexes.includes('idx_events_user_created'), true);
  358. });
  359. test('enqueues first qualifying engagement webhook and ignores scanner-first events', () => {
  360. initDatabase(tempDataDir(), 'test-secret');
  361. const user = createUser({ username: 'engagement-hook', email: 'engagement-hook@example.com', password: 'password123' });
  362. const domain = createDomain(user.id, domainFixture('engagement-hook.example'));
  363. createWebhook(user.id, {
  364. name: 'Engagement events',
  365. url: 'https://hooks.example.com/engagement',
  366. events: ['opened', 'clicked']
  367. });
  368. const eventId = createSendEvent({
  369. userId: user.id,
  370. domainId: domain.id,
  371. sender: 'sender@engagement-hook.example',
  372. recipients: ['reader@example.net'],
  373. subject: 'Webhook engagement',
  374. status: 'sent',
  375. trackingOpens: true,
  376. trackingClicks: true
  377. });
  378. recordTrackingEvent({
  379. sendEventId: eventId,
  380. eventType: 'open',
  381. source: 'scanner',
  382. occurredAt: '2026-07-09T12:00:00.000Z',
  383. userAgent: 'Scanner',
  384. ipHash: 'scanner-ip',
  385. replayKey: 'hook-scanner'
  386. });
  387. assert.equal(getSendEvent(user.id, eventId).webhookDeliveries.length, 0);
  388. recordTrackingEvent({
  389. sendEventId: eventId,
  390. eventType: 'open',
  391. source: 'direct',
  392. occurredAt: '2026-07-09T12:01:00.000Z',
  393. userAgent: 'Mozilla/5.0',
  394. ipHash: 'direct-ip',
  395. replayKey: 'hook-direct-1'
  396. });
  397. recordTrackingEvent({
  398. sendEventId: eventId,
  399. eventType: 'open',
  400. source: 'direct',
  401. occurredAt: '2026-07-09T12:02:00.000Z',
  402. userAgent: 'Mozilla/5.0',
  403. ipHash: 'direct-ip-2',
  404. replayKey: 'hook-direct-2'
  405. });
  406. const deliveries = getSendEvent(user.id, eventId).webhookDeliveries;
  407. assert.equal(deliveries.length, 1);
  408. assert.equal(deliveries[0].eventType, 'opened');
  409. });
  410. test('caps tracking writes per message and UTC day', () => {
  411. initDatabase(tempDataDir(), 'test-secret');
  412. const user = createUser({ username: 'cap-user', email: 'cap-user@example.com', password: 'password123' });
  413. const domain = createDomain(user.id, domainFixture('cap.example'));
  414. const eventId = createSendEvent({
  415. userId: user.id,
  416. domainId: domain.id,
  417. sender: 'sender@cap.example',
  418. recipients: ['reader@example.net'],
  419. subject: 'Cap',
  420. trackingOpens: true
  421. });
  422. for (let index = 0; index < 2; index += 1) {
  423. const result = recordTrackingEvent({
  424. sendEventId: eventId,
  425. eventType: 'open',
  426. source: 'direct',
  427. occurredAt: `2026-07-09T12:0${index}:00.000Z`,
  428. userAgent: 'Mozilla/5.0',
  429. ipHash: `ip-${index}`,
  430. replayKey: `cap-${index}`,
  431. maxPerDay: 2
  432. });
  433. assert.equal(result.recorded, true);
  434. }
  435. const capped = recordTrackingEvent({
  436. sendEventId: eventId,
  437. eventType: 'open',
  438. source: 'direct',
  439. occurredAt: '2026-07-09T12:03:00.000Z',
  440. userAgent: 'Mozilla/5.0',
  441. ipHash: 'ip-3',
  442. replayKey: 'cap-3',
  443. maxPerDay: 2
  444. });
  445. assert.equal(capped.recorded, false);
  446. assert.equal(capped.capped, true);
  447. const nextDay = recordTrackingEvent({
  448. sendEventId: eventId,
  449. eventType: 'open',
  450. source: 'direct',
  451. occurredAt: '2026-07-10T00:00:00.000Z',
  452. userAgent: 'Mozilla/5.0',
  453. ipHash: 'ip-4',
  454. replayKey: 'cap-4',
  455. maxPerDay: 2
  456. });
  457. assert.equal(nextDay.recorded, true);
  458. });
  459. test('prunes raw tracking events before the retention cutoff', () => {
  460. initDatabase(tempDataDir(), 'test-secret');
  461. const user = createUser({ username: 'retention', email: 'retention@example.com', password: 'password123' });
  462. const domain = createDomain(user.id, domainFixture('retention.example'));
  463. const eventId = createSendEvent({
  464. userId: user.id,
  465. domainId: domain.id,
  466. sender: 'sender@retention.example',
  467. recipients: ['reader@example.net'],
  468. subject: 'Retention',
  469. trackingOpens: true
  470. });
  471. for (const [occurredAt, replayKey] of [
  472. ['2026-01-01T00:00:00.000Z', 'old-event'],
  473. ['2026-07-09T00:00:00.000Z', 'new-event']
  474. ]) {
  475. recordTrackingEvent({
  476. sendEventId: eventId,
  477. eventType: 'open',
  478. source: 'direct',
  479. occurredAt,
  480. userAgent: 'Mozilla/5.0',
  481. ipHash: replayKey,
  482. replayKey
  483. });
  484. }
  485. assert.equal(pruneTrackingEvents({ days: 30, now: '2026-07-10T00:00:00.000Z' }), 1);
  486. const detail = getSendEvent(user.id, eventId);
  487. assert.equal(detail.tracking.events.length, 1);
  488. assert.equal(detail.tracking.events[0].occurredAt, '2026-07-09T00:00:00.000Z');
  489. });
  490. test('isolates domains, smtp credentials, and api tokens by user', () => {
  491. initDatabase(tempDataDir(), 'test-secret');
  492. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  493. claimLegacyData(admin.id);
  494. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  495. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  496. assert.equal(authenticateUser('alice', 'password123').id, alice.id);
  497. assert.equal(authenticateUser('alice', 'wrong'), null);
  498. createDomain(alice.id, domainFixture('alice.example'));
  499. assert.equal(listDomains(alice.id).length, 1);
  500. assert.equal(listDomains(bob.id).length, 0);
  501. logSendEvent({
  502. userId: alice.id,
  503. domainId: listDomains(alice.id)[0].id,
  504. sender: 'noreply@alice.example',
  505. recipients: ['user@example.com'],
  506. subject: 'Hello',
  507. status: 'queued'
  508. });
  509. assert.equal(listSendEvents(alice.id).length, 1);
  510. assert.equal(listSendEvents(bob.id).length, 0);
  511. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'copy-me-123' });
  512. assert.equal(getSmtpCredential(alice.id, { includePassword: true }).password, 'copy-me-123');
  513. assert.equal(verifySmtpCredential('smtp-alice', 'copy-me-123').user.id, alice.id);
  514. assert.equal(verifySmtpCredential('smtp-alice', 'wrong'), null);
  515. const token = createApiToken(alice.id, 'send');
  516. assert.equal(verifyApiToken(token.token).id, alice.id);
  517. });
  518. test('stores multiple smtp login credentials per user', () => {
  519. initDatabase(tempDataDir(), 'test-secret');
  520. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  521. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  522. const first = saveSmtpCredential(alice.id, { username: 'alice-main', password: 'first-secret' });
  523. const second = saveSmtpCredential(alice.id, { username: 'alice-app', password: 'second-secret' });
  524. saveSmtpCredential(bob.id, { username: 'bob-main', password: 'bob-secret' });
  525. assert.equal(first.passwordSet, true);
  526. assert.equal(second.passwordSet, true);
  527. assert.deepEqual(listSmtpCredentials(alice.id).map((credential) => credential.username), ['alice-app', 'alice-main']);
  528. assert.equal(getSmtpCredential(first.id, alice.id, { includePassword: true }).password, 'first-secret');
  529. assert.equal(getSmtpCredential(first.id, bob.id), null);
  530. assert.equal(verifySmtpCredential('alice-main', 'first-secret').user.id, alice.id);
  531. assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
  532. assert.equal(verifySmtpCredential('alice-app', 'wrong'), null);
  533. assert.equal(deleteSmtpCredential(first.id, alice.id), true);
  534. assert.equal(verifySmtpCredential('alice-main', 'first-secret'), null);
  535. assert.equal(verifySmtpCredential('alice-app', 'second-secret').user.id, alice.id);
  536. });
  537. test('stores multiple outbound smtp relays with encrypted recoverable passwords', () => {
  538. const database = initDatabase(tempDataDir(), 'test-secret');
  539. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  540. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  541. const primary = saveSmtpRelay(alice.id, {
  542. name: 'Primary relay',
  543. host: 'smtp.primary.example',
  544. port: 587,
  545. secure: false,
  546. username: 'primary-user',
  547. password: 'primary-secret',
  548. helo: 'mail.alice.example',
  549. isDefault: true
  550. });
  551. const backup = saveSmtpRelay(alice.id, {
  552. name: 'Backup relay',
  553. host: 'smtp.backup.example',
  554. port: 465,
  555. secure: true,
  556. username: 'backup-user',
  557. password: 'backup-secret',
  558. isDefault: true
  559. });
  560. saveSmtpRelay(bob.id, {
  561. name: 'Bob relay',
  562. host: 'smtp.bob.example',
  563. port: 25,
  564. secure: false,
  565. username: '',
  566. password: '',
  567. isDefault: true
  568. });
  569. assert.equal(primary.passwordSet, true);
  570. assert.equal('password' in primary, false);
  571. assert.deepEqual(
  572. listSmtpRelays(alice.id).map((relay) => [relay.name, relay.isDefault, 'password' in relay]),
  573. [
  574. ['Backup relay', true, false],
  575. ['Primary relay', false, false]
  576. ]
  577. );
  578. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
  579. assert.equal(getSmtpRelay(backup.id, bob.id), null);
  580. assert.equal(JSON.stringify(listSmtpRelays(alice.id)).includes('backup-secret'), false);
  581. const stored = database.prepare('SELECT password_secret FROM smtp_relays WHERE id = ?').get(backup.id);
  582. assert.ok(stored.password_secret);
  583. assert.equal(stored.password_secret.includes('backup-secret'), false);
  584. const updated = saveSmtpRelay(alice.id, {
  585. id: backup.id,
  586. name: 'Backup relay updated',
  587. host: 'smtp.backup.example',
  588. port: 2525,
  589. secure: false,
  590. username: 'backup-user',
  591. helo: 'helo.backup.example',
  592. isDefault: false
  593. });
  594. assert.equal(updated.port, 2525);
  595. assert.equal(updated.isDefault, false);
  596. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, 'backup-secret');
  597. const cleared = saveSmtpRelay(alice.id, {
  598. id: backup.id,
  599. name: 'Backup relay without auth',
  600. host: 'smtp.backup.example',
  601. username: '',
  602. password: ''
  603. });
  604. assert.equal(cleared.passwordSet, false);
  605. assert.equal(getSmtpRelay(backup.id, alice.id, { includePassword: true }).password, '');
  606. assert.equal(saveSmtpRelay(bob.id, {
  607. id: backup.id,
  608. name: 'Should not create',
  609. host: 'smtp.invalid.example'
  610. }), null);
  611. assert.equal(deleteSmtpRelay(backup.id, bob.id), false);
  612. assert.equal(deleteSmtpRelay(backup.id, alice.id), true);
  613. assert.equal(getSmtpRelay(backup.id, alice.id), null);
  614. });
  615. test('returns scoped send event detail with webhook deliveries', () => {
  616. initDatabase(tempDataDir(), 'test-secret');
  617. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  618. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  619. const domain = createDomain(alice.id, domainFixture('detail.example'));
  620. createWebhook(alice.id, {
  621. name: 'Delivery audit',
  622. url: 'https://hooks.example.com/mailhub',
  623. events: ['sent']
  624. });
  625. const eventId = logSendEvent({
  626. userId: alice.id,
  627. domainId: domain.id,
  628. sender: 'noreply@detail.example',
  629. recipients: ['user@example.com'],
  630. subject: 'Tracked message',
  631. status: 'sent',
  632. detail: '250 2.0.0 queued as QDETAIL',
  633. queueId: 'QDETAIL',
  634. deliveryAttempts: [{
  635. at: '2026-07-09T12:00:00.000Z',
  636. queueId: 'QDETAIL',
  637. recipient: 'user@example.com',
  638. relay: 'mx.example.net',
  639. dsn: '2.0.0',
  640. status: 'sent',
  641. response: '250 2.0.0 ok'
  642. }],
  643. deliveryLog: [{
  644. at: '2026-07-09T11:59:58.000Z',
  645. phase: 'queue',
  646. direction: 'server',
  647. response: '250 2.0.0 queued as QDETAIL',
  648. ok: true
  649. }],
  650. deliveredAt: '2026-07-09T12:00:00.000Z'
  651. });
  652. const detail = getSendEvent(alice.id, eventId);
  653. assert.equal(detail.id, eventId);
  654. assert.equal(detail.domain, 'detail.example');
  655. assert.equal(detail.queueId, 'QDETAIL');
  656. assert.equal(detail.deliveryAttempts[0].relay, 'mx.example.net');
  657. assert.equal(detail.deliveryLog[0].phase, 'queue');
  658. assert.equal(detail.webhookDeliveries.length, 1);
  659. assert.equal(detail.webhookDeliveries[0].sendEventId, eventId);
  660. assert.equal(detail.webhookDeliveries[0].eventType, 'sent');
  661. assert.equal(getSendEvent(bob.id, eventId), null);
  662. });
  663. test('send analytics includes delivery funnel and top failure reasons', () => {
  664. initDatabase(tempDataDir(), 'test-secret');
  665. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  666. const domain = createDomain(alice.id, domainFixture('analytics.example'));
  667. for (const [index, status] of ['sent', 'sent', 'queued'].entries()) {
  668. logSendEvent({
  669. userId: alice.id,
  670. domainId: domain.id,
  671. sender: 'noreply@analytics.example',
  672. recipients: ['user@example.com'],
  673. subject: `Message ${status}`,
  674. status,
  675. detail: status === 'queued' ? 'queued as QPENDING' : '250 2.0.0 ok',
  676. queueId: status === 'queued' ? 'QPENDING' : `QSENT${index + 1}`
  677. });
  678. }
  679. logSendEvent({
  680. userId: alice.id,
  681. domainId: domain.id,
  682. sender: 'noreply@analytics.example',
  683. recipients: ['bad@example.com'],
  684. subject: 'Bounce',
  685. status: 'bounced',
  686. detail: 'Mailbox unavailable',
  687. queueId: 'QBOUNCE'
  688. });
  689. logSendEvent({
  690. userId: alice.id,
  691. domainId: domain.id,
  692. sender: 'noreply@analytics.example',
  693. recipients: ['bad2@example.com'],
  694. subject: 'Failure',
  695. status: 'failed',
  696. detail: 'Mailbox\t unavailable'
  697. });
  698. const analytics = getSendAnalytics(alice.id, { days: 7 });
  699. assert.equal(analytics.summary.submitted, 5);
  700. assert.equal(analytics.summary.accepted, 4);
  701. assert.equal(analytics.summary.delivered, 2);
  702. assert.equal(analytics.summary.pending, 1);
  703. assert.equal(analytics.summary.failed, 2);
  704. assert.equal(analytics.summary.acceptanceRate, 80);
  705. assert.equal(analytics.summary.deliveryRate, 40);
  706. assert.equal(analytics.summary.failureRate, 40);
  707. assert.deepEqual(analytics.deliveryFunnel.map((item) => [item.stage, item.total, item.rate]), [
  708. ['submitted', 5, 100],
  709. ['accepted', 4, 80],
  710. ['delivered', 2, 40],
  711. ['pending', 1, 20],
  712. ['failed', 2, 40]
  713. ]);
  714. assert.equal(analytics.failureReasons[0].reason, 'Mailbox unavailable');
  715. assert.equal(analytics.failureReasons[0].total, 2);
  716. });
  717. test('send analytics reports delivered engagement rates trends and grouped links', () => {
  718. initDatabase(tempDataDir(), 'test-secret');
  719. const user = createUser({ username: 'analytics-track', email: 'analytics-track@example.com', password: 'password123' });
  720. const domain = createDomain(user.id, domainFixture('analytics-track.example'));
  721. const now = new Date().toISOString();
  722. const target = 'https://example.net/reset?token=private';
  723. const fingerprint = trackingTargetFingerprint(target, 'tracking-secret');
  724. const events = [];
  725. for (let index = 0; index < 3; index += 1) {
  726. events.push(createSendEvent({
  727. userId: user.id,
  728. domainId: domain.id,
  729. sender: 'sender@analytics-track.example',
  730. recipients: [`reader-${index}@example.net`],
  731. subject: `Tracked ${index}`,
  732. status: 'sent',
  733. trackingOpens: true,
  734. trackingClicks: true
  735. }));
  736. }
  737. const links = events.slice(0, 2).map((eventId, index) => {
  738. const token = createTrackingToken();
  739. const id = createTrackingLink(user.id, eventId, {
  740. token,
  741. targetCiphertext: encryptTrackingTarget(target, 'tracking-secret'),
  742. targetFingerprint: fingerprint,
  743. targetOrigin: 'https://example.net'
  744. });
  745. return { id, token, index };
  746. });
  747. const engagement = [
  748. { sendEventId: events[0], eventType: 'open', source: 'direct', key: 'open-direct' },
  749. { sendEventId: events[0], eventType: 'open', source: 'proxy', key: 'open-proxy' },
  750. { sendEventId: events[0], trackingLinkId: links[0].id, eventType: 'click', source: 'direct', key: 'click-one' },
  751. { sendEventId: events[1], trackingLinkId: links[1].id, eventType: 'click', source: 'scanner', key: 'click-scanner' },
  752. { sendEventId: events[1], trackingLinkId: links[1].id, eventType: 'click', source: 'direct', key: 'click-two' }
  753. ];
  754. for (const item of engagement) {
  755. recordTrackingEvent({
  756. ...item,
  757. occurredAt: now,
  758. userAgent: item.source === 'scanner' ? 'Scanner' : 'Mozilla/5.0',
  759. ipHash: item.key,
  760. replayKey: item.key
  761. });
  762. }
  763. const analytics = getSendAnalytics(user.id, { days: 7, trackingSecret: 'tracking-secret' });
  764. assert.equal(analytics.engagement.trackedDelivered, 3);
  765. assert.equal(analytics.engagement.totalOpens, 2);
  766. assert.equal(analytics.engagement.uniqueOpens, 2);
  767. assert.equal(analytics.engagement.proxyOpens, 1);
  768. assert.equal(analytics.engagement.totalClicks, 2);
  769. assert.equal(analytics.engagement.uniqueClicks, 2);
  770. assert.equal(analytics.engagement.scannerEvents, 1);
  771. assert.equal(analytics.engagement.openRate, 66.7);
  772. assert.equal(analytics.engagement.clickRate, 66.7);
  773. assert.equal(analytics.engagement.clickToOpenRate, 100);
  774. assert.equal(analytics.engagementByDay.reduce((sum, day) => sum + day.clicks, 0), 2);
  775. assert.equal(analytics.topLinks.length, 1);
  776. assert.equal(analytics.topLinks[0].clicks, 2);
  777. assert.equal(analytics.topLinks[0].uniqueClicks, 2);
  778. assert.equal(analytics.topLinks[0].target, target);
  779. });
  780. test('send analytics does not truncate windows above 5000 messages', () => {
  781. const database = initDatabase(tempDataDir(), 'test-secret');
  782. const user = createUser({ username: 'large-analytics', email: 'large-analytics@example.com', password: 'password123' });
  783. const createdAt = new Date().toISOString();
  784. const insert = database.prepare(`
  785. INSERT INTO send_events (
  786. user_id, domain_id, sender, recipients, subject, status, detail, created_at
  787. ) VALUES (?, NULL, ?, '[]', ?, 'queued', '', ?)
  788. `);
  789. database.exec('BEGIN');
  790. for (let index = 0; index < 5001; index += 1) {
  791. insert.run(user.id, 'sender@example.com', `Message ${index}`, createdAt);
  792. }
  793. database.exec('COMMIT');
  794. assert.equal(getSendAnalytics(user.id, { days: 1 }).summary.total, 5001);
  795. });
  796. test('stores account tokens as hashes and enforces token lifecycle', () => {
  797. const database = initDatabase(tempDataDir(), 'test-secret');
  798. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  799. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  800. const purpose = 'email_verification';
  801. const created = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
  802. const expectedHash = createHash('sha256').update(created.token).digest('hex');
  803. assert.equal(created.userId, alice.id);
  804. assert.equal(created.purpose, purpose);
  805. assert.equal(typeof created.token, 'string');
  806. assert.equal(created.token.length > 32, true);
  807. assert.equal(created.usedAt, null);
  808. assert.equal('tokenHash' in created, false);
  809. const stored = database.prepare('SELECT * FROM account_tokens WHERE id = ?').get(created.id);
  810. assert.equal(stored.user_id, alice.id);
  811. assert.equal(stored.purpose, purpose);
  812. assert.equal(stored.token_hash, expectedHash);
  813. assert.notEqual(stored.token_hash, created.token);
  814. assert.equal(Object.values(stored).includes(created.token), false);
  815. assert.equal(consumeAccountToken(created.token, 'password_reset'), null);
  816. const consumed = consumeAccountToken(created.token, purpose);
  817. assert.equal(consumed.id, created.id);
  818. assert.equal(consumed.userId, alice.id);
  819. assert.equal(consumed.purpose, purpose);
  820. assert.equal(typeof consumed.usedAt, 'string');
  821. assert.equal('token' in consumed, false);
  822. assert.equal(consumeAccountToken(created.token, purpose), null);
  823. const expired = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
  824. database
  825. .prepare('UPDATE account_tokens SET expires_at = ? WHERE id = ?')
  826. .run('2000-01-01T00:00:00.000Z', expired.id);
  827. assert.equal(consumeAccountToken(expired.token, purpose), null);
  828. const alreadyUsedReset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  829. assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset').id, alreadyUsedReset.id);
  830. const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  831. const otherPurpose = createAccountToken(alice.id, 'email_change', { ttlMinutes: 30 });
  832. const otherUserReset = createAccountToken(bob.id, 'password_reset', { ttlMinutes: 30 });
  833. assert.equal(invalidateAccountTokens(alice.id, 'password_reset'), 1);
  834. assert.equal(typeof database.prepare('SELECT used_at FROM account_tokens WHERE id = ?').get(reset.id).used_at, 'string');
  835. assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
  836. assert.equal(consumeAccountToken(alreadyUsedReset.token, 'password_reset'), null);
  837. assert.equal(consumeAccountToken(otherPurpose.token, 'email_change').id, otherPurpose.id);
  838. assert.equal(consumeAccountToken(otherUserReset.token, 'password_reset').id, otherUserReset.id);
  839. });
  840. test('password updates invalidate unused password reset tokens only', () => {
  841. initDatabase(tempDataDir(), 'test-secret');
  842. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  843. const reset = createAccountToken(alice.id, 'password_reset', { ttlMinutes: 30 });
  844. const verification = createAccountToken(alice.id, 'email_verification', { ttlMinutes: 30 });
  845. updateUser(alice.id, { password: 'new-password-123' });
  846. assert.equal(consumeAccountToken(reset.token, 'password_reset'), null);
  847. assert.equal(consumeAccountToken(verification.token, 'email_verification').id, verification.id);
  848. assert.equal(authenticateUser('alice', 'password123'), null);
  849. assert.equal(authenticateUser('alice', 'new-password-123').id, alice.id);
  850. });
  851. test('validates account token ttl minute boundaries', () => {
  852. initDatabase(tempDataDir(), 'test-secret');
  853. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  854. const maxTtlMinutes = 7 * 24 * 60;
  855. for (const ttlMinutes of [0, -1, 1.5, maxTtlMinutes + 1, Number.NaN, Number.POSITIVE_INFINITY]) {
  856. assert.throws(
  857. () => createAccountToken(alice.id, 'email_verification', { ttlMinutes }),
  858. /令牌有效期不正确。/
  859. );
  860. }
  861. assert.equal(createAccountToken(alice.id, 'email_verification', { ttlMinutes: 1 }).userId, alice.id);
  862. assert.equal(createAccountToken(alice.id, 'password_reset', { ttlMinutes: maxTtlMinutes }).userId, alice.id);
  863. });
  864. test('creates users with account tokens atomically', () => {
  865. const database = initDatabase(tempDataDir(), 'test-secret');
  866. assert.throws(
  867. () => createUserWithAccountToken({
  868. username: 'rollback',
  869. email: 'rollback@example.com',
  870. password: 'password123',
  871. status: 'pending_email'
  872. }, 'email_verification', { ttlMinutes: 0 }),
  873. /令牌有效期不正确。/
  874. );
  875. assert.equal(
  876. database
  877. .prepare("SELECT COUNT(*) AS count FROM users WHERE username = 'rollback' OR email = 'rollback@example.com'")
  878. .get()
  879. .count,
  880. 0
  881. );
  882. assert.equal(database.prepare('SELECT COUNT(*) AS count FROM account_tokens').get().count, 0);
  883. const { user, accountToken } = createUserWithAccountToken({
  884. username: 'atomic',
  885. email: 'atomic@example.com',
  886. password: 'password123',
  887. status: 'pending_email'
  888. }, 'email_verification', { ttlMinutes: 30 });
  889. assert.equal(user.status, 'pending_email');
  890. assert.equal(accountToken.userId, user.id);
  891. assert.equal(accountToken.purpose, 'email_verification');
  892. assert.equal(typeof accountToken.token, 'string');
  893. assert.equal('tokenHash' in accountToken, false);
  894. assert.equal(
  895. database
  896. .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?')
  897. .get(user.id, 'email_verification')
  898. .count,
  899. 1
  900. );
  901. });
  902. test('moves users through the extended status lifecycle', () => {
  903. initDatabase(tempDataDir(), 'test-secret');
  904. const pendingEmail = createUser({
  905. username: 'pending',
  906. email: 'pending@example.com',
  907. password: 'password123',
  908. status: 'pending_email'
  909. });
  910. const seededAdmin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  911. assert.equal(pendingEmail.status, 'pending_email');
  912. assert.equal(seededAdmin.status, 'active');
  913. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
  914. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
  915. assert.equal(approveUser(pendingEmail.id).status, 'active');
  916. assert.equal(markUserEmailVerified(pendingEmail.id).status, 'active');
  917. assert.equal(updateUserStatus(pendingEmail.id, 'disabled').status, 'disabled');
  918. assert.equal(updateUser(pendingEmail.id, { status: 'pending_review' }).status, 'pending_review');
  919. assert.equal(markUserEmailVerified(999999), null);
  920. assert.throws(() => updateUserStatus(pendingEmail.id, 'archived'), /用户状态不正确/);
  921. assert.throws(
  922. () => createUser({
  923. username: 'invalidstatus',
  924. email: 'invalidstatus@example.com',
  925. password: 'password123',
  926. status: 'archived'
  927. }),
  928. /用户状态不正确/
  929. );
  930. assert.equal(getUser(pendingEmail.id).status, 'pending_review');
  931. });
  932. test('lists users with owned resource counts', () => {
  933. initDatabase(tempDataDir(), 'test-secret');
  934. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  935. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  936. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  937. createDomain(alice.id, domainFixture('news.alice.example'));
  938. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  939. const aliceMailbox = createInboundMailbox(alice.id, { address: 'support@alice.example' });
  940. createInboundMessage(aliceMailbox, {
  941. sender: 'sender@example.net',
  942. recipients: ['support@alice.example'],
  943. subject: 'Inbound',
  944. rawMessage: 'Subject: Inbound\r\n\r\nHello'
  945. });
  946. saveDnsCredential(alice.id, {
  947. name: 'Alice Cloudflare',
  948. provider: 'cloudflare',
  949. zoneName: 'alice.example',
  950. credentials: { apiToken: 'alice-secret-dns-token', zoneId: 'alice-zone' }
  951. });
  952. saveDnsCredential(alice.id, {
  953. name: 'Alice News Cloudflare',
  954. provider: 'cloudflare',
  955. zoneName: 'news.alice.example',
  956. credentials: { apiToken: 'alice-news-secret-dns-token', zoneId: 'alice-news-zone' }
  957. });
  958. createApiToken(alice.id, 'primary');
  959. createApiToken(alice.id, 'secondary');
  960. createApiToken(bob.id, 'primary');
  961. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
  962. saveSmtpCredential(alice.id, { username: 'smtp-alice-app', password: 'smtp-secret-456' });
  963. logSendEvent({
  964. userId: alice.id,
  965. domainId: aliceDomain.id,
  966. sender: 'noreply@alice.example',
  967. recipients: ['one@example.com'],
  968. subject: 'First',
  969. status: 'queued'
  970. });
  971. logSendEvent({
  972. userId: alice.id,
  973. domainId: aliceDomain.id,
  974. sender: 'noreply@alice.example',
  975. recipients: ['two@example.com'],
  976. subject: 'Second',
  977. status: 'sent'
  978. });
  979. logSendEvent({
  980. userId: bob.id,
  981. domainId: bobDomain.id,
  982. sender: 'noreply@bob.example',
  983. recipients: ['bob@example.com'],
  984. subject: 'Bob',
  985. status: 'queued'
  986. });
  987. const users = listUsersWithResourceCounts();
  988. const aliceWithCounts = users.find((user) => user.id === alice.id);
  989. const bobWithCounts = users.find((user) => user.id === bob.id);
  990. assert.deepEqual(aliceWithCounts.resourceCounts, {
  991. domains: 2,
  992. dnsCredentials: 2,
  993. apiTokens: 2,
  994. inboundMailboxes: 1,
  995. inboundMessages: 1,
  996. sendEvents: 2,
  997. smtpCredential: 2
  998. });
  999. assert.deepEqual(bobWithCounts.resourceCounts, {
  1000. domains: 1,
  1001. dnsCredentials: 0,
  1002. apiTokens: 1,
  1003. inboundMailboxes: 0,
  1004. inboundMessages: 0,
  1005. sendEvents: 1,
  1006. smtpCredential: 0
  1007. });
  1008. assert.equal(typeof aliceWithCounts.resourceCounts.domains, 'number');
  1009. assert.equal('passwordHash' in aliceWithCounts, false);
  1010. assert.equal(JSON.stringify(users).includes('alice-secret-dns-token'), false);
  1011. assert.equal(JSON.stringify(users).includes('smtp-secret-123'), false);
  1012. });
  1013. test('builds admin resource inventory grouped by user with ownership warnings', () => {
  1014. initDatabase(tempDataDir(), 'test-secret');
  1015. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1016. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  1017. const bobCredential = saveDnsCredential(bob.id, {
  1018. name: 'Bob DNS',
  1019. provider: 'cloudflare',
  1020. zoneName: 'bob.example',
  1021. credentials: { apiToken: 'bob-secret-token', zoneId: 'bob-zone' }
  1022. });
  1023. const aliceDomain = createDomain(alice.id, {
  1024. ...domainFixture('alice.example'),
  1025. dnsCredentialId: bobCredential.id
  1026. });
  1027. const aliceMailbox = createInboundMailbox(alice.id, { address: 'support@alice.example' });
  1028. createInboundMessage(aliceMailbox, {
  1029. sender: 'sender@example.net',
  1030. recipients: ['support@alice.example'],
  1031. subject: 'Inbound inventory',
  1032. rawMessage: 'Subject: Inbound inventory\r\n\r\nHello'
  1033. });
  1034. createDomain(bob.id, domainFixture('bob.example'));
  1035. createApiToken(alice.id, 'primary');
  1036. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'smtp-secret-123' });
  1037. logSendEvent({
  1038. userId: alice.id,
  1039. domainId: aliceDomain.id,
  1040. sender: 'noreply@alice.example',
  1041. recipients: ['a@example.com'],
  1042. subject: 'Queued',
  1043. status: 'queued'
  1044. });
  1045. const inventory = getAdminResourceInventory();
  1046. const aliceResources = inventory.users.find((entry) => entry.user.id === alice.id);
  1047. const bobResources = inventory.users.find((entry) => entry.user.id === bob.id);
  1048. assert.equal(aliceResources.domains.length, 1);
  1049. assert.equal(aliceResources.domains[0].domain, 'alice.example');
  1050. assert.equal(aliceResources.dnsCredentials.length, 0);
  1051. assert.equal(aliceResources.apiTokens.length, 1);
  1052. assert.equal(aliceResources.inboundMailboxes.length, 1);
  1053. assert.equal(aliceResources.inboundMessageCount, 1);
  1054. assert.equal(aliceResources.smtpCredential.username, 'smtp-alice');
  1055. assert.equal(aliceResources.smtpCredential.passwordSet, true);
  1056. assert.equal(aliceResources.sendEventCount, 1);
  1057. assert.equal(bobResources.domains.length, 1);
  1058. assert.equal(bobResources.dnsCredentials.length, 1);
  1059. assert.deepEqual(inventory.warnings, [{
  1060. type: 'domain_dns_credential_owner_mismatch',
  1061. domainId: aliceDomain.id,
  1062. domain: 'alice.example',
  1063. domainUserId: alice.id,
  1064. dnsCredentialId: bobCredential.id,
  1065. dnsCredentialUserId: bob.id
  1066. }]);
  1067. assert.equal(JSON.stringify(inventory).includes('bob-secret-token'), false);
  1068. assert.equal(JSON.stringify(inventory).includes('smtp-secret-123'), false);
  1069. });
  1070. test('transfers individual resources with audit logs', () => {
  1071. initDatabase(tempDataDir(), 'test-secret');
  1072. const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
  1073. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1074. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  1075. const disabled = createUser({ username: 'disabled', email: 'disabled@example.com', password: 'password123', status: 'disabled' });
  1076. const domainOnlyCredential = saveDnsCredential(alice.id, {
  1077. name: 'Alice DNS 1',
  1078. provider: 'cloudflare',
  1079. zoneName: 'alice.example',
  1080. credentials: { apiToken: 'alice-secret-1' }
  1081. });
  1082. const clearCredential = saveDnsCredential(alice.id, {
  1083. name: 'Alice DNS 2',
  1084. provider: 'cloudflare',
  1085. zoneName: 'clear.example',
  1086. credentials: { apiToken: 'alice-secret-2' }
  1087. });
  1088. const withCredential = saveDnsCredential(alice.id, {
  1089. name: 'Alice DNS 3',
  1090. provider: 'cloudflare',
  1091. zoneName: 'with.example',
  1092. credentials: { apiToken: 'alice-secret-3' }
  1093. });
  1094. const standaloneCredential = saveDnsCredential(alice.id, {
  1095. name: 'Alice DNS 4',
  1096. provider: 'cloudflare',
  1097. zoneName: 'standalone.example',
  1098. credentials: { apiToken: 'alice-secret-4' }
  1099. });
  1100. const domainOnly = createDomain(alice.id, { ...domainFixture('domain-only.example'), dnsCredentialId: domainOnlyCredential.id });
  1101. const clearDomain = createDomain(alice.id, { ...domainFixture('clear.example'), dnsCredentialId: clearCredential.id });
  1102. const withDomain = createDomain(alice.id, { ...domainFixture('with.example'), dnsCredentialId: withCredential.id });
  1103. const domainOnlyMailbox = createInboundMailbox(alice.id, { address: 'support@domain-only.example' });
  1104. const inboundMessage = createInboundMessage(domainOnlyMailbox, {
  1105. sender: 'sender@example.net',
  1106. recipients: ['support@domain-only.example'],
  1107. subject: 'Transfer inbound',
  1108. rawMessage: 'Subject: Transfer inbound\r\n\r\nHello'
  1109. });
  1110. const apiToken = createApiToken(alice.id, 'primary');
  1111. assert.equal(transferDomain({
  1112. actorUserId: admin.id,
  1113. domainId: domainOnly.id,
  1114. targetUserId: bob.id,
  1115. dnsCredentialMode: 'domain_only'
  1116. }).userId, bob.id);
  1117. assert.equal(getDomain(domainOnly.id).dnsCredentialId, domainOnlyCredential.id);
  1118. assert.equal(listInboundMailboxes(alice.id).length, 0);
  1119. assert.equal(listInboundMailboxes(bob.id)[0].address, 'support@domain-only.example');
  1120. assert.equal(getInboundMessage(bob.id, inboundMessage.id).subject, 'Transfer inbound');
  1121. assert.equal(getInboundMessage(alice.id, inboundMessage.id), null);
  1122. assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
  1123. assert.throws(
  1124. () => transferDomain({
  1125. actorUserId: admin.id,
  1126. domainId: domainOnly.id,
  1127. targetUserId: bob.id,
  1128. dnsCredentialMode: 'with_dns_credential'
  1129. }),
  1130. /DNS 凭据归属不一致。/
  1131. );
  1132. assert.equal(getDnsCredential(domainOnlyCredential.id, alice.id).id, domainOnlyCredential.id);
  1133. assert.equal(transferDomain({
  1134. actorUserId: admin.id,
  1135. domainId: clearDomain.id,
  1136. targetUserId: bob.id,
  1137. dnsCredentialMode: 'clear_dns_credential'
  1138. }).dnsCredentialId, null);
  1139. assert.equal(getDnsCredential(clearCredential.id, alice.id).id, clearCredential.id);
  1140. assert.equal(transferDomain({
  1141. actorUserId: admin.id,
  1142. domainId: withDomain.id,
  1143. targetUserId: bob.id,
  1144. dnsCredentialMode: 'with_dns_credential'
  1145. }).userId, bob.id);
  1146. assert.equal(getDnsCredential(withCredential.id, bob.id).id, withCredential.id);
  1147. assert.equal(transferDnsCredential({
  1148. actorUserId: admin.id,
  1149. credentialId: standaloneCredential.id,
  1150. targetUserId: bob.id
  1151. }).userId, bob.id);
  1152. assert.equal(getDnsCredential(standaloneCredential.id, bob.id).id, standaloneCredential.id);
  1153. const transferredTokens = transferApiTokens({
  1154. actorUserId: admin.id,
  1155. tokenIds: [apiToken.id],
  1156. targetUserId: bob.id
  1157. });
  1158. assert.equal(transferredTokens.length, 1);
  1159. assert.equal(verifyApiToken(apiToken.token).id, bob.id);
  1160. assert.throws(
  1161. () => transferDomain({ actorUserId: admin.id, domainId: domainOnly.id, targetUserId: disabled.id }),
  1162. /目标用户不可用。/
  1163. );
  1164. assert.throws(
  1165. () => transferApiTokens({ actorUserId: admin.id, tokenIds: [apiToken.id], targetUserId: 999999 }),
  1166. /目标用户不可用。/
  1167. );
  1168. const actions = listAuditLogs({ actorUserId: admin.id }).map((entry) => entry.action);
  1169. assert.ok(actions.includes('admin.transfer_domain'));
  1170. assert.ok(actions.includes('admin.transfer_dns_credential'));
  1171. assert.ok(actions.includes('admin.transfer_api_tokens'));
  1172. assert.equal(JSON.stringify(listAuditLogs({ actorUserId: admin.id })).includes('alice-secret'), false);
  1173. });
  1174. test('previews and executes user merge with resource counts and multiple smtp credentials', () => {
  1175. initDatabase(tempDataDir(), 'test-secret');
  1176. const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
  1177. const source = createUser({ username: 'source', email: 'source@example.com', password: 'password123' });
  1178. const target = createUser({ username: 'target', email: 'target@example.com', password: 'password123' });
  1179. const credential = saveDnsCredential(source.id, {
  1180. name: 'Source DNS',
  1181. provider: 'cloudflare',
  1182. zoneName: 'source.example',
  1183. credentials: { apiToken: 'source-secret' }
  1184. });
  1185. const domain = createDomain(source.id, { ...domainFixture('source.example'), dnsCredentialId: credential.id });
  1186. const inboundMailbox = createInboundMailbox(source.id, { address: 'support@source.example' });
  1187. createInboundMessage(inboundMailbox, {
  1188. sender: 'sender@example.net',
  1189. recipients: ['support@source.example'],
  1190. subject: 'Merge inbound',
  1191. rawMessage: 'Subject: Merge inbound\r\n\r\nHello'
  1192. });
  1193. const apiToken = createApiToken(source.id, 'primary');
  1194. saveSmtpCredential(source.id, { username: 'smtp-source', password: 'source-secret-123' });
  1195. saveSmtpCredential(source.id, { username: 'smtp-source-app', password: 'source-secret-456' });
  1196. saveSmtpCredential(target.id, { username: 'smtp-target', password: 'target-secret-123' });
  1197. logSendEvent({
  1198. userId: source.id,
  1199. domainId: domain.id,
  1200. sender: 'noreply@source.example',
  1201. recipients: ['a@example.com'],
  1202. subject: 'Queued',
  1203. status: 'queued'
  1204. });
  1205. const preview = previewUserMerge({ sourceUserId: source.id, targetUserId: target.id });
  1206. assert.equal(preview.confirmationText, 'MERGE source INTO target');
  1207. assert.deepEqual(preview.counts, {
  1208. domains: 1,
  1209. dnsCredentials: 1,
  1210. apiTokens: 1,
  1211. inboundMailboxes: 1,
  1212. inboundMessages: 1,
  1213. sendEvents: 1,
  1214. smtpCredential: 2
  1215. });
  1216. assert.equal(preview.resources.source.domains[0].domain, 'source.example');
  1217. assert.equal(preview.resources.source.dnsCredentials[0].name, 'Source DNS');
  1218. assert.equal(preview.resources.source.apiTokens[0].name, 'primary');
  1219. assert.equal(preview.resources.source.inboundMailboxes[0].address, 'support@source.example');
  1220. assert.equal(preview.resources.source.inboundMessageCount, 1);
  1221. assert.equal(preview.resources.source.sendEventCount, 1);
  1222. assert.equal(preview.resources.target.domains.length, 0);
  1223. assert.deepEqual(preview.selectedCounts, {
  1224. domains: 1,
  1225. dnsCredentials: 1,
  1226. apiTokens: 1,
  1227. inboundMailboxes: 1,
  1228. inboundMessages: 1,
  1229. sendEvents: 1,
  1230. smtpCredential: 2
  1231. });
  1232. assert.equal(preview.smtp.conflict, false);
  1233. assert.deepEqual(preview.warnings, []);
  1234. assert.throws(
  1235. () => executeUserMerge({
  1236. actorUserId: admin.id,
  1237. sourceUserId: source.id,
  1238. targetUserId: target.id,
  1239. confirmation: 'wrong'
  1240. }),
  1241. /确认文本不匹配。/
  1242. );
  1243. assert.equal(getDomain(domain.id).userId, source.id);
  1244. const result = executeUserMerge({
  1245. actorUserId: admin.id,
  1246. sourceUserId: source.id,
  1247. targetUserId: target.id,
  1248. confirmation: preview.confirmationText
  1249. });
  1250. assert.deepEqual(result.counts, {
  1251. domains: 1,
  1252. dnsCredentials: 1,
  1253. apiTokens: 1,
  1254. inboundMailboxes: 1,
  1255. inboundMessages: 1,
  1256. sendEvents: 1,
  1257. smtpCredential: 2
  1258. });
  1259. assert.equal(getDomain(domain.id).userId, target.id);
  1260. assert.equal(listInboundMailboxes(target.id)[0].address, 'support@source.example');
  1261. assert.equal(listInboundMessages(target.id)[0].subject, 'Merge inbound');
  1262. assert.deepEqual(listInboundMailboxes(source.id), []);
  1263. assert.equal(getDnsCredential(credential.id, target.id).id, credential.id);
  1264. assert.equal(verifyApiToken(apiToken.token).id, target.id);
  1265. assert.equal(listSendEvents(target.id).length, 1);
  1266. assert.deepEqual(listSmtpCredentials(target.id).map((item) => item.username).sort(), ['smtp-source', 'smtp-source-app', 'smtp-target']);
  1267. assert.deepEqual(listSmtpCredentials(source.id), []);
  1268. assert.equal(getUser(source.id).status, 'disabled');
  1269. const [audit] = listAuditLogs({ action: 'admin.user_merge' });
  1270. assert.equal(audit.targetUserId, target.id);
  1271. assert.equal(audit.summary.sourceUserId, source.id);
  1272. assert.deepEqual(audit.summary.counts, result.counts);
  1273. assert.equal(JSON.stringify(audit).includes('source-secret'), false);
  1274. assert.equal(JSON.stringify(audit).includes('target-secret'), false);
  1275. });
  1276. test('stores system email settings without exposing smtp password publicly', () => {
  1277. const database = initDatabase(tempDataDir(), 'test-secret');
  1278. const saved = saveSystemEmailSettings({
  1279. host: 'smtp.example.com',
  1280. port: 465,
  1281. secure: true,
  1282. username: 'mailer@example.com',
  1283. password: 'smtp-password-123',
  1284. helo: 'mail.example.com',
  1285. fromEmail: 'notify@example.com',
  1286. fromName: 'MailHub Notify',
  1287. testRecipient: 'admin@example.com'
  1288. });
  1289. assert.equal(saved.host, 'smtp.example.com');
  1290. assert.equal(saved.port, 465);
  1291. assert.equal(saved.secure, true);
  1292. assert.equal(saved.passwordSet, true);
  1293. assert.equal('password' in saved, false);
  1294. assert.equal(JSON.stringify(saved).includes('smtp-password-123'), false);
  1295. const storedRows = database.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'").all();
  1296. assert.equal(storedRows.some((row) => row.value === 'smtp-password-123'), false);
  1297. const publicSettings = getSystemEmailSettings();
  1298. assert.equal(publicSettings.passwordSet, true);
  1299. assert.equal('password' in publicSettings, false);
  1300. assert.equal(JSON.stringify(publicSettings).includes('smtp-password-123'), false);
  1301. const internalSettings = getSystemEmailSettings({ includeSecret: true });
  1302. assert.equal(internalSettings.password, 'smtp-password-123');
  1303. assert.equal(internalSettings.passwordSet, true);
  1304. const unchangedPassword = saveSystemEmailSettings({
  1305. host: 'smtp2.example.com',
  1306. password: ''
  1307. });
  1308. assert.equal(unchangedPassword.host, 'smtp2.example.com');
  1309. assert.equal(unchangedPassword.passwordSet, true);
  1310. assert.equal(getSystemEmailSettings({ includeSecret: true }).password, 'smtp-password-123');
  1311. });
  1312. test('summarizes send analytics by user', () => {
  1313. initDatabase(tempDataDir(), 'test-secret');
  1314. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1315. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  1316. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  1317. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  1318. logSendEvent({
  1319. userId: alice.id,
  1320. domainId: aliceDomain.id,
  1321. sender: 'noreply@alice.example',
  1322. recipients: ['a@example.com', 'b@example.com'],
  1323. subject: 'Queued',
  1324. status: 'queued'
  1325. });
  1326. logSendEvent({
  1327. userId: alice.id,
  1328. domainId: aliceDomain.id,
  1329. sender: 'noreply@alice.example',
  1330. recipients: ['c@example.com'],
  1331. subject: 'Failed',
  1332. status: 'failed',
  1333. detail: 'relay rejected'
  1334. });
  1335. logSendEvent({
  1336. userId: bob.id,
  1337. domainId: bobDomain.id,
  1338. sender: 'noreply@bob.example',
  1339. recipients: ['x@example.com'],
  1340. subject: 'Hidden',
  1341. status: 'queued'
  1342. });
  1343. const analytics = getSendAnalytics(alice.id, { days: 7 });
  1344. assert.equal(analytics.summary.total, 2);
  1345. assert.equal(analytics.summary.queued, 1);
  1346. assert.equal(analytics.summary.failed, 1);
  1347. assert.equal(analytics.summary.recipients, 3);
  1348. assert.equal(analytics.summary.successRate, 50);
  1349. assert.equal(analytics.byDomain.length, 1);
  1350. assert.equal(analytics.byDomain[0].domain, 'alice.example');
  1351. assert.equal(analytics.recentFailures.length, 1);
  1352. assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
  1353. });
  1354. test('excludes queued and sent messages from recent delivery failures', () => {
  1355. initDatabase(tempDataDir(), 'test-secret');
  1356. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1357. const domain = createDomain(alice.id, domainFixture('alice.example'));
  1358. for (const event of [
  1359. { subject: 'Queued', status: 'queued', detail: 'accepted by postfix' },
  1360. { subject: 'Sent', status: 'sent', detail: '250 OK' },
  1361. { subject: 'Deferred', status: 'deferred', detail: 'temporary failure' },
  1362. { subject: 'Bounced', status: 'bounced', detail: '550 user unknown' },
  1363. { subject: 'Failed', status: 'failed', detail: 'relay rejected' }
  1364. ]) {
  1365. logSendEvent({
  1366. userId: alice.id,
  1367. domainId: domain.id,
  1368. sender: 'noreply@alice.example',
  1369. recipients: ['user@example.com'],
  1370. subject: event.subject,
  1371. status: event.status,
  1372. detail: event.detail
  1373. });
  1374. }
  1375. const analytics = getSendAnalytics(alice.id, { days: 7 });
  1376. assert.deepEqual(
  1377. analytics.recentFailures.map((event) => event.subject),
  1378. ['Failed', 'Bounced', 'Deferred']
  1379. );
  1380. });
  1381. test('stores and returns structured delivery logs for send events', () => {
  1382. initDatabase(tempDataDir(), 'test-secret');
  1383. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1384. const domain = createDomain(alice.id, domainFixture('alice.example'));
  1385. const deliveryLog = [
  1386. {
  1387. at: '2026-07-08T00:00:00.000Z',
  1388. phase: 'connect',
  1389. direction: 'system',
  1390. message: 'Connected to relay.test:25',
  1391. ok: true
  1392. },
  1393. {
  1394. at: '2026-07-08T00:00:01.000Z',
  1395. phase: 'queue',
  1396. direction: 'server',
  1397. code: 250,
  1398. response: '250 queued as ABC123',
  1399. ok: true
  1400. }
  1401. ];
  1402. logSendEvent({
  1403. userId: alice.id,
  1404. domainId: domain.id,
  1405. sender: 'noreply@alice.example',
  1406. recipients: ['user@example.com'],
  1407. subject: 'Delivery log',
  1408. status: 'queued',
  1409. detail: '250 queued as ABC123',
  1410. deliveryLog
  1411. });
  1412. const [event] = listSendEvents(alice.id);
  1413. assert.deepEqual(event.deliveryLog, deliveryLog);
  1414. });
  1415. test('records audit logs without storing secrets', () => {
  1416. initDatabase(tempDataDir(), 'test-secret');
  1417. const admin = createUser({ username: 'admin2', email: 'admin2@example.com', password: 'password123', role: 'admin' });
  1418. const user = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  1419. logAudit({
  1420. actorUserId: admin.id,
  1421. action: 'admin.temporary_password',
  1422. targetType: 'user',
  1423. targetId: String(user.id),
  1424. targetUserId: user.id,
  1425. summary: {
  1426. username: user.username,
  1427. temporaryPassword: 'secret-password',
  1428. passwordSet: true,
  1429. dkimPrivate: 'private-key',
  1430. dkimPublic: 'public-key',
  1431. dkim_private: 'private-key',
  1432. dkim_public: 'public-key',
  1433. authorization: 'Bearer top-level-token',
  1434. headers: {
  1435. authorization: 'Bearer nested-token',
  1436. from: 'admin@example.com'
  1437. },
  1438. nested: {
  1439. apiToken: 'secret-token',
  1440. note: 'kept'
  1441. },
  1442. changes: [
  1443. {
  1444. credential: 'secret-credential',
  1445. field: 'password'
  1446. },
  1447. {
  1448. field: 'password',
  1449. value: 'plain-secret',
  1450. label: 'password change'
  1451. },
  1452. {
  1453. name: 'apiToken',
  1454. oldValue: 'old-token',
  1455. newValue: 'new-token'
  1456. },
  1457. {
  1458. header: 'authorization',
  1459. value: 'Bearer token',
  1460. status: 'set'
  1461. },
  1462. {
  1463. path: 'smtp.password',
  1464. from: 'old-password',
  1465. to: 'new-password'
  1466. },
  1467. {
  1468. key: 'credentials.accessKeySecret',
  1469. before: 'old-secret',
  1470. after: 'new-secret'
  1471. },
  1472. {
  1473. field: 'displayName',
  1474. value: 'Alice Example'
  1475. },
  1476. {
  1477. field: 'password',
  1478. old: 'old-secret',
  1479. new: 'new-secret'
  1480. },
  1481. {
  1482. change: { field: 'password' },
  1483. value: 'plain-secret'
  1484. },
  1485. {
  1486. context: { path: 'smtp.password' },
  1487. from: 'old-wrapper-secret',
  1488. to: 'new-wrapper-secret'
  1489. },
  1490. {
  1491. change: { field: 'displayName' },
  1492. value: 'Alice Wrapper'
  1493. }
  1494. ]
  1495. }
  1496. });
  1497. const [entry] = listAuditLogs({ actorUserId: admin.id });
  1498. assert.equal(entry.action, 'admin.temporary_password');
  1499. assert.equal(entry.targetType, 'user');
  1500. assert.equal(entry.targetId, String(user.id));
  1501. assert.equal(entry.targetUserId, user.id);
  1502. assert.equal(entry.summary.username, 'alice');
  1503. assert.equal(entry.summary.temporaryPassword, undefined);
  1504. assert.equal(entry.summary.passwordSet, true);
  1505. assert.equal(entry.summary.dkimPrivate, undefined);
  1506. assert.equal(entry.summary.dkimPublic, 'public-key');
  1507. assert.equal(entry.summary.dkim_private, undefined);
  1508. assert.equal(entry.summary.dkim_public, 'public-key');
  1509. assert.equal(entry.summary.authorization, undefined);
  1510. assert.equal(entry.summary.headers.authorization, undefined);
  1511. assert.equal(entry.summary.headers.from, 'admin@example.com');
  1512. assert.equal(entry.summary.nested.apiToken, undefined);
  1513. assert.equal(entry.summary.nested.note, 'kept');
  1514. assert.equal(entry.summary.changes[0].credential, undefined);
  1515. assert.equal(entry.summary.changes[0].field, 'password');
  1516. assert.equal(entry.summary.changes[1].field, 'password');
  1517. assert.equal(entry.summary.changes[1].value, undefined);
  1518. assert.equal(entry.summary.changes[1].label, 'password change');
  1519. assert.equal(entry.summary.changes[2].name, 'apiToken');
  1520. assert.equal(entry.summary.changes[2].oldValue, undefined);
  1521. assert.equal(entry.summary.changes[2].newValue, undefined);
  1522. assert.equal(entry.summary.changes[3].header, 'authorization');
  1523. assert.equal(entry.summary.changes[3].value, undefined);
  1524. assert.equal(entry.summary.changes[3].status, 'set');
  1525. assert.equal(entry.summary.changes[4].path, 'smtp.password');
  1526. assert.equal(entry.summary.changes[4].from, undefined);
  1527. assert.equal(entry.summary.changes[4].to, undefined);
  1528. assert.equal(entry.summary.changes[5].key, undefined);
  1529. assert.equal(entry.summary.changes[5].before, undefined);
  1530. assert.equal(entry.summary.changes[5].after, undefined);
  1531. assert.equal(entry.summary.changes[6].field, 'displayName');
  1532. assert.equal(entry.summary.changes[6].value, 'Alice Example');
  1533. assert.equal(entry.summary.changes[7].field, 'password');
  1534. assert.equal(entry.summary.changes[7].old, undefined);
  1535. assert.equal(entry.summary.changes[7].new, undefined);
  1536. assert.deepEqual(entry.summary.changes[8].change, { field: 'password' });
  1537. assert.equal(entry.summary.changes[8].value, undefined);
  1538. assert.deepEqual(entry.summary.changes[9].context, { path: 'smtp.password' });
  1539. assert.equal(entry.summary.changes[9].from, undefined);
  1540. assert.equal(entry.summary.changes[9].to, undefined);
  1541. assert.deepEqual(entry.summary.changes[10].change, { field: 'displayName' });
  1542. assert.equal(entry.summary.changes[10].value, 'Alice Wrapper');
  1543. });
  1544. test('filters audit logs and returns newest entries first', () => {
  1545. initDatabase(tempDataDir(), 'test-secret');
  1546. const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
  1547. const alice = createUser({ username: 'alice2', email: 'alice2@example.com', password: 'password123' });
  1548. const bob = createUser({ username: 'bob2', email: 'bob2@example.com', password: 'password123' });
  1549. logAudit({
  1550. actorUserId: admin.id,
  1551. action: 'admin.disable_user',
  1552. targetType: 'user',
  1553. targetId: String(alice.id),
  1554. targetUserId: alice.id,
  1555. summary: { username: alice.username }
  1556. });
  1557. logAudit({
  1558. actorUserId: admin.id,
  1559. action: 'admin.reset_password',
  1560. targetType: 'user',
  1561. targetId: String(alice.id),
  1562. targetUserId: alice.id,
  1563. summary: { username: alice.username }
  1564. });
  1565. logAudit({
  1566. actorUserId: null,
  1567. action: 'system.rotation',
  1568. targetType: 'system',
  1569. summary: { reason: 'scheduled' }
  1570. });
  1571. logAudit({
  1572. actorUserId: admin.id,
  1573. action: 'admin.reset_password',
  1574. targetType: 'user',
  1575. targetId: String(bob.id),
  1576. targetUserId: bob.id,
  1577. summary: { username: bob.username }
  1578. });
  1579. assert.deepEqual(
  1580. listAuditLogs({ action: 'admin.reset_password' }).map((entry) => entry.summary.username),
  1581. ['bob2', 'alice2']
  1582. );
  1583. assert.deepEqual(
  1584. listAuditLogs({ targetUserId: alice.id }).map((entry) => entry.action),
  1585. ['admin.reset_password', 'admin.disable_user']
  1586. );
  1587. assert.deepEqual(
  1588. listAuditLogs({ actorUserId: null }).map((entry) => entry.action),
  1589. ['system.rotation']
  1590. );
  1591. const pastIso = '2000-01-01T00:00:00.000Z';
  1592. const futureIso = '2999-01-01T00:00:00.000Z';
  1593. assert.deepEqual(listAuditLogs({ from: futureIso }), []);
  1594. assert.deepEqual(listAuditLogs({ to: pastIso }), []);
  1595. assert.deepEqual(
  1596. listAuditLogs({ from: pastIso, to: futureIso }).map((entry) => entry.action),
  1597. ['admin.reset_password', 'system.rotation', 'admin.reset_password', 'admin.disable_user']
  1598. );
  1599. });
  1600. function domainFixture(domain) {
  1601. return {
  1602. domain,
  1603. selector: 'mh202607',
  1604. verificationToken: 'token',
  1605. dkimPublic: 'public',
  1606. dkimPrivate: 'private',
  1607. senderHost: `mail.${domain}`,
  1608. sendingIp: '127.0.0.1',
  1609. spfExtra: '',
  1610. dmarcPolicy: 'none',
  1611. dmarcRua: ''
  1612. };
  1613. }
  1614. function tempDataDir() {
  1615. return mkdtempSync(path.join(tmpdir(), 'mailhub-test-'));
  1616. }