@remit/mailbox-service 0.0.32 → 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #302 (RFC 039 Decision 3): `Address.flags.unsubscribed` is
|
|
3
|
+
* documented as "auto-mark-read until sender stops" and settable from
|
|
4
|
+
* `IntelligencePane.tsx`, but nothing consumed it — every message from an
|
|
5
|
+
* unsubscribed sender still arrived unread like any other. These tests drive
|
|
6
|
+
* `BodySyncService` end-to-end (read-path body materialization →
|
|
7
|
+
* `applyPostStoreSteps`) and assert on the actual `FlagQueueService.markAsRead`
|
|
8
|
+
* call, so a regression that drops the flag read — not just a helper in
|
|
9
|
+
* isolation — shows up here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import type {
|
|
15
|
+
AddressItem,
|
|
16
|
+
IAddressRepository,
|
|
17
|
+
IEnvelopeRepository,
|
|
18
|
+
IMessageRepository,
|
|
19
|
+
IThreadMessageRepository,
|
|
20
|
+
} from "@remit/data-ports";
|
|
21
|
+
import type { StorageService } from "@remit/storage-service";
|
|
22
|
+
import { BodySyncService } from "./body-sync.js";
|
|
23
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
24
|
+
import type { IImapConnection } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const PLAIN_EML = (fromEmail: string) =>
|
|
27
|
+
Buffer.from(
|
|
28
|
+
[
|
|
29
|
+
`From: Sender <${fromEmail}>`,
|
|
30
|
+
"To: me@example.com",
|
|
31
|
+
"Subject: Hello",
|
|
32
|
+
"Content-Type: text/plain",
|
|
33
|
+
"",
|
|
34
|
+
"body",
|
|
35
|
+
].join("\r\n"),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
interface MarkReadCall {
|
|
39
|
+
accountConfigId: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
accountId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Harness {
|
|
45
|
+
service: BodySyncService;
|
|
46
|
+
markReadCalls: MarkReadCall[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const buildHarness = (
|
|
50
|
+
flags: AddressItem["flags"],
|
|
51
|
+
withUnsubscribeConfig = true,
|
|
52
|
+
): Harness => {
|
|
53
|
+
const markReadCalls: MarkReadCall[] = [];
|
|
54
|
+
|
|
55
|
+
const messageService = {
|
|
56
|
+
get: async () => ({
|
|
57
|
+
messageId: "m-1",
|
|
58
|
+
mailboxId: "mb-inbox",
|
|
59
|
+
uid: 1,
|
|
60
|
+
}),
|
|
61
|
+
update: async () => {},
|
|
62
|
+
} as unknown as IMessageRepository;
|
|
63
|
+
|
|
64
|
+
const threadMessageService = {
|
|
65
|
+
findAllByMessageId: async () => [
|
|
66
|
+
{
|
|
67
|
+
threadMessageId: "tm-1",
|
|
68
|
+
sentDate: 1,
|
|
69
|
+
mailboxId: "mb-inbox",
|
|
70
|
+
isRead: false,
|
|
71
|
+
isDeleted: false,
|
|
72
|
+
hasStars: false,
|
|
73
|
+
hasAttachment: false,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
update: async () => {},
|
|
77
|
+
} as unknown as IThreadMessageRepository;
|
|
78
|
+
|
|
79
|
+
const storageService = {
|
|
80
|
+
storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
|
|
81
|
+
storeParsedBody: async () => {},
|
|
82
|
+
listBodyParts: async () => [],
|
|
83
|
+
} as unknown as StorageService;
|
|
84
|
+
|
|
85
|
+
const addressService = {
|
|
86
|
+
getAddress: async () => ({ flags }) as unknown as AddressItem,
|
|
87
|
+
incrementInboundCount: async () => {},
|
|
88
|
+
} as unknown as IAddressRepository;
|
|
89
|
+
|
|
90
|
+
const envelopeService = {
|
|
91
|
+
listBodyParts: async () => [],
|
|
92
|
+
} as unknown as IEnvelopeRepository;
|
|
93
|
+
|
|
94
|
+
const flagQueueService = {
|
|
95
|
+
markAsRead: async (
|
|
96
|
+
accountConfigId: string,
|
|
97
|
+
messageId: string,
|
|
98
|
+
accountId: string,
|
|
99
|
+
) => {
|
|
100
|
+
markReadCalls.push({ accountConfigId, messageId, accountId });
|
|
101
|
+
},
|
|
102
|
+
} as unknown as FlagQueueService;
|
|
103
|
+
|
|
104
|
+
const service = new BodySyncService(
|
|
105
|
+
messageService,
|
|
106
|
+
storageService,
|
|
107
|
+
threadMessageService,
|
|
108
|
+
addressService,
|
|
109
|
+
envelopeService,
|
|
110
|
+
{ info: () => {}, error: () => {} },
|
|
111
|
+
undefined,
|
|
112
|
+
undefined,
|
|
113
|
+
undefined,
|
|
114
|
+
withUnsubscribeConfig ? { flagQueueService } : undefined,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return { service, markReadCalls };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const readBody = async (
|
|
121
|
+
service: BodySyncService,
|
|
122
|
+
fromEmail = "someone@example.com",
|
|
123
|
+
) => {
|
|
124
|
+
const connection = {
|
|
125
|
+
openBox: async () => {},
|
|
126
|
+
fetchMessageBody: async () => PLAIN_EML(fromEmail),
|
|
127
|
+
} as unknown as IImapConnection;
|
|
128
|
+
return service.fetchAndGetBody(
|
|
129
|
+
"m-1",
|
|
130
|
+
"acc-1",
|
|
131
|
+
"cfg-1",
|
|
132
|
+
"INBOX",
|
|
133
|
+
async () => connection,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("Address.flags.unsubscribed drives auto-mark-read (issue #302)", () => {
|
|
138
|
+
it("marks a message from an unsubscribed sender as read at sync time", async () => {
|
|
139
|
+
const harness = buildHarness({ unsubscribed: { value: true, setAt: 1 } });
|
|
140
|
+
|
|
141
|
+
await readBody(harness.service);
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(harness.markReadCalls, [
|
|
144
|
+
{ accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("leaves read state alone for a sender without the flag", async () => {
|
|
149
|
+
const harness = buildHarness({});
|
|
150
|
+
|
|
151
|
+
await readBody(harness.service);
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("leaves read state alone once the flag is unset (no caching of the decision)", async () => {
|
|
157
|
+
const harness = buildHarness({
|
|
158
|
+
unsubscribed: { value: false, setAt: 1 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await readBody(harness.service);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("is a no-op when body sync was built without an UnsubscribeConfig", async () => {
|
|
167
|
+
const harness = buildHarness(
|
|
168
|
+
{ unsubscribed: { value: true, setAt: 1 } },
|
|
169
|
+
false,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await readBody(harness.service);
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type FilterDecision,
|
|
38
38
|
FilterPipeline,
|
|
39
39
|
} from "./filters/pipeline.js";
|
|
40
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
40
41
|
import {
|
|
41
42
|
classifyByHeaders,
|
|
42
43
|
extractAuthenticity,
|
|
@@ -257,6 +258,17 @@ export interface QuarantineConfig {
|
|
|
257
258
|
attempts: number;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* What body sync needs to auto-mark-read a message from an unsubscribed
|
|
263
|
+
* sender (issue #302, RFC 039 Decision 3). Reuses the same
|
|
264
|
+
* `FlagQueueService.markAsRead` a manual mark-as-read already goes through —
|
|
265
|
+
* local `\Seen` + `ThreadMessage.isRead` + a durable pending IMAP flag-push
|
|
266
|
+
* marker — so this fires the same round-trip, not a second primitive.
|
|
267
|
+
*/
|
|
268
|
+
export interface UnsubscribeConfig {
|
|
269
|
+
flagQueueService: FlagQueueService;
|
|
270
|
+
}
|
|
271
|
+
|
|
260
272
|
export class BodySyncService {
|
|
261
273
|
private log: BodySyncLogger;
|
|
262
274
|
private readonly filterPipeline?: FilterPipeline;
|
|
@@ -271,6 +283,7 @@ export class BodySyncService {
|
|
|
271
283
|
private readonly placementConfig?: PlacementConfig,
|
|
272
284
|
private readonly filterConfig?: FilterConfig,
|
|
273
285
|
private readonly quarantineConfig?: QuarantineConfig,
|
|
286
|
+
private readonly unsubscribeConfig?: UnsubscribeConfig,
|
|
274
287
|
) {
|
|
275
288
|
this.log = logger ?? noopLogger;
|
|
276
289
|
this.filterPipeline = filterConfig
|
|
@@ -813,6 +826,18 @@ export class BodySyncService {
|
|
|
813
826
|
});
|
|
814
827
|
}
|
|
815
828
|
|
|
829
|
+
// `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
|
|
830
|
+
// reusing the same FlagQueueService.markAsRead round-trip a manual
|
|
831
|
+
// mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
|
|
832
|
+
// the message is already \Seen), so a failure here safely re-fires on the
|
|
833
|
+
// next attempt rather than being lost behind the bodyStorageKey skip guard.
|
|
834
|
+
await this.applyUnsubscribedAutoRead(
|
|
835
|
+
messageId,
|
|
836
|
+
accountId,
|
|
837
|
+
accountConfigId,
|
|
838
|
+
parsed,
|
|
839
|
+
);
|
|
840
|
+
|
|
816
841
|
const moved = Boolean(resolved.move || filterMoved);
|
|
817
842
|
|
|
818
843
|
// ONE Message UpdateItem per synced message: bodyStorageKey + every
|
|
@@ -1136,6 +1161,59 @@ export class BodySyncService {
|
|
|
1136
1161
|
return unknown;
|
|
1137
1162
|
}
|
|
1138
1163
|
|
|
1164
|
+
/**
|
|
1165
|
+
* `flags.unsubscribed` (issue #302, RFC 039 Decision 3): "auto-mark-read
|
|
1166
|
+
* until sender stops" — fires on every new message from that sender for as
|
|
1167
|
+
* long as the flag stays set, per the flag's own doc comment; there is no
|
|
1168
|
+
* separate expiry mechanism, and no caching of the decision beyond this
|
|
1169
|
+
* per-message `Address` read. A no-op when body sync was built without an
|
|
1170
|
+
* {@link UnsubscribeConfig} or the message carries no `From` address.
|
|
1171
|
+
*/
|
|
1172
|
+
private async applyUnsubscribedAutoRead(
|
|
1173
|
+
messageId: string,
|
|
1174
|
+
accountId: string,
|
|
1175
|
+
accountConfigId: string,
|
|
1176
|
+
parsed: ParsedMail,
|
|
1177
|
+
): Promise<void> {
|
|
1178
|
+
if (!this.unsubscribeConfig) return;
|
|
1179
|
+
|
|
1180
|
+
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1181
|
+
if (!fromEmail) return;
|
|
1182
|
+
|
|
1183
|
+
const unsubscribed = await this.deriveSenderUnsubscribed(
|
|
1184
|
+
accountConfigId,
|
|
1185
|
+
fromEmail,
|
|
1186
|
+
);
|
|
1187
|
+
if (!unsubscribed) return;
|
|
1188
|
+
|
|
1189
|
+
await this.unsubscribeConfig.flagQueueService.markAsRead(
|
|
1190
|
+
accountConfigId,
|
|
1191
|
+
messageId,
|
|
1192
|
+
accountId,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
private async deriveSenderUnsubscribed(
|
|
1197
|
+
accountConfigId: string,
|
|
1198
|
+
fromEmail: string,
|
|
1199
|
+
): Promise<boolean> {
|
|
1200
|
+
try {
|
|
1201
|
+
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1202
|
+
const address = await this.addressService.getAddress(
|
|
1203
|
+
accountConfigId,
|
|
1204
|
+
addressId,
|
|
1205
|
+
);
|
|
1206
|
+
return address.flags?.unsubscribed?.value === true;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
// A genuinely-absent address means "not unsubscribed". Any other
|
|
1209
|
+
// failure (AccessDenied, throttle, infra) must NOT be silently
|
|
1210
|
+
// downgraded — let it crash so the read-state decision isn't made on
|
|
1211
|
+
// bad data.
|
|
1212
|
+
if (!(err instanceof NotFoundError)) throw err;
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1139
1217
|
/**
|
|
1140
1218
|
* Evaluate the account's active filters against a synced message (RFC 034),
|
|
1141
1219
|
* BEFORE the single Message update — so a filter's `movedByRemit` flag joins
|