@remit/data-ports 0.0.53 → 0.0.54

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/data-ports",
3
- "version": "0.0.53",
3
+ "version": "0.0.54",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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
+ }
@@ -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,6 +162,43 @@ 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
+
164
202
  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
203
  const ACCOUNT = "account-golden";
166
204
  const MESSAGE = "message-golden";
@@ -268,6 +306,17 @@ describe("derived ids are stored primary keys: a changed value orphans every row
268
306
  );
269
307
  });
270
308
 
309
+ it("deriveCalendarSuggestionId pins the calendar_suggestion primary key", () => {
310
+ assert.equal(
311
+ deriveCalendarSuggestionId(
312
+ MESSAGE,
313
+ "bodypart-golden",
314
+ "golden-invite@example.com",
315
+ ),
316
+ "1pesdr3fl02uyoz5k1qrfik3e",
317
+ );
318
+ });
319
+
271
320
  it("quarantineMessageIdHash pins the stored Message-ID hash that correlates reports", () => {
272
321
  assert.equal(
273
322
  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();
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
  >;