@remit/data-ports 0.0.53 → 0.0.55
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/conformance/calendar-suggestion.ts +293 -0
- package/src/conformance/index.ts +1 -0
- package/src/id.test.ts +78 -3
- package/src/id.ts +40 -2
- package/src/index.ts +11 -0
- package/src/interfaces/calendar-suggestion.ts +73 -0
- package/src/interfaces/calendar-unit-of-work.ts +8 -0
- package/src/sender-mute.test.ts +76 -0
- package/src/sender-mute.ts +59 -0
- package/src/types.ts +24 -0
package/package.json
CHANGED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
CalendarInviteMethod,
|
|
5
|
+
CalendarSuggestionSource,
|
|
6
|
+
CalendarSuggestionState,
|
|
7
|
+
ZoneCertainty,
|
|
8
|
+
} from "@remit/domain-enums";
|
|
9
|
+
import { deriveCalendarSuggestionId } from "../id.js";
|
|
10
|
+
import type { ICalendarSuggestionRepository } from "../interfaces/calendar-suggestion.js";
|
|
11
|
+
import type { PutCalendarSuggestionInput } from "../types.js";
|
|
12
|
+
import type { RepositoryConformanceHarness } from "./harness.js";
|
|
13
|
+
|
|
14
|
+
const suggestion = (
|
|
15
|
+
accountConfigId: string,
|
|
16
|
+
messageId: string,
|
|
17
|
+
bodyPartId: string,
|
|
18
|
+
icalUid: string,
|
|
19
|
+
overrides: Partial<PutCalendarSuggestionInput> = {},
|
|
20
|
+
): PutCalendarSuggestionInput => ({
|
|
21
|
+
accountConfigId,
|
|
22
|
+
messageId,
|
|
23
|
+
bodyPartId,
|
|
24
|
+
icalUid,
|
|
25
|
+
sequence: 0,
|
|
26
|
+
method: CalendarInviteMethod.Request,
|
|
27
|
+
source: CalendarSuggestionSource.IcalendarPart,
|
|
28
|
+
summary: "Design review",
|
|
29
|
+
dtStart: "2026-09-01T10:00:00+02:00",
|
|
30
|
+
dtEnd: "2026-09-01T11:00:00+02:00",
|
|
31
|
+
allDay: false,
|
|
32
|
+
location: "",
|
|
33
|
+
organizer: "organizer@example.test",
|
|
34
|
+
zoneCertainty: ZoneCertainty.Explicit,
|
|
35
|
+
icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export function calendarSuggestionRepositoryConformance(
|
|
40
|
+
harness: RepositoryConformanceHarness<ICalendarSuggestionRepository>,
|
|
41
|
+
): void {
|
|
42
|
+
describe("ICalendarSuggestionRepository conformance", () => {
|
|
43
|
+
let repo: ICalendarSuggestionRepository;
|
|
44
|
+
|
|
45
|
+
before(async () => {
|
|
46
|
+
repo = await harness.createRepository();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
after(() => harness.teardown());
|
|
50
|
+
|
|
51
|
+
test("put writes a Pending suggestion under its derived id", async () => {
|
|
52
|
+
const accountConfigId = harness.makeId();
|
|
53
|
+
const messageId = harness.makeId();
|
|
54
|
+
const bodyPartId = harness.makeId();
|
|
55
|
+
|
|
56
|
+
const written = await repo.put(
|
|
57
|
+
suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
assert.equal(
|
|
61
|
+
written.suggestionId,
|
|
62
|
+
deriveCalendarSuggestionId(messageId, bodyPartId, "uid-1"),
|
|
63
|
+
);
|
|
64
|
+
assert.equal(written.state, CalendarSuggestionState.Pending);
|
|
65
|
+
assert.equal(written.acceptedCalendarObjectId, "");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("put re-reading the same message converges on one row", async () => {
|
|
69
|
+
const accountConfigId = harness.makeId();
|
|
70
|
+
const messageId = harness.makeId();
|
|
71
|
+
const bodyPartId = harness.makeId();
|
|
72
|
+
|
|
73
|
+
await repo.put(
|
|
74
|
+
suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
|
|
75
|
+
);
|
|
76
|
+
await repo.put(
|
|
77
|
+
suggestion(accountConfigId, messageId, bodyPartId, "uid-1", {
|
|
78
|
+
summary: "Design review, moved",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const rows = await repo.listByMessage(accountConfigId, messageId);
|
|
83
|
+
assert.equal(rows.length, 1);
|
|
84
|
+
assert.equal(rows[0]?.summary, "Design review, moved");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("put leaves an answered suggestion answered", async () => {
|
|
88
|
+
// A re-sync must never walk a decision back to Pending: the user
|
|
89
|
+
// already added this event to a calendar, and a producer write is not
|
|
90
|
+
// allowed to un-decide that.
|
|
91
|
+
const accountConfigId = harness.makeId();
|
|
92
|
+
const messageId = harness.makeId();
|
|
93
|
+
const bodyPartId = harness.makeId();
|
|
94
|
+
|
|
95
|
+
const written = await repo.put(
|
|
96
|
+
suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
|
|
97
|
+
);
|
|
98
|
+
await repo.settle(accountConfigId, written.suggestionId, {
|
|
99
|
+
state: CalendarSuggestionState.Accepted,
|
|
100
|
+
acceptedCalendarObjectId: "cal-object-1",
|
|
101
|
+
});
|
|
102
|
+
await repo.put(
|
|
103
|
+
suggestion(accountConfigId, messageId, bodyPartId, "uid-1"),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const reread = await repo.get(accountConfigId, written.suggestionId);
|
|
107
|
+
assert.equal(reread.state, CalendarSuggestionState.Accepted);
|
|
108
|
+
assert.equal(reread.acceptedCalendarObjectId, "cal-object-1");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("get rejects a suggestion that is not there", async () => {
|
|
112
|
+
const accountConfigId = harness.makeId();
|
|
113
|
+
await assert.rejects(
|
|
114
|
+
() => repo.get(accountConfigId, harness.makeId()),
|
|
115
|
+
(error: unknown) => harness.isNotFoundError(error),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("get scopes to the account config", async () => {
|
|
120
|
+
const accountConfigId = harness.makeId();
|
|
121
|
+
const written = await repo.put(
|
|
122
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => repo.get(harness.makeId(), written.suggestionId),
|
|
127
|
+
(error: unknown) => harness.isNotFoundError(error),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("listByMessage returns every suggestion one message produced", async () => {
|
|
132
|
+
const accountConfigId = harness.makeId();
|
|
133
|
+
const messageId = harness.makeId();
|
|
134
|
+
const other = harness.makeId();
|
|
135
|
+
|
|
136
|
+
await repo.put(
|
|
137
|
+
suggestion(accountConfigId, messageId, harness.makeId(), "uid-a"),
|
|
138
|
+
);
|
|
139
|
+
await repo.put(
|
|
140
|
+
suggestion(accountConfigId, messageId, harness.makeId(), "uid-b"),
|
|
141
|
+
);
|
|
142
|
+
await repo.put(
|
|
143
|
+
suggestion(accountConfigId, other, harness.makeId(), "uid-c"),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const rows = await repo.listByMessage(accountConfigId, messageId);
|
|
147
|
+
assert.deepEqual(rows.map((row) => row.icalUid).sort(), [
|
|
148
|
+
"uid-a",
|
|
149
|
+
"uid-b",
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("listByState returns only the state asked for", async () => {
|
|
154
|
+
const accountConfigId = harness.makeId();
|
|
155
|
+
const pending = await repo.put(
|
|
156
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "p"),
|
|
157
|
+
);
|
|
158
|
+
const declined = await repo.put(
|
|
159
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "d"),
|
|
160
|
+
);
|
|
161
|
+
await repo.settle(accountConfigId, declined.suggestionId, {
|
|
162
|
+
state: CalendarSuggestionState.Declined,
|
|
163
|
+
acceptedCalendarObjectId: "",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const page = await repo.listByState(
|
|
167
|
+
accountConfigId,
|
|
168
|
+
CalendarSuggestionState.Pending,
|
|
169
|
+
);
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
page.items.map((row) => row.suggestionId),
|
|
172
|
+
[pending.suggestionId],
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("listByState scopes to the account config", async () => {
|
|
177
|
+
const accountConfigId = harness.makeId();
|
|
178
|
+
await repo.put(
|
|
179
|
+
suggestion(harness.makeId(), harness.makeId(), harness.makeId(), "x"),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const page = await repo.listByState(
|
|
183
|
+
accountConfigId,
|
|
184
|
+
CalendarSuggestionState.Pending,
|
|
185
|
+
);
|
|
186
|
+
assert.equal(page.items.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("listByState pages, and the token resumes where the page stopped", async () => {
|
|
190
|
+
const accountConfigId = harness.makeId();
|
|
191
|
+
for (const uid of ["a", "b", "c"]) {
|
|
192
|
+
await repo.put(
|
|
193
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), uid),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const first = await repo.listByState(
|
|
198
|
+
accountConfigId,
|
|
199
|
+
CalendarSuggestionState.Pending,
|
|
200
|
+
{ limit: 2 },
|
|
201
|
+
);
|
|
202
|
+
assert.equal(first.items.length, 2);
|
|
203
|
+
assert.ok(first.continuationToken);
|
|
204
|
+
|
|
205
|
+
const second = await repo.listByState(
|
|
206
|
+
accountConfigId,
|
|
207
|
+
CalendarSuggestionState.Pending,
|
|
208
|
+
{ limit: 2, continuationToken: first.continuationToken },
|
|
209
|
+
);
|
|
210
|
+
const seen = [...first.items, ...second.items].map(
|
|
211
|
+
(row) => row.suggestionId,
|
|
212
|
+
);
|
|
213
|
+
assert.equal(new Set(seen).size, 3);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("settle records the accepted resource", async () => {
|
|
217
|
+
const accountConfigId = harness.makeId();
|
|
218
|
+
const written = await repo.put(
|
|
219
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const settled = await repo.settle(accountConfigId, written.suggestionId, {
|
|
223
|
+
state: CalendarSuggestionState.Accepted,
|
|
224
|
+
acceptedCalendarObjectId: "cal-object-9",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
assert.equal(settled.state, CalendarSuggestionState.Accepted);
|
|
228
|
+
assert.equal(settled.acceptedCalendarObjectId, "cal-object-9");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("supersedeIfPending retires a card still waiting on the user", async () => {
|
|
232
|
+
const accountConfigId = harness.makeId();
|
|
233
|
+
const written = await repo.put(
|
|
234
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const retired = await repo.supersedeIfPending(
|
|
238
|
+
accountConfigId,
|
|
239
|
+
written.suggestionId,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
assert.equal(retired?.state, CalendarSuggestionState.Superseded);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("supersedeIfPending leaves an answered card alone and says so", async () => {
|
|
246
|
+
// The producer reads the pending set and writes to it in two steps. A
|
|
247
|
+
// person accepting in between must keep their acceptance — and the
|
|
248
|
+
// link to the event it put in their calendar.
|
|
249
|
+
const accountConfigId = harness.makeId();
|
|
250
|
+
const written = await repo.put(
|
|
251
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
|
|
252
|
+
);
|
|
253
|
+
await repo.settle(accountConfigId, written.suggestionId, {
|
|
254
|
+
state: CalendarSuggestionState.Accepted,
|
|
255
|
+
acceptedCalendarObjectId: "cal-object-7",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const retired = await repo.supersedeIfPending(
|
|
259
|
+
accountConfigId,
|
|
260
|
+
written.suggestionId,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
assert.equal(retired, null);
|
|
264
|
+
const reread = await repo.get(accountConfigId, written.suggestionId);
|
|
265
|
+
assert.equal(reread.state, CalendarSuggestionState.Accepted);
|
|
266
|
+
assert.equal(reread.acceptedCalendarObjectId, "cal-object-7");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("supersedeIfPending scopes to the account config", async () => {
|
|
270
|
+
const accountConfigId = harness.makeId();
|
|
271
|
+
const written = await repo.put(
|
|
272
|
+
suggestion(accountConfigId, harness.makeId(), harness.makeId(), "uid"),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
assert.equal(
|
|
276
|
+
await repo.supersedeIfPending(harness.makeId(), written.suggestionId),
|
|
277
|
+
null,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("settle rejects a suggestion that is not there", async () => {
|
|
282
|
+
const accountConfigId = harness.makeId();
|
|
283
|
+
await assert.rejects(
|
|
284
|
+
() =>
|
|
285
|
+
repo.settle(accountConfigId, harness.makeId(), {
|
|
286
|
+
state: CalendarSuggestionState.Declined,
|
|
287
|
+
acceptedCalendarObjectId: "",
|
|
288
|
+
}),
|
|
289
|
+
(error: unknown) => harness.isNotFoundError(error),
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
package/src/conformance/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { calendarCollectionRepositoryConformance } from "./calendar-collection.js";
|
|
2
2
|
export { calendarEventIndexRepositoryConformance } from "./calendar-event-index.js";
|
|
3
3
|
export { calendarObjectRepositoryConformance } from "./calendar-object.js";
|
|
4
|
+
export { calendarSuggestionRepositoryConformance } from "./calendar-suggestion.js";
|
|
4
5
|
export type { RepositoryConformanceHarness } from "./harness.js";
|
|
5
6
|
export { labelRepositoryConformance } from "./label.js";
|
|
6
7
|
export { senderSignerStandingRepositoryConformance } from "./sender-signer-standing.js";
|
package/src/id.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
deriveBodyPartId,
|
|
7
7
|
deriveCalendarId,
|
|
8
8
|
deriveCalendarObjectId,
|
|
9
|
+
deriveCalendarSuggestionId,
|
|
9
10
|
deriveCopyMessageId,
|
|
10
11
|
deriveEnvelopeId,
|
|
11
12
|
deriveMessageId,
|
|
@@ -161,8 +162,71 @@ describe("deriveCalendarObjectId", () => {
|
|
|
161
162
|
});
|
|
162
163
|
});
|
|
163
164
|
|
|
165
|
+
describe("deriveCalendarSuggestionId", () => {
|
|
166
|
+
const MESSAGE = "msg-1";
|
|
167
|
+
const PART = "part-1";
|
|
168
|
+
const UID = "invite@example.com";
|
|
169
|
+
|
|
170
|
+
it("converges a re-read of the same message on one row", () => {
|
|
171
|
+
assert.equal(
|
|
172
|
+
deriveCalendarSuggestionId(MESSAGE, PART, UID),
|
|
173
|
+
deriveCalendarSuggestionId(MESSAGE, PART, UID),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("keeps two invitations in one message apart", () => {
|
|
178
|
+
assert.notEqual(
|
|
179
|
+
deriveCalendarSuggestionId(MESSAGE, PART, UID),
|
|
180
|
+
deriveCalendarSuggestionId(MESSAGE, "part-2", UID),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("gives a revision its own row, so the superseded card survives", () => {
|
|
185
|
+
// A later message carrying the same UID is a second suggestion, never an
|
|
186
|
+
// overwrite of the first: the older message keeps its card and the user
|
|
187
|
+
// can see which revision replaced which.
|
|
188
|
+
assert.notEqual(
|
|
189
|
+
deriveCalendarSuggestionId(MESSAGE, PART, UID),
|
|
190
|
+
deriveCalendarSuggestionId("msg-2", PART, UID),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("separates two events sent as one part", () => {
|
|
195
|
+
assert.notEqual(
|
|
196
|
+
deriveCalendarSuggestionId(MESSAGE, PART, UID),
|
|
197
|
+
deriveCalendarSuggestionId(MESSAGE, PART, "other@example.com"),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("deriveThreadId", () => {
|
|
203
|
+
const ROOT = "<root@example.com>";
|
|
204
|
+
|
|
205
|
+
// #1017: the account is not part of the name, so every mailbox connected to
|
|
206
|
+
// one config resolves the same root header to one conversation. Keyed on the
|
|
207
|
+
// account, a reply sent from the second account opened a thread of its own.
|
|
208
|
+
it("gives one root header one thread across the accounts of a config", () => {
|
|
209
|
+
assert.equal(deriveThreadId("cfg-1", ROOT), deriveThreadId("cfg-1", ROOT));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("separates two account configs holding the same conversation", () => {
|
|
213
|
+
assert.notEqual(
|
|
214
|
+
deriveThreadId("cfg-1", ROOT),
|
|
215
|
+
deriveThreadId("cfg-2", ROOT),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("separates two conversations under one config", () => {
|
|
220
|
+
assert.notEqual(
|
|
221
|
+
deriveThreadId("cfg-1", ROOT),
|
|
222
|
+
deriveThreadId("cfg-1", "<other@example.com>"),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
164
227
|
describe("derived ids are stored primary keys: a changed value orphans every row already written and needs a migration, never a new expectation here", () => {
|
|
165
228
|
const ACCOUNT = "account-golden";
|
|
229
|
+
const ACCOUNT_CONFIG = "account-config-golden";
|
|
166
230
|
const MESSAGE = "message-golden";
|
|
167
231
|
const MAILBOX = "mailbox-golden";
|
|
168
232
|
const ABSENT_HEADER_SOURCE = {
|
|
@@ -221,10 +285,10 @@ describe("derived ids are stored primary keys: a changed value orphans every row
|
|
|
221
285
|
);
|
|
222
286
|
});
|
|
223
287
|
|
|
224
|
-
it("deriveThreadId pins the thread primary key, lowercased root header included", () => {
|
|
288
|
+
it("deriveThreadId pins the thread primary key, keyed on the account config, lowercased root header included", () => {
|
|
225
289
|
assert.equal(
|
|
226
|
-
deriveThreadId(
|
|
227
|
-
"
|
|
290
|
+
deriveThreadId(ACCOUNT_CONFIG, "<Golden.Root@Example.COM>"),
|
|
291
|
+
"3tl03sgy85b60rfb15u6vtiex",
|
|
228
292
|
);
|
|
229
293
|
});
|
|
230
294
|
|
|
@@ -268,6 +332,17 @@ describe("derived ids are stored primary keys: a changed value orphans every row
|
|
|
268
332
|
);
|
|
269
333
|
});
|
|
270
334
|
|
|
335
|
+
it("deriveCalendarSuggestionId pins the calendar_suggestion primary key", () => {
|
|
336
|
+
assert.equal(
|
|
337
|
+
deriveCalendarSuggestionId(
|
|
338
|
+
MESSAGE,
|
|
339
|
+
"bodypart-golden",
|
|
340
|
+
"golden-invite@example.com",
|
|
341
|
+
),
|
|
342
|
+
"1pesdr3fl02uyoz5k1qrfik3e",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
271
346
|
it("quarantineMessageIdHash pins the stored Message-ID hash that correlates reports", () => {
|
|
272
347
|
assert.equal(
|
|
273
348
|
quarantineMessageIdHash("<golden@example.com>"),
|
package/src/id.ts
CHANGED
|
@@ -64,6 +64,34 @@ export const deriveCalendarObjectId = (
|
|
|
64
64
|
): string =>
|
|
65
65
|
base36uuidv5(`calendarobject:${calendarId}:${resourceName}`, REMIT_NAMESPACE);
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Identity of a suggestion read out of a message (issue #1033). Derived from
|
|
69
|
+
* the message, the MIME part the bytes came from and the event's iCalendar
|
|
70
|
+
* UID, so re-reading the same message converges on the row it already wrote
|
|
71
|
+
* instead of stacking a second card on the message every pass.
|
|
72
|
+
*
|
|
73
|
+
* All three parts are needed. The message alone would merge two invitations
|
|
74
|
+
* sent in one mail; the part alone is not stable enough to key on across a
|
|
75
|
+
* re-parse; and the UID keeps two events sent as one part apart.
|
|
76
|
+
*
|
|
77
|
+
* The message is in the seed, so identity is per message, not per event: a
|
|
78
|
+
* later message carrying the same UID derives its own id, and the earlier
|
|
79
|
+
* suggestion survives beside it as `Superseded`. The user can see which
|
|
80
|
+
* revision they are being asked about, and the older message still has a card.
|
|
81
|
+
* The same holds for a plain resend — a redelivered copy is a new message and
|
|
82
|
+
* so a new card, which the SEQUENCE comparison then declines to supersede
|
|
83
|
+
* anything for.
|
|
84
|
+
*/
|
|
85
|
+
export const deriveCalendarSuggestionId = (
|
|
86
|
+
messageId: string,
|
|
87
|
+
bodyPartId: string,
|
|
88
|
+
icalUid: string,
|
|
89
|
+
): string =>
|
|
90
|
+
base36uuidv5(
|
|
91
|
+
`calendarsuggestion:${messageId}:${bodyPartId}:${icalUid}`,
|
|
92
|
+
REMIT_NAMESPACE,
|
|
93
|
+
);
|
|
94
|
+
|
|
67
95
|
export const isValidMessageId = (messageId: string | undefined): boolean => {
|
|
68
96
|
if (!messageId) return false;
|
|
69
97
|
const trimmed = messageId.trim();
|
|
@@ -122,12 +150,22 @@ export const deriveCopyMessageId = (
|
|
|
122
150
|
REMIT_NAMESPACE,
|
|
123
151
|
);
|
|
124
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Identity of a conversation (issue #1017). Keyed on the account config, not on
|
|
155
|
+
* the account: two mailboxes connected to one config hold the same conversation,
|
|
156
|
+
* and keying on the account gave each of them its own thread rooted at the same
|
|
157
|
+
* header — so a reply sent from the second account opened a conversation of one
|
|
158
|
+
* beside the one it answered.
|
|
159
|
+
*
|
|
160
|
+
* Messages stay per account (`deriveMessageId`). Each account keeps its own copy
|
|
161
|
+
* of a mail, and the copies meet in one thread through the shared root header.
|
|
162
|
+
*/
|
|
125
163
|
export const deriveThreadId = (
|
|
126
|
-
|
|
164
|
+
accountConfigId: string,
|
|
127
165
|
rootMessageIdHeader: string,
|
|
128
166
|
): string =>
|
|
129
167
|
base36uuidv5(
|
|
130
|
-
`thread:${
|
|
168
|
+
`thread:${accountConfigId}:${rootMessageIdHeader.toLowerCase()}`,
|
|
131
169
|
REMIT_NAMESPACE,
|
|
132
170
|
);
|
|
133
171
|
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,10 @@ export type { IAddressRepository } from "./interfaces/address.js";
|
|
|
7
7
|
export type { ICalendarCollectionRepository } from "./interfaces/calendar-collection.js";
|
|
8
8
|
export type { ICalendarEventIndexRepository } from "./interfaces/calendar-event-index.js";
|
|
9
9
|
export type { ICalendarObjectRepository } from "./interfaces/calendar-object.js";
|
|
10
|
+
export type {
|
|
11
|
+
ICalendarSuggestionRepository,
|
|
12
|
+
SettleCalendarSuggestionInput,
|
|
13
|
+
} from "./interfaces/calendar-suggestion.js";
|
|
10
14
|
export type {
|
|
11
15
|
CalendarUnitOfWorkRepositories,
|
|
12
16
|
ICalendarUnitOfWork,
|
|
@@ -46,6 +50,11 @@ export type {
|
|
|
46
50
|
IUnitOfWork,
|
|
47
51
|
UnitOfWorkRepositories,
|
|
48
52
|
} from "./interfaces/unit-of-work.js";
|
|
53
|
+
export {
|
|
54
|
+
FILTER_NO_ACTION,
|
|
55
|
+
isSenderMuted,
|
|
56
|
+
isSenderMuteFilter,
|
|
57
|
+
} from "./sender-mute.js";
|
|
49
58
|
export type {
|
|
50
59
|
AccountConfigDescription,
|
|
51
60
|
AccountConfigItem,
|
|
@@ -68,6 +77,7 @@ export type {
|
|
|
68
77
|
CalendarEventIndexItem,
|
|
69
78
|
CalendarObjectItem,
|
|
70
79
|
CalendarOccurrenceInput,
|
|
80
|
+
CalendarSuggestionItem,
|
|
71
81
|
ConfigImportItem,
|
|
72
82
|
ConfigImportUnresolvedRefItem,
|
|
73
83
|
CreateAccountConfigInput,
|
|
@@ -113,6 +123,7 @@ export type {
|
|
|
113
123
|
OutboxAttachmentItem,
|
|
114
124
|
OutboxMessageItem,
|
|
115
125
|
PutCalendarObjectInput,
|
|
126
|
+
PutCalendarSuggestionInput,
|
|
116
127
|
PutMessageFlagPushInput,
|
|
117
128
|
PutMessagePlacementMoveInput,
|
|
118
129
|
QuarantineItem,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarSuggestionItem,
|
|
3
|
+
PutCalendarSuggestionInput,
|
|
4
|
+
ResultList,
|
|
5
|
+
} from "../types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The two fields that record what a person decided about a suggestion. They
|
|
9
|
+
* move together and only through {@link ICalendarSuggestionRepository.settle},
|
|
10
|
+
* so no producer write can walk an answered card back to `Pending`, and no
|
|
11
|
+
* state can name a calendar object that was never written.
|
|
12
|
+
*
|
|
13
|
+
* `acceptedCalendarObjectId` is `""` for every outcome but `Accepted` — the
|
|
14
|
+
* named absent state, never a missing attribute.
|
|
15
|
+
*/
|
|
16
|
+
export interface SettleCalendarSuggestionInput {
|
|
17
|
+
state: CalendarSuggestionItem["state"];
|
|
18
|
+
acceptedCalendarObjectId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ICalendarSuggestionRepository {
|
|
22
|
+
/**
|
|
23
|
+
* Writes the suggestion under its derived `suggestionId`. An upsert: the
|
|
24
|
+
* producer re-reading the same message rewrites the row it already wrote.
|
|
25
|
+
*
|
|
26
|
+
* Never touches `state` or `acceptedCalendarObjectId` on a row that already
|
|
27
|
+
* exists, so a re-sync of a message whose invitation the user accepted
|
|
28
|
+
* leaves the acceptance intact. A row it creates starts `Pending`.
|
|
29
|
+
*/
|
|
30
|
+
put(input: PutCalendarSuggestionInput): Promise<CalendarSuggestionItem>;
|
|
31
|
+
get(
|
|
32
|
+
accountConfigId: string,
|
|
33
|
+
suggestionId: string,
|
|
34
|
+
): Promise<CalendarSuggestionItem>;
|
|
35
|
+
/** Every suggestion one message produced, superseded revisions included. */
|
|
36
|
+
listByMessage(
|
|
37
|
+
accountConfigId: string,
|
|
38
|
+
messageId: string,
|
|
39
|
+
): Promise<CalendarSuggestionItem[]>;
|
|
40
|
+
/**
|
|
41
|
+
* The account's suggestions in one state, newest first. Also how the
|
|
42
|
+
* producer finds the revision a new message supersedes: only a `Pending`
|
|
43
|
+
* suggestion can be superseded, so the candidate set is this one.
|
|
44
|
+
*/
|
|
45
|
+
listByState(
|
|
46
|
+
accountConfigId: string,
|
|
47
|
+
state: CalendarSuggestionItem["state"],
|
|
48
|
+
options?: { limit?: number; continuationToken?: string },
|
|
49
|
+
): Promise<ResultList<CalendarSuggestionItem>>;
|
|
50
|
+
/**
|
|
51
|
+
* Records the outcome of a person's decision. Returns the settled row.
|
|
52
|
+
*/
|
|
53
|
+
settle(
|
|
54
|
+
accountConfigId: string,
|
|
55
|
+
suggestionId: string,
|
|
56
|
+
input: SettleCalendarSuggestionInput,
|
|
57
|
+
): Promise<CalendarSuggestionItem>;
|
|
58
|
+
/**
|
|
59
|
+
* Retires a card as `Superseded`, but only while it is still `Pending`.
|
|
60
|
+
* Returns the retired row, or `null` when the card had already been
|
|
61
|
+
* answered and nothing was written.
|
|
62
|
+
*
|
|
63
|
+
* A compare-and-set rather than a plain `settle` because the producer reads
|
|
64
|
+
* the pending set and writes to it in two steps, and a person can accept a
|
|
65
|
+
* card in between. An unconditional write there would overwrite their
|
|
66
|
+
* acceptance with `Superseded` and blank `acceptedCalendarObjectId`,
|
|
67
|
+
* severing the card from the event it put in their calendar.
|
|
68
|
+
*/
|
|
69
|
+
supersedeIfPending(
|
|
70
|
+
accountConfigId: string,
|
|
71
|
+
suggestionId: string,
|
|
72
|
+
): Promise<CalendarSuggestionItem | null>;
|
|
73
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ICalendarCollectionRepository } from "./calendar-collection.js";
|
|
2
2
|
import type { ICalendarEventIndexRepository } from "./calendar-event-index.js";
|
|
3
3
|
import type { ICalendarObjectRepository } from "./calendar-object.js";
|
|
4
|
+
import type { ICalendarSuggestionRepository } from "./calendar-suggestion.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The repositories one calendar write set touches, bound to a single unit of
|
|
@@ -10,6 +11,13 @@ export interface CalendarUnitOfWorkRepositories {
|
|
|
10
11
|
calendarCollection: ICalendarCollectionRepository;
|
|
11
12
|
calendarObject: ICalendarObjectRepository;
|
|
12
13
|
calendarEventIndex: ICalendarEventIndexRepository;
|
|
14
|
+
/**
|
|
15
|
+
* Present so accepting a suggestion settles the card in the same unit that
|
|
16
|
+
* writes the resource (issue #1033). The two are one fact: a card still
|
|
17
|
+
* asking with the event already in the calendar, or a card marked accepted
|
|
18
|
+
* with no event behind it, is a state nothing later can repair.
|
|
19
|
+
*/
|
|
20
|
+
calendarSuggestion: ICalendarSuggestionRepository;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isSenderMuted, isSenderMuteFilter } from "./sender-mute.js";
|
|
4
|
+
import type { FilterItem } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const rule = (overrides: Partial<FilterItem> = {}): FilterItem =>
|
|
7
|
+
({
|
|
8
|
+
scope: "Standing",
|
|
9
|
+
literalClauses: [{ field: "From", value: "ada@example.test" }],
|
|
10
|
+
actionLabelId: "None",
|
|
11
|
+
actionMailboxId: "None",
|
|
12
|
+
...overrides,
|
|
13
|
+
}) as unknown as FilterItem;
|
|
14
|
+
|
|
15
|
+
describe("isSenderMuteFilter", () => {
|
|
16
|
+
it("recognises a standing rule that names a sender and does nothing", () => {
|
|
17
|
+
assert.equal(isSenderMuteFilter(rule()), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("declines a rule that applies a label", () => {
|
|
21
|
+
assert.equal(isSenderMuteFilter(rule({ actionLabelId: "label-1" })), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("declines a rule that moves the message", () => {
|
|
25
|
+
assert.equal(
|
|
26
|
+
isSenderMuteFilter(rule({ actionMailboxId: "mailbox-1" })),
|
|
27
|
+
false,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("declines a rule that matches on something other than the sender", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
isSenderMuteFilter(
|
|
34
|
+
rule({
|
|
35
|
+
literalClauses: [{ field: "Subject", value: "invitation" }],
|
|
36
|
+
} as Partial<FilterItem>),
|
|
37
|
+
),
|
|
38
|
+
false,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("declines a rule with no clauses, which would mute everybody", () => {
|
|
43
|
+
assert.equal(isSenderMuteFilter(rule({ literalClauses: [] })), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("declines a temporary rule", () => {
|
|
47
|
+
assert.equal(isSenderMuteFilter(rule({ scope: "Temporary" })), false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("isSenderMuted", () => {
|
|
52
|
+
it("matches an address whatever case either side was written in", () => {
|
|
53
|
+
assert.equal(isSenderMuted([rule()], "Ada@Example.TEST"), true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("matches the whole address, never a substring of one", () => {
|
|
57
|
+
// A rule about ada@example.test must not silence an address that merely
|
|
58
|
+
// contains it — the lookalike is exactly what a spoofer would send from.
|
|
59
|
+
assert.equal(
|
|
60
|
+
isSenderMuted([rule()], "not-ada@example.test.evil.test"),
|
|
61
|
+
false,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("says no when nothing names the sender", () => {
|
|
66
|
+
assert.equal(isSenderMuted([rule()], "someone@example.test"), false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("says no for an empty sender rather than matching an empty clause", () => {
|
|
70
|
+
assert.equal(isSenderMuted([rule()], ""), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("says no with no rules at all", () => {
|
|
74
|
+
assert.equal(isSenderMuted([], "ada@example.test"), false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { FilterClauseField, FilterScope } from "@remit/domain-enums";
|
|
2
|
+
import type { FilterItem } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `Filter` model's sentinel for "this rule takes no such action" — the
|
|
6
|
+
* value `actionLabelId` and `actionMailboxId` hold when a filter applies no
|
|
7
|
+
* label and moves nothing (RFC 034 Decision 3.1).
|
|
8
|
+
*/
|
|
9
|
+
export const FILTER_NO_ACTION = "None";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A standing rule that names senders and does nothing else.
|
|
13
|
+
*
|
|
14
|
+
* That combination has exactly one meaning. The index-time filter pipeline
|
|
15
|
+
* skips a filter with no label and no move, so such a rule changes nothing
|
|
16
|
+
* about where mail lands or how it is tagged; the only thing it can express is
|
|
17
|
+
* the user saying "not from this sender". `dismiss{muteSender:true}` writes
|
|
18
|
+
* one, and the calendar-suggestion producer reads it.
|
|
19
|
+
*
|
|
20
|
+
* Structural, never name-matching: the rule is recognised by its shape, so a
|
|
21
|
+
* rename in the settings UI cannot quietly un-mute a sender.
|
|
22
|
+
*/
|
|
23
|
+
export const isSenderMuteFilter = (
|
|
24
|
+
filter: Pick<
|
|
25
|
+
FilterItem,
|
|
26
|
+
"scope" | "literalClauses" | "actionLabelId" | "actionMailboxId"
|
|
27
|
+
>,
|
|
28
|
+
): boolean =>
|
|
29
|
+
filter.scope === FilterScope.Standing &&
|
|
30
|
+
filter.actionLabelId === FILTER_NO_ACTION &&
|
|
31
|
+
filter.actionMailboxId === FILTER_NO_ACTION &&
|
|
32
|
+
filter.literalClauses.length > 0 &&
|
|
33
|
+
filter.literalClauses.every(
|
|
34
|
+
(clause) =>
|
|
35
|
+
clause.field === FilterClauseField.From && clause.value.trim() !== "",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const fold = (value: string): string => value.trim().toLowerCase();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether one of these rules mutes an address. Compared case-insensitively and
|
|
42
|
+
* whole: a mail address is one value, and a substring match would let a rule
|
|
43
|
+
* about `ada@example.com` silence `not-ada@example.com.evil.test`.
|
|
44
|
+
*/
|
|
45
|
+
export const isSenderMuted = (
|
|
46
|
+
filters: readonly Pick<
|
|
47
|
+
FilterItem,
|
|
48
|
+
"scope" | "literalClauses" | "actionLabelId" | "actionMailboxId"
|
|
49
|
+
>[],
|
|
50
|
+
sender: string,
|
|
51
|
+
): boolean => {
|
|
52
|
+
const wanted = fold(sender);
|
|
53
|
+
if (wanted === "") return false;
|
|
54
|
+
return filters.some(
|
|
55
|
+
(filter) =>
|
|
56
|
+
isSenderMuteFilter(filter) &&
|
|
57
|
+
filter.literalClauses.some((clause) => fold(clause.value) === wanted),
|
|
58
|
+
);
|
|
59
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
CalendarCollectionSchema,
|
|
10
10
|
CalendarEventIndexSchema,
|
|
11
11
|
CalendarObjectSchema,
|
|
12
|
+
CalendarSuggestionSchema,
|
|
12
13
|
ConfigImportSchema,
|
|
13
14
|
ConfigImportUnresolvedRefSchema,
|
|
14
15
|
EnvelopeAddressSchema,
|
|
@@ -607,6 +608,29 @@ export type CalendarOccurrenceInput = Omit<
|
|
|
607
608
|
"calendarId" | "calendarObjectId" | "createdAt" | "updatedAt"
|
|
608
609
|
>;
|
|
609
610
|
|
|
611
|
+
export type CalendarSuggestionItem = z.infer<typeof CalendarSuggestionSchema>;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A whole suggestion, as the producer read it out of a message. Every field is
|
|
615
|
+
* derived from the message — nothing here is client-supplied — and
|
|
616
|
+
* `suggestionId` is derived from messageId + bodyPartId + icalUid, so this is
|
|
617
|
+
* an upsert: re-reading the same message rewrites its own row rather than
|
|
618
|
+
* stacking a second card on the message.
|
|
619
|
+
*
|
|
620
|
+
* `state` and `acceptedCalendarObjectId` are excluded: a producer only ever
|
|
621
|
+
* writes a `Pending` suggestion, and the two fields that record what a person
|
|
622
|
+
* decided move exclusively through `settle`, so a re-sync can never walk an
|
|
623
|
+
* answered card back to Pending.
|
|
624
|
+
*/
|
|
625
|
+
export type PutCalendarSuggestionInput = Omit<
|
|
626
|
+
CalendarSuggestionItem,
|
|
627
|
+
| "suggestionId"
|
|
628
|
+
| "state"
|
|
629
|
+
| "acceptedCalendarObjectId"
|
|
630
|
+
| "createdAt"
|
|
631
|
+
| "updatedAt"
|
|
632
|
+
>;
|
|
633
|
+
|
|
610
634
|
export type SenderSignerStandingItem = z.infer<
|
|
611
635
|
typeof SenderSignerStandingSchema
|
|
612
636
|
>;
|