@remit/data-ports 0.0.39 → 0.0.41

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/data-ports",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -37,6 +37,10 @@
37
37
  "types": "./src/folder-role.ts",
38
38
  "default": "./src/folder-role.ts"
39
39
  },
40
+ "./mutation-events": {
41
+ "types": "./src/mutation-events.ts",
42
+ "default": "./src/mutation-events.ts"
43
+ },
40
44
  "./mailbox-name": {
41
45
  "types": "./src/mailbox-name.ts",
42
46
  "default": "./src/mailbox-name.ts"
package/src/errors.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { CanonicalMailboxRoleValue } from "./folder-role.js";
1
+ import type {
2
+ CanonicalMailboxRoleValue,
3
+ FolderRoleUnresolvedReason,
4
+ } from "./folder-role.js";
5
+
6
+ export type { FolderRoleUnresolvedReason };
2
7
 
3
8
  /**
4
9
  * The half of an error a client may read: a stable `code` it branches on and
@@ -51,16 +56,6 @@ export class ConflictError extends HTTPError {
51
56
  public statusCode = 409;
52
57
  }
53
58
 
54
- /**
55
- * Why a canonical role names no folder this action may act on. `none`: the
56
- * account has no candidate at all. `stale`: the folder the user appointed is
57
- * gone from the server. `unconfirmed`: a folder matches by name, but nobody —
58
- * neither the user nor the server's own flag — ever said it holds the role.
59
- * These three and no others; a target that is merely unsettled is a different
60
- * refusal under its own code.
61
- */
62
- export type FolderRoleUnresolvedReason = "none" | "stale" | "unconfirmed";
63
-
64
59
  /**
65
60
  * A destructive action refused because the role it needs is unresolved. The
66
61
  * client reads `code` and words its prompt from `details` — never from the
@@ -254,17 +254,17 @@ describe("the adapters over resolveRoleForAccount", () => {
254
254
  mailbox("mb-trash", "[Gmail]/Trash", [MailboxSpecialUse.Trash]),
255
255
  ];
256
256
 
257
- it("still hands a stale appointment the flagged mailbox", () => {
258
- // The refusal that makes this return null lands with the surface that can
259
- // tell the user why (S3). Until then, both adapters answer exactly as they
260
- // did before the union existed.
257
+ it("withholds a stale appointment's fallback from the confirmed answer only", () => {
258
+ // A vanished appointment is not confirmation of anything, so the expunge
259
+ // gate sees nothing. Filing mail still works: the fallback is a fine place
260
+ // to put a message the user can move back.
261
261
  assert.equal(
262
262
  resolveConfirmedMailboxForRole(
263
263
  CanonicalMailboxRole.Trash,
264
264
  mailboxes,
265
265
  "mb-gone",
266
- )?.mailboxId,
267
- "mb-trash",
266
+ ),
267
+ null,
268
268
  );
269
269
  assert.equal(
270
270
  resolveMailboxForRole(CanonicalMailboxRole.Trash, mailboxes, "mb-gone")
@@ -273,6 +273,23 @@ describe("the adapters over resolveRoleForAccount", () => {
273
273
  );
274
274
  });
275
275
 
276
+ it("keeps a stale appointment on the flag, never on a folder named like one", () => {
277
+ // The confirmed adapter is not built on top of the other one: were it,
278
+ // null-on-stale would drop the seven non-Trash roles past their
279
+ // SPECIAL-USE flag and into the name hint, and a folder somebody called
280
+ // "Sent" would start collecting this account's sent mail.
281
+ const sent = [
282
+ mailbox("mb-inbox", "INBOX"),
283
+ mailbox("mb-sent-copy", "Sent"),
284
+ mailbox("mb-sent", "[Gmail]/Sent Mail", [MailboxSpecialUse.Sent]),
285
+ ];
286
+ assert.equal(
287
+ resolveMailboxForRole(CanonicalMailboxRole.Sent, sent, "mb-gone")
288
+ ?.mailboxId,
289
+ "mb-sent",
290
+ );
291
+ });
292
+
276
293
  it("keeps the name guess out of the confirmed answer", () => {
277
294
  const unflagged = [
278
295
  mailbox("mb-inbox", "INBOX"),
@@ -150,6 +150,20 @@ export const FOLDER_ROLES_SETTINGS_PATH = "Settings › Folder roles";
150
150
  */
151
151
  export const NO_TRASH_FOLDER_REASON = `This account has no Trash folder, so nothing was deleted. Appoint one under ${FOLDER_ROLES_SETTINGS_PATH}, then try again.`;
152
152
 
153
+ /**
154
+ * The folder somebody chose as this account's Trash is no longer on the mail
155
+ * server. Reader will not quietly file mail into whatever it would have picked
156
+ * had nobody chosen: the user's choice went missing and only they can replace
157
+ * it.
158
+ */
159
+ export const STALE_TRASH_FOLDER_REASON = `The folder appointed as this account's Trash is no longer on the mail server, so nothing was deleted. Appoint one under ${FOLDER_ROLES_SETTINGS_PATH}, then try again.`;
160
+
161
+ /**
162
+ * A folder matches the Trash name hint and nothing else — enough to file a
163
+ * delete somewhere retrievable, never enough to expunge a whole folder.
164
+ */
165
+ export const UNCONFIRMED_TRASH_FOLDER_REASON = `Nobody has confirmed which folder is this account's Trash, so it was not emptied. Appoint one under ${FOLDER_ROLES_SETTINGS_PATH}, then try again.`;
166
+
153
167
  /** The minimal mailbox shape role resolution reads. */
154
168
  export interface RoleMailboxCandidate extends MailboxNameCandidate {
155
169
  specialUse?: readonly string[];
@@ -249,25 +263,49 @@ export const resolveRoleForAccount = <T extends RoleMailboxCandidate>(
249
263
  */
250
264
  export type TrashAssuranceLevel = "confirmed" | "resolved";
251
265
 
252
- export const meetsTrashAssurance = <T>(
266
+ /**
267
+ * Why a canonical role names no folder an action may act on. `none`: the
268
+ * account has no candidate at all. `stale`: the folder the user appointed is
269
+ * gone from the server. `unconfirmed`: a folder matches by name, but nobody —
270
+ * neither the user nor the server's own flag — ever said it holds the role.
271
+ * These three and no others; a target that is merely unsettled is a different
272
+ * refusal under its own code.
273
+ */
274
+ export type FolderRoleUnresolvedReason = "none" | "stale" | "unconfirmed";
275
+
276
+ export type TrashGateOutcome<T> =
277
+ | { allowed: true; mailbox: T }
278
+ | { allowed: false; reason: FolderRoleUnresolvedReason };
279
+
280
+ /**
281
+ * The Trash folder a verb at this assurance level may act on, or which of the
282
+ * three reasons stops it. One switch: the delete, the expunge and the count
283
+ * reported before one all decide here, so no two of them can answer a user
284
+ * differently about the same account.
285
+ */
286
+ export const trashMailboxAt = <T>(
253
287
  resolution: RoleResolution<T>,
254
288
  level: TrashAssuranceLevel,
255
- ): boolean => {
289
+ ): TrashGateOutcome<T> => {
256
290
  switch (resolution.kind) {
257
291
  case "appointed":
258
292
  case "flagged":
259
- return true;
293
+ return { allowed: true, mailbox: resolution.mailbox };
260
294
  case "proposed":
261
- return level === "resolved";
295
+ return level === "resolved"
296
+ ? { allowed: true, mailbox: resolution.mailbox }
297
+ : { allowed: false, reason: "unconfirmed" };
298
+ case "appointment_stale":
299
+ return { allowed: false, reason: "stale" };
262
300
  default:
263
- return false;
301
+ return { allowed: false, reason: "none" };
264
302
  }
265
303
  };
266
304
 
267
- const withoutStale = <T>(
305
+ export const meetsTrashAssurance = <T>(
268
306
  resolution: RoleResolution<T>,
269
- ): { kind: "appointed"; mailbox: T } | UnappointedRoleResolution<T> =>
270
- resolution.kind === "appointment_stale" ? resolution.fallback : resolution;
307
+ level: TrashAssuranceLevel,
308
+ ): boolean => trashMailboxAt(resolution, level).allowed;
271
309
 
272
310
  /**
273
311
  * The mailbox a role is CONFIRMED to hold: the one the user appointed, or the
@@ -278,17 +316,16 @@ const withoutStale = <T>(
278
316
  * on top for the operations where being wrong only misfiles a message.
279
317
  *
280
318
  * An appointment naming a mailbox this account does not hold — deleted,
281
- * renamed, or carried in from elsewhere — is stale, and falls through to the
282
- * flag as if unset.
319
+ * renamed, or carried in from elsewhere — is stale, and answers `null` here
320
+ * too: the fallback is the folder the user did not choose, which is fine to
321
+ * file mail into and no basis at all for destroying it.
283
322
  */
284
323
  export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
285
324
  role: CanonicalMailboxRoleValue,
286
325
  mailboxes: readonly T[],
287
326
  appointedMailboxId?: string,
288
327
  ): T | null => {
289
- const resolution = withoutStale(
290
- resolveRoleForAccount(role, mailboxes, appointedMailboxId),
291
- );
328
+ const resolution = resolveRoleForAccount(role, mailboxes, appointedMailboxId);
292
329
  switch (resolution.kind) {
293
330
  case "appointed":
294
331
  case "flagged":
@@ -302,15 +339,21 @@ export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
302
339
  /**
303
340
  * The role's mailbox including the name guess: `null` only when nothing
304
341
  * matches at all, so the caller says the role has no folder rather than picking
305
- * one.
342
+ * one. A stale appointment answers with its fallback — every role but Trash
343
+ * files mail on this read, and refusing them all would be a behaviour change
344
+ * with no surface to explain it.
345
+ *
346
+ * Built on `resolveRoleForAccount` directly, never on the confirmed adapter: a
347
+ * stale appointment must still reach the `\Sent` flag here, not fall past it
348
+ * into a folder that merely reads like one.
306
349
  */
307
350
  export const resolveMailboxForRole = <T extends RoleMailboxCandidate>(
308
351
  role: CanonicalMailboxRoleValue,
309
352
  mailboxes: readonly T[],
310
353
  appointedMailboxId?: string,
311
354
  ): T | null => {
312
- const resolution = withoutStale(
313
- resolveRoleForAccount(role, mailboxes, appointedMailboxId),
314
- );
315
- return resolution.kind === "none" ? null : resolution.mailbox;
355
+ const resolution = resolveRoleForAccount(role, mailboxes, appointedMailboxId);
356
+ const effective =
357
+ resolution.kind === "appointment_stale" ? resolution.fallback : resolution;
358
+ return effective.kind === "none" ? null : effective.mailbox;
316
359
  };
@@ -31,14 +31,6 @@ export interface IMailboxSpecialUseRepository {
31
31
  findTrashMailbox(
32
32
  accountId: string,
33
33
  ): Promise<{ mailboxId: string; fullPath: string } | null>;
34
- /**
35
- * Trash without the name guess: the appointment or the \Trash flag, nothing
36
- * else. Empty Trash expunges whatever this returns, and a folder merely
37
- * named `Deleted` is not consent to destroy its contents.
38
- */
39
- findConfirmedTrashMailbox(
40
- accountId: string,
41
- ): Promise<{ mailboxId: string; fullPath: string } | null>;
42
34
  /**
43
35
  * Trash with the evidence attached, for the two verbs that weigh it: a
44
36
  * delete files mail somewhere retrievable, an Empty Trash destroys it, and
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The contract version the mail-destroying queue events are minted under.
3
+ *
4
+ * It lives here because the producer (`MessageMoveService`) and the consumer
5
+ * (the imap-worker's handlers) sit in packages that cannot import each other,
6
+ * and a version the two sides disagree about is worse than no version at all.
7
+ * Bump it whenever a handler starts relying on a field an older producer never
8
+ * set.
9
+ */
10
+ export const MUTATION_EVENT_SCHEMA_VERSION = 2;
11
+
12
+ /**
13
+ * Queue payloads are `JSON.parse`d and cast with no validation, so the declared
14
+ * type is a promise the queue cannot keep. A handler that cannot vouch for an
15
+ * event's shape abandons it — treating a missing field as "skip the check"
16
+ * makes the unverified expunge the default for every event we cannot vouch for,
17
+ * including any future producer that forgets the field.
18
+ */
19
+ export const isCurrentSchemaVersion = (value: unknown): boolean =>
20
+ value === MUTATION_EVENT_SCHEMA_VERSION;