@remit/mailbox-service 0.0.111 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -1,242 +0,0 @@
1
- /**
2
- * Classification against realistic mail, drawn from the senders reported in
3
- * issue #45 ("only personal and marketing seem to work").
4
- *
5
- * The rule table is easy to satisfy with synthetic one-header fixtures and
6
- * still wrong on real mail, because real bulk senders set several signals at
7
- * once. Every case here carries the full header set the sender actually emits,
8
- * so the test fails when rule ORDER regresses even though each individual rule
9
- * still works.
10
- */
11
-
12
- import assert from "node:assert/strict";
13
- import { describe, it } from "node:test";
14
- import { MessageCategory } from "@remit/domain-enums";
15
- import { simpleParser } from "mailparser";
16
- import { classifyByHeaders } from "./classifyByHeaders.js";
17
-
18
- const classify = async (lines: string[]) =>
19
- classifyByHeaders(await simpleParser(Buffer.from(lines.join("\r\n"))));
20
-
21
- describe("classifyByHeaders on realistic mail", () => {
22
- describe("platform notifications", () => {
23
- it("classifies an npm publish notification as automated", async () => {
24
- const category = await classify([
25
- "From: npm <notifications@npmjs.com>",
26
- "To: me@example.com",
27
- "Subject: A new version of left-pad was published",
28
- "DKIM-Signature: v=1; a=rsa-sha256; d=npmjs.com; s=s1; h=from:to",
29
- "Content-Type: text/plain",
30
- "",
31
- "published",
32
- ]);
33
- assert.equal(category, MessageCategory.automated);
34
- });
35
-
36
- it("classifies an npm mail relayed through SES as automated", async () => {
37
- const category = await classify([
38
- "Return-Path: <bounces@amazonses.com>",
39
- "From: npm <support@npmjs.com>",
40
- "To: me@example.com",
41
- "Subject: A new device signed in to your npm account",
42
- "Feedback-ID: 1.eu-west-1.abc:AmazonSES",
43
- "DKIM-Signature: v=1; a=rsa-sha256; d=amazonses.com; s=x; h=from:to",
44
- "Content-Type: text/html",
45
- "",
46
- "<p>signed in</p>",
47
- ]);
48
- assert.equal(category, MessageCategory.automated);
49
- });
50
-
51
- it("classifies a no-reply notification with no bulk headers as automated", async () => {
52
- // The reported npm case: one-to-one machine mail, aligned DKIM, and no
53
- // List-* or Precedence header at all. It used to reach the `personal`
54
- // fallback and sit among real correspondence.
55
- const category = await classify([
56
- "From: CircleCI <no-reply@circleci.com>",
57
- "To: me@example.com",
58
- "Subject: Your build failed",
59
- "DKIM-Signature: v=1; a=rsa-sha256; d=circleci.com; s=s1; h=from:to",
60
- "Content-Type: text/plain",
61
- "",
62
- "build failed",
63
- ]);
64
- assert.equal(category, MessageCategory.automated);
65
- });
66
-
67
- it("classifies X-Auto-Response-Suppress mail as automated", async () => {
68
- const category = await classify([
69
- "From: Helpdesk <ticketing@corp.example>",
70
- "To: me@example.com",
71
- "Subject: Ticket 4711 updated",
72
- "X-Auto-Response-Suppress: All",
73
- "Content-Type: text/plain",
74
- "",
75
- "updated",
76
- ]);
77
- assert.equal(category, MessageCategory.automated);
78
- });
79
- });
80
-
81
- describe("allow-listed sender domains outrank bulk headers", () => {
82
- it("classifies a GitHub pull-request notification as transactional", async () => {
83
- // GitHub sets List-ID, List-Unsubscribe AND Precedence: list. Before the
84
- // reorder, Precedence matched first and every GitHub mail — security
85
- // alerts and receipts included — landed in `automated`, contradicting
86
- // the reason GitHub is on the transactional allow-list at all.
87
- const category = await classify([
88
- "From: contributor <notifications@github.com>",
89
- "To: me@example.com",
90
- "Subject: Re: [org/repo] Fix the thing (PR #45)",
91
- "List-ID: org/repo <repo.org.github.com>",
92
- "List-Unsubscribe: <https://github.com/unsub>",
93
- "Precedence: list",
94
- "DKIM-Signature: v=1; a=rsa-sha256; d=github.com; s=pf2014; h=from:to",
95
- "Content-Type: text/plain",
96
- "",
97
- "comment",
98
- ]);
99
- assert.equal(category, MessageCategory.transactional);
100
- });
101
-
102
- it("classifies a GitHub security alert as transactional", async () => {
103
- const category = await classify([
104
- "From: GitHub <noreply@github.com>",
105
- "To: me@example.com",
106
- "Subject: [org/repo] Dependabot alert",
107
- "Precedence: bulk",
108
- "Content-Type: text/plain",
109
- "",
110
- "alert",
111
- ]);
112
- assert.equal(category, MessageCategory.transactional);
113
- });
114
-
115
- it("classifies a LinkedIn notification as social", async () => {
116
- // The reported LinkedIn case. List-Unsubscribe matched before the social
117
- // allow-list, so LinkedIn mail was filed as generic `marketing` and the
118
- // Social bucket stayed empty.
119
- const category = await classify([
120
- "Return-Path: <s-hbhcfzp@bounce.linkedin.com>",
121
- "From: LinkedIn <messages-noreply@linkedin.com>",
122
- "To: me@example.com",
123
- "Subject: You have a new invitation",
124
- "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
125
- "DKIM-Signature: v=1; a=rsa-sha256; d=linkedin.com; s=proddkim; h=from:to",
126
- "Content-Type: text/html",
127
- "",
128
- "<p>invitation</p>",
129
- ]);
130
- assert.equal(category, MessageCategory.social);
131
- });
132
-
133
- it("classifies a LinkedIn job alert from a subdomain as social", async () => {
134
- const category = await classify([
135
- "From: LinkedIn Job Alerts <jobalerts-noreply@e.linkedin.com>",
136
- "To: me@example.com",
137
- "Subject: 20 new jobs for you",
138
- "Precedence: bulk",
139
- "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
140
- "Content-Type: text/html",
141
- "",
142
- "<p>jobs</p>",
143
- ]);
144
- assert.equal(category, MessageCategory.social);
145
- });
146
- });
147
-
148
- describe("bulk mail keeps its intent bucket", () => {
149
- it("classifies a marketing blast that also sets Precedence: bulk as marketing", async () => {
150
- // Nearly every marketing platform sets Precedence: bulk. Matching it
151
- // first swallowed the whole Marketing bucket into `automated`.
152
- const category = await classify([
153
- "From: Shop <deals@shop.example>",
154
- "To: me@example.com",
155
- "Subject: 50% off everything",
156
- "Precedence: bulk",
157
- "List-Unsubscribe: <https://shop.example/unsub>",
158
- "List-Unsubscribe-Post: List-Unsubscribe=One-Click",
159
- "Content-Type: text/html",
160
- "",
161
- "<p>sale</p>",
162
- ]);
163
- assert.equal(category, MessageCategory.marketing);
164
- });
165
-
166
- it("classifies a newsletter that also sets Precedence: list as newsletter", async () => {
167
- const category = await classify([
168
- "From: Some Writer <writer@substack.example>",
169
- "To: me@example.com",
170
- "Subject: This week's issue",
171
- "Precedence: list",
172
- "List-ID: <someletter.substack.example>",
173
- "List-Unsubscribe: <https://substack.example/unsub>",
174
- "Content-Type: text/html",
175
- "",
176
- "<p>news</p>",
177
- ]);
178
- assert.equal(category, MessageCategory.newsletter);
179
- });
180
-
181
- it("classifies a mailing-list post as newsletter, not automated", async () => {
182
- const category = await classify([
183
- "From: Contributor <dev@lists.example>",
184
- "To: dev@lists.example",
185
- "Subject: [PATCH v2] fix the parser",
186
- "Precedence: list",
187
- "List-ID: <dev.lists.example>",
188
- "List-Unsubscribe: <mailto:dev-unsubscribe@lists.example>",
189
- "Content-Type: text/plain",
190
- "",
191
- "patch",
192
- ]);
193
- assert.equal(category, MessageCategory.newsletter);
194
- });
195
- });
196
-
197
- describe("personal mail stays personal", () => {
198
- it("classifies a person writing from Gmail as personal", async () => {
199
- const category = await classify([
200
- "From: Alice <alice@gmail.com>",
201
- "To: me@example.com",
202
- "Subject: lunch?",
203
- "DKIM-Signature: v=1; a=rsa-sha256; d=gmail.com; s=20230601; h=from:to",
204
- "Content-Type: text/plain",
205
- "",
206
- "lunch tomorrow?",
207
- ]);
208
- assert.equal(category, MessageCategory.personal);
209
- });
210
-
211
- it("does not treat a human support mailbox as a machine sender", async () => {
212
- // `support@` is answered by people. Adding it to the machine local-parts
213
- // would quietly bury real correspondence in `automated`.
214
- const category = await classify([
215
- "From: Acme Support <support@acme.example>",
216
- "To: me@example.com",
217
- "Subject: Re: your question",
218
- "Content-Type: text/plain",
219
- "",
220
- "answering your question",
221
- ]);
222
- assert.equal(category, MessageCategory.personal);
223
- });
224
-
225
- it("classifies a calendar invite from a colleague as transactional", async () => {
226
- const category = await classify([
227
- "From: Bob <bob@corp.example>",
228
- "To: me@example.com",
229
- "Subject: Invitation: standup",
230
- 'Content-Type: multipart/mixed; boundary="b1"',
231
- "",
232
- "--b1",
233
- "Content-Type: text/calendar; method=REQUEST",
234
- "",
235
- "BEGIN:VCALENDAR",
236
- "END:VCALENDAR",
237
- "--b1--",
238
- ]);
239
- assert.equal(category, MessageCategory.transactional);
240
- });
241
- });
242
- });
@@ -1,101 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { hasMachineHeader, isMachineLocalPart } from "./machineSenders.js";
4
-
5
- describe("isMachineLocalPart", () => {
6
- it("matches the no-reply spellings", () => {
7
- for (const localPart of [
8
- "noreply",
9
- "no-reply",
10
- "no_reply",
11
- "NoReply",
12
- "donotreply",
13
- "do-not-reply",
14
- ]) {
15
- assert.equal(isMachineLocalPart(localPart), true, localPart);
16
- }
17
- });
18
-
19
- it("matches no-reply prefixes used by platforms", () => {
20
- for (const localPart of [
21
- "noreply-github",
22
- "no-reply+abc123",
23
- "messages-noreply",
24
- ]) {
25
- assert.equal(isMachineLocalPart(localPart), true, localPart);
26
- }
27
- });
28
-
29
- it("matches notification and bounce mailboxes", () => {
30
- for (const localPart of [
31
- "notifications",
32
- "notify",
33
- "alerts",
34
- "bounces",
35
- "mailer-daemon",
36
- "postmaster",
37
- ]) {
38
- assert.equal(isMachineLocalPart(localPart), true, localPart);
39
- }
40
- });
41
-
42
- it("does not match mailboxes a person answers", () => {
43
- // A wrong entry here silently buries real correspondence in `automated`,
44
- // which is the failure mode this whole change exists to remove.
45
- for (const localPart of [
46
- "support",
47
- "info",
48
- "contact",
49
- "hello",
50
- "sales",
51
- "alice",
52
- "team",
53
- ]) {
54
- assert.equal(isMachineLocalPart(localPart), false, localPart);
55
- }
56
- });
57
-
58
- it("does not read a machine name across two words of a person's name", () => {
59
- // `bruno.reply` strips to "brunoreply", which contains "noreply" spanning
60
- // the boundary between the two words. Matching on whole words is what
61
- // keeps Bruno out of `automated`.
62
- for (const localPart of [
63
- "bruno.reply",
64
- "bruno-reply",
65
- "bruno_reply",
66
- "juno.replies",
67
- "toni.fyi",
68
- "bruno.reply+newsletter",
69
- ]) {
70
- assert.equal(isMachineLocalPart(localPart), false, localPart);
71
- }
72
- });
73
-
74
- it("still matches the qualified machine forms", () => {
75
- for (const localPart of [
76
- "noreply-github",
77
- "messages-noreply",
78
- "jobalerts-noreply",
79
- "mailer.daemon",
80
- "do-not-reply",
81
- "team.notifications",
82
- ]) {
83
- assert.equal(isMachineLocalPart(localPart), true, localPart);
84
- }
85
- });
86
- });
87
-
88
- describe("hasMachineHeader", () => {
89
- it("matches Feedback-ID regardless of case", () => {
90
- assert.equal(hasMachineHeader(["from", "Feedback-ID"]), true);
91
- assert.equal(hasMachineHeader(["feedback-id"]), true);
92
- });
93
-
94
- it("matches X-Auto-Response-Suppress", () => {
95
- assert.equal(hasMachineHeader(["x-auto-response-suppress"]), true);
96
- });
97
-
98
- it("does not match ordinary headers", () => {
99
- assert.equal(hasMachineHeader(["from", "to", "subject", "date"]), false);
100
- });
101
- });
@@ -1,346 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { DisplayNameCorrespondence } from "@remit/domain-enums";
4
- import { simpleParser } from "mailparser";
5
- import {
6
- classifyDisplayNameCorrespondence,
7
- extractOffDomainLinkDomains,
8
- extractSenderMismatch,
9
- } from "./senderMismatch.js";
10
-
11
- const parse = async (lines: string[]) =>
12
- simpleParser(Buffer.from(lines.join("\r\n")));
13
-
14
- describe("classifyDisplayNameCorrespondence", () => {
15
- it("corresponds when the name is a label of the From domain", () => {
16
- assert.equal(
17
- classifyDisplayNameCorrespondence("GitHub", "notifications.github.com"),
18
- DisplayNameCorrespondence.Corresponds,
19
- );
20
- });
21
-
22
- it("corresponds when a word of a decorated name is a label of the From domain", () => {
23
- assert.equal(
24
- classifyDisplayNameCorrespondence(
25
- "GitHub Actions",
26
- "notifications.github.com",
27
- ),
28
- DisplayNameCorrespondence.Corresponds,
29
- );
30
- });
31
-
32
- it("corresponds across a multi-part public suffix", () => {
33
- assert.equal(
34
- classifyDisplayNameCorrespondence("Sainsbury's", "mail.sainsburys.co.uk"),
35
- DisplayNameCorrespondence.Corresponds,
36
- );
37
- });
38
-
39
- it("is unrelated when the name appears nowhere in the From domain", () => {
40
- assert.equal(
41
- classifyDisplayNameCorrespondence(
42
- "InfoMedics",
43
- "serviceupdatebank.atlassian.net",
44
- ),
45
- DisplayNameCorrespondence.Unrelated,
46
- );
47
- });
48
-
49
- it("is a lookalike when a digit stands in for a letter of the domain", () => {
50
- assert.equal(
51
- classifyDisplayNameCorrespondence("InfoMedics", "1nfomedics.nl"),
52
- DisplayNameCorrespondence.Lookalike,
53
- );
54
- });
55
-
56
- it("is a lookalike for a short brand one character off", () => {
57
- assert.equal(
58
- classifyDisplayNameCorrespondence("PayPal", "paypa1.com"),
59
- DisplayNameCorrespondence.Lookalike,
60
- );
61
- });
62
-
63
- it("claims nothing when the display name is empty", () => {
64
- assert.equal(
65
- classifyDisplayNameCorrespondence("", "example.com"),
66
- DisplayNameCorrespondence.NoClaim,
67
- );
68
- assert.equal(
69
- classifyDisplayNameCorrespondence(undefined, "example.com"),
70
- DisplayNameCorrespondence.NoClaim,
71
- );
72
- });
73
-
74
- it("claims nothing when the display name is an address at the sending domain", () => {
75
- assert.equal(
76
- classifyDisplayNameCorrespondence(
77
- "billing@serviceupdatebank.atlassian.net",
78
- "serviceupdatebank.atlassian.net",
79
- ),
80
- DisplayNameCorrespondence.NoClaim,
81
- );
82
- });
83
-
84
- it("claims nothing when the display name spells the envelope address itself", () => {
85
- assert.equal(
86
- classifyDisplayNameCorrespondence("matthijs@ischen.nl", "ischen.nl"),
87
- DisplayNameCorrespondence.NoClaim,
88
- );
89
- });
90
-
91
- it("claims nothing when the display name is an address on a sibling subdomain", () => {
92
- assert.equal(
93
- classifyDisplayNameCorrespondence(
94
- "noreply@example.co.uk",
95
- "mail.example.co.uk",
96
- ),
97
- DisplayNameCorrespondence.NoClaim,
98
- );
99
- });
100
-
101
- it("is a foreign address when the display name spells an address over another domain", () => {
102
- assert.equal(
103
- classifyDisplayNameCorrespondence(
104
- "matthijs@ischen.nl",
105
- "secresaludguaviare.gov.co",
106
- ),
107
- DisplayNameCorrespondence.ForeignAddress,
108
- );
109
- });
110
-
111
- it("is a foreign address when the spelled-out address is decorated with a name", () => {
112
- assert.equal(
113
- classifyDisplayNameCorrespondence(
114
- "Matthijs (matthijs@ischen.nl)",
115
- "secresaludguaviare.gov.co",
116
- ),
117
- DisplayNameCorrespondence.ForeignAddress,
118
- );
119
- });
120
-
121
- it("compares an at sign that spells no address as an ordinary name", () => {
122
- assert.equal(
123
- classifyDisplayNameCorrespondence(
124
- "Support @ InfoMedics",
125
- "infomedics.nl",
126
- ),
127
- DisplayNameCorrespondence.Corresponds,
128
- );
129
- assert.equal(
130
- classifyDisplayNameCorrespondence(
131
- "Support @ InfoMedics",
132
- "serviceupdatebank.atlassian.net",
133
- ),
134
- DisplayNameCorrespondence.Unrelated,
135
- );
136
- });
137
-
138
- it("does not read the public suffix as a match for a brand containing it", () => {
139
- assert.equal(
140
- classifyDisplayNameCorrespondence("Netflix", "mailer.example.net"),
141
- DisplayNameCorrespondence.Unrelated,
142
- );
143
- });
144
-
145
- // Live phishing shape: a short, valuable brand name embedded as a
146
- // coincidental substring of a longer, attacker-chosen domain. "ing" sits
147
- // inside "secureingverify" the same way "irs"/"dhl"/"ups"/"kpn" sit inside
148
- // countless lookalike domains — none of that is the domain naming the
149
- // brand.
150
- it("does not match a short brand name that is merely embedded in a longer domain label (ING)", () => {
151
- assert.equal(
152
- classifyDisplayNameCorrespondence(
153
- "ING Fraudedesk",
154
- "secure-ing-verify.tk",
155
- ),
156
- DisplayNameCorrespondence.Unrelated,
157
- );
158
- });
159
-
160
- it("still matches a short brand name against its own real domain", () => {
161
- assert.equal(
162
- classifyDisplayNameCorrespondence("ING", "ing.nl"),
163
- DisplayNameCorrespondence.Corresponds,
164
- );
165
- });
166
- });
167
-
168
- describe("extractOffDomainLinkDomains", () => {
169
- it("names only the registrable domains that leave the sender's own", async () => {
170
- const parsed = await parse([
171
- "From: Jira <jira@serviceupdatebank.atlassian.net>",
172
- "To: bob@example.com",
173
- "Subject: Vordering",
174
- "Content-Type: text/html",
175
- "",
176
- '<a href="https://serviceupdatebank.atlassian.net/browse/X">ticket</a>',
177
- '<a href="https://betaal-vordering.example.org/pay">betaal nu</a>',
178
- '<a href="https://cdn.betaal-vordering.example.org/logo.png">logo</a>',
179
- ]);
180
- assert.deepEqual(
181
- extractOffDomainLinkDomains(parsed, "serviceupdatebank.atlassian.net"),
182
- ["example.org"],
183
- );
184
- });
185
-
186
- it("compares public-suffix-aware, so a co.uk sibling is not off-domain", async () => {
187
- const parsed = await parse([
188
- "From: Shop <shop@mail.example.co.uk>",
189
- "To: bob@example.com",
190
- "Subject: order",
191
- "Content-Type: text/html",
192
- "",
193
- '<a href="https://www.example.co.uk/orders">orders</a>',
194
- ]);
195
- assert.deepEqual(
196
- extractOffDomainLinkDomains(parsed, "mail.example.co.uk"),
197
- [],
198
- );
199
- });
200
-
201
- it("ignores mailto, anchors and relative hrefs", async () => {
202
- const parsed = await parse([
203
- "From: Shop <shop@example.com>",
204
- "To: bob@example.com",
205
- "Subject: order",
206
- "Content-Type: text/html",
207
- "",
208
- '<a href="mailto:help@elsewhere.example">mail us</a>',
209
- '<a href="#top">top</a>',
210
- '<a href="/orders">orders</a>',
211
- '<a href="tel:+31201234567">call</a>',
212
- ]);
213
- assert.deepEqual(extractOffDomainLinkDomains(parsed, "example.com"), []);
214
- });
215
-
216
- it("reads bare URLs out of a plain-text body", async () => {
217
- const parsed = await parse([
218
- "From: Shop <shop@example.com>",
219
- "To: bob@example.com",
220
- "Subject: order",
221
- "",
222
- "Betaal hier: https://betaal.elsewhere.example/pay?id=1",
223
- ]);
224
- assert.deepEqual(extractOffDomainLinkDomains(parsed, "example.com"), [
225
- "elsewhere.example",
226
- ]);
227
- });
228
- });
229
-
230
- describe("extractSenderMismatch", () => {
231
- const infoMedicsPhish = [
232
- "From: InfoMedics <jira@serviceupdatebank.atlassian.net>",
233
- "To: bob@example.com",
234
- "Subject: Vordering",
235
- "Authentication-Results: mx.example.com; dmarc=pass; spf=pass; dkim=pass",
236
- "X-HalOne-Spam-Probability: 1",
237
- "DKIM-Signature: v=1; a=rsa-sha256; d=custmx.one.com; s=sel; b=xxx",
238
- "Content-Type: text/html",
239
- "",
240
- '<a href="https://betaal-vordering.example.org/pay">Betaal uw factuur</a>',
241
- ];
242
-
243
- it("flags the display name and the links on a spam-classified message", async () => {
244
- const parsed = await parse(infoMedicsPhish);
245
- assert.deepEqual(
246
- extractSenderMismatch(parsed, {
247
- fromDomain: "serviceupdatebank.atlassian.net",
248
- providerSpamClassified: true,
249
- placement: "other",
250
- bulkSender: false,
251
- }),
252
- {
253
- displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
254
- offDomainLinkDomains: ["example.org"],
255
- },
256
- );
257
- });
258
-
259
- it("flags a message sitting in Junk that the provider rated clean", async () => {
260
- const parsed = await parse(infoMedicsPhish);
261
- assert.deepEqual(
262
- extractSenderMismatch(parsed, {
263
- fromDomain: "serviceupdatebank.atlassian.net",
264
- providerSpamClassified: false,
265
- placement: "junk",
266
- bulkSender: false,
267
- }),
268
- {
269
- displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
270
- offDomainLinkDomains: ["example.org"],
271
- },
272
- );
273
- });
274
-
275
- it("flags a provider-classified message outside Junk", async () => {
276
- const parsed = await parse(infoMedicsPhish);
277
- assert.deepEqual(
278
- extractSenderMismatch(parsed, {
279
- fromDomain: "serviceupdatebank.atlassian.net",
280
- providerSpamClassified: true,
281
- placement: "inbox",
282
- bulkSender: false,
283
- }),
284
- {
285
- displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
286
- offDomainLinkDomains: ["example.org"],
287
- },
288
- );
289
- });
290
-
291
- for (const placement of ["inbox", "other"] as const) {
292
- it(`compares nothing in ${placement} when the provider did not call the message spam`, async () => {
293
- const parsed = await parse(infoMedicsPhish);
294
- assert.deepEqual(
295
- extractSenderMismatch(parsed, {
296
- fromDomain: "serviceupdatebank.atlassian.net",
297
- providerSpamClassified: false,
298
- placement,
299
- bulkSender: false,
300
- }),
301
- {},
302
- );
303
- });
304
- }
305
-
306
- it("flags a display name spelling the recipient's own address over a foreign domain", async () => {
307
- const parsed = await parse([
308
- "From: matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>",
309
- "To: matthijs@ischen.nl",
310
- "Subject: Re: factura",
311
- "X-HalOne-Spam-Probability: 1",
312
- "",
313
- "hola",
314
- ]);
315
- assert.equal(
316
- extractSenderMismatch(parsed, {
317
- fromDomain: "secresaludguaviare.gov.co",
318
- providerSpamClassified: true,
319
- placement: "other",
320
- bulkSender: false,
321
- }).displayNameCorrespondence,
322
- DisplayNameCorrespondence.ForeignAddress,
323
- );
324
- });
325
-
326
- it("leaves the display name uncompared for a bulk sender", async () => {
327
- const parsed = await parse([
328
- "From: Dutch Cycling Weekly <bounce-9f2@mailer.esp.example>",
329
- "To: bob@example.com",
330
- "Subject: This week in cycling",
331
- "List-Unsubscribe: <https://mailer.esp.example/u/9f2>",
332
- "X-HalOne-Spam-Probability: 1",
333
- "Content-Type: text/html",
334
- "",
335
- '<a href="https://dutchcyclingweekly.example.org/issue/12">read</a>',
336
- ]);
337
- const signals = extractSenderMismatch(parsed, {
338
- fromDomain: "mailer.esp.example",
339
- providerSpamClassified: true,
340
- placement: "other",
341
- bulkSender: true,
342
- });
343
- assert.equal(signals.displayNameCorrespondence, undefined);
344
- assert.deepEqual(signals.offDomainLinkDomains, ["example.org"]);
345
- });
346
- });