@remit/data-ports 0.0.38 → 0.0.39

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.38",
3
+ "version": "0.0.39",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -70,6 +70,7 @@ export const accountSettingRegistry = {
70
70
  [AccountSettingName.MailboxDisplayName]: StringSettingSchema,
71
71
  [AccountSettingName.MailboxMuted]: MutedFlagSettingSchema,
72
72
  [AccountSettingName.FolderRoleAppointment]: StringSettingSchema,
73
+ [AccountSettingName.FolderRoleAppointmentLabel]: StringSettingSchema,
73
74
  /**
74
75
  * Deprecated tombstone: superseded by `FolderRoleAppointment` (RFC 032
75
76
  * exclusive-folder-appointment, #976). Kept only so `baseSettingName`
@@ -2,9 +2,15 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { CanonicalMailboxRole, MailboxSpecialUse } from "@remit/domain-enums";
4
4
  import {
5
+ composeFolderRoleAppointmentLabelName,
6
+ composeFolderRoleAppointmentName,
7
+ meetsTrashAssurance,
8
+ parseFolderRoleAppointmentLabelName,
9
+ parseFolderRoleAppointmentName,
5
10
  type RoleMailboxCandidate,
6
11
  resolveConfirmedMailboxForRole,
7
12
  resolveMailboxForRole,
13
+ resolveRoleForAccount,
8
14
  } from "./folder-role.js";
9
15
 
10
16
  const mailbox = (
@@ -165,3 +171,148 @@ describe("resolveConfirmedMailboxForRole", () => {
165
171
  );
166
172
  });
167
173
  });
174
+
175
+ describe("resolveRoleForAccount", () => {
176
+ it("answers none when nothing names a Trash folder", () => {
177
+ // #887 Done item 4: an account whose folders happen to include a Dutch
178
+ // "Prullenbak" has no Trash reader may act on — no flag, no appointment,
179
+ // and "prullenbak" is not a hint.
180
+ const mailboxes = [
181
+ mailbox("mb-inbox", "INBOX"),
182
+ mailbox("mb-werk", "Werk"),
183
+ mailbox("mb-prullenbak", "Prullenbak"),
184
+ ];
185
+ assert.deepEqual(
186
+ resolveRoleForAccount(CanonicalMailboxRole.Trash, mailboxes),
187
+ { kind: "none" },
188
+ );
189
+ });
190
+
191
+ it("proposes a folder named Trash, and that is not enough to empty it", () => {
192
+ // #887 Done item 1: the name is a guess. It files a delete somewhere
193
+ // retrievable; it never authorises an expunge.
194
+ const mailboxes = [
195
+ mailbox("mb-inbox", "INBOX"),
196
+ mailbox("mb-werk", "Werk"),
197
+ mailbox("mb-trash", "Trash"),
198
+ ];
199
+ const resolution = resolveRoleForAccount(
200
+ CanonicalMailboxRole.Trash,
201
+ mailboxes,
202
+ );
203
+ assert.deepEqual(resolution, {
204
+ kind: "proposed",
205
+ mailbox: mailboxes[2],
206
+ });
207
+ assert.equal(meetsTrashAssurance(resolution, "confirmed"), false);
208
+ assert.equal(meetsTrashAssurance(resolution, "resolved"), true);
209
+ });
210
+
211
+ it("names the appointment that went stale, and what took its place", () => {
212
+ const mailboxes = [
213
+ mailbox("mb-inbox", "INBOX"),
214
+ mailbox("mb-trash", "[Gmail]/Trash", [MailboxSpecialUse.Trash]),
215
+ ];
216
+ const resolution = resolveRoleForAccount(
217
+ CanonicalMailboxRole.Trash,
218
+ mailboxes,
219
+ "mb-rubbish-deleted-by-apple-mail",
220
+ );
221
+ assert.deepEqual(resolution, {
222
+ kind: "appointment_stale",
223
+ appointedMailboxId: "mb-rubbish-deleted-by-apple-mail",
224
+ fallback: { kind: "flagged", mailbox: mailboxes[1] },
225
+ });
226
+ assert.equal(meetsTrashAssurance(resolution, "confirmed"), false);
227
+ assert.equal(meetsTrashAssurance(resolution, "resolved"), false);
228
+ });
229
+
230
+ it("tags the appointment the user made and the flag the server set apart", () => {
231
+ const mailboxes = [
232
+ mailbox("mb-inbox", "INBOX"),
233
+ mailbox("mb-trash", "[Gmail]/Trash", [MailboxSpecialUse.Trash]),
234
+ ];
235
+ assert.equal(
236
+ resolveRoleForAccount(CanonicalMailboxRole.Trash, mailboxes).kind,
237
+ "flagged",
238
+ );
239
+ assert.equal(
240
+ resolveRoleForAccount(CanonicalMailboxRole.Trash, mailboxes, "mb-inbox")
241
+ .kind,
242
+ "appointed",
243
+ );
244
+ assert.equal(
245
+ resolveRoleForAccount(CanonicalMailboxRole.Inbox, mailboxes).kind,
246
+ "reserved",
247
+ );
248
+ });
249
+ });
250
+
251
+ describe("the adapters over resolveRoleForAccount", () => {
252
+ const mailboxes = [
253
+ mailbox("mb-inbox", "INBOX"),
254
+ mailbox("mb-trash", "[Gmail]/Trash", [MailboxSpecialUse.Trash]),
255
+ ];
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.
261
+ assert.equal(
262
+ resolveConfirmedMailboxForRole(
263
+ CanonicalMailboxRole.Trash,
264
+ mailboxes,
265
+ "mb-gone",
266
+ )?.mailboxId,
267
+ "mb-trash",
268
+ );
269
+ assert.equal(
270
+ resolveMailboxForRole(CanonicalMailboxRole.Trash, mailboxes, "mb-gone")
271
+ ?.mailboxId,
272
+ "mb-trash",
273
+ );
274
+ });
275
+
276
+ it("keeps the name guess out of the confirmed answer", () => {
277
+ const unflagged = [
278
+ mailbox("mb-inbox", "INBOX"),
279
+ mailbox("mb-bak", "Trash"),
280
+ ];
281
+ assert.equal(
282
+ resolveConfirmedMailboxForRole(CanonicalMailboxRole.Trash, unflagged),
283
+ null,
284
+ );
285
+ assert.equal(
286
+ resolveMailboxForRole(CanonicalMailboxRole.Trash, unflagged)?.mailboxId,
287
+ "mb-bak",
288
+ );
289
+ });
290
+ });
291
+
292
+ describe("the appointment name and its label sibling", () => {
293
+ it("never lets the label row parse as an appointment", () => {
294
+ // The label is display only. If the appointment parser matched it, a path
295
+ // string would be read back as a mailboxId and resolution would follow it.
296
+ const label = composeFolderRoleAppointmentLabelName(
297
+ "acc-1",
298
+ CanonicalMailboxRole.Trash,
299
+ );
300
+ assert.equal(parseFolderRoleAppointmentName(label), undefined);
301
+ assert.deepEqual(parseFolderRoleAppointmentLabelName(label), {
302
+ accountId: "acc-1",
303
+ role: CanonicalMailboxRole.Trash,
304
+ });
305
+ });
306
+
307
+ it("never lets an appointment row parse as a label", () => {
308
+ const appointment = composeFolderRoleAppointmentName(
309
+ "acc-1",
310
+ CanonicalMailboxRole.Trash,
311
+ );
312
+ assert.equal(parseFolderRoleAppointmentLabelName(appointment), undefined);
313
+ assert.deepEqual(parseFolderRoleAppointmentName(appointment), {
314
+ accountId: "acc-1",
315
+ role: CanonicalMailboxRole.Trash,
316
+ });
317
+ });
318
+ });
@@ -4,6 +4,7 @@ import {
4
4
  MailboxSpecialUse,
5
5
  } from "@remit/domain-enums";
6
6
  import {
7
+ type AccountSettingNameValue,
7
8
  composeSettingName,
8
9
  SETTING_NAME_SEPARATOR,
9
10
  } from "./account-settings.js";
@@ -37,22 +38,52 @@ export const composeFolderRoleAppointmentName = (
37
38
  );
38
39
 
39
40
  /**
40
- * Split a stored appointment name back into its two-part target. Unlike the
41
- * single-target composites (`MailboxRole#<id>`), this setting composes two ids
42
- * after the base, so it parses the suffix itself rather than reusing
43
- * `targetIdOf`.
41
+ * The stored `AccountSetting` name holding the path one role's appointed
42
+ * mailbox had at the moment it was appointed. A sibling row rather than a field
43
+ * on the appointment, so "never read by resolution" is structural: the
44
+ * repository composes only the appointment name and cannot reach this one.
44
45
  */
45
- export const parseFolderRoleAppointmentName = (
46
+ export const composeFolderRoleAppointmentLabelName = (
47
+ accountId: string,
48
+ role: CanonicalMailboxRoleValue,
49
+ ): string =>
50
+ composeSettingName(
51
+ AccountSettingName.FolderRoleAppointmentLabel,
52
+ `${accountId}${SETTING_NAME_SEPARATOR}${role}`,
53
+ );
54
+
55
+ const parseFolderRoleTarget = (
56
+ base: AccountSettingNameValue,
46
57
  name: string,
47
58
  ): { accountId: string; role: CanonicalMailboxRoleValue } | undefined => {
48
- const [base, ...rest] = name.split(SETTING_NAME_SEPARATOR);
49
- if (base !== AccountSettingName.FolderRoleAppointment) return undefined;
59
+ const [candidate, ...rest] = name.split(SETTING_NAME_SEPARATOR);
60
+ if (candidate !== base) return undefined;
50
61
  const role = rest[rest.length - 1];
51
62
  const accountId = rest.slice(0, -1).join(SETTING_NAME_SEPARATOR);
52
63
  if (!accountId || !role || !isCanonicalRole(role)) return undefined;
53
64
  return { accountId, role };
54
65
  };
55
66
 
67
+ /**
68
+ * Split a stored appointment name back into its two-part target. Unlike the
69
+ * single-target composites (`MailboxRole#<id>`), this setting composes two ids
70
+ * after the base, so it parses the suffix itself rather than reusing
71
+ * `targetIdOf`.
72
+ */
73
+ export const parseFolderRoleAppointmentName = (
74
+ name: string,
75
+ ): { accountId: string; role: CanonicalMailboxRoleValue } | undefined =>
76
+ parseFolderRoleTarget(AccountSettingName.FolderRoleAppointment, name);
77
+
78
+ /**
79
+ * The label row's counterpart, so the config-wide settings batch can recover
80
+ * the recorded path for a role the same way it recovers the appointment.
81
+ */
82
+ export const parseFolderRoleAppointmentLabelName = (
83
+ name: string,
84
+ ): { accountId: string; role: CanonicalMailboxRoleValue } | undefined =>
85
+ parseFolderRoleTarget(AccountSettingName.FolderRoleAppointmentLabel, name);
86
+
56
87
  /**
57
88
  * RFC 6154 SPECIAL-USE flag per role. Inbox has no SPECIAL-USE flag (RFC 3501
58
89
  * reserves the name itself); a role with no entry here is matched by name hint
@@ -125,38 +156,55 @@ export interface RoleMailboxCandidate extends MailboxNameCandidate {
125
156
  }
126
157
 
127
158
  /**
128
- * The mailbox a role is CONFIRMED to hold: the one the user appointed, or the
129
- * one the server flagged (RFC 6154). No name guessing `null` here means
130
- * nobody has said which folder this is, only that a folder happens to be named
131
- * something plausible. An operation that destroys mail resolves through this
132
- * and refuses when it comes back empty; `resolveMailboxForRole` adds the guess
133
- * on top for the operations where being wrong only misfiles a message.
159
+ * What an account with no appointment for the role resolves to, and equally
160
+ * what a stale appointment falls back to the fallback can never itself be an
161
+ * appointment, stale or otherwise.
162
+ */
163
+ export type UnappointedRoleResolution<T> =
164
+ | { kind: "flagged"; mailbox: T }
165
+ | { kind: "reserved"; mailbox: T }
166
+ | { kind: "proposed"; mailbox: T }
167
+ | { kind: "none" };
168
+
169
+ /**
170
+ * What resolving a role for an account answered, and on what evidence. Total:
171
+ * there is a member for every outcome, so a caller that must weigh the evidence
172
+ * — Empty Trash may only expunge what somebody designated — reads the tag
173
+ * instead of inferring it from a `null` that means four different things.
134
174
  *
135
- * An appointment naming a mailbox this account does not hold — deleted,
136
- * renamed, or carried in from elsewhere is stale, and falls through to the
137
- * flag as if unset.
175
+ * `appointment_stale` is the case a nullable answer cannot express: the user
176
+ * appointed a mailbox the account no longer holds. It carries the id they chose
177
+ * so a surface can offer the repair, and the resolution that would have applied
178
+ * had they appointed nothing.
138
179
  */
139
- export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
180
+ export type RoleResolution<T> =
181
+ | { kind: "appointed"; mailbox: T }
182
+ | UnappointedRoleResolution<T>
183
+ | {
184
+ kind: "appointment_stale";
185
+ appointedMailboxId: string;
186
+ fallback: UnappointedRoleResolution<T>;
187
+ };
188
+
189
+ const resolveWithoutAppointment = <T extends RoleMailboxCandidate>(
140
190
  role: CanonicalMailboxRoleValue,
141
191
  mailboxes: readonly T[],
142
- appointedMailboxId?: string,
143
- ): T | null => {
144
- if (appointedMailboxId) {
145
- const appointed = mailboxes.find((m) => m.mailboxId === appointedMailboxId);
146
- if (appointed) return appointed;
147
- }
148
-
192
+ ): UnappointedRoleResolution<T> => {
149
193
  const specialUse = ROLE_SPECIAL_USE[role];
150
194
  if (specialUse) {
151
195
  const flagged = mailboxes.find((m) => m.specialUse?.includes(specialUse));
152
- if (flagged) return flagged;
196
+ if (flagged) return { kind: "flagged", mailbox: flagged };
153
197
  }
154
198
 
155
199
  if (role === CanonicalMailboxRole.Inbox) {
156
- return mailboxes.find((m) => m.fullPath.toUpperCase() === "INBOX") ?? null;
200
+ const inbox = mailboxes.find((m) => m.fullPath.toUpperCase() === "INBOX");
201
+ return inbox ? { kind: "reserved", mailbox: inbox } : { kind: "none" };
157
202
  }
158
203
 
159
- return null;
204
+ const hints = ROLE_NAME_HINTS[role];
205
+ if (!hints) return { kind: "none" };
206
+ const proposed = resolveMailboxByLeafName(mailboxes, hints);
207
+ return proposed ? { kind: "proposed", mailbox: proposed } : { kind: "none" };
160
208
  };
161
209
 
162
210
  /**
@@ -173,23 +221,96 @@ export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
173
221
  * The last of those is a guess: a folder named `Deleted` is not evidence that
174
222
  * the user means it as Trash. Use it only where being wrong misfiles a message
175
223
  * a user can move back — never where it destroys mail.
224
+ */
225
+ export const resolveRoleForAccount = <T extends RoleMailboxCandidate>(
226
+ role: CanonicalMailboxRoleValue,
227
+ mailboxes: readonly T[],
228
+ appointedMailboxId?: string,
229
+ ): RoleResolution<T> => {
230
+ if (!appointedMailboxId) return resolveWithoutAppointment(role, mailboxes);
231
+
232
+ const appointed = mailboxes.find((m) => m.mailboxId === appointedMailboxId);
233
+ if (appointed) return { kind: "appointed", mailbox: appointed };
234
+
235
+ return {
236
+ kind: "appointment_stale",
237
+ appointedMailboxId,
238
+ fallback: resolveWithoutAppointment(role, mailboxes),
239
+ };
240
+ };
241
+
242
+ /**
243
+ * How much evidence a Trash verb demands. `confirmed` is what an expunge
244
+ * requires: somebody designated this folder, either the user or the server.
245
+ * `resolved` additionally accepts the name guess, for filing mail somewhere the
246
+ * user can retrieve it from. Trash only, deliberately — no other role and no
247
+ * other verb weighs its evidence, and a matrix over eight roles would be five
248
+ * gates nobody asked for.
249
+ */
250
+ export type TrashAssuranceLevel = "confirmed" | "resolved";
251
+
252
+ export const meetsTrashAssurance = <T>(
253
+ resolution: RoleResolution<T>,
254
+ level: TrashAssuranceLevel,
255
+ ): boolean => {
256
+ switch (resolution.kind) {
257
+ case "appointed":
258
+ case "flagged":
259
+ return true;
260
+ case "proposed":
261
+ return level === "resolved";
262
+ default:
263
+ return false;
264
+ }
265
+ };
266
+
267
+ const withoutStale = <T>(
268
+ resolution: RoleResolution<T>,
269
+ ): { kind: "appointed"; mailbox: T } | UnappointedRoleResolution<T> =>
270
+ resolution.kind === "appointment_stale" ? resolution.fallback : resolution;
271
+
272
+ /**
273
+ * The mailbox a role is CONFIRMED to hold: the one the user appointed, or the
274
+ * one the server flagged (RFC 6154). No name guessing — `null` here means
275
+ * nobody has said which folder this is, only that a folder happens to be named
276
+ * something plausible. An operation that destroys mail resolves through this
277
+ * and refuses when it comes back empty; `resolveMailboxForRole` adds the guess
278
+ * on top for the operations where being wrong only misfiles a message.
176
279
  *
177
- * `null` when nothing matches: the role has no folder, and the caller says so
178
- * rather than picking one.
280
+ * 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.
179
283
  */
180
- export const resolveMailboxForRole = <T extends RoleMailboxCandidate>(
284
+ export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
181
285
  role: CanonicalMailboxRoleValue,
182
286
  mailboxes: readonly T[],
183
287
  appointedMailboxId?: string,
184
288
  ): T | null => {
185
- const confirmed = resolveConfirmedMailboxForRole(
186
- role,
187
- mailboxes,
188
- appointedMailboxId,
289
+ const resolution = withoutStale(
290
+ resolveRoleForAccount(role, mailboxes, appointedMailboxId),
189
291
  );
190
- if (confirmed) return confirmed;
292
+ switch (resolution.kind) {
293
+ case "appointed":
294
+ case "flagged":
295
+ case "reserved":
296
+ return resolution.mailbox;
297
+ default:
298
+ return null;
299
+ }
300
+ };
191
301
 
192
- const hints = ROLE_NAME_HINTS[role];
193
- if (!hints) return null;
194
- return resolveMailboxByLeafName(mailboxes, hints);
302
+ /**
303
+ * The role's mailbox including the name guess: `null` only when nothing
304
+ * matches at all, so the caller says the role has no folder rather than picking
305
+ * one.
306
+ */
307
+ export const resolveMailboxForRole = <T extends RoleMailboxCandidate>(
308
+ role: CanonicalMailboxRoleValue,
309
+ mailboxes: readonly T[],
310
+ appointedMailboxId?: string,
311
+ ): T | null => {
312
+ const resolution = withoutStale(
313
+ resolveRoleForAccount(role, mailboxes, appointedMailboxId),
314
+ );
315
+ return resolution.kind === "none" ? null : resolution.mailbox;
195
316
  };
@@ -1,3 +1,4 @@
1
+ import type { RoleResolution } from "../folder-role.js";
1
2
  import type {
2
3
  MailboxSpecialUseItem,
3
4
  MailboxSpecialUseValue,
@@ -38,6 +39,16 @@ export interface IMailboxSpecialUseRepository {
38
39
  findConfirmedTrashMailbox(
39
40
  accountId: string,
40
41
  ): Promise<{ mailboxId: string; fullPath: string } | null>;
42
+ /**
43
+ * Trash with the evidence attached, for the two verbs that weigh it: a
44
+ * delete files mail somewhere retrievable, an Empty Trash destroys it, and
45
+ * they refuse on different grounds. `null` cannot tell them apart — an
46
+ * appointment naming a folder that is gone is a different answer from no
47
+ * folder at all, and only this read distinguishes them.
48
+ */
49
+ resolveTrashRole(
50
+ accountId: string,
51
+ ): Promise<RoleResolution<{ mailboxId: string; fullPath: string }>>;
41
52
  findArchiveMailbox(
42
53
  accountId: string,
43
54
  ): Promise<{ mailboxId: string; fullPath: string } | null>;