@remit/mailbox-service 0.0.112 → 0.0.114

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 (270) 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} +16 -72
  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} +38 -64
  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 +129 -0
  78. package/dist/mailbox-queue.js +216 -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 +280 -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} +13 -68
  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 -97
  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 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. 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
- });