@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,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("
|
|
44
|
-
const folders:
|
|
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-
|
|
48
|
-
fullPath: "INBOX/
|
|
49
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
27
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
229
|
-
*
|
|
230
|
-
* member (RFC 032 settings tiers: total, never a sparse
|
|
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
|
|
114
|
+
mailboxes: readonly RoleMailboxCandidate[],
|
|
236
115
|
): FolderAppointment[] =>
|
|
237
116
|
CANONICAL_ROLES.map((role) => {
|
|
238
|
-
const
|
|
239
|
-
|
|
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
|
/**
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -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.
|
|
421
|
+
await client.mailboxSpecialUse.findConfirmedTrashMailbox(accountId);
|
|
416
422
|
|
|
417
423
|
if (!trashMailbox) {
|
|
418
|
-
|
|
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
|
);
|