webhook-db.test.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import path from 'node:path';
  5. import { DatabaseSync } from 'node:sqlite';
  6. import { test } from 'node:test';
  7. import {
  8. claimWebhookDeliveries,
  9. completeWebhookDeliveryFailure,
  10. completeWebhookDeliverySuccess,
  11. createDomain,
  12. createInboundMailbox,
  13. createInboundMessage,
  14. createInboundMessageWithWebhook,
  15. createUser,
  16. createWebhook,
  17. deleteWebhook,
  18. enqueueWebhookDeliveries,
  19. enqueueInboundWebhookDeliveries,
  20. enqueueWebhookTestDelivery,
  21. getWebhook,
  22. initDatabase,
  23. listWebhookDeliveries,
  24. listWebhooks,
  25. logSendEvent,
  26. replayWebhookDelivery,
  27. rotateWebhookSecret,
  28. updateSendEventDelivery,
  29. updateWebhook
  30. } from '../src/db.js';
  31. import { MAX_WEBHOOK_ATTEMPTS } from '../src/webhook-model.js';
  32. test('isolates webhooks by user and supports domain scope filter', () => {
  33. initDatabase(tempDataDir(), 'test-secret');
  34. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  35. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  36. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  37. createWebhook(alice.id, {
  38. name: 'Alice account',
  39. url: 'https://hooks.alice.example/account',
  40. events: ['sent']
  41. });
  42. createWebhook(alice.id, {
  43. name: 'Alice domain',
  44. url: 'https://hooks.alice.example/domain',
  45. events: ['failed'],
  46. domainId: aliceDomain.id
  47. });
  48. createWebhook(bob.id, {
  49. name: 'Bob account',
  50. url: 'https://hooks.bob.example/account',
  51. events: ['sent', 'bounced']
  52. });
  53. assert.equal(listWebhooks(alice.id).length, 2);
  54. assert.equal(listWebhooks(bob.id).length, 1);
  55. assert.equal(listWebhooks(alice.id, { domainId: null }).length, 1);
  56. assert.equal(listWebhooks(alice.id, { domainId: aliceDomain.id }).length, 1);
  57. assert.equal(listWebhooks(alice.id, { domainId: aliceDomain.id })[0].name, 'Alice domain');
  58. assert.equal(listWebhooks(bob.id, { domainId: aliceDomain.id }).length, 0);
  59. });
  60. test('migrates legacy webhook deliveries without losing send-event records', () => {
  61. const dataDir = tempDataDir();
  62. const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
  63. database.exec(`
  64. CREATE TABLE users (
  65. id INTEGER PRIMARY KEY AUTOINCREMENT,
  66. username TEXT NOT NULL UNIQUE,
  67. email TEXT NOT NULL UNIQUE,
  68. password_hash TEXT NOT NULL,
  69. role TEXT NOT NULL DEFAULT 'user',
  70. status TEXT NOT NULL DEFAULT 'active',
  71. created_at TEXT NOT NULL,
  72. updated_at TEXT NOT NULL
  73. );
  74. CREATE TABLE webhooks (
  75. id INTEGER PRIMARY KEY AUTOINCREMENT,
  76. user_id INTEGER NOT NULL,
  77. domain_id INTEGER,
  78. name TEXT NOT NULL,
  79. url TEXT NOT NULL,
  80. secret_ciphertext TEXT NOT NULL,
  81. secret_prefix TEXT NOT NULL,
  82. events_json TEXT NOT NULL,
  83. enabled TEXT NOT NULL DEFAULT 'true',
  84. created_at TEXT NOT NULL,
  85. updated_at TEXT NOT NULL
  86. );
  87. CREATE TABLE webhook_deliveries (
  88. id INTEGER PRIMARY KEY AUTOINCREMENT,
  89. webhook_id INTEGER NOT NULL,
  90. user_id INTEGER NOT NULL,
  91. send_event_id INTEGER NOT NULL,
  92. event_type TEXT NOT NULL,
  93. payload_json TEXT NOT NULL,
  94. status TEXT NOT NULL,
  95. attempt_count INTEGER NOT NULL DEFAULT 0,
  96. next_attempt_at TEXT NOT NULL,
  97. last_attempt_at TEXT,
  98. response_status INTEGER,
  99. response_body_preview TEXT NOT NULL DEFAULT '',
  100. error TEXT NOT NULL DEFAULT '',
  101. created_at TEXT NOT NULL,
  102. FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE,
  103. UNIQUE(webhook_id, send_event_id, event_type)
  104. );
  105. INSERT INTO users (id, username, email, password_hash, role, status, created_at, updated_at)
  106. VALUES (1, 'legacy', 'legacy@example.com', 'hash', 'user', 'active', '2026-07-14T00:00:00.000Z', '2026-07-14T00:00:00.000Z');
  107. INSERT INTO webhooks (id, user_id, name, url, secret_ciphertext, secret_prefix, events_json, enabled, created_at, updated_at)
  108. VALUES (1, 1, 'Legacy', 'https://hooks.example.com/legacy', 'secret', 'whsec_12', '["sent"]', 'true', '2026-07-14T00:00:00.000Z', '2026-07-14T00:00:00.000Z');
  109. INSERT INTO webhook_deliveries (id, webhook_id, user_id, send_event_id, event_type, payload_json, status, attempt_count, next_attempt_at, response_body_preview, error, created_at)
  110. VALUES (1, 1, 1, 42, 'sent', '{}', 'success', 1, '2026-07-14T00:00:00.000Z', '', '', '2026-07-14T00:00:00.000Z');
  111. `);
  112. database.close();
  113. initDatabase(dataDir, 'test-secret');
  114. const [delivery] = listWebhookDeliveries(1);
  115. assert.equal(delivery.sendEventId, 42);
  116. assert.equal(delivery.inboundMessageId, null);
  117. assert.equal(listWebhooks(1)[0].mailboxId, null);
  118. });
  119. test('create returns secret once; list and get omit secret', () => {
  120. initDatabase(tempDataDir(), 'test-secret');
  121. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  122. const created = createWebhook(alice.id, {
  123. name: 'Primary',
  124. url: 'https://hooks.example.com/mail',
  125. events: ['sent', 'failed']
  126. });
  127. assert.ok(created.secret);
  128. assert.match(created.secret, /^whsec_/);
  129. assert.equal(created.secretPrefix, created.secret.slice(0, 8));
  130. assert.deepEqual(created.events, ['sent', 'failed']);
  131. assert.equal(created.enabled, true);
  132. assert.equal('secret' in listWebhooks(alice.id)[0], false);
  133. assert.equal('secret' in getWebhook(created.id, alice.id), false);
  134. assert.equal(listWebhooks(alice.id)[0].secretPrefix, created.secretPrefix);
  135. const rotated = rotateWebhookSecret(alice.id, created.id);
  136. assert.ok(rotated.secret);
  137. assert.notEqual(rotated.secret, created.secret);
  138. assert.equal(rotated.secretPrefix, rotated.secret.slice(0, 8));
  139. assert.equal('secret' in getWebhook(created.id, alice.id), false);
  140. const updated = updateWebhook(alice.id, created.id, {
  141. name: 'Renamed',
  142. enabled: false,
  143. events: ['bounced']
  144. });
  145. assert.equal(updated.name, 'Renamed');
  146. assert.equal(updated.enabled, false);
  147. assert.deepEqual(updated.events, ['bounced']);
  148. assert.equal(deleteWebhook(alice.id, created.id), true);
  149. assert.equal(getWebhook(created.id, alice.id), null);
  150. });
  151. test('enqueueWebhookDeliveries is idempotent per webhook+event+send_event', () => {
  152. initDatabase(tempDataDir(), 'test-secret');
  153. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  154. const domain = createDomain(alice.id, domainFixture('alice.example'));
  155. const webhook = createWebhook(alice.id, {
  156. name: 'Account',
  157. url: 'https://hooks.example.com/a',
  158. events: ['sent', 'failed']
  159. });
  160. const sendEvent = {
  161. id: 42,
  162. userId: alice.id,
  163. domainId: domain.id,
  164. status: 'sent',
  165. sender: 'noreply@alice.example',
  166. recipients: ['user@example.com'],
  167. subject: 'Hello',
  168. detail: 'ok',
  169. queueId: 'QUEUE42',
  170. deliveredAt: '2026-07-09T12:00:01.000Z'
  171. };
  172. const first = enqueueWebhookDeliveries(sendEvent);
  173. const second = enqueueWebhookDeliveries(sendEvent);
  174. assert.equal(first.length, 1);
  175. assert.equal(second.length, 0);
  176. const deliveries = listWebhookDeliveries(alice.id);
  177. assert.equal(deliveries.length, 1);
  178. assert.equal(deliveries[0].webhookId, webhook.id);
  179. assert.equal(deliveries[0].sendEventId, 42);
  180. assert.equal(deliveries[0].eventType, 'sent');
  181. assert.equal(deliveries[0].status, 'pending');
  182. assert.equal(deliveries[0].attemptCount, 0);
  183. const payload = JSON.parse(deliveries[0].payloadJson);
  184. assert.equal(payload.id, `whd_${deliveries[0].id}`);
  185. assert.equal(payload.type, 'email.sent');
  186. assert.equal(payload.data.message_id, 'mh-42');
  187. assert.equal(payload.data.domain, 'alice.example');
  188. assert.equal(payload.data.queue_id, 'QUEUE42');
  189. });
  190. test('mailbox webhooks only enqueue idempotent receipt callbacks for their stored mail', () => {
  191. initDatabase(tempDataDir(), 'test-secret');
  192. const alice = createUser({ username: 'inbound-alice', email: 'inbound-alice@example.com', password: 'password123' });
  193. const bob = createUser({ username: 'inbound-bob', email: 'inbound-bob@example.com', password: 'password123' });
  194. const domain = createDomain(alice.id, domainFixture('inbound-hook.example'));
  195. const mailbox = createInboundMailbox(alice.id, { address: 'support@inbound-hook.example' });
  196. const otherMailbox = createInboundMailbox(alice.id, { address: 'sales@inbound-hook.example' });
  197. const webhook = createWebhook(alice.id, {
  198. name: 'Support receipt',
  199. url: 'https://hooks.example.com/inbound',
  200. events: ['received'],
  201. mailboxId: mailbox.id
  202. });
  203. const sendWebhook = createWebhook(alice.id, {
  204. name: 'Sending only',
  205. url: 'https://hooks.example.com/send',
  206. events: ['sent']
  207. });
  208. assert.equal(webhook.domainId, null);
  209. assert.equal(webhook.mailboxId, mailbox.id);
  210. assert.equal(listWebhooks(alice.id, { mailboxId: mailbox.id }).length, 1);
  211. assert.equal(listWebhooks(alice.id, { domainId: null }).length, 1);
  212. assert.throws(() => createWebhook(alice.id, {
  213. name: 'Invalid account receipt',
  214. url: 'https://hooks.example.com/invalid-account',
  215. events: ['received']
  216. }), /received/);
  217. assert.throws(() => createWebhook(alice.id, {
  218. name: 'Invalid mailbox send',
  219. url: 'https://hooks.example.com/invalid-mailbox',
  220. events: ['sent'],
  221. mailboxId: mailbox.id
  222. }), /邮箱 Webhook/);
  223. assert.throws(() => createWebhook(bob.id, {
  224. name: 'Other user mailbox',
  225. url: 'https://hooks.example.com/other-user',
  226. events: ['received'],
  227. mailboxId: mailbox.id
  228. }), /收信邮箱/);
  229. const inboundMessage = createInboundMessage(mailbox, {
  230. sender: 'sender@example.net',
  231. recipients: ['support@inbound-hook.example'],
  232. subject: 'Receipt callback',
  233. messageId: '<inbound-message@example.net>',
  234. textBody: 'Plain text',
  235. htmlBody: '<p>HTML</p>'
  236. });
  237. const unrelatedMessage = createInboundMessage(otherMailbox, {
  238. sender: 'sender@example.net',
  239. recipients: ['sales@inbound-hook.example'],
  240. subject: 'Other mailbox'
  241. });
  242. assert.equal(enqueueInboundWebhookDeliveries(inboundMessage).length, 1);
  243. assert.equal(enqueueInboundWebhookDeliveries(inboundMessage).length, 0);
  244. assert.equal(enqueueInboundWebhookDeliveries(unrelatedMessage).length, 0);
  245. enqueueWebhookDeliveries({
  246. id: 99,
  247. userId: alice.id,
  248. domainId: domain.id,
  249. status: 'sent',
  250. sender: 'noreply@inbound-hook.example',
  251. recipients: ['reader@example.net'],
  252. subject: 'Sending path'
  253. });
  254. const deliveries = listWebhookDeliveries(alice.id);
  255. const received = deliveries.find((delivery) => delivery.eventType === 'received');
  256. assert.ok(received);
  257. assert.equal(received.webhookId, webhook.id);
  258. assert.equal(received.sendEventId, 0);
  259. assert.equal(received.inboundMessageId, inboundMessage.id);
  260. const payload = JSON.parse(received.payloadJson);
  261. assert.equal(payload.type, 'email.received');
  262. assert.equal(payload.data.mailbox, 'support@inbound-hook.example');
  263. assert.equal(payload.data.rfc_message_id, '<inbound-message@example.net>');
  264. assert.equal(payload.data.text, 'Plain text');
  265. assert.equal(payload.data.html, '<p>HTML</p>');
  266. assert.equal('raw_message' in payload.data, false);
  267. assert.equal(deliveries.find((delivery) => delivery.eventType === 'sent')?.webhookId, sendWebhook.id);
  268. const testDelivery = enqueueWebhookTestDelivery(alice.id, webhook.id);
  269. assert.equal(testDelivery.inboundMessageId, 0);
  270. assert.equal(JSON.parse(testDelivery.payloadJson).type, 'email.received');
  271. updateWebhook(alice.id, webhook.id, { enabled: false });
  272. const disabledMessage = createInboundMessage(mailbox, {
  273. sender: 'sender@example.net',
  274. recipients: ['support@inbound-hook.example'],
  275. subject: 'Disabled callback'
  276. });
  277. assert.equal(enqueueInboundWebhookDeliveries(disabledMessage).length, 0);
  278. });
  279. test('inbound message index and receipt webhook outbox commit atomically', () => {
  280. const database = initDatabase(tempDataDir(), 'test-secret');
  281. const user = createUser({
  282. username: 'atomic-inbound',
  283. email: 'atomic-inbound@example.com',
  284. password: 'password123'
  285. });
  286. createDomain(user.id, domainFixture('atomic-inbound.example'));
  287. const mailbox = createInboundMailbox(user.id, { address: 'box@atomic-inbound.example' });
  288. createWebhook(user.id, {
  289. name: 'Atomic receipt',
  290. url: 'https://hooks.example.com/atomic-receipt',
  291. events: ['received'],
  292. mailboxId: mailbox.id
  293. });
  294. database.exec(`
  295. CREATE TRIGGER reject_atomic_receipt
  296. BEFORE INSERT ON webhook_deliveries
  297. WHEN NEW.inbound_message_id IS NOT NULL
  298. BEGIN
  299. SELECT RAISE(ABORT, 'forced receipt outbox failure');
  300. END;
  301. `);
  302. const message = {
  303. sender: 'sender@example.net',
  304. recipients: [mailbox.address],
  305. subject: 'Atomic receipt'
  306. };
  307. assert.throws(
  308. () => createInboundMessageWithWebhook(mailbox, message),
  309. /forced receipt outbox failure/
  310. );
  311. assert.equal(database.prepare('SELECT COUNT(*) AS total FROM inbound_messages').get().total, 0);
  312. assert.equal(listWebhookDeliveries(user.id).length, 0);
  313. database.exec('DROP TRIGGER reject_atomic_receipt;');
  314. const created = createInboundMessageWithWebhook(mailbox, message);
  315. const [delivery] = listWebhookDeliveries(user.id, { eventType: 'received' });
  316. assert.equal(delivery.inboundMessageId, created.id);
  317. });
  318. test('domain override skips account webhooks for that event', () => {
  319. initDatabase(tempDataDir(), 'test-secret');
  320. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  321. const domain = createDomain(alice.id, domainFixture('override.example'));
  322. const account = createWebhook(alice.id, {
  323. name: 'Account',
  324. url: 'https://hooks.example.com/account',
  325. events: ['sent', 'failed']
  326. });
  327. const domainHook = createWebhook(alice.id, {
  328. name: 'Domain',
  329. url: 'https://hooks.example.com/domain',
  330. events: ['sent'],
  331. domainId: domain.id
  332. });
  333. enqueueWebhookDeliveries({
  334. id: 7,
  335. userId: alice.id,
  336. domainId: domain.id,
  337. domain: 'override.example',
  338. status: 'sent',
  339. sender: 'noreply@override.example',
  340. recipients: ['a@example.com'],
  341. subject: 'Override',
  342. detail: '',
  343. queueId: 'Q7'
  344. });
  345. const sent = listWebhookDeliveries(alice.id);
  346. assert.equal(sent.length, 1);
  347. assert.equal(sent[0].webhookId, domainHook.id);
  348. enqueueWebhookDeliveries({
  349. id: 8,
  350. userId: alice.id,
  351. domainId: domain.id,
  352. domain: 'override.example',
  353. status: 'failed',
  354. sender: 'noreply@override.example',
  355. recipients: ['a@example.com'],
  356. subject: 'Fallback',
  357. detail: 'error',
  358. queueId: 'Q8'
  359. });
  360. const failed = listWebhookDeliveries(alice.id, { eventType: 'failed' });
  361. assert.equal(failed.length, 1);
  362. assert.equal(failed[0].webhookId, account.id);
  363. });
  364. test('logSendEvent with failed status creates webhook delivery', () => {
  365. initDatabase(tempDataDir(), 'test-secret');
  366. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  367. const domain = createDomain(alice.id, domainFixture('fail.example'));
  368. createWebhook(alice.id, {
  369. name: 'Failures',
  370. url: 'https://hooks.example.com/failed',
  371. events: ['failed']
  372. });
  373. const eventId = logSendEvent({
  374. userId: alice.id,
  375. domainId: domain.id,
  376. sender: 'noreply@fail.example',
  377. recipients: ['user@example.com'],
  378. subject: 'Boom',
  379. status: 'failed',
  380. detail: 'SMTP rejected'
  381. });
  382. const deliveries = listWebhookDeliveries(alice.id);
  383. assert.equal(deliveries.length, 1);
  384. assert.equal(deliveries[0].sendEventId, eventId);
  385. assert.equal(deliveries[0].eventType, 'failed');
  386. const payload = JSON.parse(deliveries[0].payloadJson);
  387. assert.equal(payload.type, 'email.failed');
  388. assert.equal(payload.data.domain, 'fail.example');
  389. assert.equal(payload.data.detail, 'SMTP rejected');
  390. });
  391. test('updateSendEventDelivery terminal status change enqueues delivery', () => {
  392. initDatabase(tempDataDir(), 'test-secret');
  393. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  394. const domain = createDomain(alice.id, domainFixture('track.example'));
  395. createWebhook(alice.id, {
  396. name: 'Sent',
  397. url: 'https://hooks.example.com/sent',
  398. events: ['sent']
  399. });
  400. const eventId = logSendEvent({
  401. userId: alice.id,
  402. domainId: domain.id,
  403. sender: 'noreply@track.example',
  404. recipients: ['recipient@example.net'],
  405. subject: 'Tracked',
  406. status: 'queued',
  407. detail: '250 2.0.0 Ok: queued as 1DAEBC3EC8'
  408. });
  409. assert.equal(listWebhookDeliveries(alice.id).length, 0);
  410. assert.equal(
  411. updateSendEventDelivery('1DAEBC3EC8', {
  412. at: '2026-07-08T04:15:21.000Z',
  413. queueId: '1DAEBC3EC8',
  414. recipient: 'recipient@example.net',
  415. relay: 'mx.example.net[203.0.113.25]:25',
  416. dsn: '2.0.0',
  417. status: 'sent',
  418. response: '250 OK',
  419. raw: 'raw postfix line'
  420. }),
  421. true
  422. );
  423. const deliveries = listWebhookDeliveries(alice.id);
  424. assert.equal(deliveries.length, 1);
  425. assert.equal(deliveries[0].sendEventId, eventId);
  426. assert.equal(deliveries[0].eventType, 'sent');
  427. const payload = JSON.parse(deliveries[0].payloadJson);
  428. assert.equal(payload.type, 'email.sent');
  429. assert.equal(payload.data.domain, 'track.example');
  430. assert.equal(payload.data.queue_id, '1DAEBC3EC8');
  431. assert.equal(payload.data.delivered_at, '2026-07-08T04:15:21.000Z');
  432. // Same terminal status again (duplicate attempt ignored) must not create another delivery.
  433. updateSendEventDelivery('1DAEBC3EC8', {
  434. at: '2026-07-08T04:15:21.000Z',
  435. queueId: '1DAEBC3EC8',
  436. recipient: 'recipient@example.net',
  437. relay: 'mx.example.net[203.0.113.25]:25',
  438. dsn: '2.0.0',
  439. status: 'sent',
  440. response: '250 OK',
  441. raw: 'raw postfix line'
  442. });
  443. assert.equal(listWebhookDeliveries(alice.id).length, 1);
  444. });
  445. test('claim, complete success/failure, replay, test delivery, and dead path', () => {
  446. initDatabase(tempDataDir(), 'test-secret');
  447. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  448. createDomain(alice.id, domainFixture('worker.example'));
  449. const webhook = createWebhook(alice.id, {
  450. name: 'Worker',
  451. url: 'https://hooks.example.com/worker',
  452. events: ['sent', 'failed']
  453. });
  454. enqueueWebhookDeliveries({
  455. id: 99,
  456. userId: alice.id,
  457. domainId: null,
  458. status: 'sent',
  459. sender: 'noreply@worker.example',
  460. recipients: ['a@example.com'],
  461. subject: 'Work',
  462. detail: '',
  463. queueId: 'W99'
  464. });
  465. const claimed = claimWebhookDeliveries(5);
  466. assert.equal(claimed.length, 1);
  467. assert.equal(claimed[0].webhook.id, webhook.id);
  468. assert.equal(claimed[0].webhook.url, 'https://hooks.example.com/worker');
  469. assert.ok(claimed[0].webhook.secret);
  470. assert.equal(claimed[0].delivery.status, 'processing');
  471. assert.match(claimed[0].webhook.secret, /^whsec_/);
  472. const success = completeWebhookDeliverySuccess(claimed[0].delivery.id, {
  473. responseStatus: 200,
  474. bodyPreview: 'ok'
  475. });
  476. assert.equal(success.status, 'success');
  477. assert.equal(success.attemptCount, 1);
  478. assert.equal(success.responseStatus, 200);
  479. const testDelivery = enqueueWebhookTestDelivery(alice.id, webhook.id);
  480. assert.equal(testDelivery.sendEventId, 0);
  481. assert.equal(testDelivery.status, 'pending');
  482. const testPayload = JSON.parse(testDelivery.payloadJson);
  483. assert.equal(testPayload.data.test, true);
  484. assert.equal(testPayload.data.message_id, 'mh-test');
  485. assert.equal(testPayload.id, `whd_${testDelivery.id}`);
  486. const reused = enqueueWebhookTestDelivery(alice.id, webhook.id);
  487. assert.equal(reused.id, testDelivery.id);
  488. assert.equal(reused.status, 'pending');
  489. assert.equal(reused.attemptCount, 0);
  490. const claimedTest = claimWebhookDeliveries(5);
  491. assert.equal(claimedTest.length, 1);
  492. const failed = completeWebhookDeliveryFailure(claimedTest[0].delivery.id, {
  493. responseStatus: 500,
  494. bodyPreview: 'err',
  495. error: 'server error'
  496. });
  497. assert.equal(failed.status, 'pending');
  498. assert.equal(failed.attemptCount, 1);
  499. assert.ok(failed.nextAttemptAt > failed.lastAttemptAt);
  500. const replayed = replayWebhookDelivery(alice.id, failed.id);
  501. assert.equal(replayed.status, 'pending');
  502. assert.equal(replayed.attemptCount, 0);
  503. assert.equal(replayed.error, '');
  504. assert.equal(replayed.responseBodyPreview, '');
  505. const processing = claimWebhookDeliveries(1)[0];
  506. assert.throws(() => replayWebhookDelivery(alice.id, processing.delivery.id), /投递中|租约/);
  507. enqueueWebhookDeliveries({
  508. id: 100,
  509. userId: alice.id,
  510. status: 'failed',
  511. sender: 'noreply@worker.example',
  512. recipients: ['b@example.com'],
  513. subject: 'Dead path',
  514. detail: 'x'
  515. });
  516. let row = listWebhookDeliveries(alice.id, { eventType: 'failed' }).find((d) => d.sendEventId === 100);
  517. assert.ok(row);
  518. while (row.status !== 'dead') {
  519. row = completeWebhookDeliveryFailure(row.id, { responseStatus: 502, error: 'down' });
  520. }
  521. assert.equal(row.status, 'dead');
  522. assert.equal(row.attemptCount, MAX_WEBHOOK_ATTEMPTS);
  523. const reset = replayWebhookDelivery(alice.id, row.id);
  524. assert.equal(reset.status, 'pending');
  525. assert.equal(reset.attemptCount, 0);
  526. });
  527. function domainFixture(domain) {
  528. return {
  529. domain,
  530. selector: 'mh202607',
  531. verificationToken: 'token',
  532. dkimPublic: 'public',
  533. dkimPrivate: 'private',
  534. senderHost: `mail.${domain}`,
  535. sendingIp: '127.0.0.1',
  536. spfExtra: '',
  537. dmarcPolicy: 'none',
  538. dmarcRua: ''
  539. };
  540. }
  541. function tempDataDir() {
  542. return mkdtempSync(path.join(tmpdir(), 'mailhub-webhook-db-'));
  543. }