@remit/backend 0.0.67 → 0.0.68

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/backend",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,11 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type { AccountSettingItem } from "@remit/data-ports";
4
+ import type { RoleMailboxCandidate } from "@remit/data-ports/folder-role";
4
5
  import { CanonicalMailboxRole, MailboxSpecialUse } from "@remit/domain-enums";
5
6
  import {
6
7
  CANONICAL_ROLES,
7
- type FolderCandidate,
8
- findFolderForRole,
9
8
  groupFolderAppointmentsByAccount,
10
9
  loadFolderAppointmentsForAccount,
11
10
  resolveFolderAppointments,
@@ -40,60 +39,18 @@ describe("CANONICAL_ROLES", () => {
40
39
  });
41
40
  });
42
41
 
43
- describe("findFolderForRole", () => {
44
- const folders: FolderCandidate[] = [
45
- { mailboxId: "mb-inbox", fullPath: "INBOX" },
42
+ describe("resolveFolderAppointments", () => {
43
+ const folders: RoleMailboxCandidate[] = [
44
+ { mailboxId: "mb-inbox", fullPath: "INBOX", hierarchyDelimiter: "/" },
46
45
  {
47
- mailboxId: "mb-drafts-empty",
48
- fullPath: "INBOX/Drafts",
49
- specialUse: [MailboxSpecialUse.Drafts],
46
+ mailboxId: "mb-concepten",
47
+ fullPath: "INBOX/Concepten",
48
+ hierarchyDelimiter: "/",
50
49
  },
51
- { mailboxId: "mb-concepten", fullPath: "INBOX/Concepten" },
52
- { mailboxId: "mb-sent", fullPath: "INBOX/Sent" },
53
- { mailboxId: "mb-sent-messages", fullPath: "INBOX/Sent Messages" },
54
- { mailboxId: "mb-news", fullPath: "INBOX/Nieuwsbrieven" },
55
- ];
56
-
57
- it("matches the reserved INBOX name for Inbox", () => {
58
- assert.equal(
59
- findFolderForRole(CanonicalMailboxRole.Inbox, folders),
60
- "mb-inbox",
61
- );
62
- });
63
-
64
- it("prefers the SPECIAL-USE flag over a name hint", () => {
65
- assert.equal(
66
- findFolderForRole(CanonicalMailboxRole.Drafts, folders),
67
- "mb-drafts-empty",
68
- );
69
- });
70
-
71
- it("falls back to a weak name hint when no flag is present", () => {
72
- assert.equal(
73
- findFolderForRole(CanonicalMailboxRole.Sent, folders),
74
- "mb-sent",
75
- );
76
- });
77
-
78
- it("returns null when nothing matches", () => {
79
- assert.equal(findFolderForRole(CanonicalMailboxRole.Junk, folders), null);
80
- });
81
-
82
- it("never matches a plain user folder", () => {
83
- assert.equal(
84
- findFolderForRole(CanonicalMailboxRole.Archive, folders),
85
- null,
86
- );
87
- });
88
- });
89
-
90
- describe("resolveFolderAppointments", () => {
91
- const folders: FolderCandidate[] = [
92
- { mailboxId: "mb-inbox", fullPath: "INBOX" },
93
- { mailboxId: "mb-concepten", fullPath: "INBOX/Concepten" },
94
50
  {
95
51
  mailboxId: "mb-spam",
96
52
  fullPath: "INBOX/Spam",
53
+ hierarchyDelimiter: "/",
97
54
  specialUse: [MailboxSpecialUse.Junk],
98
55
  },
99
56
  ];
@@ -1,67 +1,29 @@
1
- import type {
2
- CanonicalMailboxRole as CanonicalMailboxRoleValue,
3
- FolderAppointment,
4
- } from "@remit/api-openapi-types";
1
+ import type { FolderAppointment } from "@remit/api-openapi-types";
5
2
  import type {
6
3
  AccountSettingItem,
7
4
  IAccountSettingRepository,
8
5
  IMailboxRepository,
9
6
  } from "@remit/data-ports";
10
7
  import {
11
- baseSettingName,
12
- composeSettingName,
13
- SETTING_NAME_SEPARATOR,
14
- } from "@remit/data-ports/account-settings";
15
- import {
16
- AccountSettingName,
17
- CanonicalMailboxRole,
18
- MailboxSpecialUse,
19
- } from "@remit/domain-enums";
8
+ CANONICAL_ROLES,
9
+ type CanonicalMailboxRoleValue,
10
+ composeFolderRoleAppointmentName,
11
+ parseFolderRoleAppointmentName,
12
+ type RoleMailboxCandidate,
13
+ resolveMailboxForRole,
14
+ } from "@remit/data-ports/folder-role";
20
15
 
21
16
  /**
22
17
  * RFC 032 exclusive-folder-appointment (#976): a per-account role→mailbox map.
23
18
  * Each row is a `FolderRoleAppointment#<accountId>#<role>` AccountSetting (RFC
24
19
  * 032 settings tiers), so a role can never be persisted twice for one account —
25
20
  * writing it replaces whichever mailbox previously held it. This module owns
26
- * that persistence plus the read-side proposal (`findFolderForRole`) that fills
27
- * any role the user hasn't appointed yet.
21
+ * that persistence; the read side is `resolveMailboxForRole`
22
+ * (`@remit/data-ports/folder-role`), the same rule every special-folder lookup
23
+ * in the backend and the workers goes through.
28
24
  */
29
25
 
30
- const FOLDER_ROLE_APPOINTMENT = AccountSettingName.FolderRoleAppointment;
31
-
32
- /** The fixed anchor set, in the RFC's canonical display order. */
33
- export const CANONICAL_ROLES: readonly CanonicalMailboxRoleValue[] =
34
- Object.values(CanonicalMailboxRole);
35
-
36
- const composeAppointmentName = (
37
- accountId: string,
38
- role: CanonicalMailboxRoleValue,
39
- ): string =>
40
- composeSettingName(
41
- FOLDER_ROLE_APPOINTMENT,
42
- `${accountId}${SETTING_NAME_SEPARATOR}${role}`,
43
- );
44
-
45
- /**
46
- * Split a stored `FolderRoleAppointment#<accountId>#<role>` name back into its
47
- * two-part target. Unlike the single-target composites (`MailboxRole#<id>`),
48
- * this setting composes two ids after the base, so it parses the suffix itself
49
- * rather than reusing `targetIdOf`.
50
- */
51
- const parseAppointmentTarget = (
52
- name: string,
53
- ): { accountId: string; role: string } | undefined => {
54
- if (baseSettingName(name) !== FOLDER_ROLE_APPOINTMENT) return undefined;
55
- const idx = name.indexOf(SETTING_NAME_SEPARATOR);
56
- if (idx === -1) return undefined;
57
- const rest = name.slice(idx + SETTING_NAME_SEPARATOR.length);
58
- const roleIdx = rest.lastIndexOf(SETTING_NAME_SEPARATOR);
59
- if (roleIdx === -1) return undefined;
60
- const accountId = rest.slice(0, roleIdx);
61
- const role = rest.slice(roleIdx + SETTING_NAME_SEPARATOR.length);
62
- if (!accountId || !role) return undefined;
63
- return { accountId, role };
64
- };
26
+ export { CANONICAL_ROLES };
65
27
 
66
28
  const stringValueOf = (item: AccountSettingItem): string | undefined => {
67
29
  const { value } = item;
@@ -78,7 +40,7 @@ export const groupFolderAppointmentsByAccount = (
78
40
  ): Map<string, Map<string, string>> => {
79
41
  const byAccount = new Map<string, Map<string, string>>();
80
42
  for (const setting of settings) {
81
- const target = parseAppointmentTarget(setting.name);
43
+ const target = parseFolderRoleAppointmentName(setting.name);
82
44
  if (!target) continue;
83
45
  const mailboxId = stringValueOf(setting);
84
46
  if (mailboxId === undefined) continue;
@@ -103,7 +65,7 @@ export const loadFolderAppointmentsForAccount = async (
103
65
  CANONICAL_ROLES.map(async (role) => {
104
66
  const item = await accountSetting.get(
105
67
  accountConfigId,
106
- composeAppointmentName(accountId, role),
68
+ composeFolderRoleAppointmentName(accountId, role),
107
69
  );
108
70
  return [role, item ? stringValueOf(item) : undefined] as const;
109
71
  }),
@@ -128,7 +90,7 @@ export const writeFolderRoleAppointment = (
128
90
  role: CanonicalMailboxRoleValue,
129
91
  mailboxId: string | null,
130
92
  ): Promise<unknown> => {
131
- const name = composeAppointmentName(accountId, role);
93
+ const name = composeFolderRoleAppointmentName(accountId, role);
132
94
  if (mailboxId === null) {
133
95
  return accountSetting.delete(accountConfigId, name);
134
96
  }
@@ -139,110 +101,21 @@ export const writeFolderRoleAppointment = (
139
101
  });
140
102
  };
141
103
 
142
- /** The minimal folder shape `findFolderForRole` needs to detect a role. */
143
- export interface FolderCandidate {
144
- mailboxId: string;
145
- fullPath: string;
146
- specialUse?: readonly string[];
147
- }
148
-
149
- // RFC 6154 SPECIAL-USE flag per role. Inbox has no SPECIAL-USE flag (RFC 3501
150
- // reserves the name itself); a role with no entry here is matched by name hint
151
- // only.
152
- const ROLE_TO_SPECIAL_USE: Partial<Record<CanonicalMailboxRoleValue, string>> =
153
- {
154
- [CanonicalMailboxRole.Drafts]: MailboxSpecialUse.Drafts,
155
- [CanonicalMailboxRole.Sent]: MailboxSpecialUse.Sent,
156
- [CanonicalMailboxRole.Archive]: MailboxSpecialUse.Archive,
157
- [CanonicalMailboxRole.Junk]: MailboxSpecialUse.Junk,
158
- [CanonicalMailboxRole.Trash]: MailboxSpecialUse.Trash,
159
- [CanonicalMailboxRole.All]: MailboxSpecialUse.All,
160
- [CanonicalMailboxRole.Flagged]: MailboxSpecialUse.Flagged,
161
- };
162
-
163
- // Weak name hints (RFC 032's tier 3 of `findFolderForRole`): used solely to
164
- // seed a PROPOSAL a human confirms, never to persist a role by itself. Kept
165
- // intentionally small — this is a fallback for providers with no SPECIAL-USE
166
- // support, not a substitute for server truth.
167
- const ROLE_NAME_HINTS: Partial<
168
- Record<CanonicalMailboxRoleValue, readonly string[]>
169
- > = {
170
- [CanonicalMailboxRole.Drafts]: ["drafts", "draft", "concepten"],
171
- [CanonicalMailboxRole.Sent]: [
172
- "sent",
173
- "sent mail",
174
- "sent items",
175
- "sent messages",
176
- ],
177
- [CanonicalMailboxRole.Archive]: ["archive", "archives"],
178
- [CanonicalMailboxRole.Junk]: ["junk", "spam"],
179
- [CanonicalMailboxRole.Trash]: [
180
- "trash",
181
- "bin",
182
- "deleted",
183
- "deleted items",
184
- "deleted messages",
185
- ],
186
- [CanonicalMailboxRole.All]: ["all mail", "all"],
187
- };
188
-
189
- const leafName = (fullPath: string): string => {
190
- const parts = fullPath.split("/");
191
- return (parts[parts.length - 1] || fullPath).toLowerCase();
192
- };
193
-
194
- /**
195
- * The single best EXISTING folder for a canonical role (RFC 032
196
- * exclusive-folder-appointment): the IMAP SPECIAL-USE flag first (server
197
- * truth, language-independent), then — for Inbox only — the reserved `INBOX`
198
- * name (RFC 3501), then a weak name hint used solely to seed a proposal a
199
- * human confirms. `null` when nothing matches; the role stays unfilled.
200
- */
201
- export const findFolderForRole = (
202
- role: CanonicalMailboxRoleValue,
203
- folders: readonly FolderCandidate[],
204
- ): string | null => {
205
- const specialUse = ROLE_TO_SPECIAL_USE[role];
206
- if (specialUse) {
207
- const flagged = folders.find((f) => f.specialUse?.includes(specialUse));
208
- if (flagged) return flagged.mailboxId;
209
- }
210
-
211
- if (role === CanonicalMailboxRole.Inbox) {
212
- const inbox = folders.find((f) => f.fullPath.toUpperCase() === "INBOX");
213
- if (inbox) return inbox.mailboxId;
214
- }
215
-
216
- const hints = ROLE_NAME_HINTS[role];
217
- if (hints) {
218
- const match = folders.find((f) => hints.includes(leafName(f.fullPath)));
219
- if (match) return match.mailboxId;
220
- }
221
-
222
- return null;
223
- };
224
-
225
104
  /**
226
105
  * Resolve the full appointment set for one account: the user's persisted
227
106
  * choice when set (and still a real mailbox — a deleted mailbox's stale
228
- * appointment is treated as unfilled and re-proposed), else a server-proposed
229
- * `findFolderForRole` guess. Always returns one entry per `CANONICAL_ROLES`
230
- * member (RFC 032 settings tiers: total, never a sparse array), so the map is
231
- * never empty for a normal provider.
107
+ * appointment is treated as unfilled and re-proposed), else the server's
108
+ * SPECIAL-USE flag, else a name proposal. Always returns one entry per
109
+ * `CANONICAL_ROLES` member (RFC 032 settings tiers: total, never a sparse
110
+ * array), so the map is never empty for a normal provider.
232
111
  */
233
112
  export const resolveFolderAppointments = (
234
113
  persisted: ReadonlyMap<string, string>,
235
- mailboxes: readonly FolderCandidate[],
114
+ mailboxes: readonly RoleMailboxCandidate[],
236
115
  ): FolderAppointment[] =>
237
116
  CANONICAL_ROLES.map((role) => {
238
- const persistedId = persisted.get(role);
239
- const validPersisted =
240
- persistedId && mailboxes.some((m) => m.mailboxId === persistedId)
241
- ? persistedId
242
- : undefined;
243
- const mailboxId =
244
- validPersisted ?? findFolderForRole(role, mailboxes) ?? undefined;
245
- return mailboxId ? { role, mailboxId } : { role };
117
+ const found = resolveMailboxForRole(role, mailboxes, persisted.get(role));
118
+ return found ? { role, mailboxId: found.mailboxId } : { role };
246
119
  });
247
120
 
248
121
  /**
@@ -5,6 +5,7 @@ import type {
5
5
  import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
6
  import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
7
7
  import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
8
+ import { NoTrashMailboxError } from "@remit/mailbox-service";
8
9
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
10
  import { getAccountConfigIdFromEvent } from "../auth.js";
10
11
  import {
@@ -411,14 +412,18 @@ export const TrashOperations: Record<
411
412
  const account = await client.account.get(accountId);
412
413
  assertAccountOwnership(account, accountConfigId, "act");
413
414
 
415
+ // The same confirmed resolution `emptyTrash` expunges through, so the
416
+ // count reported is the count of the folder that gets emptied. It
417
+ // refuses rather than returning zero when no folder is appointed and
418
+ // none is flagged: "0 deleted" reads as success, and the user goes on
419
+ // believing their Trash is empty.
414
420
  const trashMailbox =
415
- await client.mailboxSpecialUse.findTrashMailbox(accountId);
421
+ await client.mailboxSpecialUse.findConfirmedTrashMailbox(accountId);
416
422
 
417
423
  if (!trashMailbox) {
418
- return { deletedCount: 0 };
424
+ throw new NoTrashMailboxError();
419
425
  }
420
426
 
421
- // Get count of messages in trash before emptying
422
427
  const messages = await client.message.listAllByMailbox(
423
428
  trashMailbox.mailboxId,
424
429
  );