@remit/imap-worker 0.0.1
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/README.md +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteObjectsCommand,
|
|
3
|
+
ListObjectsV2Command,
|
|
4
|
+
S3Client,
|
|
5
|
+
} from "@aws-sdk/client-s3";
|
|
6
|
+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
7
|
+
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
8
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
9
|
+
import { resolveSqsCredentials } from "@remit/sqs-client";
|
|
10
|
+
import { env } from "expect-env";
|
|
11
|
+
|
|
12
|
+
export interface DeleteAccountObjectsEvent {
|
|
13
|
+
type: "DELETE_ACCOUNT_OBJECTS";
|
|
14
|
+
accountConfigId: string;
|
|
15
|
+
continuationToken?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const s3 = new S3Client({});
|
|
19
|
+
|
|
20
|
+
const sqsQueueUrl = env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
21
|
+
const isLocal = sqsQueueUrl.startsWith("http://localhost");
|
|
22
|
+
|
|
23
|
+
const sqs = new SQSClient({
|
|
24
|
+
endpoint: isLocal ? new URL(sqsQueueUrl).origin : undefined,
|
|
25
|
+
...(isLocal && { protocol: AwsQueryProtocol }),
|
|
26
|
+
credentials: resolveSqsCredentials(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const BATCH_SIZE = 1_000;
|
|
30
|
+
const MIN_REMAINING_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
export const handleDeleteAccountObjects = async (
|
|
33
|
+
event: DeleteAccountObjectsEvent,
|
|
34
|
+
log: Logger,
|
|
35
|
+
getRemainingTimeMs?: () => number,
|
|
36
|
+
): Promise<void> => {
|
|
37
|
+
const BUCKET_NAME = env.S3_BUCKET_NAME;
|
|
38
|
+
const { accountConfigId, continuationToken } = event;
|
|
39
|
+
const prefix = `accounts/${accountConfigId}/`;
|
|
40
|
+
|
|
41
|
+
log.info(
|
|
42
|
+
{ accountConfigId, prefix, hasContinuation: !!continuationToken },
|
|
43
|
+
"Deleting account objects from S3",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
let currentToken = continuationToken;
|
|
47
|
+
let totalDeleted = 0;
|
|
48
|
+
|
|
49
|
+
// eslint-disable-next-line no-constant-condition
|
|
50
|
+
while (true) {
|
|
51
|
+
// Check remaining time before starting a new page
|
|
52
|
+
if (getRemainingTimeMs && getRemainingTimeMs() < MIN_REMAINING_MS) {
|
|
53
|
+
log.info(
|
|
54
|
+
{ accountConfigId, totalDeleted, continuationToken: currentToken },
|
|
55
|
+
"Near timeout, re-enqueuing with continuation token",
|
|
56
|
+
);
|
|
57
|
+
await reenqueue(accountConfigId, currentToken);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const listResult = await s3.send(
|
|
62
|
+
new ListObjectsV2Command({
|
|
63
|
+
Bucket: BUCKET_NAME,
|
|
64
|
+
Prefix: prefix,
|
|
65
|
+
MaxKeys: BATCH_SIZE,
|
|
66
|
+
ContinuationToken: currentToken,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const keys = (listResult.Contents ?? [])
|
|
71
|
+
.map((obj) => obj.Key)
|
|
72
|
+
.filter((k): k is string => k !== undefined);
|
|
73
|
+
|
|
74
|
+
if (keys.length > 0) {
|
|
75
|
+
await s3.send(
|
|
76
|
+
new DeleteObjectsCommand({
|
|
77
|
+
Bucket: BUCKET_NAME,
|
|
78
|
+
Delete: {
|
|
79
|
+
Objects: keys.map((Key) => ({ Key })),
|
|
80
|
+
Quiet: true,
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
totalDeleted += keys.length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!listResult.IsTruncated) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
currentToken = listResult.NextContinuationToken;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
log.info(
|
|
95
|
+
{ accountConfigId, totalDeleted },
|
|
96
|
+
"Finished deleting account objects",
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const reenqueue = async (
|
|
101
|
+
accountConfigId: string,
|
|
102
|
+
continuationToken: string | undefined,
|
|
103
|
+
): Promise<void> => {
|
|
104
|
+
const event: DeleteAccountObjectsEvent = {
|
|
105
|
+
type: "DELETE_ACCOUNT_OBJECTS",
|
|
106
|
+
accountConfigId,
|
|
107
|
+
continuationToken,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await sqs.send(
|
|
111
|
+
new SendMessageCommand({
|
|
112
|
+
QueueUrl: sqsQueueUrl,
|
|
113
|
+
MessageBody: JSON.stringify(event),
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
+
import {
|
|
4
|
+
guardConnectionCursor,
|
|
5
|
+
isCursorRebuildNeeded,
|
|
6
|
+
MailboxCursorPausedError,
|
|
7
|
+
} from "@remit/mailbox-service";
|
|
8
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
9
|
+
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
10
|
+
import type { EmptyTrashEvent } from "../events.js";
|
|
11
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
12
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle EMPTY_TRASH events.
|
|
16
|
+
* Permanently deletes all messages in the Trash mailbox.
|
|
17
|
+
*/
|
|
18
|
+
export const handleEmptyTrash = async (
|
|
19
|
+
event: EmptyTrashEvent,
|
|
20
|
+
log: Logger,
|
|
21
|
+
): Promise<void> => {
|
|
22
|
+
const {
|
|
23
|
+
account: accountService,
|
|
24
|
+
message: messageService,
|
|
25
|
+
threadMessage: threadMessageService,
|
|
26
|
+
mailbox: mailboxService,
|
|
27
|
+
secrets,
|
|
28
|
+
} = await getClient();
|
|
29
|
+
|
|
30
|
+
const { accountId, trashMailboxId, trashMailboxPath } = event;
|
|
31
|
+
|
|
32
|
+
log.info(
|
|
33
|
+
{ event: event.type, accountId, trashMailboxPath },
|
|
34
|
+
"Handling event",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const account = await accountService.get(accountId);
|
|
38
|
+
if (!account) {
|
|
39
|
+
throw new Error(`Account ${accountId} not found`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isAccountDeleted(account, log)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await withOAuthLifecycle(
|
|
47
|
+
buildLifecycleDeps(secrets, accountService),
|
|
48
|
+
account,
|
|
49
|
+
log,
|
|
50
|
+
async (credentials) => {
|
|
51
|
+
const mailbox = await mailboxService.get(accountId, trashMailboxId);
|
|
52
|
+
|
|
53
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
54
|
+
// paused never even opens a connection. Optimization only — the
|
|
55
|
+
// guardConnectionCursor openBox wrap below is the structural guarantee.
|
|
56
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
57
|
+
log.info(
|
|
58
|
+
{ accountId, mailboxId: trashMailboxId },
|
|
59
|
+
"Mailbox cursor not normal; pausing empty-trash this round",
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
65
|
+
|
|
66
|
+
await scope
|
|
67
|
+
.getConnection()
|
|
68
|
+
.then(async (rawConnection) => {
|
|
69
|
+
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
70
|
+
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
71
|
+
// reveals it. Local rows stay marked for deletion and are picked
|
|
72
|
+
// up once the mailbox returns to normal.
|
|
73
|
+
const connection = guardConnectionCursor(
|
|
74
|
+
rawConnection,
|
|
75
|
+
{ mailboxService },
|
|
76
|
+
accountId,
|
|
77
|
+
mailbox,
|
|
78
|
+
);
|
|
79
|
+
await connection.openBox(trashMailboxPath, false);
|
|
80
|
+
|
|
81
|
+
// Search for all messages in Trash
|
|
82
|
+
const uids = await connection.search(["ALL"]);
|
|
83
|
+
|
|
84
|
+
if (uids.length > 0) {
|
|
85
|
+
// Delete all messages on IMAP
|
|
86
|
+
await connection.deleteMessages(uids);
|
|
87
|
+
log.info(
|
|
88
|
+
{ count: uids.length },
|
|
89
|
+
"Deleted messages from IMAP trash",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Delete all local messages in trash
|
|
94
|
+
const localMessages =
|
|
95
|
+
await messageService.listAllByMailbox(trashMailboxId);
|
|
96
|
+
|
|
97
|
+
for (const message of localMessages) {
|
|
98
|
+
// Delete the Message entity
|
|
99
|
+
await messageService.delete(message.messageId);
|
|
100
|
+
|
|
101
|
+
// Delete the ThreadMessage entity
|
|
102
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
103
|
+
account.accountConfigId,
|
|
104
|
+
message.messageId,
|
|
105
|
+
);
|
|
106
|
+
if (threadMessage) {
|
|
107
|
+
await threadMessageService.delete(
|
|
108
|
+
threadMessage.accountConfigId,
|
|
109
|
+
threadMessage.threadMessageId,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
log.info(
|
|
115
|
+
{ accountId, deletedCount: localMessages.length },
|
|
116
|
+
"Trash emptied successfully",
|
|
117
|
+
);
|
|
118
|
+
})
|
|
119
|
+
.catch((error: unknown) => {
|
|
120
|
+
if (error instanceof MailboxCursorPausedError) {
|
|
121
|
+
log.info(
|
|
122
|
+
{
|
|
123
|
+
accountId,
|
|
124
|
+
mailboxId: trashMailboxId,
|
|
125
|
+
cursorState: error.state,
|
|
126
|
+
},
|
|
127
|
+
"Mailbox cursor not normal; pausing empty-trash this round",
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
})
|
|
133
|
+
.finally(() => scope.disconnect());
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { FLAG_PUSH_MAX_ATTEMPTS, getFlagPushMaxAttempts } from "./flag-push.js";
|
|
4
|
+
|
|
5
|
+
describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts / #1289's getPlacementMoveMaxAttempts)", () => {
|
|
6
|
+
it("parses the CDK-injected env var", () => {
|
|
7
|
+
assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "3" }), 3);
|
|
8
|
+
assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "5" }), 5);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("defaults to 3 when unset", () => {
|
|
12
|
+
assert.equal(getFlagPushMaxAttempts({}), 3);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("defaults to 3 on a non-numeric or non-positive value", () => {
|
|
16
|
+
assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "nope" }), 3);
|
|
17
|
+
assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "0" }), 3);
|
|
18
|
+
assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "-1" }), 3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("the module-level constant reflects the actual process env at load time", () => {
|
|
22
|
+
assert.equal(typeof FLAG_PUSH_MAX_ATTEMPTS, "number");
|
|
23
|
+
assert.ok(FLAG_PUSH_MAX_ATTEMPTS > 0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
+
import { MetricUnit, metrics } from "@remit/logger-lambda";
|
|
4
|
+
import {
|
|
5
|
+
guardConnectionCursor,
|
|
6
|
+
isCursorRebuildNeeded,
|
|
7
|
+
MailboxCursorPausedError,
|
|
8
|
+
resolveExhaustedFlagPushFailure,
|
|
9
|
+
} from "@remit/mailbox-service";
|
|
10
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
11
|
+
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
12
|
+
import type { FlagPushEvent } from "../events.js";
|
|
13
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
14
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fallback when `FLAG_PUSH_MAX_ATTEMPTS` is unset (local dev, unit tests).
|
|
18
|
+
* Matches the shared `MAX_RECEIVE_COUNT` every queue's redrive policy uses
|
|
19
|
+
* (`infra/stacks/dev/stacks/remit-queue-stack.ts`), same pattern as
|
|
20
|
+
* `BODY_SYNC_MAX_ATTEMPTS` (#1270) / `PLACEMENT_MOVE_MAX_ATTEMPTS` (#1289).
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_FLAG_PUSH_MAX_ATTEMPTS = 3;
|
|
23
|
+
|
|
24
|
+
export const getFlagPushMaxAttempts = (
|
|
25
|
+
processEnv: NodeJS.ProcessEnv = process.env,
|
|
26
|
+
): number => {
|
|
27
|
+
const raw = processEnv.FLAG_PUSH_MAX_ATTEMPTS;
|
|
28
|
+
if (!raw) return DEFAULT_FLAG_PUSH_MAX_ATTEMPTS;
|
|
29
|
+
const parsed = Number.parseInt(raw, 10);
|
|
30
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
31
|
+
? parsed
|
|
32
|
+
: DEFAULT_FLAG_PUSH_MAX_ATTEMPTS;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
|
|
39
|
+
* flag-push marker: resolves the message's UID and CURRENT mailbox fresh
|
|
40
|
+
* from the Message row (never a value captured at enqueue — invariant 1),
|
|
41
|
+
* pushes the IMAP STORE (add or remove, per the marker's `operation`), and
|
|
42
|
+
* clears the marker ONLY on confirmed success.
|
|
43
|
+
*
|
|
44
|
+
* Precedence (epic invariant 2):
|
|
45
|
+
* - While pending, resync never reverts the flag — nothing in this handler
|
|
46
|
+
* (or anywhere else) reads flags FROM IMAP back into `MessageFlag`/
|
|
47
|
+
* `ThreadMessage` for an existing row; the only flag-state writes happen
|
|
48
|
+
* here (confirmed push) or in `FlagQueueService` (the user's own local
|
|
49
|
+
* flip, which already applied before this marker existed).
|
|
50
|
+
* - A later flip of the SAME field already replaced this marker (`put`) by
|
|
51
|
+
* the time this event is processed, OR advanced it past `pending` — either
|
|
52
|
+
* way `markerService.find` returns the CURRENT marker, so this handler
|
|
53
|
+
* always drives the freshest intent, never a stale one.
|
|
54
|
+
* - An external delete supersedes the marker entirely — handled by
|
|
55
|
+
* `resolveExhaustedFlagPushFailure`'s `reconciled` outcome.
|
|
56
|
+
*
|
|
57
|
+
* Cursor-guarded (#1272, epic #1281 invariant 5): the connection is wrapped
|
|
58
|
+
* via `guardConnectionCursor` around the mailbox's current `openBox` choke
|
|
59
|
+
* point, so no stored UID touches the server while the mailbox's axis is
|
|
60
|
+
* being rebuilt. A trip pauses the push (routine, no alarm) — the marker
|
|
61
|
+
* stays durable and pushes again on the next event or sync tick.
|
|
62
|
+
*/
|
|
63
|
+
export const handleFlagPush = async (
|
|
64
|
+
event: FlagPushEvent,
|
|
65
|
+
log: Logger,
|
|
66
|
+
receiveCount = 1,
|
|
67
|
+
): Promise<void> => {
|
|
68
|
+
const {
|
|
69
|
+
account: accountService,
|
|
70
|
+
mailbox: mailboxService,
|
|
71
|
+
message: messageService,
|
|
72
|
+
threadMessage: threadMessageService,
|
|
73
|
+
flagPush: markerService,
|
|
74
|
+
secrets,
|
|
75
|
+
} = await getClient();
|
|
76
|
+
|
|
77
|
+
const { accountId, accountConfigId, messageId, flagName } = event;
|
|
78
|
+
|
|
79
|
+
const marker = await markerService.find(messageId, flagName);
|
|
80
|
+
if (!marker) {
|
|
81
|
+
log.info(
|
|
82
|
+
{ messageId, flagName, accountId },
|
|
83
|
+
"No pending flag-push marker (already confirmed or superseded); nothing to push",
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const account = await accountService.get(accountId);
|
|
89
|
+
if (!account) {
|
|
90
|
+
throw new Error(`Account ${accountId} not found`);
|
|
91
|
+
}
|
|
92
|
+
if (isAccountDeleted(account, log)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const [message] = await messageService.get([messageId]);
|
|
97
|
+
|
|
98
|
+
// The message row is already gone — some other reconciliation path (body
|
|
99
|
+
// sync, placement move, a prior flag-push exhaustion) already deleted it.
|
|
100
|
+
// The marker is orphaned; drop it without touching IMAP.
|
|
101
|
+
if (!message) {
|
|
102
|
+
await markerService.delete(messageId, flagName);
|
|
103
|
+
log.info(
|
|
104
|
+
{ messageId, flagName, accountId },
|
|
105
|
+
"Message row no longer exists; flag-push marker dropped without pushing",
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The worker has picked up the event and is about to actually attempt the
|
|
111
|
+
// IMAP STORE — advance the state engine (pending/queued -> processing).
|
|
112
|
+
// Idempotent to call again on a redelivered event (a prior attempt that
|
|
113
|
+
// died mid-flight already left it here).
|
|
114
|
+
await markerService.updateState(messageId, flagName, "processing");
|
|
115
|
+
|
|
116
|
+
const mailbox = await mailboxService.get(accountId, message.mailboxId);
|
|
117
|
+
|
|
118
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
119
|
+
// paused never even borrows a connection. Optimization only — the
|
|
120
|
+
// guardConnectionCursor wrap below is the structural guarantee (#1272).
|
|
121
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
122
|
+
log.info(
|
|
123
|
+
{ messageId, flagName, accountId, cursorState: mailbox.cursorState },
|
|
124
|
+
"Mailbox cursor not normal; pausing outbound flag push this round",
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await withOAuthLifecycle(
|
|
130
|
+
buildLifecycleDeps(secrets, accountService),
|
|
131
|
+
account,
|
|
132
|
+
log,
|
|
133
|
+
async (credentials) => {
|
|
134
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
135
|
+
|
|
136
|
+
await scope
|
|
137
|
+
.getConnection()
|
|
138
|
+
.then(async (rawConnection) => {
|
|
139
|
+
const connection = guardConnectionCursor(
|
|
140
|
+
rawConnection,
|
|
141
|
+
{ mailboxService },
|
|
142
|
+
accountId,
|
|
143
|
+
mailbox,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await connection.openBox(mailbox.fullPath, false);
|
|
147
|
+
|
|
148
|
+
if (marker.operation === "add") {
|
|
149
|
+
await connection.addFlags([message.uid], [flagName]);
|
|
150
|
+
} else {
|
|
151
|
+
await connection.removeFlags([message.uid], [flagName]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Confirmed IMAP acknowledgement — clears ONLY here, never on
|
|
155
|
+
// attempt (the defect issue #1273 fixes).
|
|
156
|
+
await markerService.delete(messageId, flagName);
|
|
157
|
+
|
|
158
|
+
log.info(
|
|
159
|
+
{
|
|
160
|
+
messageId,
|
|
161
|
+
flagName,
|
|
162
|
+
accountId,
|
|
163
|
+
operation: marker.operation,
|
|
164
|
+
uid: message.uid,
|
|
165
|
+
mailboxPath: mailbox.fullPath,
|
|
166
|
+
},
|
|
167
|
+
"Flag push confirmed on IMAP; marker cleared",
|
|
168
|
+
);
|
|
169
|
+
})
|
|
170
|
+
.catch(async (error: unknown) => {
|
|
171
|
+
// Expected pause (epic #1281 invariant 3), not a fault: ack and
|
|
172
|
+
// skip rather than propagating into queue retry/DLQ. The marker
|
|
173
|
+
// stays durable; the push resumes once the mailbox returns to
|
|
174
|
+
// normal.
|
|
175
|
+
if (error instanceof MailboxCursorPausedError) {
|
|
176
|
+
log.info(
|
|
177
|
+
{ messageId, flagName, accountId, cursorState: error.state },
|
|
178
|
+
"UIDVALIDITY changed; mailbox cursor tripped, pausing outbound flag push",
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (receiveCount < FLAG_PUSH_MAX_ATTEMPTS) {
|
|
184
|
+
// Transient push failure — expected (connections drop). No
|
|
185
|
+
// alarm; queue redelivery retries from the still-durable marker.
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Redelivery budget exhausted: resolve into exactly one of the
|
|
190
|
+
// two terminal outcomes (epic invariant 3) instead of
|
|
191
|
+
// dead-lettering with no diagnosis.
|
|
192
|
+
const { outcome } = await resolveExhaustedFlagPushFailure(
|
|
193
|
+
{ markerService, messageService, threadMessageService, log },
|
|
194
|
+
{
|
|
195
|
+
accountId,
|
|
196
|
+
accountConfigId,
|
|
197
|
+
messageId,
|
|
198
|
+
flagName,
|
|
199
|
+
uid: message.uid,
|
|
200
|
+
mailboxPath: mailbox.fullPath,
|
|
201
|
+
getConnection: scope.getConnection,
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (outcome === "reconciled") {
|
|
206
|
+
metrics.addMetric(
|
|
207
|
+
"flagPushStaleRowReconciled",
|
|
208
|
+
MetricUnit.Count,
|
|
209
|
+
1,
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
metrics.addMetric("flagPushFailed", MetricUnit.Count, 1);
|
|
215
|
+
log.error(
|
|
216
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
217
|
+
"Flag push retry exhausted; message still exists at its mailbox",
|
|
218
|
+
);
|
|
219
|
+
// Terminal — never re-thrown, so the caller acks either way.
|
|
220
|
+
})
|
|
221
|
+
.finally(() => scope.disconnect());
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
};
|