@remit/imap-worker 0.0.71 → 0.0.73
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 -2
- package/src/handlers/append-sent-message.ts +1 -1
- package/src/handlers/empty-trash.test.ts +118 -3
- package/src/handlers/empty-trash.ts +90 -31
- package/src/handlers/flag-push-placement-race.test.ts +18 -4
- package/src/handlers/flag-push.ts +1 -1
- package/src/handlers/mailbox-management.ts +1 -1
- package/src/handlers/message-copy.test.ts +56 -25
- package/src/handlers/message-copy.ts +43 -20
- package/src/handlers/message-delete-terminal.ts +11 -7
- package/src/handlers/message-delete.test.ts +225 -88
- package/src/handlers/message-delete.ts +92 -12
- package/src/handlers/message-move-terminal.test.ts +65 -20
- package/src/handlers/message-move-terminal.ts +10 -6
- package/src/handlers/message-move.test.ts +26 -17
- package/src/handlers/message-move.ts +27 -10
- package/src/handlers/placement-move-push.ts +2 -1
- package/src/handlers/sync-message-body.ts +2 -2
- package/src/handlers/sync-messages.ts +1 -1
- package/src/filter-config.test.ts +0 -171
- package/src/filter-config.ts +0 -63
- package/src/handlers/restore-source-placement.ts +0 -135
- package/src/handlers/thread-message-rows.ts +0 -77
- package/src/is-not-found.ts +0 -17
- package/src/message-embedder.test.ts +0 -39
- package/src/message-embedder.ts +0 -33
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
FilterItem,
|
|
5
|
-
IFilterAnchorRepository,
|
|
6
|
-
IFilterRepository,
|
|
7
|
-
IMessageLabelRepository,
|
|
8
|
-
} from "@remit/data-ports";
|
|
9
|
-
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
10
|
-
import {
|
|
11
|
-
type FilterMessage,
|
|
12
|
-
FilterPipeline,
|
|
13
|
-
type MessageEmbedder,
|
|
14
|
-
NO_ACTION,
|
|
15
|
-
type PlacementMoveService,
|
|
16
|
-
} from "@remit/mailbox-service";
|
|
17
|
-
import { buildFilterConfig, type FilterConfigDeps } from "./filter-config.js";
|
|
18
|
-
import { _resetMessageEmbedderForTest } from "./message-embedder.js";
|
|
19
|
-
|
|
20
|
-
const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
|
|
21
|
-
({
|
|
22
|
-
filterId: "flt-semantic",
|
|
23
|
-
accountConfigId: "cfg-1",
|
|
24
|
-
name: "receipts",
|
|
25
|
-
scope: "Standing",
|
|
26
|
-
state: FilterState.Active,
|
|
27
|
-
hasAnchor: true,
|
|
28
|
-
ruleChangedAt: 1,
|
|
29
|
-
matchOperator: FilterMatchOperator.Or,
|
|
30
|
-
literalClauses: [],
|
|
31
|
-
actionLabelId: NO_ACTION,
|
|
32
|
-
actionMailboxId: destinationMailboxId,
|
|
33
|
-
createdAt: 1,
|
|
34
|
-
updatedAt: 1,
|
|
35
|
-
}) as unknown as FilterItem;
|
|
36
|
-
|
|
37
|
-
const CURRENT_MODEL = "test-model@3";
|
|
38
|
-
|
|
39
|
-
const repos = (filter: FilterItem): FilterConfigDeps => ({
|
|
40
|
-
filterService: {
|
|
41
|
-
listByAccountAndState: async () => [filter],
|
|
42
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
43
|
-
} as unknown as IFilterRepository,
|
|
44
|
-
filterAnchorService: {
|
|
45
|
-
get: async () => ({
|
|
46
|
-
anchorEmbedding: [1, 0, 0],
|
|
47
|
-
anchorEmbeddingId: CURRENT_MODEL,
|
|
48
|
-
}),
|
|
49
|
-
} as unknown as IFilterAnchorRepository,
|
|
50
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
51
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const message: FilterMessage = {
|
|
55
|
-
from: "billing@stripe.com",
|
|
56
|
-
fromName: "Stripe",
|
|
57
|
-
subject: "Your receipt",
|
|
58
|
-
text: "Thanks for your payment",
|
|
59
|
-
listId: "",
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
describe("buildFilterConfig", () => {
|
|
63
|
-
it("returns undefined without a placement mover — no move path, filters stay off", () => {
|
|
64
|
-
const deps = repos(anchorOnlyFilter("mbx-archive"));
|
|
65
|
-
deps.placementMoveService = undefined;
|
|
66
|
-
|
|
67
|
-
assert.equal(buildFilterConfig(deps), undefined);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("wires an embedder into the config", () => {
|
|
71
|
-
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
72
|
-
|
|
73
|
-
assert.ok(config);
|
|
74
|
-
assert.ok(config.embedder);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("lights up a semantic anchor-only filter on the body-sync pass", async () => {
|
|
78
|
-
let embedCalls = 0;
|
|
79
|
-
const embedder: MessageEmbedder = {
|
|
80
|
-
embed: async () => {
|
|
81
|
-
embedCalls += 1;
|
|
82
|
-
return [1, 0, 0];
|
|
83
|
-
},
|
|
84
|
-
embeddingId: CURRENT_MODEL,
|
|
85
|
-
};
|
|
86
|
-
const config = buildFilterConfig(
|
|
87
|
-
repos(anchorOnlyFilter("mbx-archive")),
|
|
88
|
-
embedder,
|
|
89
|
-
);
|
|
90
|
-
assert.ok(config);
|
|
91
|
-
|
|
92
|
-
const decision = await new FilterPipeline(config, {
|
|
93
|
-
info: () => {},
|
|
94
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
95
|
-
|
|
96
|
-
assert.deepEqual(decision.move, {
|
|
97
|
-
destinationMailboxId: "mbx-archive",
|
|
98
|
-
filterId: "flt-semantic",
|
|
99
|
-
});
|
|
100
|
-
assert.equal(embedCalls, 1);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("does not match when the message embedding diverges from the anchor", async () => {
|
|
104
|
-
const embedder: MessageEmbedder = {
|
|
105
|
-
embed: async () => [0, 1, 0],
|
|
106
|
-
embeddingId: CURRENT_MODEL,
|
|
107
|
-
};
|
|
108
|
-
const config = buildFilterConfig(
|
|
109
|
-
repos(anchorOnlyFilter("mbx-archive")),
|
|
110
|
-
embedder,
|
|
111
|
-
);
|
|
112
|
-
assert.ok(config);
|
|
113
|
-
|
|
114
|
-
const decision = await new FilterPipeline(config, {
|
|
115
|
-
info: () => {},
|
|
116
|
-
}).evaluate("cfg-1", "m-2", message);
|
|
117
|
-
|
|
118
|
-
assert.equal(decision.move, undefined);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// An instance with semantic search off (issue #1068). `off` selects an embedder
|
|
123
|
-
// that throws by design, and the throw lands in the pipeline's per-filter catch:
|
|
124
|
-
// one error-level `filter_anchor_match_failed` for every semantic filter on
|
|
125
|
-
// every message it syncs. The pipeline already has a designed skip for a
|
|
126
|
-
// missing embedder, so the config passes none.
|
|
127
|
-
describe("buildFilterConfig with semantic search off", () => {
|
|
128
|
-
const withProviderOff = async (body: () => Promise<void>): Promise<void> => {
|
|
129
|
-
const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
130
|
-
process.env.SEARCH_EMBEDDING_PROVIDER = "off";
|
|
131
|
-
_resetMessageEmbedderForTest();
|
|
132
|
-
try {
|
|
133
|
-
await body();
|
|
134
|
-
} finally {
|
|
135
|
-
if (previous === undefined) {
|
|
136
|
-
delete process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
137
|
-
} else {
|
|
138
|
-
process.env.SEARCH_EMBEDDING_PROVIDER = previous;
|
|
139
|
-
}
|
|
140
|
-
_resetMessageEmbedderForTest();
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
it("wires no embedder", async () => {
|
|
145
|
-
await withProviderOff(async () => {
|
|
146
|
-
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
147
|
-
|
|
148
|
-
assert.ok(config);
|
|
149
|
-
assert.equal(config.embedder, undefined);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("skips the semantic filter without logging an error", async () => {
|
|
154
|
-
await withProviderOff(async () => {
|
|
155
|
-
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
156
|
-
assert.ok(config);
|
|
157
|
-
|
|
158
|
-
const errors: unknown[] = [];
|
|
159
|
-
const decision = await new FilterPipeline(config, {
|
|
160
|
-
info: () => {},
|
|
161
|
-
debug: () => {},
|
|
162
|
-
warn: () => {},
|
|
163
|
-
error: (fields) => errors.push(fields.alert),
|
|
164
|
-
}).evaluate("cfg-1", "m-off", message);
|
|
165
|
-
|
|
166
|
-
assert.deepEqual(decision.labels, []);
|
|
167
|
-
assert.equal(decision.move, undefined);
|
|
168
|
-
assert.deepEqual(errors, []);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
});
|
package/src/filter-config.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
IFilterAnchorRepository,
|
|
3
|
-
IFilterRepository,
|
|
4
|
-
IMessageLabelRepository,
|
|
5
|
-
} from "@remit/data-ports";
|
|
6
|
-
import type {
|
|
7
|
-
FilterConfig,
|
|
8
|
-
MessageEmbedder,
|
|
9
|
-
PlacementMoveService,
|
|
10
|
-
} from "@remit/mailbox-service";
|
|
11
|
-
import {
|
|
12
|
-
EMBEDDING_PROVIDER_OFF,
|
|
13
|
-
readEmbeddingProviderFromEnv,
|
|
14
|
-
} from "@remit/search-service/from-env";
|
|
15
|
-
import { getMessageEmbedder } from "./message-embedder.js";
|
|
16
|
-
|
|
17
|
-
export interface FilterConfigDeps {
|
|
18
|
-
filterService: IFilterRepository;
|
|
19
|
-
filterAnchorService: IFilterAnchorRepository;
|
|
20
|
-
messageLabelService: IMessageLabelRepository;
|
|
21
|
-
placementMoveService?: PlacementMoveService;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The env-selected embedder, or none on an instance with semantic search off.
|
|
26
|
-
*
|
|
27
|
-
* `off` embeds nothing by design, and its embedder says so by throwing
|
|
28
|
-
* (EmbeddingDisabledError). The pipeline has a designed skip for a *missing*
|
|
29
|
-
* embedder — semantic filters are passed over and logged at debug — but a
|
|
30
|
-
* present embedder that throws lands in the per-filter catch instead, which is
|
|
31
|
-
* an error-level `filter_anchor_match_failed` for every semantic filter on
|
|
32
|
-
* every synced message. Same outcome, a log nobody can read.
|
|
33
|
-
*/
|
|
34
|
-
const embedderFromEnv = (): MessageEmbedder | undefined =>
|
|
35
|
-
readEmbeddingProviderFromEnv() === EMBEDDING_PROVIDER_OFF
|
|
36
|
-
? undefined
|
|
37
|
-
: getMessageEmbedder();
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Assemble the index-time filter config the body-sync pass runs (RFC 034). The
|
|
41
|
-
* embedder is provisioned from env exactly as the backend read-path and
|
|
42
|
-
* search-index worker provision theirs, so a semantic (anchor-only) filter is
|
|
43
|
-
* evaluated on incoming mail instead of silently skipped.
|
|
44
|
-
*
|
|
45
|
-
* Absent the placement mover there is no move path, so filters stay off — a
|
|
46
|
-
* matched filter's actions reuse the same enqueue plumbing the placement mover
|
|
47
|
-
* owns.
|
|
48
|
-
*/
|
|
49
|
-
export const buildFilterConfig = (
|
|
50
|
-
deps: FilterConfigDeps,
|
|
51
|
-
embedder?: MessageEmbedder,
|
|
52
|
-
): FilterConfig | undefined => {
|
|
53
|
-
const { placementMoveService } = deps;
|
|
54
|
-
if (!placementMoveService) return undefined;
|
|
55
|
-
const resolved = embedder ?? embedderFromEnv();
|
|
56
|
-
return {
|
|
57
|
-
filterService: deps.filterService,
|
|
58
|
-
filterAnchorService: deps.filterAnchorService,
|
|
59
|
-
messageLabelService: deps.messageLabelService,
|
|
60
|
-
placementMoveService,
|
|
61
|
-
...(resolved ? { embedder: resolved } : {}),
|
|
62
|
-
};
|
|
63
|
-
};
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
IMessageRepository,
|
|
3
|
-
IThreadMessageRepository,
|
|
4
|
-
MessageItem,
|
|
5
|
-
ThreadMessageItem,
|
|
6
|
-
} from "@remit/data-ports";
|
|
7
|
-
import { MessageStatus } from "@remit/domain-enums";
|
|
8
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
9
|
-
import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
|
|
10
|
-
|
|
11
|
-
export interface RestoreSourcePlacementDeps {
|
|
12
|
-
messageService: Pick<IMessageRepository, "updateForMove">;
|
|
13
|
-
threadMessageService: Pick<
|
|
14
|
-
IThreadMessageRepository,
|
|
15
|
-
"findAllByMessageId" | "update"
|
|
16
|
-
>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface RestoreSourcePlacementInput {
|
|
20
|
-
accountConfigId: string;
|
|
21
|
-
messageId: string;
|
|
22
|
-
sourceMailboxId: string;
|
|
23
|
-
uid: number;
|
|
24
|
-
/**
|
|
25
|
-
* What the row records about the mutation that will now never happen.
|
|
26
|
-
* `synced` where the row is a faithful projection of the source again — the
|
|
27
|
-
* server never received the command, or has just said the message is still
|
|
28
|
-
* there. `failed` where the product refused an operation the user asked for,
|
|
29
|
-
* which is a failure the row has to carry.
|
|
30
|
-
*/
|
|
31
|
-
syncStatus: MessageItem["syncStatus"];
|
|
32
|
-
/**
|
|
33
|
-
* The listing rows, where the caller has already read them to pick this
|
|
34
|
-
* outcome. Omitted, they are read here.
|
|
35
|
-
*/
|
|
36
|
-
threadMessages?: ThreadMessageItem[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Put a row back on the pre-mutation source pair and hand its listing rows back
|
|
41
|
-
* with it.
|
|
42
|
-
*
|
|
43
|
-
* `status: moving` has to be cleared here or the row is stuck: a give-up that
|
|
44
|
-
* writes `syncStatus: failed` and returns leaves the row naming the destination
|
|
45
|
-
* with the source's uid — the pair `bindsForeignUid` calls a lie and
|
|
46
|
-
* `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
|
|
47
|
-
* (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
|
|
48
|
-
* on a UIDVALIDITY change), so the message is undeletable and unmovable for good
|
|
49
|
-
* (issue #1005).
|
|
50
|
-
*
|
|
51
|
-
* `updateForMove`, not `updateUid`: both write the same settled pair, but
|
|
52
|
-
* `updateUid` also appends a `message.moved` outbox row so the search index can
|
|
53
|
-
* refresh the mailbox it stores per message. Nothing here moved. The optimistic
|
|
54
|
-
* `updateForMove` that pointed the row at the destination never enqueued one
|
|
55
|
-
* either, so the index still holds the source and a revert has nothing to
|
|
56
|
-
* correct — the re-index would be a forced pass over an unchanged document.
|
|
57
|
-
*
|
|
58
|
-
* The caller must have evidence, not an inference: this writes a placement, and
|
|
59
|
-
* a placement written on a guess binds live mail to a uid that names somebody
|
|
60
|
-
* else's message. Two kinds of caller qualify, and nothing else does.
|
|
61
|
-
*
|
|
62
|
-
* A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
|
|
63
|
-
* answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
|
|
64
|
-
* this the server's own answer rather than a revert on ambiguity (what PR #652
|
|
65
|
-
* was pulled for).
|
|
66
|
-
*
|
|
67
|
-
* A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
|
|
68
|
-
* openBox guard threw before the outbound command was issued (issue #1203), and
|
|
69
|
-
* restores the pre-mutation pair off its own event. On a FIRST delivery that is
|
|
70
|
-
* not an inference but an undo: the command provably never left, so the row is
|
|
71
|
-
* put back exactly as it was found. A redelivery cannot claim that — the
|
|
72
|
-
* earlier attempt's tagged OK can be lost with the connection — so it asks
|
|
73
|
-
* `probePausedPlacement` which folder holds the message and only reaches here
|
|
74
|
-
* once the answer rules the destination out. Either way the source mailbox is
|
|
75
|
-
* by definition awaiting a cursor rebuild, which matches its rows by Message-ID
|
|
76
|
-
* and adjudicates the uid: handing the row back is what puts it in the set the
|
|
77
|
-
* rebuild walks, since a row still naming the destination is in neither
|
|
78
|
-
* folder's.
|
|
79
|
-
*
|
|
80
|
-
* A row another path deleted while the probe was in flight has nothing left to
|
|
81
|
-
* restore, and a thread row whose composites have moved on is being rewritten
|
|
82
|
-
* by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
|
|
83
|
-
* the conditional-check miss as one — and both are settled states here, not
|
|
84
|
-
* faults. Throwing on them would escape the caller's `.catch()` and cost the
|
|
85
|
-
* record its ack and its resync, which is the contract the terminal resolvers
|
|
86
|
-
* state: never re-thrown.
|
|
87
|
-
*/
|
|
88
|
-
export const restoreSourcePlacement = async (
|
|
89
|
-
deps: RestoreSourcePlacementDeps,
|
|
90
|
-
input: RestoreSourcePlacementInput,
|
|
91
|
-
): Promise<void> => {
|
|
92
|
-
const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
|
|
93
|
-
input;
|
|
94
|
-
|
|
95
|
-
const skipNotFound = (error: unknown): void => {
|
|
96
|
-
if (isNotFoundError(error)) return;
|
|
97
|
-
throw error;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const messageRestored = await deps.messageService
|
|
101
|
-
.updateForMove(messageId, {
|
|
102
|
-
mailboxId: sourceMailboxId,
|
|
103
|
-
uid,
|
|
104
|
-
status: MessageStatus.active,
|
|
105
|
-
syncStatus,
|
|
106
|
-
})
|
|
107
|
-
.then(() => true)
|
|
108
|
-
.catch((error: unknown) => {
|
|
109
|
-
skipNotFound(error);
|
|
110
|
-
return false;
|
|
111
|
-
});
|
|
112
|
-
if (!messageRestored) return;
|
|
113
|
-
|
|
114
|
-
const threadMessages =
|
|
115
|
-
input.threadMessages ??
|
|
116
|
-
(await deps.threadMessageService.findAllByMessageId(
|
|
117
|
-
accountConfigId,
|
|
118
|
-
messageId,
|
|
119
|
-
));
|
|
120
|
-
for (const threadMessage of threadMessages) {
|
|
121
|
-
const args = buildThreadMessageMoveRevert(
|
|
122
|
-
threadMessage,
|
|
123
|
-
uid,
|
|
124
|
-
sourceMailboxId,
|
|
125
|
-
);
|
|
126
|
-
await deps.threadMessageService
|
|
127
|
-
.update(
|
|
128
|
-
threadMessage.accountConfigId,
|
|
129
|
-
threadMessage.threadMessageId,
|
|
130
|
-
args.set,
|
|
131
|
-
{ composites: args.composites },
|
|
132
|
-
)
|
|
133
|
-
.catch(skipNotFound);
|
|
134
|
-
}
|
|
135
|
-
};
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Build the `set` and `composites` payload for the ThreadMessage update on a
|
|
5
|
-
* MESSAGE_DELETE move-to-trash.
|
|
6
|
-
*
|
|
7
|
-
* The CURRENT row state goes in `composites`; the NEW values go in `set`.
|
|
8
|
-
* ElectroDB uses `composites` to run the conditional check on the existing row
|
|
9
|
-
* AND to compute the previous sort-key values needed to recompute the new ones.
|
|
10
|
-
* Passing the NEW values in `composites` makes the conditional check fail with
|
|
11
|
-
* ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
|
|
12
|
-
* the caller silently drops the update. Same root cause as PR #186 fixed for
|
|
13
|
-
* `flag-queue.ts`.
|
|
14
|
-
*/
|
|
15
|
-
export type ThreadMessageRowState = Pick<
|
|
16
|
-
ThreadMessageItem,
|
|
17
|
-
| "sentDate"
|
|
18
|
-
| "mailboxId"
|
|
19
|
-
| "isRead"
|
|
20
|
-
| "isDeleted"
|
|
21
|
-
| "hasStars"
|
|
22
|
-
| "hasAttachment"
|
|
23
|
-
>;
|
|
24
|
-
|
|
25
|
-
const currentComposites = (threadMessage: ThreadMessageRowState) => ({
|
|
26
|
-
sentDate: threadMessage.sentDate,
|
|
27
|
-
mailboxId: threadMessage.mailboxId,
|
|
28
|
-
isRead: threadMessage.isRead,
|
|
29
|
-
isDeleted: threadMessage.isDeleted,
|
|
30
|
-
hasStars: threadMessage.hasStars,
|
|
31
|
-
hasAttachment: threadMessage.hasAttachment,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export const buildThreadMessageTrashUpdate = (
|
|
35
|
-
threadMessage: ThreadMessageRowState,
|
|
36
|
-
newUid: number,
|
|
37
|
-
destinationMailboxId: string,
|
|
38
|
-
) => ({
|
|
39
|
-
set: {
|
|
40
|
-
uid: newUid,
|
|
41
|
-
mailboxId: destinationMailboxId,
|
|
42
|
-
isDeleted: true,
|
|
43
|
-
},
|
|
44
|
-
composites: currentComposites(threadMessage),
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The inverse payload: put the thread row back where the message still is on
|
|
49
|
-
* the server. The delete was recorded optimistically, so a delete that gives up
|
|
50
|
-
* with the message still at its source has to hand the row back rather than
|
|
51
|
-
* leave it claiming Trash — an invisible `failed` on a row the user cannot see
|
|
52
|
-
* is the shape of the incident this whole change is about.
|
|
53
|
-
*/
|
|
54
|
-
export const buildThreadMessageMoveRevert = (
|
|
55
|
-
threadMessage: ThreadMessageRowState,
|
|
56
|
-
sourceUid: number,
|
|
57
|
-
sourceMailboxId: string,
|
|
58
|
-
) => ({
|
|
59
|
-
set: {
|
|
60
|
-
uid: sourceUid,
|
|
61
|
-
mailboxId: sourceMailboxId,
|
|
62
|
-
isDeleted: false,
|
|
63
|
-
},
|
|
64
|
-
composites: currentComposites(threadMessage),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Hand a row back after an abandoned expunge. The mail never left Trash, so
|
|
69
|
-
* only the deletion mark reverts — the uid and mailbox on the row are still
|
|
70
|
-
* where the server has it.
|
|
71
|
-
*/
|
|
72
|
-
export const buildThreadMessageUndelete = (
|
|
73
|
-
threadMessage: ThreadMessageRowState,
|
|
74
|
-
) => ({
|
|
75
|
-
set: { isDeleted: false },
|
|
76
|
-
composites: currentComposites(threadMessage),
|
|
77
|
-
});
|
package/src/is-not-found.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A repository lookup or write for a row that no longer exists rejects with a
|
|
3
|
-
* `NotFoundError` (name-matched, since the class crosses the data-ports adapter
|
|
4
|
-
* boundary — see `@remit/data-ports/errors`).
|
|
5
|
-
*
|
|
6
|
-
* A worker event that references a deliberately-deleted mailbox is completed or
|
|
7
|
-
* moot work, never a transient fault: every redelivery re-throws the same
|
|
8
|
-
* `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
|
|
9
|
-
* that permanently-failing head message stalls the whole account's per-group
|
|
10
|
-
* FIFO. Handlers use this predicate to resolve such an event terminally (ack
|
|
11
|
-
* with a WARN) instead of retrying forever (issues #287, #289, #290).
|
|
12
|
-
*
|
|
13
|
-
* The guard is narrow on purpose: only a genuine not-found terminates. Real
|
|
14
|
-
* IMAP/infra failures carry other errors and must still propagate to be retried.
|
|
15
|
-
*/
|
|
16
|
-
export const isNotFoundError = (error: unknown): boolean =>
|
|
17
|
-
(error as { name?: string })?.name === "NotFoundError";
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
_resetMessageEmbedderForTest,
|
|
5
|
-
getMessageEmbedder,
|
|
6
|
-
} from "./message-embedder.js";
|
|
7
|
-
|
|
8
|
-
describe("getMessageEmbedder", () => {
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
_resetMessageEmbedderForTest();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("adapts the batch embedding service into a single-text message embedder", async () => {
|
|
14
|
-
const vector = await getMessageEmbedder().embed("invoice from stripe");
|
|
15
|
-
|
|
16
|
-
assert.ok(Array.isArray(vector));
|
|
17
|
-
assert.ok(vector.length > 0);
|
|
18
|
-
assert.ok(vector.every((value) => typeof value === "number"));
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("embeds the same text deterministically", async () => {
|
|
22
|
-
const embedder = getMessageEmbedder();
|
|
23
|
-
const a = await embedder.embed("your monthly receipt");
|
|
24
|
-
const b = await embedder.embed("your monthly receipt");
|
|
25
|
-
|
|
26
|
-
assert.deepEqual(a, b);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("memoizes the embedder across calls", () => {
|
|
30
|
-
assert.strictEqual(getMessageEmbedder(), getMessageEmbedder());
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("rebuilds after a reset", () => {
|
|
34
|
-
const first = getMessageEmbedder();
|
|
35
|
-
_resetMessageEmbedderForTest();
|
|
36
|
-
|
|
37
|
-
assert.notStrictEqual(getMessageEmbedder(), first);
|
|
38
|
-
});
|
|
39
|
-
});
|
package/src/message-embedder.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { MessageEmbedder } from "@remit/mailbox-service";
|
|
2
|
-
import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Adapt the batch {@link EmbeddingService} the rest of the stack composes from
|
|
6
|
-
* env into the single-text {@link MessageEmbedder} the filter pipeline needs: one
|
|
7
|
-
* incoming message becomes one message-level vector to compare against a filter's
|
|
8
|
-
* persisted anchor (RFC 034 Decision 2.1/2.3).
|
|
9
|
-
*
|
|
10
|
-
* The embedder is selected by the same `SEARCH_EMBEDDING_*` env the backend and
|
|
11
|
-
* search-index worker read, so the worker embeds under the identical model the
|
|
12
|
-
* anchors were built with. The instance is memoized across warm invocations so a
|
|
13
|
-
* local Transformers.js model loads once per container rather than per event.
|
|
14
|
-
*/
|
|
15
|
-
let cached: MessageEmbedder | undefined;
|
|
16
|
-
|
|
17
|
-
export const getMessageEmbedder = (): MessageEmbedder => {
|
|
18
|
-
if (cached) return cached;
|
|
19
|
-
const service = buildEmbeddingServiceFromEnv();
|
|
20
|
-
cached = {
|
|
21
|
-
embed: async (text: string): Promise<number[]> => {
|
|
22
|
-
const [vector] = await service.embed([text]);
|
|
23
|
-
return vector;
|
|
24
|
-
},
|
|
25
|
-
embeddingId: service.embeddingId,
|
|
26
|
-
};
|
|
27
|
-
return cached;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/** Reset the singleton — test use only. */
|
|
31
|
-
export const _resetMessageEmbedderForTest = (): void => {
|
|
32
|
-
cached = undefined;
|
|
33
|
-
};
|