@remit/drizzle-service 0.0.63 → 0.0.65
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 +221 -72
- package/src/repair/junk-only-address.ts +116 -68
- package/src/repos/i4-account-setting.ts +23 -1
- package/src/repos/i4-address-junk-move.sqlite.test.ts +119 -15
- package/src/repos/i4-address.ts +15 -7
- package/src/repos/i4-mailbox-special-use.ts +161 -1
- package/src/repos/i4-outbox-message.test.ts +46 -0
- package/src/repos/i4-outbox-message.ts +39 -4
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
JUNK_FOLDER_NAMES,
|
|
3
|
-
TRASH_FOLDER_NAMES,
|
|
4
|
-
} from "@remit/data-ports/mailbox-role";
|
|
5
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
1
|
+
import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
|
|
6
2
|
|
|
7
3
|
export interface JunkOnlyRepairClient {
|
|
8
4
|
all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
|
|
@@ -19,6 +15,12 @@ export interface JunkOnlyReport {
|
|
|
19
15
|
readonly restored: number;
|
|
20
16
|
}
|
|
21
17
|
|
|
18
|
+
/** A statement or fragment with the values its `?` placeholders take, in order. */
|
|
19
|
+
export interface BoundSql {
|
|
20
|
+
readonly sql: string;
|
|
21
|
+
readonly params: readonly unknown[];
|
|
22
|
+
}
|
|
23
|
+
|
|
22
24
|
export const JUNK_ONLY_FLAG = "junkOnly";
|
|
23
25
|
|
|
24
26
|
const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
|
|
@@ -26,41 +28,23 @@ const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
|
|
|
26
28
|
const flagIsSet = (name: string): string =>
|
|
27
29
|
`coalesce(json_extract(${STORED_FLAGS}, '$.${name}.value'), 0) = 1`;
|
|
28
30
|
|
|
29
|
-
const
|
|
30
|
-
names.map((name) => `'${name}'`).join(", ");
|
|
31
|
-
|
|
32
|
-
const MAILBOX_LEAF = `lower(substr(
|
|
33
|
-
mailbox.full_path,
|
|
34
|
-
length(rtrim(
|
|
35
|
-
mailbox.full_path,
|
|
36
|
-
replace(mailbox.full_path, mailbox.hierarchy_delimiter, '')
|
|
37
|
-
)) + 1
|
|
38
|
-
))`;
|
|
39
|
-
|
|
40
|
-
const mailboxCarriesRole = (
|
|
41
|
-
specialUse: string,
|
|
42
|
-
names: readonly string[],
|
|
43
|
-
): string => `(
|
|
44
|
-
exists (
|
|
45
|
-
SELECT 1 FROM mailbox_special_use_entry entry
|
|
46
|
-
WHERE entry.mailbox_id = message.mailbox_id
|
|
47
|
-
AND entry.special_use = '${specialUse}'
|
|
48
|
-
)
|
|
49
|
-
OR exists (
|
|
50
|
-
SELECT 1 FROM mailbox
|
|
51
|
-
WHERE mailbox.mailbox_id = message.mailbox_id
|
|
52
|
-
AND (
|
|
53
|
-
mailbox.special_use LIKE '%"${specialUse}"%'
|
|
54
|
-
OR ${MAILBOX_LEAF} IN (${quoted(names)})
|
|
55
|
-
)
|
|
56
|
-
)
|
|
57
|
-
)`;
|
|
31
|
+
const EMPTY: BoundSql = { sql: "", params: [] };
|
|
58
32
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
33
|
+
const MATCHES_NOTHING: BoundSql = { sql: "0 = 1", params: [] };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the message sits in one of these mailboxes. An empty list is
|
|
37
|
+
* false — no mailbox holds the role, so no message is in one. That reading is
|
|
38
|
+
* right for Trash and wrong for Junk, which is why an unresolvable Junk folder
|
|
39
|
+
* is handled before this is ever reached.
|
|
40
|
+
*/
|
|
41
|
+
const inMailboxes = (mailboxIds: readonly string[]): BoundSql =>
|
|
42
|
+
mailboxIds.length === 0
|
|
43
|
+
? { sql: "(0 = 1)", params: [] }
|
|
44
|
+
: {
|
|
45
|
+
sql: `(message.mailbox_id IN (${mailboxIds.map(() => "?").join(", ")}))`,
|
|
46
|
+
params: mailboxIds,
|
|
47
|
+
};
|
|
64
48
|
|
|
65
49
|
const sightingWhere = (extra: string): string => `exists (
|
|
66
50
|
SELECT 1 FROM envelope_address
|
|
@@ -68,47 +52,95 @@ const sightingWhere = (extra: string): string => `exists (
|
|
|
68
52
|
WHERE envelope_address.address_id = address.address_id${extra}
|
|
69
53
|
)`;
|
|
70
54
|
|
|
71
|
-
const
|
|
72
|
-
const SIGHTING_IN_LIVE_MAIL = sightingWhere(
|
|
73
|
-
` AND NOT ${IN_JUNK} AND NOT ${IN_TRASH}`,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
export const ACCOUNT_HAS_CORRESPONDED = `(
|
|
55
|
+
const ACCOUNT_HAS_CORRESPONDED = `(
|
|
77
56
|
address.outbound_count > 0
|
|
78
57
|
OR address.reply_count > 0
|
|
79
58
|
OR ${flagIsSet("vip")}
|
|
80
59
|
OR ${flagIsSet("trusted")}
|
|
81
60
|
)`;
|
|
82
61
|
|
|
83
|
-
|
|
62
|
+
/**
|
|
63
|
+
* No account in scope has a Junk folder, so nothing is known about any
|
|
64
|
+
* sighting: the mark cannot be earned. Silence is not the same as "every
|
|
65
|
+
* message is live mail" — read that way, one move would restore every address
|
|
66
|
+
* the sweep had withheld.
|
|
67
|
+
*/
|
|
68
|
+
const withholdable = (roles: JunkRoleMailboxes): BoundSql => {
|
|
69
|
+
if (roles.junkMailboxIds.length === 0) return MATCHES_NOTHING;
|
|
70
|
+
const inJunk = inMailboxes(roles.junkMailboxIds);
|
|
71
|
+
const inTrash = inMailboxes(roles.trashMailboxIds);
|
|
72
|
+
return {
|
|
73
|
+
sql: `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
|
|
84
74
|
AND NOT ${ACCOUNT_HAS_CORRESPONDED}
|
|
85
|
-
AND ${
|
|
86
|
-
AND NOT ${
|
|
75
|
+
AND ${sightingWhere(` AND ${inJunk.sql}`)}
|
|
76
|
+
AND NOT ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)}`,
|
|
77
|
+
params: [...inJunk.params, ...inJunk.params, ...inTrash.params],
|
|
78
|
+
};
|
|
79
|
+
};
|
|
87
80
|
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The same silence lifts no mark either — "stands on live mail" is
|
|
83
|
+
* unanswerable without knowing which folder is Junk. Standing the account
|
|
84
|
+
* gave the address itself still lifts it: that evidence needs no folder.
|
|
85
|
+
*/
|
|
86
|
+
const restorable = (roles: JunkRoleMailboxes): BoundSql => {
|
|
87
|
+
if (roles.junkMailboxIds.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
sql: `${flagIsSet(JUNK_ONLY_FLAG)}
|
|
90
|
+
AND ${ACCOUNT_HAS_CORRESPONDED}`,
|
|
91
|
+
params: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const inJunk = inMailboxes(roles.junkMailboxIds);
|
|
95
|
+
const inTrash = inMailboxes(roles.trashMailboxIds);
|
|
96
|
+
return {
|
|
97
|
+
sql: `${flagIsSet(JUNK_ONLY_FLAG)}
|
|
98
|
+
AND (${ACCOUNT_HAS_CORRESPONDED}
|
|
99
|
+
OR ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)})`,
|
|
100
|
+
params: [...inJunk.params, ...inTrash.params],
|
|
101
|
+
};
|
|
102
|
+
};
|
|
90
103
|
|
|
91
|
-
export const withholdSql = (
|
|
92
|
-
|
|
104
|
+
export const withholdSql = (
|
|
105
|
+
roles: JunkRoleMailboxes,
|
|
106
|
+
now: number,
|
|
107
|
+
setBy: string,
|
|
108
|
+
scope: BoundSql = EMPTY,
|
|
109
|
+
): BoundSql => {
|
|
110
|
+
const predicate = withholdable(roles);
|
|
111
|
+
return {
|
|
112
|
+
sql: `UPDATE address
|
|
93
113
|
SET flags = json_set(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}',
|
|
94
114
|
json_object('value', json('true'), 'setAt', CAST(? AS INTEGER), 'setBy', ?)),
|
|
95
115
|
updated_at = ?
|
|
96
|
-
WHERE ${
|
|
116
|
+
WHERE ${predicate.sql}${scope.sql}`,
|
|
117
|
+
params: [now, setBy, now, ...predicate.params, ...scope.params],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
97
120
|
|
|
98
|
-
export const restoreSql = (
|
|
99
|
-
|
|
121
|
+
export const restoreSql = (
|
|
122
|
+
roles: JunkRoleMailboxes,
|
|
123
|
+
now: number,
|
|
124
|
+
scope: BoundSql = EMPTY,
|
|
125
|
+
): BoundSql => {
|
|
126
|
+
const predicate = restorable(roles);
|
|
127
|
+
return {
|
|
128
|
+
sql: `UPDATE address
|
|
100
129
|
SET flags = json_remove(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}'), updated_at = ?
|
|
101
|
-
WHERE ${
|
|
130
|
+
WHERE ${predicate.sql}${scope.sql}`,
|
|
131
|
+
params: [now, ...predicate.params, ...scope.params],
|
|
132
|
+
};
|
|
133
|
+
};
|
|
102
134
|
|
|
103
135
|
const REPAIR_SET_BY = "junk-only-repair";
|
|
104
136
|
|
|
105
137
|
const countWhere = async (
|
|
106
138
|
client: JunkOnlyRepairClient,
|
|
107
|
-
predicate:
|
|
139
|
+
predicate: BoundSql,
|
|
108
140
|
): Promise<number> => {
|
|
109
141
|
const [row] = (await client.all(
|
|
110
|
-
`SELECT count(*) AS row_count FROM address WHERE ${predicate}`,
|
|
111
|
-
|
|
142
|
+
`SELECT count(*) AS row_count FROM address WHERE ${predicate.sql}`,
|
|
143
|
+
predicate.params,
|
|
112
144
|
)) as { row_count: number }[];
|
|
113
145
|
return row?.row_count ?? 0;
|
|
114
146
|
};
|
|
@@ -116,23 +148,39 @@ const countWhere = async (
|
|
|
116
148
|
export const sweepJunkOnlyAddresses = async (
|
|
117
149
|
client: JunkOnlyRepairClient,
|
|
118
150
|
mode: JunkOnlyRepairMode,
|
|
151
|
+
roles: JunkRoleMailboxes,
|
|
119
152
|
now: number = Date.now(),
|
|
120
153
|
): Promise<JunkOnlyReport> => {
|
|
121
|
-
const
|
|
122
|
-
const
|
|
154
|
+
const withholdableCount = await countWhere(client, withholdable(roles));
|
|
155
|
+
const restorableCount = await countWhere(client, restorable(roles));
|
|
123
156
|
|
|
124
157
|
if (mode === "check") {
|
|
125
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
mode,
|
|
160
|
+
withholdable: withholdableCount,
|
|
161
|
+
withheld: 0,
|
|
162
|
+
restorable: restorableCount,
|
|
163
|
+
restored: 0,
|
|
164
|
+
};
|
|
126
165
|
}
|
|
127
166
|
|
|
167
|
+
const withhold = withholdSql(roles, now, REPAIR_SET_BY);
|
|
128
168
|
const withheld =
|
|
129
|
-
|
|
169
|
+
withholdableCount === 0
|
|
130
170
|
? 0
|
|
131
|
-
: await client.run(
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
171
|
+
: await client.run(withhold.sql, withhold.params);
|
|
172
|
+
|
|
173
|
+
const restore = restoreSql(roles, now);
|
|
174
|
+
const restored =
|
|
175
|
+
restorableCount === 0 ? 0 : await client.run(restore.sql, restore.params);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
mode,
|
|
179
|
+
withholdable: withholdableCount,
|
|
180
|
+
withheld,
|
|
181
|
+
restorable: restorableCount,
|
|
182
|
+
restored,
|
|
183
|
+
};
|
|
136
184
|
};
|
|
137
185
|
|
|
138
186
|
export const formatJunkOnlyReport = (report: JunkOnlyReport): string[] => {
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
IAccountSettingRepository,
|
|
6
6
|
UpsertAccountSettingInput,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import { eq } from "drizzle-orm";
|
|
8
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
9
9
|
import type { Db } from "../db.js";
|
|
10
10
|
import { accountSettingTable } from "../schema/i4-account-setting.js";
|
|
11
11
|
|
|
@@ -48,6 +48,28 @@ export class AccountSettingRepo implements IAccountSettingRepository {
|
|
|
48
48
|
return row ? rowToAccountSetting(row) : null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The named settings of several configs in one read, for callers that would
|
|
53
|
+
* otherwise `get` once per account. Missing rows are simply absent from the
|
|
54
|
+
* result rather than an error.
|
|
55
|
+
*/
|
|
56
|
+
async getMany(
|
|
57
|
+
accountConfigIds: readonly string[],
|
|
58
|
+
names: readonly string[],
|
|
59
|
+
): Promise<AccountSettingItem[]> {
|
|
60
|
+
if (accountConfigIds.length === 0 || names.length === 0) return [];
|
|
61
|
+
const rows = await this.db
|
|
62
|
+
.select()
|
|
63
|
+
.from(accountSettingTable)
|
|
64
|
+
.where(
|
|
65
|
+
and(
|
|
66
|
+
inArray(accountSettingTable.accountConfigId, [...accountConfigIds]),
|
|
67
|
+
inArray(accountSettingTable.name, [...names]),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
return rows.map(rowToAccountSetting);
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
async listByAccountConfig(
|
|
52
74
|
accountConfigId: string,
|
|
53
75
|
): Promise<AccountSettingItem[]> {
|
|
@@ -3,6 +3,7 @@ import { after, before, beforeEach, describe, test } from "node:test";
|
|
|
3
3
|
import type Database from "better-sqlite3";
|
|
4
4
|
import { createTestDb, type TestDb } from "../test-db.js";
|
|
5
5
|
import { AddressRepo } from "./i4-address.js";
|
|
6
|
+
import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
6
7
|
|
|
7
8
|
const CONFIG = "cfg-1";
|
|
8
9
|
|
|
@@ -11,8 +12,34 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
11
12
|
let sqlite: Database.Database;
|
|
12
13
|
let close: () => Promise<void>;
|
|
13
14
|
let repo: AddressRepo;
|
|
15
|
+
let specialUse: MailboxSpecialUseRepo;
|
|
14
16
|
|
|
15
|
-
const
|
|
17
|
+
const account = (accountId: string): void => {
|
|
18
|
+
sqlite
|
|
19
|
+
.prepare(
|
|
20
|
+
`INSERT OR IGNORE INTO account (
|
|
21
|
+
account_id, account_config_id, username, email, imap_host,
|
|
22
|
+
imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
|
|
23
|
+
connection_state, created_at, updated_at
|
|
24
|
+
) VALUES (?, ?, 'user', 'user@example.com', 'imap.example.com',
|
|
25
|
+
993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
|
|
26
|
+
)
|
|
27
|
+
.run(accountId, CONFIG);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A folder the server designated, written the way mailbox sync writes one:
|
|
32
|
+
* the denormalized column AND the entry row. The paths are deliberately not
|
|
33
|
+
* English — the role has to be read off the designation, not guessed from
|
|
34
|
+
* the name.
|
|
35
|
+
*/
|
|
36
|
+
const mailbox = (
|
|
37
|
+
mailboxId: string,
|
|
38
|
+
designation: string | null,
|
|
39
|
+
folder: { accountId?: string; fullPath?: string } = {},
|
|
40
|
+
): void => {
|
|
41
|
+
const accountId = folder.accountId ?? "acc";
|
|
42
|
+
account(accountId);
|
|
16
43
|
sqlite
|
|
17
44
|
.prepare(
|
|
18
45
|
`INSERT INTO mailbox (
|
|
@@ -21,9 +48,22 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
21
48
|
unseen_count, deleted_count, total_size, last_sync_uid,
|
|
22
49
|
high_water_mark_uid, last_message_sync_at, special_use,
|
|
23
50
|
created_at, updated_at
|
|
24
|
-
) VALUES (?,
|
|
51
|
+
) VALUES (?, ?, '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
|
|
52
|
+
)
|
|
53
|
+
.run(
|
|
54
|
+
mailboxId,
|
|
55
|
+
accountId,
|
|
56
|
+
folder.fullPath ?? mailboxId,
|
|
57
|
+
designation ? JSON.stringify([designation]) : null,
|
|
58
|
+
);
|
|
59
|
+
if (!designation) return;
|
|
60
|
+
sqlite
|
|
61
|
+
.prepare(
|
|
62
|
+
`INSERT INTO mailbox_special_use_entry (
|
|
63
|
+
mailbox_special_use_id, mailbox_id, special_use
|
|
64
|
+
) VALUES (?, ?, ?)`,
|
|
25
65
|
)
|
|
26
|
-
.run(mailboxId
|
|
66
|
+
.run(`${mailboxId}-${designation}`, mailboxId, designation);
|
|
27
67
|
};
|
|
28
68
|
|
|
29
69
|
const message = (messageId: string, mailboxId: string): void => {
|
|
@@ -84,19 +124,38 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
84
124
|
before(async () => {
|
|
85
125
|
({ db, sqlite, close } = await createTestDb());
|
|
86
126
|
repo = new AddressRepo(db as never);
|
|
127
|
+
specialUse = new MailboxSpecialUseRepo(db as never);
|
|
87
128
|
});
|
|
88
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The roles the caller resolves once and hands down, covering every account
|
|
132
|
+
* under the config — which is the scope the predicate reads, because the
|
|
133
|
+
* address book is keyed by config.
|
|
134
|
+
*/
|
|
135
|
+
const reconcile = async (messageId: string): Promise<void> =>
|
|
136
|
+
repo.reconcileJunkOnlyForMessage(
|
|
137
|
+
messageId,
|
|
138
|
+
await specialUse.resolveJunkRolesForConfig(CONFIG),
|
|
139
|
+
);
|
|
140
|
+
|
|
89
141
|
after(async () => {
|
|
90
142
|
await close();
|
|
91
143
|
});
|
|
92
144
|
|
|
93
145
|
beforeEach(() => {
|
|
94
|
-
for (const table of [
|
|
146
|
+
for (const table of [
|
|
147
|
+
"address",
|
|
148
|
+
"envelope_address",
|
|
149
|
+
"message",
|
|
150
|
+
"mailbox",
|
|
151
|
+
"mailbox_special_use_entry",
|
|
152
|
+
"account",
|
|
153
|
+
]) {
|
|
95
154
|
sqlite.exec(`DELETE FROM ${table}`);
|
|
96
155
|
}
|
|
97
|
-
mailbox("inbox", null);
|
|
98
|
-
mailbox("junk",
|
|
99
|
-
mailbox("trash",
|
|
156
|
+
mailbox("inbox", null, { fullPath: "INBOX" });
|
|
157
|
+
mailbox("junk", "Junk", { fullPath: "Ongewenst" });
|
|
158
|
+
mailbox("trash", "Trash", { fullPath: "Prullenbak" });
|
|
100
159
|
});
|
|
101
160
|
|
|
102
161
|
test("a message moved into Junk stops the sender being suggested", async () => {
|
|
@@ -106,7 +165,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
106
165
|
assert.deepEqual(await suggested("spammer"), ["spammer"]);
|
|
107
166
|
|
|
108
167
|
moveTo("msg", "junk");
|
|
109
|
-
await
|
|
168
|
+
await reconcile("msg");
|
|
110
169
|
|
|
111
170
|
assert.equal(await withheld("spammer"), true);
|
|
112
171
|
assert.deepEqual(await suggested("spammer"), []);
|
|
@@ -119,7 +178,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
119
178
|
await repo.incrementOutboundCount(CONFIG, "client", Date.now());
|
|
120
179
|
|
|
121
180
|
moveTo("msg", "junk");
|
|
122
|
-
await
|
|
181
|
+
await reconcile("msg");
|
|
123
182
|
|
|
124
183
|
assert.equal(await withheld("client"), false);
|
|
125
184
|
});
|
|
@@ -132,7 +191,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
132
191
|
sighting("colleague", "real");
|
|
133
192
|
|
|
134
193
|
moveTo("spam", "junk");
|
|
135
|
-
await
|
|
194
|
+
await reconcile("spam");
|
|
136
195
|
|
|
137
196
|
assert.equal(await withheld("colleague"), false);
|
|
138
197
|
});
|
|
@@ -152,7 +211,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
152
211
|
assert.deepEqual(await suggested("misfiled"), []);
|
|
153
212
|
|
|
154
213
|
moveTo("msg", "inbox");
|
|
155
|
-
await
|
|
214
|
+
await reconcile("msg");
|
|
156
215
|
|
|
157
216
|
assert.deepEqual(await suggested("misfiled"), ["misfiled"]);
|
|
158
217
|
});
|
|
@@ -171,7 +230,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
171
230
|
sighting("spammer", "msg");
|
|
172
231
|
|
|
173
232
|
moveTo("msg", "trash");
|
|
174
|
-
await
|
|
233
|
+
await reconcile("msg");
|
|
175
234
|
|
|
176
235
|
assert.equal(await withheld("spammer"), true);
|
|
177
236
|
});
|
|
@@ -183,20 +242,65 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
183
242
|
sighting("bystander", "other");
|
|
184
243
|
|
|
185
244
|
moveTo("msg", "junk");
|
|
186
|
-
await
|
|
245
|
+
await reconcile("msg");
|
|
187
246
|
|
|
188
247
|
assert.equal(await withheld("bystander"), false);
|
|
189
248
|
});
|
|
190
249
|
|
|
250
|
+
test("a sender met only in Junk on two accounts of one config stays withheld", async () => {
|
|
251
|
+
mailbox("junk-b", "Junk", {
|
|
252
|
+
accountId: "acc-b",
|
|
253
|
+
fullPath: "Indésirables",
|
|
254
|
+
});
|
|
255
|
+
message("ma", "junk");
|
|
256
|
+
message("mb", "junk-b");
|
|
257
|
+
await harvest("spammer");
|
|
258
|
+
sighting("spammer", "ma");
|
|
259
|
+
sighting("spammer", "mb");
|
|
260
|
+
|
|
261
|
+
await reconcile("ma");
|
|
262
|
+
assert.equal(await withheld("spammer"), true);
|
|
263
|
+
|
|
264
|
+
await reconcile("mb");
|
|
265
|
+
|
|
266
|
+
assert.equal(await withheld("spammer"), true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The name hints are English only, so an account whose server flags nothing
|
|
271
|
+
* and whose Junk folder is called `Ongewenst` resolves no Junk at all. That
|
|
272
|
+
* silence must lift no mark: reading it as "every sighting is live mail"
|
|
273
|
+
* would hand the spammer back on the next move.
|
|
274
|
+
*/
|
|
275
|
+
test("a move never lifts a mark when no folder holds Junk", async () => {
|
|
276
|
+
sqlite.exec("DELETE FROM mailbox_special_use_entry");
|
|
277
|
+
sqlite.exec("UPDATE mailbox SET special_use = NULL");
|
|
278
|
+
message("msg", "inbox");
|
|
279
|
+
await repo.upsertJunkAddress({
|
|
280
|
+
addressId: "spammer",
|
|
281
|
+
accountConfigId: CONFIG,
|
|
282
|
+
displayName: "Name",
|
|
283
|
+
localPart: "spammer",
|
|
284
|
+
domain: "example.com",
|
|
285
|
+
normalizedEmail: "spammer@example.com",
|
|
286
|
+
normalizedCompound: "name spammer@example.com",
|
|
287
|
+
});
|
|
288
|
+
sighting("spammer", "msg");
|
|
289
|
+
|
|
290
|
+
await reconcile("msg");
|
|
291
|
+
|
|
292
|
+
assert.equal(await withheld("spammer"), true);
|
|
293
|
+
});
|
|
294
|
+
|
|
191
295
|
test("a second reconcile of the same move writes nothing new", async () => {
|
|
192
296
|
message("msg", "inbox");
|
|
193
297
|
await harvest("spammer");
|
|
194
298
|
sighting("spammer", "msg");
|
|
195
299
|
moveTo("msg", "junk");
|
|
196
|
-
await
|
|
300
|
+
await reconcile("msg");
|
|
197
301
|
const first = await repo.getAddress(CONFIG, "spammer");
|
|
198
302
|
|
|
199
|
-
await
|
|
303
|
+
await reconcile("msg");
|
|
200
304
|
|
|
201
305
|
assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
|
|
202
306
|
});
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
UpdateAddressInput,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
12
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
13
|
+
import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
|
|
13
14
|
import { shouldPromoteWellknown } from "@remit/data-ports/wellknown";
|
|
14
15
|
import {
|
|
15
16
|
and,
|
|
@@ -26,6 +27,7 @@ import { NotFoundError } from "../error.js";
|
|
|
26
27
|
import { envelopeAddressId } from "../id.js";
|
|
27
28
|
import { decodeToken, resultList } from "../pagination.js";
|
|
28
29
|
import {
|
|
30
|
+
type BoundSql,
|
|
29
31
|
JUNK_ONLY_FLAG,
|
|
30
32
|
restoreSql,
|
|
31
33
|
withholdSql,
|
|
@@ -331,15 +333,21 @@ export class AddressRepo implements IAddressRepository {
|
|
|
331
333
|
return rowToAddress(row);
|
|
332
334
|
}
|
|
333
335
|
|
|
334
|
-
async reconcileJunkOnlyForMessage(
|
|
335
|
-
|
|
336
|
+
async reconcileJunkOnlyForMessage(
|
|
337
|
+
messageId: string,
|
|
338
|
+
roles: JunkRoleMailboxes,
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const scope: BoundSql = {
|
|
341
|
+
sql: ` AND address.address_id IN (
|
|
336
342
|
SELECT address_id FROM envelope_address WHERE message_id = ?
|
|
337
|
-
)
|
|
343
|
+
)`,
|
|
344
|
+
params: [messageId],
|
|
345
|
+
};
|
|
338
346
|
const now = Date.now();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
342
|
-
await this.db.run(boundToDrizzle(
|
|
347
|
+
const withhold = withholdSql(roles, now, JUNK_MOVE, scope);
|
|
348
|
+
await this.db.run(boundToDrizzle(withhold.sql, withhold.params));
|
|
349
|
+
const restore = restoreSql(roles, now, scope);
|
|
350
|
+
await this.db.run(boundToDrizzle(restore.sql, restore.params));
|
|
343
351
|
}
|
|
344
352
|
|
|
345
353
|
async getAddress(
|