@remit/drizzle-service 0.0.49 → 0.0.51
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/repair/junk-only-address.sqlite.test.ts +535 -0
- package/src/repair/junk-only-address.ts +158 -0
- package/src/repos/address-search-predicates.ts +15 -0
- package/src/repos/i4-address-junk-move.sqlite.test.ts +203 -0
- package/src/repos/i4-address.test.ts +217 -0
- package/src/repos/i4-address.ts +105 -0
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +146 -5
- package/src/repos/i4-mailbox-special-use.ts +111 -69
|
@@ -80,6 +80,21 @@ const flagValue = (name: string): SQL<number> =>
|
|
|
80
80
|
export const addressPreference = (): SQL<number> =>
|
|
81
81
|
sql<number>`(2 * ${flagValue("vip")} + ${flagValue("trusted")})`;
|
|
82
82
|
|
|
83
|
+
const accountHasCorresponded = (): SQL<number> =>
|
|
84
|
+
sql<number>`(${addressTable.outboundCount} + ${addressTable.replyCount}
|
|
85
|
+
+ ${flagValue("vip")} + ${flagValue("trusted")})`;
|
|
86
|
+
|
|
87
|
+
const accountHasFlagged = (): SQL<number> =>
|
|
88
|
+
sql<number>`(${flagValue("blocked")} + ${flagValue("muted")})`;
|
|
89
|
+
|
|
90
|
+
export const addressListable = (term: string | undefined): SQL => {
|
|
91
|
+
const shown = sql`${flagValue("junkOnly")} = 0
|
|
92
|
+
or ${accountHasCorresponded()} > 0
|
|
93
|
+
or ${accountHasFlagged()} > 0`;
|
|
94
|
+
if (!term) return sql`(${shown})`;
|
|
95
|
+
return sql`(${shown} or ${addressTable.normalizedEmail} = ${term.toLowerCase()})`;
|
|
96
|
+
};
|
|
97
|
+
|
|
83
98
|
export const addressCorrespondence = (): SQL<number> =>
|
|
84
99
|
sql<number>`(${addressTable.replyCount} + ${addressTable.inboundCount} + ${addressTable.outboundCount})`;
|
|
85
100
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, beforeEach, describe, test } from "node:test";
|
|
3
|
+
import type Database from "better-sqlite3";
|
|
4
|
+
import { createTestDb, type TestDb } from "../test-db.js";
|
|
5
|
+
import { AddressRepo } from "./i4-address.js";
|
|
6
|
+
|
|
7
|
+
const CONFIG = "cfg-1";
|
|
8
|
+
|
|
9
|
+
describe("reconciling one message's addresses at the moment it moves", () => {
|
|
10
|
+
let db: TestDb;
|
|
11
|
+
let sqlite: Database.Database;
|
|
12
|
+
let close: () => Promise<void>;
|
|
13
|
+
let repo: AddressRepo;
|
|
14
|
+
|
|
15
|
+
const mailbox = (mailboxId: string, specialUse: string | null): void => {
|
|
16
|
+
sqlite
|
|
17
|
+
.prepare(
|
|
18
|
+
`INSERT INTO mailbox (
|
|
19
|
+
mailbox_id, account_id, namespace_prefix, hierarchy_delimiter,
|
|
20
|
+
full_path, uid_validity, uid_next, highest_modseq, message_count,
|
|
21
|
+
unseen_count, deleted_count, total_size, last_sync_uid,
|
|
22
|
+
high_water_mark_uid, last_message_sync_at, special_use,
|
|
23
|
+
created_at, updated_at
|
|
24
|
+
) VALUES (?, 'acc', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
|
|
25
|
+
)
|
|
26
|
+
.run(mailboxId, mailboxId, specialUse);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const message = (messageId: string, mailboxId: string): void => {
|
|
30
|
+
sqlite
|
|
31
|
+
.prepare(
|
|
32
|
+
`INSERT INTO message (
|
|
33
|
+
message_id, mailbox_id, uid, sequence_number, rfc822_size,
|
|
34
|
+
internal_date, envelope_id, root_body_part_id, created_at, updated_at
|
|
35
|
+
) VALUES (?, ?, 1, 1, 10, 0, 'env', 'body', 0, 0)`,
|
|
36
|
+
)
|
|
37
|
+
.run(messageId, mailboxId);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const sighting = (addressId: string, messageId: string): void => {
|
|
41
|
+
sqlite
|
|
42
|
+
.prepare(
|
|
43
|
+
`INSERT INTO envelope_address (
|
|
44
|
+
envelope_address_id, message_id, address_id, display_name,
|
|
45
|
+
normalized_email, address_role, address_order, created_at, updated_at
|
|
46
|
+
) VALUES (?, ?, ?, 'Name', ?, 'From', 0, 0, 0)`,
|
|
47
|
+
)
|
|
48
|
+
.run(
|
|
49
|
+
`${addressId}-${messageId}`,
|
|
50
|
+
messageId,
|
|
51
|
+
addressId,
|
|
52
|
+
`${addressId}@example.com`,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const harvest = async (addressId: string) =>
|
|
57
|
+
repo.upsertCorrespondentAddress({
|
|
58
|
+
addressId,
|
|
59
|
+
accountConfigId: CONFIG,
|
|
60
|
+
displayName: "Name",
|
|
61
|
+
localPart: addressId,
|
|
62
|
+
domain: "example.com",
|
|
63
|
+
normalizedEmail: `${addressId}@example.com`,
|
|
64
|
+
normalizedCompound: `name ${addressId}@example.com`,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const moveTo = (messageId: string, mailboxId: string): void => {
|
|
68
|
+
sqlite
|
|
69
|
+
.prepare("UPDATE message SET mailbox_id = ? WHERE message_id = ?")
|
|
70
|
+
.run(mailboxId, messageId);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const withheld = async (addressId: string): Promise<boolean> =>
|
|
74
|
+
(await repo.getAddress(CONFIG, addressId)).flags?.junkOnly?.value === true;
|
|
75
|
+
|
|
76
|
+
const suggested = async (term: string): Promise<string[]> =>
|
|
77
|
+
(
|
|
78
|
+
await repo.listByAccountConfig({
|
|
79
|
+
accountConfigId: CONFIG,
|
|
80
|
+
search: term,
|
|
81
|
+
})
|
|
82
|
+
).items.map((item) => item.addressId);
|
|
83
|
+
|
|
84
|
+
before(async () => {
|
|
85
|
+
({ db, sqlite, close } = await createTestDb());
|
|
86
|
+
repo = new AddressRepo(db as never);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
after(async () => {
|
|
90
|
+
await close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
for (const table of ["address", "envelope_address", "message", "mailbox"]) {
|
|
95
|
+
sqlite.exec(`DELETE FROM ${table}`);
|
|
96
|
+
}
|
|
97
|
+
mailbox("inbox", null);
|
|
98
|
+
mailbox("junk", '["Junk"]');
|
|
99
|
+
mailbox("trash", '["Trash"]');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a message moved into Junk stops the sender being suggested", async () => {
|
|
103
|
+
message("msg", "inbox");
|
|
104
|
+
await harvest("spammer");
|
|
105
|
+
sighting("spammer", "msg");
|
|
106
|
+
assert.deepEqual(await suggested("spammer"), ["spammer"]);
|
|
107
|
+
|
|
108
|
+
moveTo("msg", "junk");
|
|
109
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
110
|
+
|
|
111
|
+
assert.equal(await withheld("spammer"), true);
|
|
112
|
+
assert.deepEqual(await suggested("spammer"), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a sender the account has written to survives the move", async () => {
|
|
116
|
+
message("msg", "inbox");
|
|
117
|
+
await harvest("client");
|
|
118
|
+
sighting("client", "msg");
|
|
119
|
+
await repo.incrementOutboundCount(CONFIG, "client", Date.now());
|
|
120
|
+
|
|
121
|
+
moveTo("msg", "junk");
|
|
122
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
123
|
+
|
|
124
|
+
assert.equal(await withheld("client"), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a sender still on live mail survives the move", async () => {
|
|
128
|
+
message("spam", "inbox");
|
|
129
|
+
message("real", "inbox");
|
|
130
|
+
await harvest("colleague");
|
|
131
|
+
sighting("colleague", "spam");
|
|
132
|
+
sighting("colleague", "real");
|
|
133
|
+
|
|
134
|
+
moveTo("spam", "junk");
|
|
135
|
+
await repo.reconcileJunkOnlyForMessage("spam");
|
|
136
|
+
|
|
137
|
+
assert.equal(await withheld("colleague"), false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a message rescued out of Junk offers the sender again", async () => {
|
|
141
|
+
message("msg", "junk");
|
|
142
|
+
await repo.upsertJunkAddress({
|
|
143
|
+
addressId: "misfiled",
|
|
144
|
+
accountConfigId: CONFIG,
|
|
145
|
+
displayName: "Name",
|
|
146
|
+
localPart: "misfiled",
|
|
147
|
+
domain: "example.com",
|
|
148
|
+
normalizedEmail: "misfiled@example.com",
|
|
149
|
+
normalizedCompound: "name misfiled@example.com",
|
|
150
|
+
});
|
|
151
|
+
sighting("misfiled", "msg");
|
|
152
|
+
assert.deepEqual(await suggested("misfiled"), []);
|
|
153
|
+
|
|
154
|
+
moveTo("msg", "inbox");
|
|
155
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(await suggested("misfiled"), ["misfiled"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a spam message moved to Trash keeps the sender withheld", async () => {
|
|
161
|
+
message("msg", "junk");
|
|
162
|
+
await repo.upsertJunkAddress({
|
|
163
|
+
addressId: "spammer",
|
|
164
|
+
accountConfigId: CONFIG,
|
|
165
|
+
displayName: "Name",
|
|
166
|
+
localPart: "spammer",
|
|
167
|
+
domain: "example.com",
|
|
168
|
+
normalizedEmail: "spammer@example.com",
|
|
169
|
+
normalizedCompound: "name spammer@example.com",
|
|
170
|
+
});
|
|
171
|
+
sighting("spammer", "msg");
|
|
172
|
+
|
|
173
|
+
moveTo("msg", "trash");
|
|
174
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
175
|
+
|
|
176
|
+
assert.equal(await withheld("spammer"), true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("touches no address the message does not carry", async () => {
|
|
180
|
+
message("msg", "inbox");
|
|
181
|
+
message("other", "junk");
|
|
182
|
+
await harvest("bystander");
|
|
183
|
+
sighting("bystander", "other");
|
|
184
|
+
|
|
185
|
+
moveTo("msg", "junk");
|
|
186
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
187
|
+
|
|
188
|
+
assert.equal(await withheld("bystander"), false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a second reconcile of the same move writes nothing new", async () => {
|
|
192
|
+
message("msg", "inbox");
|
|
193
|
+
await harvest("spammer");
|
|
194
|
+
sighting("spammer", "msg");
|
|
195
|
+
moveTo("msg", "junk");
|
|
196
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
197
|
+
const first = await repo.getAddress(CONFIG, "spammer");
|
|
198
|
+
|
|
199
|
+
await repo.reconcileJunkOnlyForMessage("msg");
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -990,6 +990,223 @@ describe("AddressRepo", () => {
|
|
|
990
990
|
await repo.deleteAddress(configB, b.addressId);
|
|
991
991
|
});
|
|
992
992
|
|
|
993
|
+
describe("addresses met only in Junk (#822)", () => {
|
|
994
|
+
const junkInput = (accountConfigId: string, email: string) => ({
|
|
995
|
+
...makeAddressInput(accountConfigId, email),
|
|
996
|
+
displayName: "Pharma Deals",
|
|
997
|
+
normalizedCompound: `pharma deals ${email}`,
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("a suggestion list never offers a withheld address", async () => {
|
|
1001
|
+
const accountConfigId = randomId();
|
|
1002
|
+
const withheld = await repo.upsertJunkAddress(
|
|
1003
|
+
junkInput(accountConfigId, "sales@pharma.example"),
|
|
1004
|
+
);
|
|
1005
|
+
const ordinary = await repo.upsertCorrespondentAddress(
|
|
1006
|
+
makeAddressInput(accountConfigId, "colleague@pharma.example"),
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
const page = await repo.listByAccountConfig({
|
|
1010
|
+
accountConfigId,
|
|
1011
|
+
search: "pharma",
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
assert.deepEqual(
|
|
1015
|
+
page.items.map((a) => a.addressId),
|
|
1016
|
+
[ordinary.addressId],
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
1020
|
+
withheld.addressId,
|
|
1021
|
+
ordinary.addressId,
|
|
1022
|
+
]);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("an exact address still resolves a withheld row", async () => {
|
|
1026
|
+
const accountConfigId = randomId();
|
|
1027
|
+
const withheld = await repo.upsertJunkAddress(
|
|
1028
|
+
junkInput(accountConfigId, "sales@pharma.example"),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
const page = await repo.listByAccountConfig({
|
|
1032
|
+
accountConfigId,
|
|
1033
|
+
search: "sales@pharma.example",
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
assert.deepEqual(
|
|
1037
|
+
page.items.map((a) => a.addressId),
|
|
1038
|
+
[withheld.addressId],
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
await repo.deleteAddress(accountConfigId, withheld.addressId);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test("a bare listing never offers a withheld address", async () => {
|
|
1045
|
+
const accountConfigId = randomId();
|
|
1046
|
+
const withheld = await repo.upsertJunkAddress(
|
|
1047
|
+
junkInput(accountConfigId, "sales@pharma.example"),
|
|
1048
|
+
);
|
|
1049
|
+
const ordinary = await repo.upsertCorrespondentAddress(
|
|
1050
|
+
makeAddressInput(accountConfigId, "colleague@pharma.example"),
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
const page = await repo.listByAccountConfig({ accountConfigId });
|
|
1054
|
+
|
|
1055
|
+
assert.deepEqual(
|
|
1056
|
+
page.items.map((a) => a.addressId),
|
|
1057
|
+
[ordinary.addressId],
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
1061
|
+
withheld.addressId,
|
|
1062
|
+
ordinary.addressId,
|
|
1063
|
+
]);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test("the row is still there to resolve the message that carried it", async () => {
|
|
1067
|
+
const accountConfigId = randomId();
|
|
1068
|
+
const input = junkInput(accountConfigId, "sales@pharma.example");
|
|
1069
|
+
const withheld = await repo.upsertJunkAddress(input);
|
|
1070
|
+
|
|
1071
|
+
const fetched = await repo.getAddress(accountConfigId, input.addressId);
|
|
1072
|
+
assert.equal(fetched.normalizedEmail, "sales@pharma.example");
|
|
1073
|
+
assert.equal(fetched.flags?.junkOnly?.value, true);
|
|
1074
|
+
|
|
1075
|
+
await repo.deleteAddress(accountConfigId, withheld.addressId);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("one sighting on live mail restores it", async () => {
|
|
1079
|
+
const accountConfigId = randomId();
|
|
1080
|
+
const input = junkInput(accountConfigId, "misfiled@pharma.example");
|
|
1081
|
+
await repo.upsertJunkAddress(input);
|
|
1082
|
+
|
|
1083
|
+
const harvested = await repo.upsertCorrespondentAddress(input);
|
|
1084
|
+
|
|
1085
|
+
assert.equal(harvested.flags?.junkOnly, undefined);
|
|
1086
|
+
const page = await repo.listByAccountConfig({
|
|
1087
|
+
accountConfigId,
|
|
1088
|
+
search: "misfiled",
|
|
1089
|
+
});
|
|
1090
|
+
assert.deepEqual(
|
|
1091
|
+
page.items.map((a) => a.addressId),
|
|
1092
|
+
[input.addressId],
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
await repo.deleteAddress(accountConfigId, input.addressId);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("a sighting in Junk leaves an address the account knows alone", async () => {
|
|
1099
|
+
const accountConfigId = randomId();
|
|
1100
|
+
const known = makeAddressInput(accountConfigId, "friend@pharma.example");
|
|
1101
|
+
await repo.upsertCorrespondentAddress({
|
|
1102
|
+
...known,
|
|
1103
|
+
displayName: "Real Friend",
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const after = await repo.upsertJunkAddress({
|
|
1107
|
+
...known,
|
|
1108
|
+
displayName: "Pharma Deals",
|
|
1109
|
+
normalizedCompound: "pharma deals friend@pharma.example",
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
assert.equal(after.displayName, "Real Friend");
|
|
1113
|
+
assert.equal(after.flags?.junkOnly, undefined);
|
|
1114
|
+
|
|
1115
|
+
await repo.deleteAddress(accountConfigId, known.addressId);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test("a sighting in Trash decides nothing either way", async () => {
|
|
1119
|
+
const accountConfigId = randomId();
|
|
1120
|
+
const input = junkInput(accountConfigId, "discarded@pharma.example");
|
|
1121
|
+
await repo.upsertJunkAddress(input);
|
|
1122
|
+
|
|
1123
|
+
const after = await repo.upsertAddress(input);
|
|
1124
|
+
|
|
1125
|
+
assert.equal(after.flags?.junkOnly?.value, true);
|
|
1126
|
+
|
|
1127
|
+
await repo.deleteAddress(accountConfigId, input.addressId);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test("a sender the account blocked stays findable, and marked", async () => {
|
|
1131
|
+
const accountConfigId = randomId();
|
|
1132
|
+
const input = junkInput(accountConfigId, "reported@pharma.example");
|
|
1133
|
+
await repo.upsertJunkAddress(input);
|
|
1134
|
+
await repo.mergeFlags(accountConfigId, input.addressId, {
|
|
1135
|
+
blocked: { value: true, setAt: 1 },
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
const page = await repo.listByAccountConfig({
|
|
1139
|
+
accountConfigId,
|
|
1140
|
+
search: "pharma",
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
assert.deepEqual(
|
|
1144
|
+
page.items.map((a) => a.addressId),
|
|
1145
|
+
[input.addressId],
|
|
1146
|
+
);
|
|
1147
|
+
assert.equal(page.items[0].flags?.junkOnly?.value, true);
|
|
1148
|
+
|
|
1149
|
+
await repo.deleteAddress(accountConfigId, input.addressId);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
test("the account's own mail to a sender offers them again", async () => {
|
|
1153
|
+
const accountConfigId = randomId();
|
|
1154
|
+
const input = junkInput(accountConfigId, "supplier@pharma.example");
|
|
1155
|
+
await repo.upsertJunkAddress(input);
|
|
1156
|
+
await repo.incrementOutboundCount(accountConfigId, input.addressId, 1);
|
|
1157
|
+
|
|
1158
|
+
const page = await repo.listByAccountConfig({
|
|
1159
|
+
accountConfigId,
|
|
1160
|
+
search: "pharma",
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
assert.deepEqual(
|
|
1164
|
+
page.items.map((a) => a.addressId),
|
|
1165
|
+
[input.addressId],
|
|
1166
|
+
);
|
|
1167
|
+
|
|
1168
|
+
await repo.deleteAddress(accountConfigId, input.addressId);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("clearing the mark by hand offers the address again", async () => {
|
|
1172
|
+
const accountConfigId = randomId();
|
|
1173
|
+
const input = junkInput(accountConfigId, "rescued@pharma.example");
|
|
1174
|
+
await repo.upsertJunkAddress(input);
|
|
1175
|
+
|
|
1176
|
+
await repo.mergeFlags(accountConfigId, input.addressId, {
|
|
1177
|
+
junkOnly: null,
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
const page = await repo.listByAccountConfig({
|
|
1181
|
+
accountConfigId,
|
|
1182
|
+
search: "pharma",
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
assert.deepEqual(
|
|
1186
|
+
page.items.map((a) => a.addressId),
|
|
1187
|
+
[input.addressId],
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
await repo.deleteAddress(accountConfigId, input.addressId);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test("clearing the mark keeps the rest of the flags", async () => {
|
|
1194
|
+
const accountConfigId = randomId();
|
|
1195
|
+
const input = junkInput(accountConfigId, "noisy@pharma.example");
|
|
1196
|
+
await repo.upsertJunkAddress(input);
|
|
1197
|
+
await repo.mergeFlags(accountConfigId, input.addressId, {
|
|
1198
|
+
muted: { value: true, setAt: 7 },
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
const harvested = await repo.upsertCorrespondentAddress(input);
|
|
1202
|
+
|
|
1203
|
+
assert.equal(harvested.flags?.junkOnly, undefined);
|
|
1204
|
+
assert.equal(harvested.flags?.muted?.value, true);
|
|
1205
|
+
|
|
1206
|
+
await repo.deleteAddress(accountConfigId, input.addressId);
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
|
|
993
1210
|
describe("continuation token rejection (#172)", () => {
|
|
994
1211
|
for (const [label, token] of [
|
|
995
1212
|
["an unparseable", "not-a-cursor"],
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -24,10 +24,16 @@ import type { Db } from "../db.js";
|
|
|
24
24
|
import { NotFoundError } from "../error.js";
|
|
25
25
|
import { envelopeAddressId as deriveEnvelopeAddressId } from "../id.js";
|
|
26
26
|
import { decodeToken, resultList } from "../pagination.js";
|
|
27
|
+
import {
|
|
28
|
+
JUNK_ONLY_FLAG,
|
|
29
|
+
restoreSql,
|
|
30
|
+
withholdSql,
|
|
31
|
+
} from "../repair/junk-only-address.js";
|
|
27
32
|
import { addressTable } from "../schema/i4-address.js";
|
|
28
33
|
import { envelopeAddressTable } from "../schema/message-data.js";
|
|
29
34
|
import {
|
|
30
35
|
addressCorrespondence,
|
|
36
|
+
addressListable,
|
|
31
37
|
addressMatchRank,
|
|
32
38
|
addressPreference,
|
|
33
39
|
addressRecency,
|
|
@@ -151,6 +157,23 @@ function rowToEnvelopeAddress(
|
|
|
151
157
|
|
|
152
158
|
const VIP_SUGGESTIONS_DEFAULT_LIMIT = 10;
|
|
153
159
|
|
|
160
|
+
const JUNK_HARVEST = "junk-harvest";
|
|
161
|
+
const JUNK_MOVE = "junk-move";
|
|
162
|
+
|
|
163
|
+
const withoutJunkOnlyFlagSql = (): SQL<string> =>
|
|
164
|
+
sql<string>`json_remove(coalesce(nullif(${addressTable.flags}, ''), '{}'), ${`$.${JUNK_ONLY_FLAG}`})`;
|
|
165
|
+
|
|
166
|
+
const boundToDrizzle = (query: string, params: readonly unknown[]): SQL => {
|
|
167
|
+
const chunks = query.split("?");
|
|
168
|
+
const head = sql.raw(chunks[0]);
|
|
169
|
+
return chunks
|
|
170
|
+
.slice(1)
|
|
171
|
+
.reduce(
|
|
172
|
+
(acc, chunk, index) => sql`${acc}${params[index]}${sql.raw(chunk)}`,
|
|
173
|
+
sql`${head}`,
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
154
177
|
export class AddressRepo implements IAddressRepository {
|
|
155
178
|
constructor(private db: DB) {}
|
|
156
179
|
|
|
@@ -220,6 +243,87 @@ export class AddressRepo implements IAddressRepository {
|
|
|
220
243
|
return rowToAddress(row);
|
|
221
244
|
}
|
|
222
245
|
|
|
246
|
+
async upsertCorrespondentAddress(
|
|
247
|
+
input: CreateAddressInput,
|
|
248
|
+
): Promise<AddressItem> {
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
const [row] = await this.db
|
|
251
|
+
.insert(addressTable)
|
|
252
|
+
.values({
|
|
253
|
+
addressId: input.addressId,
|
|
254
|
+
accountConfigId: input.accountConfigId,
|
|
255
|
+
displayName: input.displayName,
|
|
256
|
+
localPart: input.localPart,
|
|
257
|
+
domain: input.domain,
|
|
258
|
+
normalizedEmail: input.normalizedEmail,
|
|
259
|
+
normalizedCompound: input.normalizedCompound,
|
|
260
|
+
flags: input.flags ?? {},
|
|
261
|
+
inboundCount: input.inboundCount ?? 0,
|
|
262
|
+
outboundCount: input.outboundCount ?? 0,
|
|
263
|
+
replyCount: input.replyCount ?? 0,
|
|
264
|
+
lastInboundAt: input.lastInboundAt ?? 0,
|
|
265
|
+
lastOutboundAt: input.lastOutboundAt,
|
|
266
|
+
lastReplyAt: input.lastReplyAt ?? 0,
|
|
267
|
+
createdAt: now,
|
|
268
|
+
updatedAt: now,
|
|
269
|
+
})
|
|
270
|
+
.onConflictDoUpdate({
|
|
271
|
+
target: addressTable.addressId,
|
|
272
|
+
set: input.displayName
|
|
273
|
+
? {
|
|
274
|
+
displayName: input.displayName,
|
|
275
|
+
normalizedCompound: input.normalizedCompound,
|
|
276
|
+
flags: withoutJunkOnlyFlagSql(),
|
|
277
|
+
updatedAt: now,
|
|
278
|
+
}
|
|
279
|
+
: { flags: withoutJunkOnlyFlagSql(), updatedAt: now },
|
|
280
|
+
})
|
|
281
|
+
.returning();
|
|
282
|
+
return rowToAddress(row);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async upsertJunkAddress(input: CreateAddressInput): Promise<AddressItem> {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const [row] = await this.db
|
|
288
|
+
.insert(addressTable)
|
|
289
|
+
.values({
|
|
290
|
+
addressId: input.addressId,
|
|
291
|
+
accountConfigId: input.accountConfigId,
|
|
292
|
+
displayName: input.displayName,
|
|
293
|
+
localPart: input.localPart,
|
|
294
|
+
domain: input.domain,
|
|
295
|
+
normalizedEmail: input.normalizedEmail,
|
|
296
|
+
normalizedCompound: input.normalizedCompound,
|
|
297
|
+
flags: {
|
|
298
|
+
...(input.flags ?? {}),
|
|
299
|
+
[JUNK_ONLY_FLAG]: { value: true, setAt: now, setBy: JUNK_HARVEST },
|
|
300
|
+
},
|
|
301
|
+
inboundCount: input.inboundCount ?? 0,
|
|
302
|
+
outboundCount: input.outboundCount ?? 0,
|
|
303
|
+
replyCount: input.replyCount ?? 0,
|
|
304
|
+
lastInboundAt: input.lastInboundAt ?? 0,
|
|
305
|
+
lastOutboundAt: input.lastOutboundAt,
|
|
306
|
+
lastReplyAt: input.lastReplyAt ?? 0,
|
|
307
|
+
createdAt: now,
|
|
308
|
+
updatedAt: now,
|
|
309
|
+
})
|
|
310
|
+
.onConflictDoNothing()
|
|
311
|
+
.returning();
|
|
312
|
+
if (!row) return this.getAddress(input.accountConfigId, input.addressId);
|
|
313
|
+
return rowToAddress(row);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async reconcileJunkOnlyForMessage(messageId: string): Promise<void> {
|
|
317
|
+
const scope = ` AND address.address_id IN (
|
|
318
|
+
SELECT address_id FROM envelope_address WHERE message_id = ?
|
|
319
|
+
)`;
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
await this.db.run(
|
|
322
|
+
boundToDrizzle(withholdSql(scope), [now, JUNK_MOVE, now, messageId]),
|
|
323
|
+
);
|
|
324
|
+
await this.db.run(boundToDrizzle(restoreSql(scope), [now, messageId]));
|
|
325
|
+
}
|
|
326
|
+
|
|
223
327
|
async getAddress(
|
|
224
328
|
accountConfigId: string,
|
|
225
329
|
addressId: string,
|
|
@@ -573,6 +677,7 @@ export class AddressRepo implements IAddressRepository {
|
|
|
573
677
|
and(
|
|
574
678
|
eq(addressTable.accountConfigId, accountConfigId),
|
|
575
679
|
search ? addressSearchMatch(search) : undefined,
|
|
680
|
+
addressListable(search),
|
|
576
681
|
position ? after(order, position) : undefined,
|
|
577
682
|
),
|
|
578
683
|
)
|