@remit/drizzle-service 0.0.83 → 0.0.85
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/mailbox-sync-status-backfill.sqlite.test.ts +118 -0
- package/src/repair/junk-only-address.sqlite.test.ts +7 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +131 -0
- package/src/repos/i4-address.ts +36 -1
- package/src/repos/i4-mailbox.sqlite.test.ts +57 -21
- package/src/repos/i4-mailbox.test.ts +508 -13
- package/src/repos/i4-mailbox.ts +226 -24
- package/src/schema/i4-mailbox.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import {
|
|
5
|
+
applyMigration,
|
|
6
|
+
migrationJournal,
|
|
7
|
+
} from "./test-shipped-sqlite-schema.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `sync_status` becomes NOT NULL, and on a real install almost every row is
|
|
11
|
+
* NULL: the sweep's insert is the only create path that omitted it, and the
|
|
12
|
+
* sweep is what discovers a folder in the first place.
|
|
13
|
+
*
|
|
14
|
+
* drizzle-kit does not emit data migrations, and SQLite cannot alter
|
|
15
|
+
* nullability, so the schema change is a copy-and-swap whose `INSERT … SELECT`
|
|
16
|
+
* would violate the new constraint on every one of those rows. Because drizzle
|
|
17
|
+
* runs a folder's whole pending set inside one `BEGIN`/`COMMIT`, that failure
|
|
18
|
+
* rolls the entities set back and the migrate one-shot that gates all six
|
|
19
|
+
* services never completes.
|
|
20
|
+
*
|
|
21
|
+
* So the pair is proven here in the order the migrator runs it, against the
|
|
22
|
+
* data it will actually meet.
|
|
23
|
+
*/
|
|
24
|
+
const BACKFILL = "0026_mailbox_sync_status_backfill";
|
|
25
|
+
const SCHEMA_CHANGE = "0027_mailbox_sync_status_total";
|
|
26
|
+
|
|
27
|
+
const atPredecessor = (): Database.Database => {
|
|
28
|
+
const sqlite = new Database(":memory:");
|
|
29
|
+
for (const entry of migrationJournal()) {
|
|
30
|
+
if (entry.tag === BACKFILL) return sqlite;
|
|
31
|
+
applyMigration(sqlite, entry.tag);
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`${BACKFILL} is not in the journal`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const seed = (
|
|
37
|
+
sqlite: Database.Database,
|
|
38
|
+
rows: Array<{ mailboxId: string; syncStatus: string | null }>,
|
|
39
|
+
): void => {
|
|
40
|
+
const insert = sqlite.prepare(
|
|
41
|
+
`INSERT INTO mailbox (
|
|
42
|
+
mailbox_id, account_id, namespace_type, namespace_prefix,
|
|
43
|
+
hierarchy_delimiter, full_path, uid_validity, uid_next, highest_modseq,
|
|
44
|
+
message_count, unseen_count, deleted_count, total_size, last_sync_uid,
|
|
45
|
+
high_water_mark_uid, last_message_sync_at, parent_mailbox_id,
|
|
46
|
+
sync_status, cursor_state, created_at, updated_at
|
|
47
|
+
) VALUES (?, 'acct', 'personal', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0,
|
|
48
|
+
'None', ?, 'normal', 0, 0)`,
|
|
49
|
+
);
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
insert.run(row.mailboxId, `Folder/${row.mailboxId}`, row.syncStatus);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe("mailbox sync_status becomes total", () => {
|
|
56
|
+
test("the backfill runs first, so the rebuild sees no NULLs", () => {
|
|
57
|
+
const sqlite = atPredecessor();
|
|
58
|
+
seed(sqlite, [
|
|
59
|
+
{ mailboxId: "swept-1", syncStatus: null },
|
|
60
|
+
{ mailboxId: "swept-2", syncStatus: null },
|
|
61
|
+
{ mailboxId: "created", syncStatus: "pending" },
|
|
62
|
+
{ mailboxId: "failed", syncStatus: "failed" },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
applyMigration(sqlite, BACKFILL);
|
|
66
|
+
applyMigration(sqlite, SCHEMA_CHANGE);
|
|
67
|
+
|
|
68
|
+
const rows = sqlite
|
|
69
|
+
.prepare(
|
|
70
|
+
"SELECT mailbox_id, sync_status FROM mailbox ORDER BY mailbox_id",
|
|
71
|
+
)
|
|
72
|
+
.all() as Array<{ mailbox_id: string; sync_status: string }>;
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(rows, [
|
|
75
|
+
{ mailbox_id: "created", sync_status: "pending" },
|
|
76
|
+
{ mailbox_id: "failed", sync_status: "failed" },
|
|
77
|
+
{ mailbox_id: "swept-1", sync_status: "synced" },
|
|
78
|
+
{ mailbox_id: "swept-2", sync_status: "synced" },
|
|
79
|
+
]);
|
|
80
|
+
sqlite.close();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the rebuild is refused without the backfill ahead of it", () => {
|
|
84
|
+
// The failure this ordering exists to prevent, made visible: without the
|
|
85
|
+
// backfill the copy-and-swap cannot insert a NULL into the new column.
|
|
86
|
+
const sqlite = atPredecessor();
|
|
87
|
+
seed(sqlite, [{ mailboxId: "swept-1", syncStatus: null }]);
|
|
88
|
+
|
|
89
|
+
assert.throws(
|
|
90
|
+
() => applyMigration(sqlite, SCHEMA_CHANGE),
|
|
91
|
+
/NOT NULL constraint failed/,
|
|
92
|
+
);
|
|
93
|
+
sqlite.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the rebuild keeps the account index and the new column", () => {
|
|
97
|
+
const sqlite = atPredecessor();
|
|
98
|
+
applyMigration(sqlite, BACKFILL);
|
|
99
|
+
applyMigration(sqlite, SCHEMA_CHANGE);
|
|
100
|
+
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
101
|
+
|
|
102
|
+
const columns = (
|
|
103
|
+
sqlite.prepare("PRAGMA table_info(mailbox)").all() as Array<{
|
|
104
|
+
name: string;
|
|
105
|
+
}>
|
|
106
|
+
).map((column) => column.name);
|
|
107
|
+
assert.equal(columns.includes("pending_path"), true);
|
|
108
|
+
assert.equal(columns.includes("old_path"), false);
|
|
109
|
+
|
|
110
|
+
const indexes = (
|
|
111
|
+
sqlite.prepare("PRAGMA index_list(mailbox)").all() as Array<{
|
|
112
|
+
name: string;
|
|
113
|
+
}>
|
|
114
|
+
).map((index) => index.name);
|
|
115
|
+
assert.equal(indexes.includes("mailbox_by_account_id"), true);
|
|
116
|
+
sqlite.close();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -7,7 +7,10 @@ import Database from "better-sqlite3";
|
|
|
7
7
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
8
8
|
import { AccountSettingRepo } from "../repos/i4-account-setting.js";
|
|
9
9
|
import { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
applyMigration,
|
|
12
|
+
shippedTableDdl,
|
|
13
|
+
} from "../test-shipped-sqlite-schema.js";
|
|
11
14
|
import {
|
|
12
15
|
type JunkOnlyRepairClient,
|
|
13
16
|
type JunkOnlyRepairMode,
|
|
@@ -197,6 +200,9 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
197
200
|
]) {
|
|
198
201
|
sqlite.exec(shippedTableDdl(DDL_TAG, table));
|
|
199
202
|
}
|
|
203
|
+
// `mailbox` is read through the repo, which selects every column the
|
|
204
|
+
// entity declares — including the rename target added after this DDL.
|
|
205
|
+
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
200
206
|
sqlite.exec(
|
|
201
207
|
readFileSync(
|
|
202
208
|
new URL(
|
|
@@ -75,6 +75,137 @@ describe("concurrent flag merges on one address", () => {
|
|
|
75
75
|
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test("marking a blocked sender never-spam drops the block", async () => {
|
|
79
|
+
const addr = await address();
|
|
80
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
81
|
+
blocked: { value: true, setAt: 1 },
|
|
82
|
+
muted: { value: true, setAt: 1 },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
86
|
+
neverSpam: { value: true, setAt: 2 },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
90
|
+
assert.equal(merged.flags?.neverSpam?.value, true);
|
|
91
|
+
assert.equal(merged.flags?.blocked, undefined);
|
|
92
|
+
assert.equal(
|
|
93
|
+
merged.flags?.muted?.value,
|
|
94
|
+
true,
|
|
95
|
+
"a flag on another axis is untouched",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("blocking a never-spam sender drops the grant", async () => {
|
|
102
|
+
const addr = await address();
|
|
103
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
104
|
+
neverSpam: { value: true, setAt: 1 },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
108
|
+
blocked: { value: true, setAt: 2 },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
112
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
113
|
+
assert.equal(merged.flags?.neverSpam, undefined);
|
|
114
|
+
|
|
115
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a never-spam grant turned off leaves a later block alone", async () => {
|
|
119
|
+
const addr = await address();
|
|
120
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
121
|
+
neverSpam: { value: false, setAt: 1 },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
125
|
+
blocked: { value: true, setAt: 2 },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
129
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
130
|
+
assert.equal(
|
|
131
|
+
merged.flags?.neverSpam?.value,
|
|
132
|
+
false,
|
|
133
|
+
"an explicit false is not a contradiction and keeps its audit trail",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A row written before the invariant existed still carries both. Healing it
|
|
140
|
+
// while patching an unrelated key would revoke a placement instruction the
|
|
141
|
+
// user never touched, so the write leaves it and the read arbitrates.
|
|
142
|
+
test("a patch about another flag leaves a legacy both-set row alone", async () => {
|
|
143
|
+
const accountConfigId = randomId();
|
|
144
|
+
const addr = await repo.createAddress({
|
|
145
|
+
addressId: randomId(),
|
|
146
|
+
accountConfigId,
|
|
147
|
+
localPart: "legacy",
|
|
148
|
+
domain: "example.com",
|
|
149
|
+
normalizedEmail: "legacy@example.com",
|
|
150
|
+
normalizedCompound: "legacy@example.com:legacy",
|
|
151
|
+
flags: {
|
|
152
|
+
blocked: { value: true, setAt: 1 },
|
|
153
|
+
neverSpam: { value: true, setAt: 2 },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
158
|
+
muted: { value: true, setAt: 3 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
162
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
163
|
+
assert.equal(merged.flags?.neverSpam?.value, true);
|
|
164
|
+
assert.equal(merged.flags?.muted?.value, true);
|
|
165
|
+
|
|
166
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("writing either placement flag heals a legacy both-set row", async () => {
|
|
170
|
+
const accountConfigId = randomId();
|
|
171
|
+
const addr = await repo.createAddress({
|
|
172
|
+
addressId: randomId(),
|
|
173
|
+
accountConfigId,
|
|
174
|
+
localPart: "legacy",
|
|
175
|
+
domain: "example.com",
|
|
176
|
+
normalizedEmail: "legacy2@example.com",
|
|
177
|
+
normalizedCompound: "legacy2@example.com:legacy",
|
|
178
|
+
flags: {
|
|
179
|
+
blocked: { value: true, setAt: 1 },
|
|
180
|
+
neverSpam: { value: true, setAt: 2 },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
185
|
+
neverSpam: { value: true, setAt: 3 },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
189
|
+
assert.equal(merged.flags?.neverSpam?.value, true);
|
|
190
|
+
assert.equal(merged.flags?.blocked, undefined);
|
|
191
|
+
|
|
192
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("one patch naming both contradictory flags settles on the block", async () => {
|
|
196
|
+
const addr = await address();
|
|
197
|
+
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
|
198
|
+
blocked: { value: true, setAt: 1 },
|
|
199
|
+
neverSpam: { value: true, setAt: 1 },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
203
|
+
assert.equal(merged.flags?.blocked?.value, true);
|
|
204
|
+
assert.equal(merged.flags?.neverSpam, undefined);
|
|
205
|
+
|
|
206
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
207
|
+
});
|
|
208
|
+
|
|
78
209
|
test("a concurrent merge does not resurrect a deleted flag", async () => {
|
|
79
210
|
const addr = await address();
|
|
80
211
|
await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -70,6 +70,41 @@ type MergeAttempt =
|
|
|
70
70
|
| { outcome: "missing" }
|
|
71
71
|
| { outcome: "contended" };
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* `blocked` and `neverSpam` are two directly contradictory statements about
|
|
75
|
+
* where a sender's mail belongs (issue #605), so a row asserting both is not a
|
|
76
|
+
* tie to break — it is a row that should not exist. This is the only place that
|
|
77
|
+
* sees both keys at once: `buildFlagsPatch` stays a pure per-key translation,
|
|
78
|
+
* and the merge fold is the read-modify-write of the whole map.
|
|
79
|
+
*
|
|
80
|
+
* The flag the patch just raised wins, so the instruction the user gave last is
|
|
81
|
+
* the one that stands. A patch raising both resolves to `blocked` — the same
|
|
82
|
+
* direction the `blocked`/`vip` same-second tie already breaks in.
|
|
83
|
+
*
|
|
84
|
+
* Only a patch that raises one of the two is allowed to drop the other. A row
|
|
85
|
+
* that somehow already carries both survives a patch about anything else
|
|
86
|
+
* untouched: dropping a placement instruction as a side effect of writing
|
|
87
|
+
* `muted` would be a silent revocation the user never asked for. Such a row is
|
|
88
|
+
* resolved on read — `resolveSenderPlacement` reads it as `Blocked` — and
|
|
89
|
+
* healed the next time either key is written.
|
|
90
|
+
*/
|
|
91
|
+
const dropContradictedPlacementFlag = (
|
|
92
|
+
next: AddressFlags,
|
|
93
|
+
patch: FlagsMergePatch,
|
|
94
|
+
): AddressFlags => {
|
|
95
|
+
if (next.blocked?.value !== true || next.neverSpam?.value !== true)
|
|
96
|
+
return next;
|
|
97
|
+
if (patch.blocked?.value === true) {
|
|
98
|
+
const { neverSpam: _neverSpam, ...rest } = next;
|
|
99
|
+
return rest;
|
|
100
|
+
}
|
|
101
|
+
if (patch.neverSpam?.value === true) {
|
|
102
|
+
const { blocked: _blocked, ...rest } = next;
|
|
103
|
+
return rest;
|
|
104
|
+
}
|
|
105
|
+
return next;
|
|
106
|
+
};
|
|
107
|
+
|
|
73
108
|
/**
|
|
74
109
|
* The stored `"<display name> <email>"` compound, folded in JavaScript exactly
|
|
75
110
|
* as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
|
|
@@ -496,7 +531,7 @@ export class AddressRepo implements IAddressRepository {
|
|
|
496
531
|
}
|
|
497
532
|
(next[key] as AddressFlags[keyof AddressFlags]) = value;
|
|
498
533
|
}
|
|
499
|
-
return next;
|
|
534
|
+
return dropContradictedPlacementFlag(next, patch);
|
|
500
535
|
});
|
|
501
536
|
}
|
|
502
537
|
|
|
@@ -68,31 +68,39 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
68
68
|
assert.equal(reread.highestModseq, "9007199254740993");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test("
|
|
72
|
-
// A renamed parent is set pending by the caller; its children's new paths
|
|
73
|
-
// are equally absent from the server until MAILBOX_RENAME lands, so they
|
|
74
|
-
// must be pending too — otherwise a reconcile in that window reaps the
|
|
75
|
-
// child as server-deleted.
|
|
71
|
+
test("a row written without a state reads back synced (D1)", async () => {
|
|
76
72
|
const accountId = randomUUID();
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
73
|
+
const created = await repo.create(
|
|
74
|
+
makeMailboxInput(accountId, "Discovered"),
|
|
75
|
+
);
|
|
76
|
+
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
77
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
78
|
+
assert.equal(fetched.syncStatus, MailboxSyncStatus.synced);
|
|
79
|
+
});
|
|
85
80
|
|
|
86
|
-
|
|
81
|
+
test("pendingPath round-trips and clears", async () => {
|
|
82
|
+
const accountId = randomUUID();
|
|
83
|
+
const created = await repo.create(makeMailboxInput(accountId, "Archive"));
|
|
84
|
+
assert.equal(created.pendingPath, undefined);
|
|
87
85
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
87
|
+
from: [MailboxSyncStatus.synced],
|
|
88
|
+
to: MailboxSyncStatus.pending,
|
|
89
|
+
set: { pendingPath: "Records" },
|
|
90
|
+
});
|
|
91
|
+
assert.equal(claimed?.pendingPath, "Records");
|
|
92
|
+
assert.equal(
|
|
93
|
+
(await repo.get(accountId, created.mailboxId)).pendingPath,
|
|
94
|
+
"Records",
|
|
95
|
+
);
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
const settled = await repo.transition(accountId, created.mailboxId, {
|
|
98
|
+
from: [MailboxSyncStatus.pending],
|
|
99
|
+
to: MailboxSyncStatus.synced,
|
|
100
|
+
set: { fullPath: "Records", pendingPath: null },
|
|
101
|
+
});
|
|
102
|
+
assert.equal(settled?.pendingPath, undefined);
|
|
103
|
+
assert.equal(settled?.fullPath, "Records");
|
|
96
104
|
});
|
|
97
105
|
});
|
|
98
106
|
|
|
@@ -115,6 +123,9 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
115
123
|
const sqlite = new Database(":memory:");
|
|
116
124
|
sqlite.exec(shippedTableDdl("0000_happy_roland_deschain", "mailbox"));
|
|
117
125
|
applyMigration(sqlite, "0002_highest_modseq_text");
|
|
126
|
+
applyMigration(sqlite, "0026_mailbox_sync_status_backfill");
|
|
127
|
+
applyMigration(sqlite, "0027_mailbox_sync_status_total");
|
|
128
|
+
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
118
129
|
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
119
130
|
repo = new MailboxRepo(db as never);
|
|
120
131
|
close = async () => {
|
|
@@ -157,6 +168,31 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
157
168
|
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
158
169
|
});
|
|
159
170
|
|
|
171
|
+
test("a row inserted without a state reads back synced", async () => {
|
|
172
|
+
const accountId = randomUUID();
|
|
173
|
+
const created = await repo.create(makeMailboxInput(accountId, "Notes"));
|
|
174
|
+
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("pendingPath round-trips and clears on the shipped shape", async () => {
|
|
178
|
+
const accountId = randomUUID();
|
|
179
|
+
const created = await repo.create(makeMailboxInput(accountId, "Receipts"));
|
|
180
|
+
|
|
181
|
+
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
182
|
+
from: [MailboxSyncStatus.synced],
|
|
183
|
+
to: MailboxSyncStatus.pending,
|
|
184
|
+
set: { pendingPath: "Invoices" },
|
|
185
|
+
});
|
|
186
|
+
assert.equal(claimed?.pendingPath, "Invoices");
|
|
187
|
+
|
|
188
|
+
const cleared = await repo.transition(accountId, created.mailboxId, {
|
|
189
|
+
from: [MailboxSyncStatus.pending],
|
|
190
|
+
to: MailboxSyncStatus.synced,
|
|
191
|
+
set: { pendingPath: null },
|
|
192
|
+
});
|
|
193
|
+
assert.equal(cleared?.pendingPath, undefined);
|
|
194
|
+
});
|
|
195
|
+
|
|
160
196
|
test("round-trips a cursor above 2^53 with its exact digits", async () => {
|
|
161
197
|
const accountId = randomUUID();
|
|
162
198
|
const modseq = "18446744073709551615";
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import {
|
|
6
|
+
envelopeId as deriveEnvelopeId,
|
|
7
|
+
rootBodyPartId as deriveRootBodyPartId,
|
|
8
|
+
} from "../id.js";
|
|
9
|
+
import {
|
|
10
|
+
filterTable,
|
|
11
|
+
mailboxSpecialUseTable,
|
|
12
|
+
mailboxTable,
|
|
13
|
+
messageTable,
|
|
14
|
+
outboxTable,
|
|
15
|
+
threadMessageTable,
|
|
16
|
+
} from "../schema.js";
|
|
3
17
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
18
|
+
import { runInTransaction } from "../tx.js";
|
|
4
19
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
20
|
+
import { DrizzleMessageRepository, deleteMessageSubtree } from "./message.js";
|
|
21
|
+
|
|
22
|
+
/** Every state a row can carry, for a transition that decides against none. */
|
|
23
|
+
const EVERY_STATE = [
|
|
24
|
+
MailboxSyncStatus.synced,
|
|
25
|
+
MailboxSyncStatus.pending,
|
|
26
|
+
MailboxSyncStatus.failed,
|
|
27
|
+
MailboxSyncStatus.deleting,
|
|
28
|
+
] as const;
|
|
5
29
|
|
|
6
30
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
7
31
|
return {
|
|
@@ -143,19 +167,6 @@ describe("MailboxRepo", () => {
|
|
|
143
167
|
assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
|
|
144
168
|
});
|
|
145
169
|
|
|
146
|
-
test("renameChildPaths updates all children", async () => {
|
|
147
|
-
const accountId = randomId();
|
|
148
|
-
await repo.create(makeMailboxInput(accountId, "OldName/Sub1"));
|
|
149
|
-
await repo.create(makeMailboxInput(accountId, "OldName/Sub2"));
|
|
150
|
-
|
|
151
|
-
await repo.renameChildPaths(accountId, "OldName", "NewName");
|
|
152
|
-
|
|
153
|
-
const sub1 = await repo.findByPath(accountId, "NewName/Sub1");
|
|
154
|
-
const sub2 = await repo.findByPath(accountId, "NewName/Sub2");
|
|
155
|
-
assert.ok(sub1, "Sub1 renamed");
|
|
156
|
-
assert.ok(sub2, "Sub2 renamed");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
170
|
test("cross-tenant: get refuses a foreign account", async () => {
|
|
160
171
|
const accountId = randomId();
|
|
161
172
|
const other = randomId();
|
|
@@ -217,6 +228,490 @@ describe("MailboxRepo", () => {
|
|
|
217
228
|
await repo.delete(accountB, b.mailboxId);
|
|
218
229
|
});
|
|
219
230
|
|
|
231
|
+
describe("transition — the conditional write (D3)", () => {
|
|
232
|
+
test("an accepted from-state applies the new state and the set fields", async () => {
|
|
233
|
+
const accountId = randomId();
|
|
234
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Archive"));
|
|
235
|
+
|
|
236
|
+
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
237
|
+
from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
|
|
238
|
+
to: MailboxSyncStatus.pending,
|
|
239
|
+
set: { pendingPath: "Records" },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
assert.equal(written?.syncStatus, MailboxSyncStatus.pending);
|
|
243
|
+
assert.equal(written?.pendingPath, "Records");
|
|
244
|
+
assert.equal(written?.fullPath, "Archive", "fullPath stays confirmed");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a state outside `from` writes nothing and leaves the row byte-identical", async () => {
|
|
248
|
+
const accountId = randomId();
|
|
249
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Work"));
|
|
250
|
+
const before = await repo.get(accountId, mailbox.mailboxId);
|
|
251
|
+
|
|
252
|
+
const lost = await repo.transition(accountId, mailbox.mailboxId, {
|
|
253
|
+
from: [MailboxSyncStatus.deleting],
|
|
254
|
+
to: MailboxSyncStatus.synced,
|
|
255
|
+
set: { fullPath: "Nope" },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
assert.equal(lost, null);
|
|
259
|
+
assert.deepEqual(await repo.get(accountId, mailbox.mailboxId), before);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("an absent id is a null, not a throw", async () => {
|
|
263
|
+
assert.equal(
|
|
264
|
+
await repo.transition(randomId(), "no-such-mailbox", {
|
|
265
|
+
from: EVERY_STATE,
|
|
266
|
+
to: MailboxSyncStatus.synced,
|
|
267
|
+
}),
|
|
268
|
+
null,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("wherePendingPath as a string matches only that target", async () => {
|
|
273
|
+
const accountId = randomId();
|
|
274
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Bills"));
|
|
275
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
276
|
+
from: [MailboxSyncStatus.synced],
|
|
277
|
+
to: MailboxSyncStatus.pending,
|
|
278
|
+
set: { pendingPath: "Invoices" },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
assert.equal(
|
|
282
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
283
|
+
from: [MailboxSyncStatus.pending],
|
|
284
|
+
wherePendingPath: "Somewhere else",
|
|
285
|
+
to: MailboxSyncStatus.synced,
|
|
286
|
+
}),
|
|
287
|
+
null,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const settled = await repo.transition(accountId, mailbox.mailboxId, {
|
|
291
|
+
from: [MailboxSyncStatus.pending],
|
|
292
|
+
wherePendingPath: "Invoices",
|
|
293
|
+
to: MailboxSyncStatus.synced,
|
|
294
|
+
set: { fullPath: "Invoices", pendingPath: null },
|
|
295
|
+
});
|
|
296
|
+
assert.equal(settled?.fullPath, "Invoices");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("wherePendingPath: null refuses a row a rename has claimed", async () => {
|
|
300
|
+
// The seventh-state hole (D3): a create settle that predicates on
|
|
301
|
+
// `pending` alone matches a row a rename has since claimed, writes
|
|
302
|
+
// `synced` with the rename target still on it, and the rename then
|
|
303
|
+
// never runs and is never marked failed.
|
|
304
|
+
const accountId = randomId();
|
|
305
|
+
const claimed = await repo.create(makeMailboxInput(accountId, "Notes"));
|
|
306
|
+
await repo.transition(accountId, claimed.mailboxId, {
|
|
307
|
+
from: [MailboxSyncStatus.synced],
|
|
308
|
+
to: MailboxSyncStatus.pending,
|
|
309
|
+
set: { pendingPath: "Journal" },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const createSettle = await repo.transition(accountId, claimed.mailboxId, {
|
|
313
|
+
from: [MailboxSyncStatus.pending],
|
|
314
|
+
wherePendingPath: null,
|
|
315
|
+
to: MailboxSyncStatus.synced,
|
|
316
|
+
});
|
|
317
|
+
assert.equal(createSettle, null);
|
|
318
|
+
|
|
319
|
+
const still = await repo.get(accountId, claimed.mailboxId);
|
|
320
|
+
assert.equal(still.syncStatus, MailboxSyncStatus.pending);
|
|
321
|
+
assert.equal(still.pendingPath, "Journal");
|
|
322
|
+
|
|
323
|
+
const creating = await repo.create({
|
|
324
|
+
...makeMailboxInput(accountId, "Fresh"),
|
|
325
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
326
|
+
});
|
|
327
|
+
const settled = await repo.transition(accountId, creating.mailboxId, {
|
|
328
|
+
from: [MailboxSyncStatus.pending],
|
|
329
|
+
wherePendingPath: null,
|
|
330
|
+
to: MailboxSyncStatus.synced,
|
|
331
|
+
});
|
|
332
|
+
assert.equal(settled?.syncStatus, MailboxSyncStatus.synced);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("omitting wherePendingPath predicates on syncStatus alone", async () => {
|
|
336
|
+
const accountId = randomId();
|
|
337
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Old"));
|
|
338
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
339
|
+
from: [MailboxSyncStatus.synced],
|
|
340
|
+
to: MailboxSyncStatus.pending,
|
|
341
|
+
set: { pendingPath: "New" },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
345
|
+
from: [MailboxSyncStatus.pending],
|
|
346
|
+
to: MailboxSyncStatus.failed,
|
|
347
|
+
});
|
|
348
|
+
assert.equal(written?.syncStatus, MailboxSyncStatus.failed);
|
|
349
|
+
assert.equal(written?.pendingPath, "New");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("a rename target survives only under pending and failed", async () => {
|
|
353
|
+
// The invariant, over the only writer of either field: a caller that
|
|
354
|
+
// asks for the seventh combination does not get it. `synced` with a
|
|
355
|
+
// rename target on it is what strands a folder whose rename then never
|
|
356
|
+
// runs and is never marked failed.
|
|
357
|
+
const accountId = randomId();
|
|
358
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Trips"));
|
|
359
|
+
|
|
360
|
+
for (const to of EVERY_STATE) {
|
|
361
|
+
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
362
|
+
from: EVERY_STATE,
|
|
363
|
+
to,
|
|
364
|
+
set: { pendingPath: "Holidays" },
|
|
365
|
+
});
|
|
366
|
+
assert.equal(
|
|
367
|
+
written?.pendingPath,
|
|
368
|
+
to === MailboxSyncStatus.pending || to === MailboxSyncStatus.failed
|
|
369
|
+
? "Holidays"
|
|
370
|
+
: undefined,
|
|
371
|
+
`transition to ${to}`,
|
|
372
|
+
);
|
|
373
|
+
assert.deepEqual(
|
|
374
|
+
await repo.get(accountId, mailbox.mailboxId),
|
|
375
|
+
written,
|
|
376
|
+
"what the transition returned is what the row now says",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("two overlapping transitions with the same from: exactly one wins", async () => {
|
|
382
|
+
const accountId = randomId();
|
|
383
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Races"));
|
|
384
|
+
|
|
385
|
+
const outcomes = await Promise.all([
|
|
386
|
+
repo.transition(accountId, mailbox.mailboxId, {
|
|
387
|
+
from: [MailboxSyncStatus.synced],
|
|
388
|
+
to: MailboxSyncStatus.deleting,
|
|
389
|
+
}),
|
|
390
|
+
repo.transition(accountId, mailbox.mailboxId, {
|
|
391
|
+
from: [MailboxSyncStatus.synced],
|
|
392
|
+
to: MailboxSyncStatus.pending,
|
|
393
|
+
}),
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
assert.equal(outcomes.filter((outcome) => outcome !== null).length, 1);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("cross-tenant: a foreign account transitions nothing", async () => {
|
|
400
|
+
const accountId = randomId();
|
|
401
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Private"));
|
|
402
|
+
|
|
403
|
+
assert.equal(
|
|
404
|
+
await repo.transition(randomId(), mailbox.mailboxId, {
|
|
405
|
+
from: EVERY_STATE,
|
|
406
|
+
to: MailboxSyncStatus.deleting,
|
|
407
|
+
}),
|
|
408
|
+
null,
|
|
409
|
+
);
|
|
410
|
+
assert.equal(
|
|
411
|
+
(await repo.get(accountId, mailbox.mailboxId)).syncStatus,
|
|
412
|
+
MailboxSyncStatus.synced,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("transitionSubtree — the intent, all-or-nothing (D6)", () => {
|
|
418
|
+
const seedSubtree = async (accountId: string) => ({
|
|
419
|
+
parent: await repo.create(makeMailboxInput(accountId, "Work")),
|
|
420
|
+
child: await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
421
|
+
grandchild: await repo.create(
|
|
422
|
+
makeMailboxInput(accountId, "Work/Projects/Alpha"),
|
|
423
|
+
),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("transitions the folder and every descendant, each from its own path", async () => {
|
|
427
|
+
const accountId = randomId();
|
|
428
|
+
const { parent, child, grandchild } = await seedSubtree(accountId);
|
|
429
|
+
|
|
430
|
+
const written = await repo.transitionSubtree(
|
|
431
|
+
accountId,
|
|
432
|
+
parent.mailboxId,
|
|
433
|
+
{
|
|
434
|
+
from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
|
|
435
|
+
to: MailboxSyncStatus.pending,
|
|
436
|
+
rowSet: (row) => ({
|
|
437
|
+
pendingPath: row.fullPath.replace("Work", "Archive"),
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
assert.equal(written?.length, 3);
|
|
443
|
+
assert.equal(
|
|
444
|
+
(await repo.get(accountId, parent.mailboxId)).pendingPath,
|
|
445
|
+
"Archive",
|
|
446
|
+
);
|
|
447
|
+
assert.equal(
|
|
448
|
+
(await repo.get(accountId, child.mailboxId)).pendingPath,
|
|
449
|
+
"Archive/Projects",
|
|
450
|
+
);
|
|
451
|
+
assert.equal(
|
|
452
|
+
(await repo.get(accountId, grandchild.mailboxId)).pendingPath,
|
|
453
|
+
"Archive/Projects/Alpha",
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("one descendant outside `from` refuses the intent and writes nothing", async () => {
|
|
458
|
+
const accountId = randomId();
|
|
459
|
+
const { parent, child, grandchild } = await seedSubtree(accountId);
|
|
460
|
+
await repo.transition(accountId, grandchild.mailboxId, {
|
|
461
|
+
from: [MailboxSyncStatus.synced],
|
|
462
|
+
to: MailboxSyncStatus.deleting,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const refused = await repo.transitionSubtree(
|
|
466
|
+
accountId,
|
|
467
|
+
parent.mailboxId,
|
|
468
|
+
{
|
|
469
|
+
from: [MailboxSyncStatus.synced],
|
|
470
|
+
to: MailboxSyncStatus.pending,
|
|
471
|
+
rowSet: (row) => ({ pendingPath: `Moved/${row.fullPath}` }),
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
assert.equal(refused, null);
|
|
476
|
+
for (const row of [parent, child]) {
|
|
477
|
+
const after = await repo.get(accountId, row.mailboxId);
|
|
478
|
+
assert.equal(after.syncStatus, MailboxSyncStatus.synced);
|
|
479
|
+
assert.equal(after.pendingPath, undefined);
|
|
480
|
+
}
|
|
481
|
+
assert.equal(
|
|
482
|
+
(await repo.get(accountId, grandchild.mailboxId)).syncStatus,
|
|
483
|
+
MailboxSyncStatus.deleting,
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("the from-state predicate rides each UPDATE, not a prior read", async () => {
|
|
488
|
+
// A read-then-check-then-write version passes the case above and still
|
|
489
|
+
// misses a row that leaves an accepted state after the read. The
|
|
490
|
+
// predicate is on the UPDATE, so the affected-row count catches it.
|
|
491
|
+
const accountId = randomId();
|
|
492
|
+
const { parent, child } = await seedSubtree(accountId);
|
|
493
|
+
|
|
494
|
+
const refused = await repo.transitionSubtree(
|
|
495
|
+
accountId,
|
|
496
|
+
parent.mailboxId,
|
|
497
|
+
{
|
|
498
|
+
from: [MailboxSyncStatus.synced],
|
|
499
|
+
to: MailboxSyncStatus.pending,
|
|
500
|
+
rowSet: (row) => {
|
|
501
|
+
if (row.mailboxId === parent.mailboxId) {
|
|
502
|
+
// Not a real concurrent writer — the shape of one. The row
|
|
503
|
+
// leaves `synced` after the subtree was resolved.
|
|
504
|
+
db.update(mailboxTable)
|
|
505
|
+
.set({ syncStatus: MailboxSyncStatus.deleting })
|
|
506
|
+
.where(eq(mailboxTable.mailboxId, child.mailboxId))
|
|
507
|
+
.run();
|
|
508
|
+
}
|
|
509
|
+
return { pendingPath: row.fullPath };
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
assert.equal(refused, null);
|
|
515
|
+
assert.equal(
|
|
516
|
+
(await repo.get(accountId, parent.mailboxId)).syncStatus,
|
|
517
|
+
MailboxSyncStatus.synced,
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("an absent folder is a null", async () => {
|
|
522
|
+
assert.equal(
|
|
523
|
+
await repo.transitionSubtree(randomId(), "no-such-mailbox", {
|
|
524
|
+
from: EVERY_STATE,
|
|
525
|
+
to: MailboxSyncStatus.pending,
|
|
526
|
+
rowSet: () => ({}),
|
|
527
|
+
}),
|
|
528
|
+
null,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("deleteMailboxWithMail — the folder's mail goes with it (D8)", () => {
|
|
534
|
+
const seedMessage = async (mailboxId: string, uid: number) => {
|
|
535
|
+
const messageId = randomId();
|
|
536
|
+
await new DrizzleMessageRepository(db as never).create({
|
|
537
|
+
messageId,
|
|
538
|
+
mailboxId,
|
|
539
|
+
uid,
|
|
540
|
+
sequenceNumber: uid,
|
|
541
|
+
rfc822Size: 10,
|
|
542
|
+
internalDate: 1700000000000,
|
|
543
|
+
envelopeId: deriveEnvelopeId(messageId),
|
|
544
|
+
rootBodyPartId: deriveRootBodyPartId(messageId),
|
|
545
|
+
});
|
|
546
|
+
await db.insert(threadMessageTable).values({
|
|
547
|
+
threadMessageId: randomId(),
|
|
548
|
+
accountConfigId: "acct",
|
|
549
|
+
threadId: randomId(),
|
|
550
|
+
messageId,
|
|
551
|
+
mailboxId,
|
|
552
|
+
uid,
|
|
553
|
+
referenceOrder: 0,
|
|
554
|
+
internalDate: 1700000000000,
|
|
555
|
+
sentDate: 1700000000000,
|
|
556
|
+
isRead: false,
|
|
557
|
+
hasAttachment: false,
|
|
558
|
+
hasStars: false,
|
|
559
|
+
isDeleted: false,
|
|
560
|
+
createdAt: 1700000000000,
|
|
561
|
+
updatedAt: 1700000000000,
|
|
562
|
+
});
|
|
563
|
+
return messageId;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const outboxEventsFor = async (messageId: string) =>
|
|
567
|
+
db.select().from(outboxTable).where(eq(outboxTable.messageId, messageId));
|
|
568
|
+
|
|
569
|
+
test("removes the folder, its mail and its own child rows, and nothing else's", async () => {
|
|
570
|
+
const accountId = randomId();
|
|
571
|
+
const doomed = await repo.create(makeMailboxInput(accountId, "Receipts"));
|
|
572
|
+
const spared = await repo.create(makeMailboxInput(accountId, "Keep"));
|
|
573
|
+
const doomedMessage = await seedMessage(doomed.mailboxId, 1);
|
|
574
|
+
const sparedMessage = await seedMessage(spared.mailboxId, 1);
|
|
575
|
+
await db.insert(mailboxSpecialUseTable).values({
|
|
576
|
+
mailboxSpecialUseId: randomId(),
|
|
577
|
+
mailboxId: doomed.mailboxId,
|
|
578
|
+
specialUse: "Archive",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
await repo.deleteMailboxWithMail(accountId, doomed.mailboxId);
|
|
582
|
+
|
|
583
|
+
await assert.rejects(
|
|
584
|
+
() => repo.get(accountId, doomed.mailboxId),
|
|
585
|
+
/Mailbox not found/,
|
|
586
|
+
);
|
|
587
|
+
assert.deepEqual(
|
|
588
|
+
await db
|
|
589
|
+
.select()
|
|
590
|
+
.from(messageTable)
|
|
591
|
+
.where(eq(messageTable.mailboxId, doomed.mailboxId)),
|
|
592
|
+
[],
|
|
593
|
+
);
|
|
594
|
+
assert.deepEqual(
|
|
595
|
+
await db
|
|
596
|
+
.select()
|
|
597
|
+
.from(threadMessageTable)
|
|
598
|
+
.where(eq(threadMessageTable.mailboxId, doomed.mailboxId)),
|
|
599
|
+
[],
|
|
600
|
+
);
|
|
601
|
+
assert.deepEqual(
|
|
602
|
+
await db
|
|
603
|
+
.select()
|
|
604
|
+
.from(mailboxSpecialUseTable)
|
|
605
|
+
.where(eq(mailboxSpecialUseTable.mailboxId, doomed.mailboxId)),
|
|
606
|
+
[],
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
// The search index is cleared by the outbox row, not by the delete.
|
|
610
|
+
const removals = await outboxEventsFor(doomedMessage);
|
|
611
|
+
assert.deepEqual(
|
|
612
|
+
removals.map((row) => row.event),
|
|
613
|
+
["message.removed"],
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const survivors = await db
|
|
617
|
+
.select()
|
|
618
|
+
.from(messageTable)
|
|
619
|
+
.where(eq(messageTable.mailboxId, spared.mailboxId));
|
|
620
|
+
assert.equal(survivors.length, 1);
|
|
621
|
+
assert.equal(
|
|
622
|
+
(await outboxEventsFor(sparedMessage)).some(
|
|
623
|
+
(row) => row.event === "message.removed",
|
|
624
|
+
),
|
|
625
|
+
false,
|
|
626
|
+
);
|
|
627
|
+
assert.equal(
|
|
628
|
+
(await repo.get(accountId, spared.mailboxId)).mailboxId,
|
|
629
|
+
spared.mailboxId,
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("leaves a filter bound to the folder alone", async () => {
|
|
634
|
+
// D16 refuses the delete while a binding stands, so there is nothing to
|
|
635
|
+
// unbind — and deleting a user's filters as a side effect of a folder
|
|
636
|
+
// delete is the outcome the design rules out. This is the test that
|
|
637
|
+
// stops a future refactor doing it.
|
|
638
|
+
const accountId = randomId();
|
|
639
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Bound"));
|
|
640
|
+
const filterId = randomId();
|
|
641
|
+
await db.insert(filterTable).values({
|
|
642
|
+
filterId,
|
|
643
|
+
accountConfigId: accountId,
|
|
644
|
+
name: "Invoices → Bound",
|
|
645
|
+
scope: "Standing",
|
|
646
|
+
ruleChangedAt: 0,
|
|
647
|
+
actionChangedAt: 0,
|
|
648
|
+
actionMailboxId: mailbox.mailboxId,
|
|
649
|
+
createdAt: 0,
|
|
650
|
+
updatedAt: 0,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
|
|
654
|
+
|
|
655
|
+
const rows = await db
|
|
656
|
+
.select()
|
|
657
|
+
.from(filterTable)
|
|
658
|
+
.where(eq(filterTable.filterId, filterId));
|
|
659
|
+
assert.equal(rows.length, 1);
|
|
660
|
+
assert.equal(rows[0].actionMailboxId, mailbox.mailboxId);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("re-running after a partial removal completes", async () => {
|
|
664
|
+
const accountId = randomId();
|
|
665
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Resume"));
|
|
666
|
+
const first = await seedMessage(mailbox.mailboxId, 1);
|
|
667
|
+
await seedMessage(mailbox.mailboxId, 2);
|
|
668
|
+
|
|
669
|
+
// The shape an interruption leaves: one message's rows already gone,
|
|
670
|
+
// the mailbox row still there and still `deleting`.
|
|
671
|
+
await runInTransaction(db, (tx) => deleteMessageSubtree(tx, [first]));
|
|
672
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
673
|
+
from: EVERY_STATE,
|
|
674
|
+
to: MailboxSyncStatus.deleting,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
|
|
678
|
+
|
|
679
|
+
await assert.rejects(
|
|
680
|
+
() => repo.get(accountId, mailbox.mailboxId),
|
|
681
|
+
/Mailbox not found/,
|
|
682
|
+
);
|
|
683
|
+
assert.deepEqual(
|
|
684
|
+
await db
|
|
685
|
+
.select()
|
|
686
|
+
.from(messageTable)
|
|
687
|
+
.where(eq(messageTable.mailboxId, mailbox.mailboxId)),
|
|
688
|
+
[],
|
|
689
|
+
);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("cross-tenant: a foreign account removes neither the row nor its mail", async () => {
|
|
693
|
+
const accountId = randomId();
|
|
694
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Mine"));
|
|
695
|
+
await seedMessage(mailbox.mailboxId, 1);
|
|
696
|
+
|
|
697
|
+
await repo.deleteMailboxWithMail(randomId(), mailbox.mailboxId);
|
|
698
|
+
|
|
699
|
+
assert.equal(
|
|
700
|
+
(await repo.get(accountId, mailbox.mailboxId)).mailboxId,
|
|
701
|
+
mailbox.mailboxId,
|
|
702
|
+
);
|
|
703
|
+
assert.equal(
|
|
704
|
+
(
|
|
705
|
+
await db
|
|
706
|
+
.select()
|
|
707
|
+
.from(messageTable)
|
|
708
|
+
.where(eq(messageTable.mailboxId, mailbox.mailboxId))
|
|
709
|
+
).length,
|
|
710
|
+
1,
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
220
715
|
describe("continuation token rejection (#172)", () => {
|
|
221
716
|
for (const [label, token] of [
|
|
222
717
|
["an unparseable", "not-a-cursor"],
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -3,16 +3,32 @@ import type {
|
|
|
3
3
|
CreateMailboxInput,
|
|
4
4
|
IMailboxRepository,
|
|
5
5
|
MailboxItem,
|
|
6
|
+
MailboxStatePredicate,
|
|
7
|
+
MailboxSubtreeTransitionIntent,
|
|
8
|
+
MailboxTransitionIntent,
|
|
9
|
+
MailboxTransitionWrite,
|
|
6
10
|
ResultList,
|
|
7
11
|
UpdateMailboxInput,
|
|
8
12
|
} from "@remit/data-ports";
|
|
9
|
-
import { MailboxCursorState
|
|
10
|
-
import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
|
|
13
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
14
|
+
import { and, asc, eq, gt, inArray, isNull, or, type SQL } from "drizzle-orm";
|
|
11
15
|
import shortUuid from "short-uuid";
|
|
12
16
|
import type { Db } from "../db.js";
|
|
13
17
|
import { NotFoundError } from "../error.js";
|
|
14
18
|
import { decodeToken, resultList } from "../pagination.js";
|
|
15
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
mailboxAttributeTable,
|
|
21
|
+
mailboxFlagTable,
|
|
22
|
+
mailboxSpecialUseTable,
|
|
23
|
+
mailboxTable,
|
|
24
|
+
} from "../schema/i4-mailbox.js";
|
|
25
|
+
import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
|
|
26
|
+
import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
|
|
27
|
+
import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
|
|
28
|
+
import { messageTable } from "../schema/message-data.js";
|
|
29
|
+
import { threadMessageTable } from "../schema/thread-message.js";
|
|
30
|
+
import { runInTransaction } from "../tx.js";
|
|
31
|
+
import { deleteMessageSubtree } from "./message.js";
|
|
16
32
|
|
|
17
33
|
const base36Translator = shortUuid.createTranslator(
|
|
18
34
|
shortUuid.constants.uuid25Base36,
|
|
@@ -21,6 +37,63 @@ const generateMailboxId = () => base36Translator.fromUUID(randomUUID());
|
|
|
21
37
|
|
|
22
38
|
type DB = Db<Record<string, unknown>>;
|
|
23
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Message subtrees removed per transaction by {@link MailboxRepo.deleteMailboxWithMail},
|
|
42
|
+
* matching `SUBTREE_BATCH_SIZE` in the account purge. On SQLite each batch holds
|
|
43
|
+
* the process's only write slot, so the bound is what keeps a large folder's
|
|
44
|
+
* delete from parking every other writer behind it (D8).
|
|
45
|
+
*/
|
|
46
|
+
const MAIL_DELETE_BATCH_SIZE = 100;
|
|
47
|
+
|
|
48
|
+
/** Refuses a subtree intent from inside the transaction, so the throw is the rollback. */
|
|
49
|
+
class SubtreeContested extends Error {
|
|
50
|
+
constructor() {
|
|
51
|
+
super("mailbox subtree transition contested");
|
|
52
|
+
this.name = "SubtreeContested";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The WHERE terms of a folder-state transition (folder-rename-and-delete.md D3). */
|
|
57
|
+
const stateTerms = (expected: MailboxStatePredicate): SQL[] => {
|
|
58
|
+
const terms: SQL[] = [
|
|
59
|
+
inArray(mailboxTable.syncStatus, [...expected.from]),
|
|
60
|
+
] as SQL[];
|
|
61
|
+
if (expected.wherePendingPath === undefined) return terms;
|
|
62
|
+
terms.push(
|
|
63
|
+
expected.wherePendingPath === null
|
|
64
|
+
? isNull(mailboxTable.pendingPath)
|
|
65
|
+
: eq(mailboxTable.pendingPath, expected.wherePendingPath),
|
|
66
|
+
);
|
|
67
|
+
return terms;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A rename target only means something while a rename is outstanding or has
|
|
72
|
+
* just failed, so the two states that cannot carry one drop it here rather than
|
|
73
|
+
* relying on every caller to remember. That is what makes the invariant — a
|
|
74
|
+
* non-null `pendingPath` only under `pending` or `failed` — hold by
|
|
75
|
+
* construction: this is the only writer of either field, and `synced` with a
|
|
76
|
+
* target on it is the seventh combination the design calls unreachable.
|
|
77
|
+
*/
|
|
78
|
+
const KEEPS_A_RENAME_TARGET: readonly MailboxItem["syncStatus"][] = [
|
|
79
|
+
"pending",
|
|
80
|
+
"failed",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const transitionSet = (
|
|
84
|
+
to: MailboxItem["syncStatus"],
|
|
85
|
+
write: MailboxTransitionWrite | undefined,
|
|
86
|
+
): Partial<typeof mailboxTable.$inferInsert> => ({
|
|
87
|
+
syncStatus: to,
|
|
88
|
+
...(write?.fullPath !== undefined ? { fullPath: write.fullPath } : {}),
|
|
89
|
+
...(KEEPS_A_RENAME_TARGET.includes(to)
|
|
90
|
+
? write?.pendingPath !== undefined
|
|
91
|
+
? { pendingPath: write.pendingPath }
|
|
92
|
+
: {}
|
|
93
|
+
: { pendingPath: null }),
|
|
94
|
+
updatedAt: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
|
|
24
97
|
export function rowToMailbox(
|
|
25
98
|
row: typeof mailboxTable.$inferSelect,
|
|
26
99
|
): MailboxItem {
|
|
@@ -43,9 +116,9 @@ export function rowToMailbox(
|
|
|
43
116
|
lastMessageSyncAt: row.lastMessageSyncAt,
|
|
44
117
|
initialSyncCompletedAt: row.initialSyncCompletedAt ?? undefined,
|
|
45
118
|
parentMailboxId: row.parentMailboxId,
|
|
46
|
-
syncStatus:
|
|
119
|
+
syncStatus: row.syncStatus as MailboxItem["syncStatus"],
|
|
120
|
+
...(row.pendingPath !== null ? { pendingPath: row.pendingPath } : {}),
|
|
47
121
|
cursorState: (row.cursorState as MailboxItem["cursorState"]) ?? undefined,
|
|
48
|
-
oldPath: row.oldPath ?? undefined,
|
|
49
122
|
specialUse: (row.specialUse as MailboxItem["specialUse"]) ?? undefined,
|
|
50
123
|
createdAt: row.createdAt,
|
|
51
124
|
updatedAt: row.updatedAt,
|
|
@@ -78,9 +151,11 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
78
151
|
lastMessageSyncAt: input.lastMessageSyncAt,
|
|
79
152
|
initialSyncCompletedAt: input.initialSyncCompletedAt,
|
|
80
153
|
parentMailboxId: input.parentMailboxId ?? "",
|
|
81
|
-
|
|
154
|
+
// Total per D1: an insert that names no state is a folder the
|
|
155
|
+
// server just told us about, and a folder the server told us
|
|
156
|
+
// about is confirmed.
|
|
157
|
+
syncStatus: input.syncStatus ?? "synced",
|
|
82
158
|
cursorState: input.cursorState ?? MailboxCursorState.normal,
|
|
83
|
-
oldPath: input.oldPath,
|
|
84
159
|
specialUse: input.specialUse ?? null,
|
|
85
160
|
createdAt: now,
|
|
86
161
|
updatedAt: now,
|
|
@@ -161,16 +236,12 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
161
236
|
updates.initialSyncCompletedAt = input.initialSyncCompletedAt;
|
|
162
237
|
if (input.parentMailboxId !== undefined)
|
|
163
238
|
updates.parentMailboxId = input.parentMailboxId;
|
|
164
|
-
if (input.syncStatus !== undefined) updates.syncStatus = input.syncStatus;
|
|
165
239
|
if (input.cursorState !== undefined)
|
|
166
240
|
updates.cursorState = input.cursorState;
|
|
167
|
-
if (input.oldPath !== undefined) updates.oldPath = input.oldPath;
|
|
168
241
|
if (input.specialUse !== undefined) updates.specialUse = input.specialUse;
|
|
169
242
|
|
|
170
243
|
if (remove) {
|
|
171
244
|
for (const field of remove) {
|
|
172
|
-
if (field === "syncStatus") updates.syncStatus = null;
|
|
173
|
-
if (field === "oldPath") updates.oldPath = null;
|
|
174
245
|
if (field === "specialUse") updates.specialUse = null;
|
|
175
246
|
}
|
|
176
247
|
}
|
|
@@ -189,6 +260,81 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
189
260
|
return rowToMailbox(row);
|
|
190
261
|
}
|
|
191
262
|
|
|
263
|
+
async transition(
|
|
264
|
+
accountId: string,
|
|
265
|
+
mailboxId: string,
|
|
266
|
+
intent: MailboxTransitionIntent,
|
|
267
|
+
): Promise<MailboxItem | null> {
|
|
268
|
+
const [row] = await this.db
|
|
269
|
+
.update(mailboxTable)
|
|
270
|
+
.set(transitionSet(intent.to, intent.set))
|
|
271
|
+
.where(
|
|
272
|
+
and(
|
|
273
|
+
eq(mailboxTable.accountId, accountId),
|
|
274
|
+
eq(mailboxTable.mailboxId, mailboxId),
|
|
275
|
+
...stateTerms(intent),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
.returning();
|
|
279
|
+
return row ? rowToMailbox(row) : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async transitionSubtree(
|
|
283
|
+
accountId: string,
|
|
284
|
+
mailboxId: string,
|
|
285
|
+
intent: MailboxSubtreeTransitionIntent,
|
|
286
|
+
): Promise<MailboxItem[] | null> {
|
|
287
|
+
return runInTransaction(this.db, async (tx) => {
|
|
288
|
+
const repo = new MailboxRepo(tx);
|
|
289
|
+
const root = await repo
|
|
290
|
+
.get(accountId, mailboxId)
|
|
291
|
+
.catch((error: unknown) => {
|
|
292
|
+
if (error instanceof NotFoundError) return null;
|
|
293
|
+
throw error;
|
|
294
|
+
});
|
|
295
|
+
if (!root) return null;
|
|
296
|
+
|
|
297
|
+
const subtree = [
|
|
298
|
+
root,
|
|
299
|
+
...(await repo.findByPathPrefix(
|
|
300
|
+
accountId,
|
|
301
|
+
root.fullPath,
|
|
302
|
+
root.hierarchyDelimiter,
|
|
303
|
+
)),
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
const written: MailboxItem[] = [];
|
|
307
|
+
for (const row of subtree) {
|
|
308
|
+
// The from-state predicate rides each UPDATE rather than a read
|
|
309
|
+
// taken before them (D3). Read-then-check-then-write is safe on
|
|
310
|
+
// SQLite only because `runInTransaction` serializes top-level
|
|
311
|
+
// writes; under Postgres READ COMMITTED a single-row transition
|
|
312
|
+
// committing in between is missed entirely.
|
|
313
|
+
const [updated] = await tx
|
|
314
|
+
.update(mailboxTable)
|
|
315
|
+
.set(transitionSet(intent.to, intent.rowSet(row)))
|
|
316
|
+
.where(
|
|
317
|
+
and(
|
|
318
|
+
eq(mailboxTable.accountId, accountId),
|
|
319
|
+
eq(mailboxTable.mailboxId, row.mailboxId),
|
|
320
|
+
inArray(mailboxTable.syncStatus, [...intent.from]),
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
.returning();
|
|
324
|
+
if (updated) written.push(rowToMailbox(updated));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A subtree cannot be half-renamed: one row that moved out from under
|
|
328
|
+
// this call refuses the whole intent, and the throw is what rolls the
|
|
329
|
+
// rest back.
|
|
330
|
+
if (written.length !== subtree.length) throw new SubtreeContested();
|
|
331
|
+
return written;
|
|
332
|
+
}).catch((error: unknown) => {
|
|
333
|
+
if (error instanceof SubtreeContested) return null;
|
|
334
|
+
throw error;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
192
338
|
async resolveAccountId(mailboxId: string): Promise<string | null> {
|
|
193
339
|
const [row] = await this.db
|
|
194
340
|
.select({ accountId: mailboxTable.accountId })
|
|
@@ -335,22 +481,78 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
335
481
|
return rows.map(rowToMailbox);
|
|
336
482
|
}
|
|
337
483
|
|
|
338
|
-
async
|
|
484
|
+
async deleteMailboxWithMail(
|
|
339
485
|
accountId: string,
|
|
340
|
-
|
|
341
|
-
newPath: string,
|
|
342
|
-
delimiter = "/",
|
|
486
|
+
mailboxId: string,
|
|
343
487
|
): Promise<void> {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
488
|
+
// Tenant scope, and the re-entry guard in the same read: a redelivery that
|
|
489
|
+
// arrives after the final commit finds no row and has nothing left to do,
|
|
490
|
+
// exactly as `delete` no-ops. Every removal below keys on `mailboxId`
|
|
491
|
+
// alone, so this is what stops a foreign accountId reaching them.
|
|
492
|
+
const [owned] = await this.db
|
|
493
|
+
.select({ mailboxId: mailboxTable.mailboxId })
|
|
494
|
+
.from(mailboxTable)
|
|
495
|
+
.where(
|
|
496
|
+
and(
|
|
497
|
+
eq(mailboxTable.accountId, accountId),
|
|
498
|
+
eq(mailboxTable.mailboxId, mailboxId),
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
if (!owned) return;
|
|
502
|
+
|
|
503
|
+
// Ordered, batched and resumable rather than one transaction (D8). The
|
|
504
|
+
// caller keeps the row `deleting` until the last commit, so an interrupted
|
|
505
|
+
// run re-enters here and continues against whatever is left.
|
|
506
|
+
for (;;) {
|
|
507
|
+
const rows = await this.db
|
|
508
|
+
.select({ messageId: messageTable.messageId })
|
|
509
|
+
.from(messageTable)
|
|
510
|
+
.where(eq(messageTable.mailboxId, mailboxId))
|
|
511
|
+
.limit(MAIL_DELETE_BATCH_SIZE);
|
|
512
|
+
if (rows.length === 0) break;
|
|
513
|
+
const messageIds = rows.map((row) => row.messageId);
|
|
514
|
+
|
|
515
|
+
await runInTransaction(this.db, async (tx) => {
|
|
516
|
+
// The primitive the rest of the codebase deletes mail with: nine
|
|
517
|
+
// per-message child tables plus one `message.removed` outbox row
|
|
518
|
+
// each, which is what clears the search index. A bespoke table
|
|
519
|
+
// list would orphan those nine and leave deleted mail searchable.
|
|
520
|
+
await deleteMessageSubtree(tx, messageIds);
|
|
521
|
+
await tx
|
|
522
|
+
.delete(threadMessageTable)
|
|
523
|
+
.where(inArray(threadMessageTable.messageId, messageIds));
|
|
353
524
|
});
|
|
354
525
|
}
|
|
526
|
+
|
|
527
|
+
await this.db
|
|
528
|
+
.delete(mailboxSpecialUseTable)
|
|
529
|
+
.where(eq(mailboxSpecialUseTable.mailboxId, mailboxId));
|
|
530
|
+
await this.db
|
|
531
|
+
.delete(mailboxAttributeTable)
|
|
532
|
+
.where(eq(mailboxAttributeTable.mailboxId, mailboxId));
|
|
533
|
+
await this.db
|
|
534
|
+
.delete(mailboxFlagTable)
|
|
535
|
+
.where(eq(mailboxFlagTable.mailboxId, mailboxId));
|
|
536
|
+
await this.db
|
|
537
|
+
.delete(mailboxLockTable)
|
|
538
|
+
.where(eq(mailboxLockTable.mailboxId, mailboxId));
|
|
539
|
+
await this.db
|
|
540
|
+
.delete(messageFlagPushTable)
|
|
541
|
+
.where(eq(messageFlagPushTable.mailboxId, mailboxId));
|
|
542
|
+
await this.db
|
|
543
|
+
.delete(messagePlacementMoveTable)
|
|
544
|
+
.where(
|
|
545
|
+
or(
|
|
546
|
+
eq(messagePlacementMoveTable.sourceMailboxId, mailboxId),
|
|
547
|
+
eq(messagePlacementMoveTable.destinationMailboxId, mailboxId),
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// `filter` also carries a mailboxId and is deliberately not in that list:
|
|
552
|
+
// D16 refuses the delete while any filter or role appointment is bound, so
|
|
553
|
+
// there is nothing to unbind, and deleting a user's filters as a side
|
|
554
|
+
// effect of a folder delete is the outcome the design rules out.
|
|
555
|
+
|
|
556
|
+
await this.delete(accountId, mailboxId);
|
|
355
557
|
}
|
|
356
558
|
}
|
package/src/schema/i4-mailbox.ts
CHANGED
|
@@ -2,3 +2,5 @@ import * as entities from "@remit/drizzle-sqlite-schema";
|
|
|
2
2
|
|
|
3
3
|
export const mailboxTable = entities.mailboxes;
|
|
4
4
|
export const mailboxSpecialUseTable = entities.mailboxSpecialUseEntries;
|
|
5
|
+
export const mailboxAttributeTable = entities.mailboxAttributeEntries;
|
|
6
|
+
export const mailboxFlagTable = entities.mailboxFlags;
|