@remit/data-ports 0.0.82 → 0.0.84

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 (174) hide show
  1. package/dist/account-settings.d.ts +371 -0
  2. package/dist/account-settings.js +114 -0
  3. package/dist/conformance/account.d.ts +3 -0
  4. package/dist/conformance/account.js +118 -0
  5. package/dist/conformance/calendar-collection.d.ts +3 -0
  6. package/dist/conformance/calendar-collection.js +251 -0
  7. package/dist/conformance/calendar-event-index.d.ts +3 -0
  8. package/dist/conformance/calendar-event-index.js +114 -0
  9. package/dist/conformance/calendar-feed-token.d.ts +3 -0
  10. package/dist/conformance/calendar-feed-token.js +97 -0
  11. package/dist/conformance/calendar-object.d.ts +3 -0
  12. package/dist/conformance/calendar-object.js +156 -0
  13. package/dist/conformance/calendar-suggestion.d.ts +3 -0
  14. package/dist/conformance/calendar-suggestion.js +255 -0
  15. package/{src/conformance/harness.ts → dist/conformance/harness.d.ts} +4 -4
  16. package/dist/conformance/harness.js +1 -0
  17. package/dist/conformance/index.js +8 -0
  18. package/dist/conformance/label.d.ts +3 -0
  19. package/dist/conformance/label.js +92 -0
  20. package/dist/conformance/sender-signer-standing.d.ts +3 -0
  21. package/dist/conformance/sender-signer-standing.js +81 -0
  22. package/dist/display-name.d.ts +1 -0
  23. package/dist/display-name.js +26 -0
  24. package/dist/errors.d.ts +150 -0
  25. package/dist/errors.js +181 -0
  26. package/dist/filter-ttl.d.ts +7 -0
  27. package/{src/filter-ttl.ts → dist/filter-ttl.js} +8 -11
  28. package/dist/folder-role.d.ts +239 -0
  29. package/dist/folder-role.js +276 -0
  30. package/dist/id.d.ts +124 -0
  31. package/{src/id.ts → dist/id.js} +41 -127
  32. package/dist/index.d.ts +37 -0
  33. package/dist/index.js +5 -0
  34. package/dist/interfaces/account-config.d.ts +15 -0
  35. package/dist/interfaces/account-config.js +1 -0
  36. package/dist/interfaces/account-export-request.d.ts +10 -0
  37. package/dist/interfaces/account-export-request.js +1 -0
  38. package/dist/interfaces/account-setting.d.ts +7 -0
  39. package/dist/interfaces/account-setting.js +1 -0
  40. package/dist/interfaces/account.d.ts +29 -0
  41. package/dist/interfaces/account.js +1 -0
  42. package/dist/interfaces/address.d.ts +69 -0
  43. package/dist/interfaces/address.js +1 -0
  44. package/dist/interfaces/calendar-collection.d.ts +38 -0
  45. package/dist/interfaces/calendar-collection.js +1 -0
  46. package/dist/interfaces/calendar-event-index.d.ts +19 -0
  47. package/dist/interfaces/calendar-event-index.js +1 -0
  48. package/dist/interfaces/calendar-feed-token.d.ts +31 -0
  49. package/dist/interfaces/calendar-feed-token.js +1 -0
  50. package/dist/interfaces/calendar-object.d.ts +36 -0
  51. package/dist/interfaces/calendar-object.js +1 -0
  52. package/dist/interfaces/calendar-store.d.ts +93 -0
  53. package/dist/interfaces/calendar-store.js +1 -0
  54. package/dist/interfaces/calendar-suggestion.d.ts +62 -0
  55. package/dist/interfaces/calendar-suggestion.js +1 -0
  56. package/dist/interfaces/config-import.d.ts +8 -0
  57. package/dist/interfaces/config-import.js +1 -0
  58. package/dist/interfaces/envelope.d.ts +24 -0
  59. package/dist/interfaces/envelope.js +1 -0
  60. package/dist/interfaces/filter.d.ts +28 -0
  61. package/dist/interfaces/filter.js +1 -0
  62. package/dist/interfaces/label.d.ts +17 -0
  63. package/dist/interfaces/label.js +1 -0
  64. package/dist/interfaces/mailbox-lock.d.ts +12 -0
  65. package/dist/interfaces/mailbox-lock.js +1 -0
  66. package/dist/interfaces/mailbox-special-use.d.ts +54 -0
  67. package/dist/interfaces/mailbox-special-use.js +1 -0
  68. package/dist/interfaces/mailbox.d.ts +79 -0
  69. package/dist/interfaces/mailbox.js +1 -0
  70. package/dist/interfaces/message-flag-push.d.ts +9 -0
  71. package/dist/interfaces/message-flag-push.js +1 -0
  72. package/dist/interfaces/message-flag.d.ts +14 -0
  73. package/dist/interfaces/message-flag.js +1 -0
  74. package/dist/interfaces/message-label.d.ts +21 -0
  75. package/dist/interfaces/message-label.js +1 -0
  76. package/dist/interfaces/message-placement-move.d.ts +8 -0
  77. package/dist/interfaces/message-placement-move.js +1 -0
  78. package/dist/interfaces/message.d.ts +60 -0
  79. package/dist/interfaces/message.js +1 -0
  80. package/dist/interfaces/organize-job-request.d.ts +10 -0
  81. package/dist/interfaces/organize-job-request.js +1 -0
  82. package/dist/interfaces/outbox-attachment.d.ts +101 -0
  83. package/dist/interfaces/outbox-attachment.js +7 -0
  84. package/dist/interfaces/outbox-message.d.ts +70 -0
  85. package/dist/interfaces/outbox-message.js +20 -0
  86. package/dist/interfaces/quarantine.d.ts +20 -0
  87. package/dist/interfaces/quarantine.js +1 -0
  88. package/dist/interfaces/sender-signer-standing.d.ts +26 -0
  89. package/dist/interfaces/sender-signer-standing.js +1 -0
  90. package/dist/interfaces/sync-warning.d.ts +11 -0
  91. package/dist/interfaces/sync-warning.js +1 -0
  92. package/dist/interfaces/thread-message.d.ts +202 -0
  93. package/dist/interfaces/thread-message.js +1 -0
  94. package/dist/mailbox-name.d.ts +35 -0
  95. package/dist/mailbox-name.js +68 -0
  96. package/dist/message-parts.d.ts +22 -0
  97. package/dist/message-parts.js +18 -0
  98. package/dist/message-settlement.d.ts +35 -0
  99. package/{src/message-settlement.ts → dist/message-settlement.js} +5 -19
  100. package/dist/mutation-events.d.ts +19 -0
  101. package/{src/mutation-events.ts → dist/mutation-events.js} +2 -5
  102. package/dist/sender-mute.d.ts +26 -0
  103. package/{src/sender-mute.ts → dist/sender-mute.js} +12 -34
  104. package/dist/sync-warning-state.d.ts +3 -0
  105. package/dist/sync-warning-state.js +5 -0
  106. package/dist/types.d.ts +491 -0
  107. package/dist/types.js +1 -0
  108. package/dist/update-manifest.d.ts +24 -0
  109. package/dist/update-manifest.js +20 -0
  110. package/dist/virtual-copy.d.ts +8 -0
  111. package/{src/virtual-copy.ts → dist/virtual-copy.js} +10 -15
  112. package/dist/wellknown.d.ts +47 -0
  113. package/{src/wellknown.ts → dist/wellknown.js} +26 -49
  114. package/package.json +44 -79
  115. package/src/account-settings.ts +0 -194
  116. package/src/conformance/account.ts +0 -104
  117. package/src/conformance/calendar-collection.ts +0 -342
  118. package/src/conformance/calendar-event-index.ts +0 -203
  119. package/src/conformance/calendar-feed-token.ts +0 -148
  120. package/src/conformance/calendar-object.ts +0 -269
  121. package/src/conformance/calendar-suggestion.ts +0 -490
  122. package/src/conformance/label.ts +0 -136
  123. package/src/conformance/sender-signer-standing.ts +0 -117
  124. package/src/display-name.test.ts +0 -101
  125. package/src/display-name.ts +0 -40
  126. package/src/errors.ts +0 -226
  127. package/src/folder-role.test.ts +0 -422
  128. package/src/folder-role.ts +0 -447
  129. package/src/id.test.ts +0 -352
  130. package/src/index.ts +0 -170
  131. package/src/interfaces/account-config.ts +0 -25
  132. package/src/interfaces/account-export-request.ts +0 -21
  133. package/src/interfaces/account-setting.ts +0 -14
  134. package/src/interfaces/account.ts +0 -43
  135. package/src/interfaces/address.ts +0 -122
  136. package/src/interfaces/calendar-collection.ts +0 -60
  137. package/src/interfaces/calendar-event-index.ts +0 -34
  138. package/src/interfaces/calendar-feed-token.ts +0 -38
  139. package/src/interfaces/calendar-object.ts +0 -58
  140. package/src/interfaces/calendar-suggestion.ts +0 -93
  141. package/src/interfaces/calendar-unit-of-work.ts +0 -47
  142. package/src/interfaces/config-import.ts +0 -16
  143. package/src/interfaces/envelope.ts +0 -32
  144. package/src/interfaces/filter-anchor-transaction.ts +0 -23
  145. package/src/interfaces/filter-anchor.ts +0 -11
  146. package/src/interfaces/filter.ts +0 -41
  147. package/src/interfaces/label.ts +0 -30
  148. package/src/interfaces/mailbox-lock.ts +0 -32
  149. package/src/interfaces/mailbox-special-use.ts +0 -60
  150. package/src/interfaces/mailbox.ts +0 -115
  151. package/src/interfaces/message-flag-push.ts +0 -17
  152. package/src/interfaces/message-flag.ts +0 -15
  153. package/src/interfaces/message-label.ts +0 -25
  154. package/src/interfaces/message-placement-move.ts +0 -15
  155. package/src/interfaces/message.ts +0 -56
  156. package/src/interfaces/organize-job-request.ts +0 -19
  157. package/src/interfaces/outbox-attachment.ts +0 -132
  158. package/src/interfaces/outbox-message.ts +0 -106
  159. package/src/interfaces/quarantine.ts +0 -22
  160. package/src/interfaces/sender-signer-standing.ts +0 -37
  161. package/src/interfaces/thread-message.ts +0 -264
  162. package/src/interfaces/unit-of-work.ts +0 -28
  163. package/src/mailbox-name.test.ts +0 -106
  164. package/src/mailbox-name.ts +0 -90
  165. package/src/mailbox-state-doors.test.ts +0 -104
  166. package/src/message-placement-doors.test.ts +0 -100
  167. package/src/message-settlement.test.ts +0 -132
  168. package/src/sender-mute.test.ts +0 -76
  169. package/src/types.ts +0 -820
  170. package/src/update-manifest.test.ts +0 -130
  171. package/src/update-manifest.ts +0 -22
  172. package/src/virtual-copy.test.ts +0 -34
  173. package/tsconfig.json +0 -8
  174. /package/{src/conformance/index.ts → dist/conformance/index.d.ts} +0 -0
@@ -1,264 +0,0 @@
1
- import type {
2
- CreateThreadMessageInput,
3
- ResultList,
4
- SearchOptions,
5
- ThreadMessageFieldTerm,
6
- ThreadMessageItem,
7
- UpdateThreadMessageInput,
8
- } from "../types.js";
9
-
10
- export interface IThreadMessageRepository {
11
- create(input: CreateThreadMessageInput): Promise<ThreadMessageItem>;
12
- get(
13
- accountConfigId: string,
14
- threadMessageId: string,
15
- ): Promise<ThreadMessageItem>;
16
- get(
17
- accountConfigId: string,
18
- threadMessageIds: string[],
19
- ): Promise<ThreadMessageItem[]>;
20
- update(
21
- accountConfigId: string,
22
- threadMessageId: string,
23
- input: UpdateThreadMessageInput,
24
- options?: {
25
- composites?: {
26
- sentDate?: number;
27
- mailboxId?: string;
28
- isRead?: boolean;
29
- isDeleted?: boolean;
30
- hasStars?: boolean;
31
- hasAttachment?: boolean;
32
- };
33
- },
34
- ): Promise<ThreadMessageItem>;
35
- delete(accountConfigId: string, threadMessageId: string): Promise<void>;
36
- deleteMany(
37
- keys: Array<{ accountConfigId: string; threadMessageId: string }>,
38
- ): Promise<void>;
39
- listByAccount(
40
- accountConfigId: string,
41
- options?: { limit?: number; continuationToken?: string },
42
- ): Promise<ResultList<ThreadMessageItem>>;
43
- listByDate(
44
- accountConfigId: string,
45
- options?: {
46
- order?: "asc" | "desc";
47
- limit?: number;
48
- continuationToken?: string;
49
- inboxMailboxIds?: Set<string>;
50
- excludeDeleted?: boolean;
51
- /**
52
- * Row criteria applied inside the query, so a page is a page of
53
- * matches however rare the criterion is. Filtering the rows a page
54
- * returned instead is what makes a listing look empty whenever the
55
- * matching mail sits below the newest page (#308).
56
- */
57
- search?: SearchOptions;
58
- },
59
- ): Promise<ResultList<ThreadMessageItem>>;
60
- /**
61
- * Search rows for a config across every mailbox in `mailboxIds` — the
62
- * cross-account, cross-folder counterpart of `searchByMailbox`. A row whose
63
- * From matches any `query` term ranks above one that only mentions the
64
- * terms, and each tier runs in date order.
65
- *
66
- * The caller supplies the mailbox scope, so a search reaching Archive, Sent, Spam and
67
- * custom folders is a matter of which set is passed. An empty or absent
68
- * `mailboxIds` means no narrowing (every mailbox of the config), which is why
69
- * handlers pass an explicitly built set rather than relying on the default.
70
- *
71
- * Matching follows `searchByMailbox`: `query` splits on whitespace and every
72
- * term must match subject OR From, case-insensitively. `limit` is a page size
73
- * over MATCHES, clamped server-side, so a short page means the matches ran
74
- * out — never that a read window was exhausted.
75
- *
76
- * Rows are per mailbox, not per conversation: the same mail filed in two
77
- * folders is two rows sharing a `threadId`.
78
- */
79
- searchByDate(
80
- accountConfigId: string,
81
- search: SearchOptions,
82
- options?: {
83
- order?: "asc" | "desc";
84
- limit?: number;
85
- continuationToken?: string;
86
- mailboxIds?: Set<string>;
87
- excludeDeleted?: boolean;
88
- },
89
- ): Promise<ResultList<ThreadMessageItem>>;
90
- /**
91
- * Rows of a config whose sender, subject or `List-Id` column matches a set
92
- * of substring terms, newest first, across every mailbox — the store-side
93
- * narrowing a rule back-apply reads with.
94
- *
95
- * The terms are evaluated in the query, so `limit` is a page size over
96
- * MATCHES and the scan reaches the whole config however old the matching
97
- * mail is. Paging a date-ordered listing and filtering the rows it returned
98
- * is what makes a rule for a quiet sender report a handful of matches on a
99
- * mailbox holding thousands (#459).
100
- *
101
- * `operator` combines the terms — `"and"` requires every one, `"or"` any.
102
- * An empty `terms` narrows nothing under either operator: the whole config,
103
- * newest first.
104
- *
105
- * A term an implementation cannot evaluate faithfully is IGNORED, never
106
- * approximated: the result may be wider than the terms asked for and never
107
- * narrower, so the caller's own matcher stays the judge.
108
- *
109
- * Matching follows `searchByMailbox`: accent- and case-insensitive
110
- * substring over the same folded columns.
111
- *
112
- * Rows are per mailbox, not per conversation: the same mail filed in two
113
- * folders is two rows sharing a `threadId`. `mailboxIds` narrows the rows
114
- * to those folders; omitting it reads every mailbox.
115
- */
116
- listByFieldTerms(
117
- accountConfigId: string,
118
- terms: readonly ThreadMessageFieldTerm[],
119
- options?: {
120
- operator?: "and" | "or";
121
- order?: "asc" | "desc";
122
- limit?: number;
123
- continuationToken?: string;
124
- excludeDeleted?: boolean;
125
- mailboxIds?: readonly string[];
126
- },
127
- ): Promise<ResultList<ThreadMessageItem>>;
128
- /**
129
- * List starred rows for a config, newest first, across every mailbox.
130
- *
131
- * Backed by the `byStarred` index (pk = accountConfigId, sk = hasStars +
132
- * sentDate). Starredness is read from `hasStars` — the boolean of record —
133
- * never from the presentation-only `star` colour. `mailboxIds` narrows the
134
- * result to a caller-supplied set (used to drop muted mailboxes and the
135
- * folders a star never surfaces from); omitting it returns every mailbox.
136
- *
137
- * Rows are per mailbox, not per conversation: the same mail filed in two
138
- * folders is two rows sharing a `threadId`. Callers that render one row per
139
- * conversation must collapse by `threadId` across the pages they have
140
- * accumulated — this cannot be done inside a single page.
141
- */
142
- listByStarred(
143
- accountConfigId: string,
144
- options?: {
145
- order?: "asc" | "desc";
146
- limit?: number;
147
- continuationToken?: string;
148
- mailboxIds?: Set<string>;
149
- excludeDeleted?: boolean;
150
- /** Row criteria applied inside the query; see `listByDate`. */
151
- search?: SearchOptions;
152
- },
153
- ): Promise<ResultList<ThreadMessageItem>>;
154
- /**
155
- * COUNT of matching CONVERSATIONS over the same predicate and the same
156
- * mailbox scope the three cross-account listing modes read with — the scoped
157
- * counterpart of `countByMailbox`.
158
- *
159
- * A page size bounds the rows a response carries and has no bearing on how
160
- * much matches, so a count returns no rows and is answered in full.
161
- *
162
- * Distinct on `threadId`, unlike `countByMailbox`, because the cross-account
163
- * listing is collapsed by thread before it is rendered: a row is per mailbox,
164
- * so one message reachable through a real folder and a virtual copy of it is
165
- * several rows, and two matching messages of one conversation are two more.
166
- */
167
- countThreadsInScope(
168
- accountConfigId: string,
169
- search: SearchOptions,
170
- options?: {
171
- mailboxIds?: Set<string>;
172
- excludeDeleted?: boolean;
173
- },
174
- ): Promise<number>;
175
- /**
176
- * Every message of a thread, across all mailboxes of the account. A
177
- * conversation spans INBOX, Sent and any folder its messages were filed
178
- * in, so this listing is deliberately not scoped to a mailbox (#46).
179
- *
180
- * Ordered by `sentDate` with `threadMessageId` breaking ties, ascending
181
- * unless `order` says otherwise — a conversation reads in the order it
182
- * happened (#81). Every implementation sorts in the query, so the order
183
- * holds across pages and not merely within one page.
184
- */
185
- listByThread(
186
- threadId: string,
187
- accountConfigId: string,
188
- options?: {
189
- order?: "asc" | "desc";
190
- limit?: number;
191
- continuationToken?: string;
192
- excludeDeleted?: boolean;
193
- },
194
- ): Promise<ResultList<ThreadMessageItem>>;
195
- findByMessageId(
196
- accountConfigId: string,
197
- messageId: string,
198
- ): Promise<ThreadMessageItem | null>;
199
- findAllByMessageId(
200
- accountConfigId: string,
201
- messageId: string,
202
- ): Promise<ThreadMessageItem[]>;
203
- getByMessageId(
204
- accountConfigId: string,
205
- messageId: string,
206
- ): Promise<ThreadMessageItem>;
207
- listByMailbox(
208
- accountConfigId: string,
209
- mailboxId: string,
210
- options?: {
211
- order?: "asc" | "desc";
212
- limit?: number;
213
- continuationToken?: string;
214
- attributes?: string[];
215
- excludeDeleted?: boolean;
216
- },
217
- ): Promise<ResultList<ThreadMessageItem>>;
218
- countByThread(accountConfigId: string, threadId: string): Promise<number>;
219
- searchByMailbox(
220
- accountConfigId: string,
221
- mailboxId: string,
222
- search: SearchOptions,
223
- options?: {
224
- order?: "asc" | "desc";
225
- count?: number;
226
- continuationToken?: string;
227
- excludeDeleted?: boolean;
228
- },
229
- ): Promise<ResultList<ThreadMessageItem>>;
230
- searchByMailboxWindow(
231
- accountConfigId: string,
232
- mailboxId: string,
233
- search: SearchOptions,
234
- options?: {
235
- order?: "asc" | "desc";
236
- limit?: number;
237
- continuationToken?: string;
238
- attributes?: string[];
239
- excludeDeleted?: boolean;
240
- },
241
- ): Promise<ResultList<ThreadMessageItem>>;
242
- /**
243
- * COUNT of matching MESSAGES in one mailbox, under the same predicate
244
- * `searchByMailboxWindow` reads with — the per-mailbox counterpart of
245
- * `countThreadsInScope`, and not distinct on `threadId` because the listing
246
- * it answers for renders a row per message.
247
- *
248
- * The number is over the whole mailbox. No page size bounds it and no cursor
249
- * narrows it, so it does not shrink as the caller pages and does not change
250
- * when the page size does (#305). There is nothing to cap: the count is one
251
- * aggregate read that returns no rows.
252
- */
253
- countByMailbox(
254
- accountConfigId: string,
255
- mailboxId: string,
256
- search: SearchOptions,
257
- options?: {
258
- excludeDeleted?: boolean;
259
- order?: "asc" | "desc";
260
- },
261
- ): Promise<number>;
262
- listAllByAccount(accountConfigId: string): Promise<ThreadMessageItem[]>;
263
- deleteAllByAccount(accountConfigId: string): Promise<number>;
264
- }
@@ -1,28 +0,0 @@
1
- import type { IAddressRepository } from "./address.js";
2
- import type { IEnvelopeRepository } from "./envelope.js";
3
- import type { IMessageRepository } from "./message.js";
4
- import type { IThreadMessageRepository } from "./thread-message.js";
5
-
6
- /**
7
- * The repositories a `saveMessage` write set touches, bound to a single unit of
8
- * work. On Postgres these are transaction-bound so every write — including the
9
- * transactional-outbox rows the message write appends — commits or rolls back
10
- * together.
11
- */
12
- export interface UnitOfWorkRepositories {
13
- message: IMessageRepository;
14
- envelope: IEnvelopeRepository;
15
- address: IAddressRepository;
16
- threadMessage: IThreadMessageRepository;
17
- }
18
-
19
- /**
20
- * Runs a set of related writes as one atomic unit. The callback receives
21
- * repositories bound to the unit of work; a throw rolls the whole set back.
22
- *
23
- * Backends without cross-entity transactions supply a pass-through
24
- * implementation that runs the callback against the plain repositories.
25
- */
26
- export interface IUnitOfWork {
27
- transaction<T>(fn: (repos: UnitOfWorkRepositories) => Promise<T>): Promise<T>;
28
- }
@@ -1,106 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { mailboxLeafName, resolveMailboxByLeafName } from "./mailbox-name.js";
4
-
5
- const JUNK_NAMES = ["junk", "spam", "junk e-mail", "junk email", "bulk mail"];
6
-
7
- const mailbox = (
8
- mailboxId: string,
9
- fullPath: string,
10
- hierarchyDelimiter = "/",
11
- ) => ({ mailboxId, fullPath, hierarchyDelimiter });
12
-
13
- describe("resolveMailboxByLeafName", () => {
14
- it("resolves a Junk folder nested under INBOX", () => {
15
- // The layout that broke report-spam's name fallback: an INBOX-prefixed
16
- // namespace where Junk is `INBOX/Spam`, which never equals "spam".
17
- const found = resolveMailboxByLeafName(
18
- [
19
- mailbox("m-inbox", "INBOX"),
20
- mailbox("m-spam", "INBOX/Spam"),
21
- mailbox("m-sent", "INBOX/Sent"),
22
- ],
23
- JUNK_NAMES,
24
- );
25
- assert.equal(found?.mailboxId, "m-spam");
26
- });
27
-
28
- it("resolves under a non-slash delimiter and a non-INBOX prefix", () => {
29
- const found = resolveMailboxByLeafName(
30
- [mailbox("m-junk", "Mail.Junk E-mail", ".")],
31
- JUNK_NAMES,
32
- );
33
- assert.equal(found?.mailboxId, "m-junk");
34
- });
35
-
36
- it("treats a flat namespace with no delimiter as its own leaf", () => {
37
- const found = resolveMailboxByLeafName(
38
- [mailbox("m-junk", "Junk", "")],
39
- JUNK_NAMES,
40
- );
41
- assert.equal(found?.mailboxId, "m-junk");
42
- });
43
-
44
- it("prefers the shallowest match over a deeper one", () => {
45
- const found = resolveMailboxByLeafName(
46
- [
47
- mailbox("m-buried", "Archive/2019/Spam"),
48
- mailbox("m-real", "INBOX/Junk"),
49
- ],
50
- JUNK_NAMES,
51
- );
52
- assert.equal(found?.mailboxId, "m-real");
53
- });
54
-
55
- it("prefers the better name at equal depth", () => {
56
- const found = resolveMailboxByLeafName(
57
- [mailbox("m-bulk", "INBOX/Bulk Mail"), mailbox("m-junk", "INBOX/Junk")],
58
- JUNK_NAMES,
59
- );
60
- assert.equal(found?.mailboxId, "m-junk");
61
- });
62
-
63
- it("answers null when no folder carries a conventional name", () => {
64
- const found = resolveMailboxByLeafName(
65
- [mailbox("m-inbox", "INBOX"), mailbox("m-work", "INBOX/Work")],
66
- JUNK_NAMES,
67
- );
68
- assert.equal(found, null);
69
- });
70
- });
71
-
72
- describe("mailboxLeafName", () => {
73
- it("takes the leaf under a dot-delimited namespace", () => {
74
- assert.equal(
75
- mailboxLeafName({
76
- fullPath: "INBOX.Projects.Q3",
77
- hierarchyDelimiter: ".",
78
- }),
79
- "Q3",
80
- );
81
- });
82
-
83
- it("takes the leaf under a slash-delimited namespace", () => {
84
- assert.equal(
85
- mailboxLeafName({
86
- fullPath: "INBOX/Sent Messages",
87
- hierarchyDelimiter: "/",
88
- }),
89
- "Sent Messages",
90
- );
91
- });
92
-
93
- it("keeps the whole name when the server reports no delimiter", () => {
94
- assert.equal(
95
- mailboxLeafName({ fullPath: "Projects/Q3", hierarchyDelimiter: "" }),
96
- "Projects/Q3",
97
- );
98
- });
99
-
100
- it("keeps a top-level name that carries no delimiter", () => {
101
- assert.equal(
102
- mailboxLeafName({ fullPath: "INBOX", hierarchyDelimiter: "." }),
103
- "INBOX",
104
- );
105
- });
106
- });
@@ -1,90 +0,0 @@
1
- export interface MailboxPath {
2
- fullPath: string;
3
- hierarchyDelimiter: string;
4
- }
5
-
6
- export interface MailboxNameCandidate extends MailboxPath {
7
- mailboxId: string;
8
- }
9
-
10
- // A flat mailbox namespace reports no delimiter at all — ImapFlow gives `""`
11
- // for a NIL LIST delimiter — and splitting on "" returns single characters, so
12
- // the path is its own leaf.
13
- const segments = (mailbox: MailboxPath): string[] =>
14
- mailbox.hierarchyDelimiter.length === 0
15
- ? [mailbox.fullPath]
16
- : mailbox.fullPath.split(mailbox.hierarchyDelimiter);
17
-
18
- /**
19
- * The folder's own name: the last segment of its path under the delimiter its
20
- * own server reports (`INBOX/Spam` → `Spam`, `INBOX.Projects.Q3` → `Q3`).
21
- */
22
- export const mailboxLeafName = (mailbox: MailboxPath): string => {
23
- const parts = segments(mailbox);
24
- return parts[parts.length - 1] || mailbox.fullPath;
25
- };
26
-
27
- /**
28
- * Where a path under a renamed folder lands, or `undefined` when the rename did
29
- * not move it. IMAP RENAME moves the whole subtree in one command, so a path
30
- * inside the renamed branch moves exactly as far as its prefix does, and a path
31
- * merely starting with the same characters (`Workshop` beside `Work`) does not
32
- * move at all.
33
- *
34
- * Sliced rather than replaced. `String.prototype.replace` with a string pattern
35
- * expands `$&`, `` $` ``, `$'` and `$$` in the replacement, so a rename to a
36
- * folder whose name contains one of those wrote a different path locally from
37
- * the one the RENAME carried to the server — the row and the server diverge and
38
- * the next sweep inserts a second row for the real path.
39
- */
40
- export const rebaseMailboxPath = (
41
- recorded: string,
42
- oldPath: string,
43
- newPath: string,
44
- delimiter: string,
45
- ): string | undefined => {
46
- if (recorded === oldPath) return newPath;
47
- if (delimiter.length === 0) return undefined;
48
- if (!recorded.startsWith(`${oldPath}${delimiter}`)) return undefined;
49
- return `${newPath}${recorded.slice(oldPath.length)}`;
50
- };
51
-
52
- const rank = (
53
- mailbox: MailboxNameCandidate,
54
- names: readonly string[],
55
- ): number | null => {
56
- const parts = segments(mailbox);
57
- const nameIndex = names.indexOf(mailboxLeafName(mailbox).toLowerCase());
58
- if (nameIndex < 0) return null;
59
- // Depth outranks the name: a "Spam" buried under Trash or an archive must
60
- // never beat the account's real "Junk" one level up.
61
- return parts.length * names.length + nameIndex;
62
- };
63
-
64
- /**
65
- * A special-use folder by conventional name, for servers that advertise no
66
- * special-use flag for it. Matches the folder's own leaf segment against its
67
- * mailbox's own hierarchy delimiter, so it resolves at any depth under any
68
- * prefix (`INBOX/Spam`, `Mail.Junk E-mail`, `[Gmail]/Spam`) without knowing
69
- * which prefixes a server uses. `names` is ordered best-first and must be
70
- * lower case.
71
- */
72
- export const resolveMailboxByLeafName = <T extends MailboxNameCandidate>(
73
- mailboxes: readonly T[],
74
- names: readonly string[],
75
- ): T | null => {
76
- let best: { mailbox: T; rank: number } | null = null;
77
- for (const mailbox of mailboxes) {
78
- const candidate = rank(mailbox, names);
79
- if (candidate === null) continue;
80
- if (
81
- !best ||
82
- candidate < best.rank ||
83
- (candidate === best.rank &&
84
- mailbox.fullPath.localeCompare(best.mailbox.fullPath) < 0)
85
- ) {
86
- best = { mailbox, rank: candidate };
87
- }
88
- }
89
- return best?.mailbox ?? null;
90
- };
@@ -1,104 +0,0 @@
1
- /**
2
- * The doors onto a folder's mutation state, proved by the compiler.
3
- *
4
- * `docs/architecture/folder-rename-and-delete.md` D3 says every transition of a
5
- * folder's state is a conditional write. A rule like that is worth exactly as
6
- * much as the thing enforcing it, and a convention enforces nothing: every
7
- * writer in the tree before this reached for the general-purpose `update()`,
8
- * because nothing stopped them.
9
- *
10
- * So `syncStatus` and `pendingPath` are gone from `UpdateMailboxInput`, and
11
- * `pendingPath` from `CreateMailboxInput` too — a create records no rename
12
- * target (T1). What is left is `transition` and `transitionSubtree`, both of
13
- * which take a predicate. Every `@ts-expect-error` below is a door that is
14
- * shut: put the field back and these stop erroring, which fails the build.
15
- *
16
- * `@ts-expect-error` is the assertion here, so this file is checked rather than
17
- * run. The one runtime case exists to keep the suite honest about having loaded
18
- * it.
19
- */
20
-
21
- import assert from "node:assert/strict";
22
- import { test } from "node:test";
23
- import type {
24
- CreateMailboxInput,
25
- MailboxStatePredicate,
26
- MailboxSubtreeTransitionIntent,
27
- MailboxTransitionIntent,
28
- UpdateMailboxInput,
29
- } from "./types.js";
30
-
31
- // --- The general-purpose writer refuses both state fields -------------------
32
-
33
- // @ts-expect-error `syncStatus` is mutation state: write it through a transition
34
- const syncStatus: UpdateMailboxInput = { syncStatus: "pending" };
35
-
36
- // @ts-expect-error `pendingPath` is mutation state
37
- const pendingPath: UpdateMailboxInput = { pendingPath: "Archive/2025" };
38
-
39
- // The insert records a state — the intent recorders say which — but never a
40
- // rename target, because a create has none.
41
- const created: CreateMailboxInput = {
42
- accountId: "acct-1",
43
- namespacePrefix: "",
44
- hierarchyDelimiter: "/",
45
- fullPath: "Archive",
46
- uidValidity: 1,
47
- uidNext: 1,
48
- highestModseq: "0",
49
- messageCount: 0,
50
- unseenCount: 0,
51
- deletedCount: 0,
52
- totalSize: 0,
53
- lastSyncUid: 0,
54
- highWaterMarkUid: 0,
55
- lastMessageSyncAt: 0,
56
- syncStatus: "pending",
57
- };
58
-
59
- // @ts-expect-error a create records no rename target (T1)
60
- const createdWithTarget: CreateMailboxInput = { ...created, pendingPath: "X" };
61
-
62
- // Everything else about a folder still goes through `update()`, unchanged.
63
- const ordinary: UpdateMailboxInput = {
64
- fullPath: "Archive/2025",
65
- messageCount: 12,
66
- cursorState: "cursor_invalid",
67
- };
68
-
69
- // --- The transitions are the doors, and they take a predicate ---------------
70
-
71
- const transition: MailboxTransitionIntent = {
72
- from: ["pending"],
73
- wherePendingPath: null,
74
- to: "synced",
75
- set: { fullPath: "INBOX/Archive", pendingPath: null },
76
- };
77
-
78
- const subtree: MailboxSubtreeTransitionIntent = {
79
- from: ["synced", "failed"],
80
- to: "pending",
81
- rowSet: (row) => ({ pendingPath: row.fullPath }),
82
- };
83
-
84
- // The predicate names states, never a free-form column: a caller cannot smuggle
85
- // an unrelated field into the WHERE clause and call it a lock.
86
- const notAState: MailboxStatePredicate = {
87
- from: ["synced"],
88
- // @ts-expect-error `fullPath` is not part of a folder's mutation state
89
- fullPath: "INBOX",
90
- };
91
-
92
- test("the folder-state doors are the two transitions", () => {
93
- assert.equal(transition.to, "synced");
94
- assert.equal(
95
- subtree.rowSet({ fullPath: "Work" } as never).pendingPath,
96
- "Work",
97
- );
98
- assert.equal(ordinary.messageCount, 12);
99
- assert.deepEqual(
100
- [syncStatus, pendingPath, createdWithTarget, notAState].length,
101
- 4,
102
- "each field taken off an input, and the predicate's own shape, is pinned above",
103
- );
104
- });
@@ -1,100 +0,0 @@
1
- /**
2
- * The two doors, proved by the compiler.
3
- *
4
- * `docs/architecture/imap-mutations.md` R3 says a placement is written only as
5
- * a conditional write. A rule like that is worth exactly as much as the thing
6
- * enforcing it, and a convention enforces nothing: the first four writers on
7
- * this branch each reached for the general-purpose `update()` and nobody
8
- * noticed, because nothing stopped them.
9
- *
10
- * So the placement fields are gone from every input type but the two that are
11
- * allowed to carry them — `transitionPlacement`, which takes a predicate, and
12
- * the settle in `updateUid`, which takes no input object at all and writes what
13
- * the mail server confirmed. Every `@ts-expect-error` below is a door that is
14
- * shut: delete the omission from `UpdateMessageInput` and these stop erroring,
15
- * which fails the build.
16
- *
17
- * `@ts-expect-error` is the assertion here, so this file is checked rather than
18
- * run. The one runtime case exists to keep the suite honest about having loaded
19
- * it.
20
- */
21
-
22
- import assert from "node:assert/strict";
23
- import { test } from "node:test";
24
- import type {
25
- PlacementPredicate,
26
- PlacementTransitionInput,
27
- UpdateMessageInput,
28
- } from "./types.js";
29
-
30
- // --- The general-purpose writer refuses every placement field ---------------
31
-
32
- // @ts-expect-error `status` is a placement field: write it through a transition
33
- const status: UpdateMessageInput = { status: "moving" };
34
-
35
- // @ts-expect-error `syncStatus` is a placement field
36
- const syncStatus: UpdateMessageInput = { syncStatus: "failed" };
37
-
38
- // @ts-expect-error `abandonedMutation` is a placement field
39
- const abandonedMutation: UpdateMessageInput = { abandonedMutation: "delete" };
40
-
41
- // @ts-expect-error `mailboxId` is a placement field
42
- const mailboxId: UpdateMessageInput = { mailboxId: "mbx-1" };
43
-
44
- // @ts-expect-error `uid` is a placement field
45
- const uid: UpdateMessageInput = { uid: 42 };
46
-
47
- // @ts-expect-error `originalMailboxId` is a placement field
48
- const originalMailboxId: UpdateMessageInput = { originalMailboxId: "mbx-1" };
49
-
50
- // @ts-expect-error `originalUid` is a placement field
51
- const originalUid: UpdateMessageInput = { originalUid: 42 };
52
-
53
- // Everything else about a message still goes through it, unchanged.
54
- const ordinary: UpdateMessageInput = {
55
- bodyStorageKey: "s3://body",
56
- category: "personal",
57
- spamReport: { reportedAt: 0 },
58
- };
59
-
60
- // --- The transition is the door, and it takes a predicate -------------------
61
-
62
- const transition: {
63
- expected: PlacementPredicate;
64
- next: PlacementTransitionInput;
65
- } = {
66
- expected: { status: ["moving", "deleting"], mailboxId: "mbx-1", uid: 42 },
67
- next: {
68
- status: "active",
69
- syncStatus: "abandoned",
70
- abandonedMutation: "delete",
71
- mailboxId: "mbx-1",
72
- uid: 42,
73
- originalMailboxId: null,
74
- originalUid: null,
75
- },
76
- };
77
-
78
- // The predicate names states, never a free-form column: a caller cannot smuggle
79
- // an unrelated field into the WHERE clause and call it a lock.
80
- // @ts-expect-error `category` is not part of a placement
81
- const notAPlacement: PlacementPredicate = { category: "personal" };
82
-
83
- test("the placement doors are the transition and the settle", () => {
84
- assert.equal(transition.next.status, "active");
85
- assert.equal(ordinary.category, "personal");
86
- assert.deepEqual(
87
- [
88
- status,
89
- syncStatus,
90
- abandonedMutation,
91
- mailboxId,
92
- uid,
93
- originalMailboxId,
94
- originalUid,
95
- notAPlacement,
96
- ].length,
97
- 8,
98
- "each of the seven fields, and the predicate's own shape, is pinned above",
99
- );
100
- });