@remit/data-ports 0.0.38 → 0.0.40

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.40",
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`
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
@@ -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,165 @@ 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("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
+ assert.equal(
262
+ resolveConfirmedMailboxForRole(
263
+ CanonicalMailboxRole.Trash,
264
+ mailboxes,
265
+ "mb-gone",
266
+ ),
267
+ null,
268
+ );
269
+ assert.equal(
270
+ resolveMailboxForRole(CanonicalMailboxRole.Trash, mailboxes, "mb-gone")
271
+ ?.mailboxId,
272
+ "mb-trash",
273
+ );
274
+ });
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
+
293
+ it("keeps the name guess out of the confirmed answer", () => {
294
+ const unflagged = [
295
+ mailbox("mb-inbox", "INBOX"),
296
+ mailbox("mb-bak", "Trash"),
297
+ ];
298
+ assert.equal(
299
+ resolveConfirmedMailboxForRole(CanonicalMailboxRole.Trash, unflagged),
300
+ null,
301
+ );
302
+ assert.equal(
303
+ resolveMailboxForRole(CanonicalMailboxRole.Trash, unflagged)?.mailboxId,
304
+ "mb-bak",
305
+ );
306
+ });
307
+ });
308
+
309
+ describe("the appointment name and its label sibling", () => {
310
+ it("never lets the label row parse as an appointment", () => {
311
+ // The label is display only. If the appointment parser matched it, a path
312
+ // string would be read back as a mailboxId and resolution would follow it.
313
+ const label = composeFolderRoleAppointmentLabelName(
314
+ "acc-1",
315
+ CanonicalMailboxRole.Trash,
316
+ );
317
+ assert.equal(parseFolderRoleAppointmentName(label), undefined);
318
+ assert.deepEqual(parseFolderRoleAppointmentLabelName(label), {
319
+ accountId: "acc-1",
320
+ role: CanonicalMailboxRole.Trash,
321
+ });
322
+ });
323
+
324
+ it("never lets an appointment row parse as a label", () => {
325
+ const appointment = composeFolderRoleAppointmentName(
326
+ "acc-1",
327
+ CanonicalMailboxRole.Trash,
328
+ );
329
+ assert.equal(parseFolderRoleAppointmentLabelName(appointment), undefined);
330
+ assert.deepEqual(parseFolderRoleAppointmentName(appointment), {
331
+ accountId: "acc-1",
332
+ role: CanonicalMailboxRole.Trash,
333
+ });
334
+ });
335
+ });
@@ -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
@@ -119,44 +150,75 @@ export const FOLDER_ROLES_SETTINGS_PATH = "Settings › Folder roles";
119
150
  */
120
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.`;
121
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
+
122
167
  /** The minimal mailbox shape role resolution reads. */
123
168
  export interface RoleMailboxCandidate extends MailboxNameCandidate {
124
169
  specialUse?: readonly string[];
125
170
  }
126
171
 
127
172
  /**
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.
173
+ * What an account with no appointment for the role resolves to, and equally
174
+ * what a stale appointment falls back to the fallback can never itself be an
175
+ * appointment, stale or otherwise.
176
+ */
177
+ export type UnappointedRoleResolution<T> =
178
+ | { kind: "flagged"; mailbox: T }
179
+ | { kind: "reserved"; mailbox: T }
180
+ | { kind: "proposed"; mailbox: T }
181
+ | { kind: "none" };
182
+
183
+ /**
184
+ * What resolving a role for an account answered, and on what evidence. Total:
185
+ * there is a member for every outcome, so a caller that must weigh the evidence
186
+ * — Empty Trash may only expunge what somebody designated — reads the tag
187
+ * instead of inferring it from a `null` that means four different things.
134
188
  *
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.
189
+ * `appointment_stale` is the case a nullable answer cannot express: the user
190
+ * appointed a mailbox the account no longer holds. It carries the id they chose
191
+ * so a surface can offer the repair, and the resolution that would have applied
192
+ * had they appointed nothing.
138
193
  */
139
- export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
194
+ export type RoleResolution<T> =
195
+ | { kind: "appointed"; mailbox: T }
196
+ | UnappointedRoleResolution<T>
197
+ | {
198
+ kind: "appointment_stale";
199
+ appointedMailboxId: string;
200
+ fallback: UnappointedRoleResolution<T>;
201
+ };
202
+
203
+ const resolveWithoutAppointment = <T extends RoleMailboxCandidate>(
140
204
  role: CanonicalMailboxRoleValue,
141
205
  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
-
206
+ ): UnappointedRoleResolution<T> => {
149
207
  const specialUse = ROLE_SPECIAL_USE[role];
150
208
  if (specialUse) {
151
209
  const flagged = mailboxes.find((m) => m.specialUse?.includes(specialUse));
152
- if (flagged) return flagged;
210
+ if (flagged) return { kind: "flagged", mailbox: flagged };
153
211
  }
154
212
 
155
213
  if (role === CanonicalMailboxRole.Inbox) {
156
- return mailboxes.find((m) => m.fullPath.toUpperCase() === "INBOX") ?? null;
214
+ const inbox = mailboxes.find((m) => m.fullPath.toUpperCase() === "INBOX");
215
+ return inbox ? { kind: "reserved", mailbox: inbox } : { kind: "none" };
157
216
  }
158
217
 
159
- return null;
218
+ const hints = ROLE_NAME_HINTS[role];
219
+ if (!hints) return { kind: "none" };
220
+ const proposed = resolveMailboxByLeafName(mailboxes, hints);
221
+ return proposed ? { kind: "proposed", mailbox: proposed } : { kind: "none" };
160
222
  };
161
223
 
162
224
  /**
@@ -173,23 +235,125 @@ export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
173
235
  * The last of those is a guess: a folder named `Deleted` is not evidence that
174
236
  * the user means it as Trash. Use it only where being wrong misfiles a message
175
237
  * a user can move back — never where it destroys mail.
238
+ */
239
+ export const resolveRoleForAccount = <T extends RoleMailboxCandidate>(
240
+ role: CanonicalMailboxRoleValue,
241
+ mailboxes: readonly T[],
242
+ appointedMailboxId?: string,
243
+ ): RoleResolution<T> => {
244
+ if (!appointedMailboxId) return resolveWithoutAppointment(role, mailboxes);
245
+
246
+ const appointed = mailboxes.find((m) => m.mailboxId === appointedMailboxId);
247
+ if (appointed) return { kind: "appointed", mailbox: appointed };
248
+
249
+ return {
250
+ kind: "appointment_stale",
251
+ appointedMailboxId,
252
+ fallback: resolveWithoutAppointment(role, mailboxes),
253
+ };
254
+ };
255
+
256
+ /**
257
+ * How much evidence a Trash verb demands. `confirmed` is what an expunge
258
+ * requires: somebody designated this folder, either the user or the server.
259
+ * `resolved` additionally accepts the name guess, for filing mail somewhere the
260
+ * user can retrieve it from. Trash only, deliberately — no other role and no
261
+ * other verb weighs its evidence, and a matrix over eight roles would be five
262
+ * gates nobody asked for.
263
+ */
264
+ export type TrashAssuranceLevel = "confirmed" | "resolved";
265
+
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>(
287
+ resolution: RoleResolution<T>,
288
+ level: TrashAssuranceLevel,
289
+ ): TrashGateOutcome<T> => {
290
+ switch (resolution.kind) {
291
+ case "appointed":
292
+ case "flagged":
293
+ return { allowed: true, mailbox: resolution.mailbox };
294
+ case "proposed":
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" };
300
+ default:
301
+ return { allowed: false, reason: "none" };
302
+ }
303
+ };
304
+
305
+ export const meetsTrashAssurance = <T>(
306
+ resolution: RoleResolution<T>,
307
+ level: TrashAssuranceLevel,
308
+ ): boolean => trashMailboxAt(resolution, level).allowed;
309
+
310
+ /**
311
+ * The mailbox a role is CONFIRMED to hold: the one the user appointed, or the
312
+ * one the server flagged (RFC 6154). No name guessing — `null` here means
313
+ * nobody has said which folder this is, only that a folder happens to be named
314
+ * something plausible. An operation that destroys mail resolves through this
315
+ * and refuses when it comes back empty; `resolveMailboxForRole` adds the guess
316
+ * on top for the operations where being wrong only misfiles a message.
176
317
  *
177
- * `null` when nothing matches: the role has no folder, and the caller says so
178
- * rather than picking one.
318
+ * An appointment naming a mailbox this account does not hold deleted,
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.
179
322
  */
180
- export const resolveMailboxForRole = <T extends RoleMailboxCandidate>(
323
+ export const resolveConfirmedMailboxForRole = <T extends RoleMailboxCandidate>(
181
324
  role: CanonicalMailboxRoleValue,
182
325
  mailboxes: readonly T[],
183
326
  appointedMailboxId?: string,
184
327
  ): T | null => {
185
- const confirmed = resolveConfirmedMailboxForRole(
186
- role,
187
- mailboxes,
188
- appointedMailboxId,
189
- );
190
- if (confirmed) return confirmed;
328
+ const resolution = resolveRoleForAccount(role, mailboxes, appointedMailboxId);
329
+ switch (resolution.kind) {
330
+ case "appointed":
331
+ case "flagged":
332
+ case "reserved":
333
+ return resolution.mailbox;
334
+ default:
335
+ return null;
336
+ }
337
+ };
191
338
 
192
- const hints = ROLE_NAME_HINTS[role];
193
- if (!hints) return null;
194
- return resolveMailboxByLeafName(mailboxes, hints);
339
+ /**
340
+ * The role's mailbox including the name guess: `null` only when nothing
341
+ * matches at all, so the caller says the role has no folder rather than picking
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.
349
+ */
350
+ export const resolveMailboxForRole = <T extends RoleMailboxCandidate>(
351
+ role: CanonicalMailboxRoleValue,
352
+ mailboxes: readonly T[],
353
+ appointedMailboxId?: string,
354
+ ): T | null => {
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;
195
359
  };
@@ -1,3 +1,4 @@
1
+ import type { RoleResolution } from "../folder-role.js";
1
2
  import type {
2
3
  MailboxSpecialUseItem,
3
4
  MailboxSpecialUseValue,
@@ -31,13 +32,15 @@ export interface IMailboxSpecialUseRepository {
31
32
  accountId: string,
32
33
  ): Promise<{ mailboxId: string; fullPath: string } | null>;
33
34
  /**
34
- * Trash without the name guess: the appointment or the \Trash flag, nothing
35
- * else. Empty Trash expunges whatever this returns, and a folder merely
36
- * named `Deleted` is not consent to destroy its contents.
35
+ * Trash with the evidence attached, for the two verbs that weigh it: a
36
+ * delete files mail somewhere retrievable, an Empty Trash destroys it, and
37
+ * they refuse on different grounds. `null` cannot tell them apart an
38
+ * appointment naming a folder that is gone is a different answer from no
39
+ * folder at all, and only this read distinguishes them.
37
40
  */
38
- findConfirmedTrashMailbox(
41
+ resolveTrashRole(
39
42
  accountId: string,
40
- ): Promise<{ mailboxId: string; fullPath: string } | null>;
43
+ ): Promise<RoleResolution<{ mailboxId: string; fullPath: string }>>;
41
44
  findArchiveMailbox(
42
45
  accountId: string,
43
46
  ): Promise<{ mailboxId: string; fullPath: string } | null>;