@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 +1 -1
- package/src/account-settings.ts +1 -0
- package/src/errors.ts +6 -11
- package/src/folder-role.test.ts +168 -0
- package/src/folder-role.ts +203 -39
- package/src/interfaces/mailbox-special-use.ts +8 -5
package/package.json
CHANGED
package/src/account-settings.ts
CHANGED
|
@@ -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 {
|
|
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
|
package/src/folder-role.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/folder-role.ts
CHANGED
|
@@ -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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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 [
|
|
49
|
-
if (
|
|
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
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
214
|
+
const inbox = mailboxes.find((m) => m.fullPath.toUpperCase() === "INBOX");
|
|
215
|
+
return inbox ? { kind: "reserved", mailbox: inbox } : { kind: "none" };
|
|
157
216
|
}
|
|
158
217
|
|
|
159
|
-
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
41
|
+
resolveTrashRole(
|
|
39
42
|
accountId: string,
|
|
40
|
-
): Promise<{ mailboxId: string; fullPath: string }
|
|
43
|
+
): Promise<RoleResolution<{ mailboxId: string; fullPath: string }>>;
|
|
41
44
|
findArchiveMailbox(
|
|
42
45
|
accountId: string,
|
|
43
46
|
): Promise<{ mailboxId: string; fullPath: string } | null>;
|