@remit/data-ports 0.0.55 → 0.0.57
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 +5 -1
- package/src/conformance/calendar-collection.ts +20 -0
- package/src/conformance/calendar-feed-token.ts +148 -0
- package/src/conformance/index.ts +1 -0
- package/src/id.ts +9 -0
- package/src/index.ts +3 -0
- package/src/interfaces/address.ts +12 -0
- package/src/interfaces/calendar-feed-token.ts +38 -0
- package/src/interfaces/calendar-unit-of-work.ts +8 -0
- package/src/interfaces/outbox-attachment.ts +6 -7
- package/src/interfaces/outbox-message.ts +10 -0
- package/src/types.ts +11 -0
- package/src/virtual-copy.test.ts +34 -0
- package/src/virtual-copy.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/data-ports",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.57",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/mutation-events.ts",
|
|
38
38
|
"default": "./src/mutation-events.ts"
|
|
39
39
|
},
|
|
40
|
+
"./virtual-copy": {
|
|
41
|
+
"types": "./src/virtual-copy.ts",
|
|
42
|
+
"default": "./src/virtual-copy.ts"
|
|
43
|
+
},
|
|
40
44
|
"./mailbox-name": {
|
|
41
45
|
"types": "./src/mailbox-name.ts",
|
|
42
46
|
"default": "./src/mailbox-name.ts"
|
|
@@ -204,6 +204,26 @@ export function calendarCollectionRepositoryConformance(
|
|
|
204
204
|
assert.equal(reread.syncSequence, 5);
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
+
// Every write and every delete of an object in the collection goes through
|
|
208
|
+
// this bump, which makes the collection the one timestamp that only moves
|
|
209
|
+
// forward — the calendar feed serves it as Last-Modified, and a validator
|
|
210
|
+
// that moved backwards on a delete would tell a subscriber its stale copy
|
|
211
|
+
// is still good.
|
|
212
|
+
test("bumpSyncSequence carries the collection's modification time forward", async () => {
|
|
213
|
+
const accountConfigId = harness.makeId();
|
|
214
|
+
const calendar = await repo.create({
|
|
215
|
+
accountConfigId,
|
|
216
|
+
urlSegment: "touched",
|
|
217
|
+
displayName: "Touched",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await repo.bumpSyncSequence(accountConfigId, calendar.calendarId);
|
|
221
|
+
const bumped = await repo.get(accountConfigId, calendar.calendarId);
|
|
222
|
+
|
|
223
|
+
assert.ok(bumped.updatedAt >= calendar.updatedAt);
|
|
224
|
+
assert.equal(bumped.createdAt, calendar.createdAt);
|
|
225
|
+
});
|
|
226
|
+
|
|
207
227
|
test("bumpSyncSequence throws a not-found error for a missing collection", async () => {
|
|
208
228
|
await assert.rejects(
|
|
209
229
|
repo.bumpSyncSequence(harness.makeId(), harness.makeId()),
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { deriveCalendarFeedTokenId } from "../id.js";
|
|
4
|
+
import type { ICalendarFeedTokenRepository } from "../interfaces/calendar-feed-token.js";
|
|
5
|
+
import type { RepositoryConformanceHarness } from "./harness.js";
|
|
6
|
+
|
|
7
|
+
const hashOf = (label: string): string => label.padEnd(64, "0");
|
|
8
|
+
|
|
9
|
+
export function calendarFeedTokenRepositoryConformance(
|
|
10
|
+
harness: RepositoryConformanceHarness<ICalendarFeedTokenRepository>,
|
|
11
|
+
): void {
|
|
12
|
+
describe("ICalendarFeedTokenRepository conformance", () => {
|
|
13
|
+
let repo: ICalendarFeedTokenRepository;
|
|
14
|
+
|
|
15
|
+
before(async () => {
|
|
16
|
+
repo = await harness.createRepository();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
after(() => harness.teardown());
|
|
20
|
+
|
|
21
|
+
test("put derives the id from the calendar and starts unrotated", async () => {
|
|
22
|
+
const accountConfigId = harness.makeId();
|
|
23
|
+
const calendarId = harness.makeId();
|
|
24
|
+
|
|
25
|
+
const minted = await repo.put({
|
|
26
|
+
accountConfigId,
|
|
27
|
+
calendarId,
|
|
28
|
+
tokenHash: hashOf("a"),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.equal(minted.feedTokenId, deriveCalendarFeedTokenId(calendarId));
|
|
32
|
+
assert.equal(minted.calendarId, calendarId);
|
|
33
|
+
assert.equal(minted.tokenHash, hashOf("a"));
|
|
34
|
+
assert.equal(minted.rotatedAt, 0);
|
|
35
|
+
assert.ok(minted.createdAt > 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a second put rotates the one address rather than adding another", async () => {
|
|
39
|
+
const accountConfigId = harness.makeId();
|
|
40
|
+
const calendarId = harness.makeId();
|
|
41
|
+
|
|
42
|
+
const minted = await repo.put({
|
|
43
|
+
accountConfigId,
|
|
44
|
+
calendarId,
|
|
45
|
+
tokenHash: hashOf("b"),
|
|
46
|
+
});
|
|
47
|
+
const rotated = await repo.put({
|
|
48
|
+
accountConfigId,
|
|
49
|
+
calendarId,
|
|
50
|
+
tokenHash: hashOf("c"),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(rotated.feedTokenId, minted.feedTokenId);
|
|
54
|
+
assert.equal(rotated.tokenHash, hashOf("c"));
|
|
55
|
+
assert.equal(
|
|
56
|
+
rotated.createdAt,
|
|
57
|
+
minted.createdAt,
|
|
58
|
+
"the feed is the same feed under a new secret",
|
|
59
|
+
);
|
|
60
|
+
assert.ok(
|
|
61
|
+
rotated.rotatedAt > 0,
|
|
62
|
+
"a replacement is distinguishable from a first mint",
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
await repo.findByTokenHash(hashOf("b")),
|
|
66
|
+
null,
|
|
67
|
+
"the address that was rotated away no longer resolves",
|
|
68
|
+
);
|
|
69
|
+
assert.equal(
|
|
70
|
+
(await repo.findByTokenHash(hashOf("c")))?.calendarId,
|
|
71
|
+
calendarId,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("findByCalendar answers only for the account config that owns it", async () => {
|
|
76
|
+
const accountConfigId = harness.makeId();
|
|
77
|
+
const stranger = harness.makeId();
|
|
78
|
+
const calendarId = harness.makeId();
|
|
79
|
+
await repo.put({
|
|
80
|
+
accountConfigId,
|
|
81
|
+
calendarId,
|
|
82
|
+
tokenHash: hashOf("d"),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.equal(
|
|
86
|
+
(await repo.findByCalendar(accountConfigId, calendarId))?.tokenHash,
|
|
87
|
+
hashOf("d"),
|
|
88
|
+
);
|
|
89
|
+
assert.equal(await repo.findByCalendar(stranger, calendarId), null);
|
|
90
|
+
assert.equal(
|
|
91
|
+
await repo.findByCalendar(accountConfigId, harness.makeId()),
|
|
92
|
+
null,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("findByTokenHash is unscoped, and names the owner it found", async () => {
|
|
97
|
+
const accountConfigId = harness.makeId();
|
|
98
|
+
const calendarId = harness.makeId();
|
|
99
|
+
await repo.put({
|
|
100
|
+
accountConfigId,
|
|
101
|
+
calendarId,
|
|
102
|
+
tokenHash: hashOf("e"),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const found = await repo.findByTokenHash(hashOf("e"));
|
|
106
|
+
|
|
107
|
+
assert.equal(found?.accountConfigId, accountConfigId);
|
|
108
|
+
assert.equal(found?.calendarId, calendarId);
|
|
109
|
+
assert.equal(await repo.findByTokenHash(hashOf("nothing")), null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("delete stops the address resolving", async () => {
|
|
113
|
+
const accountConfigId = harness.makeId();
|
|
114
|
+
const calendarId = harness.makeId();
|
|
115
|
+
await repo.put({
|
|
116
|
+
accountConfigId,
|
|
117
|
+
calendarId,
|
|
118
|
+
tokenHash: hashOf("f"),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await repo.delete(accountConfigId, calendarId);
|
|
122
|
+
|
|
123
|
+
assert.equal(await repo.findByTokenHash(hashOf("f")), null);
|
|
124
|
+
assert.equal(
|
|
125
|
+
await repo.findByCalendar(accountConfigId, calendarId),
|
|
126
|
+
null,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("delete leaves another account config's address alone", async () => {
|
|
131
|
+
const accountConfigId = harness.makeId();
|
|
132
|
+
const stranger = harness.makeId();
|
|
133
|
+
const calendarId = harness.makeId();
|
|
134
|
+
await repo.put({
|
|
135
|
+
accountConfigId,
|
|
136
|
+
calendarId,
|
|
137
|
+
tokenHash: hashOf("g"),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await repo.delete(stranger, calendarId);
|
|
141
|
+
|
|
142
|
+
assert.equal(
|
|
143
|
+
(await repo.findByTokenHash(hashOf("g")))?.calendarId,
|
|
144
|
+
calendarId,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
package/src/conformance/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { calendarCollectionRepositoryConformance } from "./calendar-collection.js";
|
|
2
2
|
export { calendarEventIndexRepositoryConformance } from "./calendar-event-index.js";
|
|
3
|
+
export { calendarFeedTokenRepositoryConformance } from "./calendar-feed-token.js";
|
|
3
4
|
export { calendarObjectRepositoryConformance } from "./calendar-object.js";
|
|
4
5
|
export { calendarSuggestionRepositoryConformance } from "./calendar-suggestion.js";
|
|
5
6
|
export type { RepositoryConformanceHarness } from "./harness.js";
|
package/src/id.ts
CHANGED
|
@@ -47,6 +47,15 @@ export const deriveCalendarId = (
|
|
|
47
47
|
REMIT_NAMESPACE,
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Identity of a calendar's feed address (issue #1067). Derived from the
|
|
52
|
+
* collection, so one calendar holds one active token by construction: rotating
|
|
53
|
+
* rewrites the row it already has instead of leaving a second live address
|
|
54
|
+
* nobody can see to revoke.
|
|
55
|
+
*/
|
|
56
|
+
export const deriveCalendarFeedTokenId = (calendarId: string): string =>
|
|
57
|
+
base36uuidv5(`calendarfeedtoken:${calendarId}`, REMIT_NAMESPACE);
|
|
58
|
+
|
|
50
59
|
/**
|
|
51
60
|
* Identity of a calendar resource (issue #15). Derived from its collection and
|
|
52
61
|
* its resource name — the last path segment of the resource's URL — so a
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type { IAccountSettingRepository } from "./interfaces/account-setting.js"
|
|
|
6
6
|
export type { IAddressRepository } from "./interfaces/address.js";
|
|
7
7
|
export type { ICalendarCollectionRepository } from "./interfaces/calendar-collection.js";
|
|
8
8
|
export type { ICalendarEventIndexRepository } from "./interfaces/calendar-event-index.js";
|
|
9
|
+
export type { ICalendarFeedTokenRepository } from "./interfaces/calendar-feed-token.js";
|
|
9
10
|
export type { ICalendarObjectRepository } from "./interfaces/calendar-object.js";
|
|
10
11
|
export type {
|
|
11
12
|
ICalendarSuggestionRepository,
|
|
@@ -75,6 +76,7 @@ export type {
|
|
|
75
76
|
BodyPartUpsertInput,
|
|
76
77
|
CalendarCollectionItem,
|
|
77
78
|
CalendarEventIndexItem,
|
|
79
|
+
CalendarFeedTokenItem,
|
|
78
80
|
CalendarObjectItem,
|
|
79
81
|
CalendarOccurrenceInput,
|
|
80
82
|
CalendarSuggestionItem,
|
|
@@ -122,6 +124,7 @@ export type {
|
|
|
122
124
|
OrganizeJobRequestItem,
|
|
123
125
|
OutboxAttachmentItem,
|
|
124
126
|
OutboxMessageItem,
|
|
127
|
+
PutCalendarFeedTokenInput,
|
|
125
128
|
PutCalendarObjectInput,
|
|
126
129
|
PutCalendarSuggestionInput,
|
|
127
130
|
PutMessageFlagPushInput,
|
|
@@ -95,6 +95,18 @@ export interface IAddressRepository {
|
|
|
95
95
|
cursor?: string;
|
|
96
96
|
limit?: number;
|
|
97
97
|
}): Promise<ResultList<AddressItem>>;
|
|
98
|
+
/**
|
|
99
|
+
* Every address stored under the configuration, for a reader that must see
|
|
100
|
+
* the whole set: nothing is hidden, so a row the machine marked `junkOnly`
|
|
101
|
+
* still comes back with whatever the user decided about it. Ordered by
|
|
102
|
+
* `addressId`, which no write moves, so a page boundary cannot skip a row
|
|
103
|
+
* while message sync updates counts and timestamps underneath the read.
|
|
104
|
+
*/
|
|
105
|
+
listAllByAccountConfigPage(input: {
|
|
106
|
+
accountConfigId: string;
|
|
107
|
+
cursor?: string;
|
|
108
|
+
limit?: number;
|
|
109
|
+
}): Promise<ResultList<AddressItem>>;
|
|
98
110
|
createEnvelopeAddress(
|
|
99
111
|
input: CreateEnvelopeAddressInput,
|
|
100
112
|
): Promise<EnvelopeAddressItem>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarFeedTokenItem,
|
|
3
|
+
PutCalendarFeedTokenInput,
|
|
4
|
+
} from "../types.js";
|
|
5
|
+
|
|
6
|
+
export interface ICalendarFeedTokenRepository {
|
|
7
|
+
/**
|
|
8
|
+
* Mints the calendar's feed address, or replaces the token it already has.
|
|
9
|
+
*
|
|
10
|
+
* One statement, and an upsert on the derived `feedTokenId`: a calendar holds
|
|
11
|
+
* exactly one active address, so there is no state in which a revoked token
|
|
12
|
+
* still resolves alongside its replacement. `createdAt` survives a
|
|
13
|
+
* replacement — the feed is the same feed under a new secret — and
|
|
14
|
+
* `rotatedAt` is stamped only when a row was already there, so a caller can
|
|
15
|
+
* tell a fresh address from a rotated one without a prior read.
|
|
16
|
+
*/
|
|
17
|
+
put(input: PutCalendarFeedTokenInput): Promise<CalendarFeedTokenItem>;
|
|
18
|
+
/**
|
|
19
|
+
* The address a calendar holds, or `null` when it has none. Scoped by account
|
|
20
|
+
* config: a management read is always somebody's, and an unscoped one would
|
|
21
|
+
* answer for a calendar the caller does not own.
|
|
22
|
+
*/
|
|
23
|
+
findByCalendar(
|
|
24
|
+
accountConfigId: string,
|
|
25
|
+
calendarId: string,
|
|
26
|
+
): Promise<CalendarFeedTokenItem | null>;
|
|
27
|
+
/**
|
|
28
|
+
* The row a token hash names, or `null` when nothing answers to it.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately unscoped, and the only read that is: a subscriber arrives with
|
|
31
|
+
* no session at all, so the hash is the whole lookup key and the row it finds
|
|
32
|
+
* is what names the owner. The caller still compares the stored hash against
|
|
33
|
+
* the presented one before serving anything.
|
|
34
|
+
*/
|
|
35
|
+
findByTokenHash(tokenHash: string): Promise<CalendarFeedTokenItem | null>;
|
|
36
|
+
/** Revokes the calendar's address. Every subscribed client stops resolving. */
|
|
37
|
+
delete(accountConfigId: string, calendarId: string): Promise<void>;
|
|
38
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ICalendarCollectionRepository } from "./calendar-collection.js";
|
|
2
2
|
import type { ICalendarEventIndexRepository } from "./calendar-event-index.js";
|
|
3
|
+
import type { ICalendarFeedTokenRepository } from "./calendar-feed-token.js";
|
|
3
4
|
import type { ICalendarObjectRepository } from "./calendar-object.js";
|
|
4
5
|
import type { ICalendarSuggestionRepository } from "./calendar-suggestion.js";
|
|
5
6
|
|
|
@@ -18,6 +19,13 @@ export interface CalendarUnitOfWorkRepositories {
|
|
|
18
19
|
* with no event behind it, is a state nothing later can repair.
|
|
19
20
|
*/
|
|
20
21
|
calendarSuggestion: ICalendarSuggestionRepository;
|
|
22
|
+
/**
|
|
23
|
+
* Present so removing a calendar takes its feed address with it (issue
|
|
24
|
+
* #1067). A token row outliving its collection is a credential pointing at
|
|
25
|
+
* nothing — it answers 404, but nobody can see it to revoke it, and a later
|
|
26
|
+
* calendar under the same derived id would inherit it.
|
|
27
|
+
*/
|
|
28
|
+
calendarFeedToken: ICalendarFeedTokenRepository;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -52,13 +52,12 @@ export interface IOutboxAttachmentRepository {
|
|
|
52
52
|
* from these rows and the receiving server rejects it.
|
|
53
53
|
*
|
|
54
54
|
* A count-then-insert only satisfies that under serializable isolation or an
|
|
55
|
-
* external lock. **The drizzle/SQLite implementation gets it from
|
|
56
|
-
* schema
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* (`SQLITE_BUSY_SNAPSHOT`), not silently over-admit.
|
|
55
|
+
* external lock. **The drizzle/SQLite implementation gets it from the lock,
|
|
56
|
+
* not from the schema or the isolation level:** `runInTransaction` opens a
|
|
57
|
+
* unit with `BEGIN IMMEDIATE`, so it holds the file's write lock from before
|
|
58
|
+
* its first read, and the module-level write queue in `tx.ts` orders the
|
|
59
|
+
* units inside one process. Two writer processes against one SQLite file are
|
|
60
|
+
* ordered by that lock rather than over-admitting.
|
|
62
61
|
*
|
|
63
62
|
* **An adapter without serializable isolation must not do a
|
|
64
63
|
* read-then-write.** On DynamoDB in particular, a Query followed by a
|
|
@@ -92,5 +92,15 @@ export interface IOutboxMessageRepository {
|
|
|
92
92
|
accountId: string,
|
|
93
93
|
options?: { limit?: number; continuationToken?: string },
|
|
94
94
|
): Promise<ResultList<OutboxMessageItem>>;
|
|
95
|
+
/**
|
|
96
|
+
* One keyset scan over several accounts, ordered globally by createdAt then
|
|
97
|
+
* outboxMessageId. The continuation token names a position in that single
|
|
98
|
+
* ordering rather than one position per account, so a second page stays
|
|
99
|
+
* coherent however the accounts' rows interleave.
|
|
100
|
+
*/
|
|
101
|
+
listByAccounts(
|
|
102
|
+
accountIds: string[],
|
|
103
|
+
options?: { limit?: number; continuationToken?: string },
|
|
104
|
+
): Promise<ResultList<OutboxMessageItem>>;
|
|
95
105
|
listQueued(accountId: string): Promise<OutboxMessageItem[]>;
|
|
96
106
|
}
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
BodyPartStorageSchema,
|
|
9
9
|
CalendarCollectionSchema,
|
|
10
10
|
CalendarEventIndexSchema,
|
|
11
|
+
CalendarFeedTokenSchema,
|
|
11
12
|
CalendarObjectSchema,
|
|
12
13
|
CalendarSuggestionSchema,
|
|
13
14
|
ConfigImportSchema,
|
|
@@ -584,6 +585,16 @@ export type UpdateCalendarCollectionInput = Partial<
|
|
|
584
585
|
Pick<CalendarCollectionItem, "displayName" | "color" | "timezone">
|
|
585
586
|
>;
|
|
586
587
|
|
|
588
|
+
export type CalendarFeedTokenItem = z.infer<typeof CalendarFeedTokenSchema>;
|
|
589
|
+
|
|
590
|
+
// `feedTokenId` is derived from `calendarId`, which is what makes one active
|
|
591
|
+
// token per calendar structural rather than enforced. `rotatedAt` is the
|
|
592
|
+
// repository's to stamp: only it can tell a mint from a replacement.
|
|
593
|
+
export type PutCalendarFeedTokenInput = Omit<
|
|
594
|
+
CalendarFeedTokenItem,
|
|
595
|
+
"feedTokenId" | "rotatedAt" | "createdAt" | "updatedAt"
|
|
596
|
+
>;
|
|
597
|
+
|
|
587
598
|
/**
|
|
588
599
|
* A whole calendar resource. Every projected column is supplied together with
|
|
589
600
|
* the `icalData` it was projected from — the repository never derives one from
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { MailboxItem } from "./types.js";
|
|
4
|
+
import { isVirtualCopyMailbox } from "./virtual-copy.js";
|
|
5
|
+
|
|
6
|
+
const mailbox = (fullPath: string, specialUse?: string[]): MailboxItem =>
|
|
7
|
+
({
|
|
8
|
+
mailboxId: `mbx-${fullPath}`,
|
|
9
|
+
fullPath,
|
|
10
|
+
...(specialUse ? { specialUse } : {}),
|
|
11
|
+
}) as MailboxItem;
|
|
12
|
+
|
|
13
|
+
describe("which folders hold a second copy of mail that lives elsewhere", () => {
|
|
14
|
+
it("reads the special-use the server advertises", () => {
|
|
15
|
+
assert.equal(isVirtualCopyMailbox(mailbox("Everything", ["All"])), true);
|
|
16
|
+
assert.equal(isVirtualCopyMailbox(mailbox("Pinned", ["Flagged"])), true);
|
|
17
|
+
assert.equal(isVirtualCopyMailbox(mailbox("Urgent", ["Important"])), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("falls back to the well-known path when the server advertises nothing", () => {
|
|
21
|
+
assert.equal(isVirtualCopyMailbox(mailbox("[Gmail]/All Mail")), true);
|
|
22
|
+
assert.equal(isVirtualCopyMailbox(mailbox("[gmail]/starred")), true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("matches the whole path, so a folder of the user's own is real mail", () => {
|
|
26
|
+
assert.equal(isVirtualCopyMailbox(mailbox("Starred ideas")), false);
|
|
27
|
+
assert.equal(isVirtualCopyMailbox(mailbox("[Gmail]/All Mail/2024")), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads an ordinary folder as the place mail lives", () => {
|
|
31
|
+
assert.equal(isVirtualCopyMailbox(mailbox("INBOX")), false);
|
|
32
|
+
assert.equal(isVirtualCopyMailbox(mailbox("Rubbish", ["Junk"])), false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
2
|
+
import type { MailboxItem } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Special-use folders that hold a second copy of mail already reachable through
|
|
6
|
+
* the folder it actually lives in — Gmail's All Mail, Starred and Important.
|
|
7
|
+
*/
|
|
8
|
+
const VIRTUAL_COPY_SPECIAL_USE: readonly string[] = [
|
|
9
|
+
MailboxSpecialUse.All,
|
|
10
|
+
MailboxSpecialUse.Flagged,
|
|
11
|
+
MailboxSpecialUse.Important,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Well-known Gmail virtual paths, for servers that do not advertise the
|
|
16
|
+
* special-use attribute. Whole path, never a prefix — a user's own
|
|
17
|
+
* `Starred ideas` folder is real mail.
|
|
18
|
+
*/
|
|
19
|
+
const VIRTUAL_COPY_FULL_PATHS: readonly string[] = [
|
|
20
|
+
"[gmail]/all mail",
|
|
21
|
+
"[gmail]/starred",
|
|
22
|
+
"[gmail]/important",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether this folder holds mail that also lives in a real folder. Such a
|
|
27
|
+
* folder is a view, not a filing location: seeing a message in it says nothing
|
|
28
|
+
* about where the message was filed, so it neither picks which duplicate a
|
|
29
|
+
* listing drops nor decides where a message lives.
|
|
30
|
+
*/
|
|
31
|
+
export const isVirtualCopyMailbox = (mailbox: MailboxItem): boolean =>
|
|
32
|
+
mailbox.specialUse?.some((use) => VIRTUAL_COPY_SPECIAL_USE.includes(use)) ===
|
|
33
|
+
true || VIRTUAL_COPY_FULL_PATHS.includes(mailbox.fullPath.toLowerCase());
|