db.test.js 66 KB

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