db.test.js 69 KB

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