@remit/imap-worker 0.0.65 → 0.0.67

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/imap-worker",
3
- "version": "0.0.65",
3
+ "version": "0.0.67",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -25,6 +25,7 @@
25
25
  "dev": "node --import tsx src/e2e-processor-shim.ts"
26
26
  },
27
27
  "devDependencies": {
28
+ "@remit/drizzle-service": "*",
28
29
  "@remit/queue-sidecar": "*",
29
30
  "@remit/storage-service": "*",
30
31
  "aws-sdk-client-mock": "*",
@@ -229,6 +229,25 @@ describe("handleMessageCopy", () => {
229
229
  assert.equal(called("threadMessage.update").length, 1);
230
230
  });
231
231
 
232
+ // Issue #1122. `deriveCopyMessageId` reuses the row, so copying the same
233
+ // message into the same folder twice issues a second server COPY and leaves
234
+ // the destination holding both. Nothing here can rule the older one out the
235
+ // way the trash probe's source check does — a COPY leaves the source
236
+ // untouched — so the row must settle on the highest match. Settling on 12
237
+ // points it at the earlier copy and orphans the one just made.
238
+ it("settles on the copy this event made, not on an earlier copy of the same message", async () => {
239
+ h.connection.copyMessages = async () => ({ uidMap: new Map() });
240
+ h.destinationHolds = [12, 42];
241
+
242
+ await handleMessageCopy(event, noopLog, 1, deps());
243
+
244
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
245
+ "new-msg",
246
+ 42,
247
+ "dst-mbx",
248
+ ]);
249
+ });
250
+
232
251
  it("reconciles the optimistic row away when the destination does not hold the copy", async () => {
233
252
  h.connection.copyMessages = async () => ({ uidMap: new Map() });
234
253
  h.destinationHolds = [];
@@ -16,7 +16,7 @@ import type { MessageCopyEvent } from "../events.js";
16
16
  import { isNotFoundError } from "../is-not-found.js";
17
17
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
18
18
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
19
- import { searchMailboxByMessageId } from "./message-move.js";
19
+ import { searchMailboxForHighestMessageIdUid } from "./message-move.js";
20
20
 
21
21
  export interface MessageCopyDeps {
22
22
  getClient: typeof getClient;
@@ -188,11 +188,18 @@ export const handleMessageCopy = async (
188
188
  // handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
189
189
  // snapshot alone, and opening the destination through it trips that
190
190
  // mailbox into a rebuild.
191
+ //
192
+ // This is the call site with no source-side gate to fall back on
193
+ // (#1122): a COPY leaves the source untouched, so nothing here can rule
194
+ // an older destination copy out the way `confirmTrashMoveUid` does. The
195
+ // fresh copy is the highest matching uid, and `deriveCopyMessageId`
196
+ // makes a repeated copy of the same message a second server COPY, so
197
+ // this probe must never bind to the earlier one.
191
198
  const probeDestination = async (
192
199
  connection: IImapConnection,
193
200
  ): Promise<CopyProbe> => {
194
201
  if (!copyRow.messageIdHeader) return { kind: "unprobeable" };
195
- const probedUid = await searchMailboxByMessageId(
202
+ const probedUid = await searchMailboxForHighestMessageIdUid(
196
203
  connection,
197
204
  destinationMailboxPath,
198
205
  copyRow.messageIdHeader,
@@ -516,6 +516,30 @@ describe("handleMessageDelete", () => {
516
516
  );
517
517
  });
518
518
 
519
+ // Issue #1122. Trash already holds an older copy of this Message-ID (a
520
+ // resend, a sieve `fileinto` + `keep`, an earlier delete of another
521
+ // folder's copy). Settling on uid 12 points the row at that copy and
522
+ // orphans the one this MOVE just delivered: Empty Trash then expunges by
523
+ // the wrong uid and the fresh copy survives, unreachable.
524
+ it("settles on the copy this move delivered, not on an older copy already in Trash", async () => {
525
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
526
+ sourceNoLongerHoldsTheUid();
527
+ h.destinationSearchUids = [12, 77];
528
+
529
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
530
+
531
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
532
+ "msg-1",
533
+ 77,
534
+ "trash-mbx",
535
+ ]);
536
+ assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
537
+ uid: 77,
538
+ mailboxId: "trash-mbx",
539
+ isDeleted: true,
540
+ });
541
+ });
542
+
519
543
  it("probes the DESTINATION mailbox, read-only, by Message-ID", async () => {
520
544
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
521
545
  sourceNoLongerHoldsTheUid();
@@ -650,11 +674,11 @@ describe("handleMessageDelete", () => {
650
674
  assert.equal(called("emitEvent").length, 0);
651
675
  });
652
676
 
653
- // `searchMailboxByMessageId` returns the LOWEST matching uid, and one
654
- // Message-ID can have several server copies in one account while
677
+ // One Message-ID can have several server copies in one account while
655
678
  // `deriveMessageId` gives them one local row. A source that still holds
656
679
  // the uid proves the MOVE did not happen, so any hit at the destination
657
- // is a different copy.
680
+ // is a different copy — including the highest one #1122 now takes, since
681
+ // with no fresh copy at all the highest match is still an older copy.
658
682
  it("never takes a destination hit while the source still holds the uid (duplicate Message-ID)", async () => {
659
683
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
660
684
  h.destinationSearchUids = [100];
@@ -23,7 +23,10 @@ import { isNotFoundError } from "../is-not-found.js";
23
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
25
25
  import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
26
- import { emitMoveResync, searchMailboxByMessageId } from "./message-move.js";
26
+ import {
27
+ emitMoveResync,
28
+ searchMailboxForHighestMessageIdUid,
29
+ } from "./message-move.js";
27
30
  import {
28
31
  buildThreadMessageMoveRevert,
29
32
  buildThreadMessageTrashUpdate,
@@ -70,14 +73,14 @@ export const deleteAllThreadMessagesForMessage = async (
70
73
  * never be opened through the source's guard.
71
74
  *
72
75
  * The source is asked first, and a source that still holds the uid ends it: the
73
- * MOVE did not happen, so nothing at the destination can be this message.
74
- * Skipping that question is not safe, because `searchMailboxByMessageId`
75
- * returns the LOWEST matching uid rather than the one that just arrived, and
76
- * one Message-ID can have several server copies in one account (a sieve
77
- * `fileinto` + `keep`, a multi-label store, a resend) while
78
- * `deriveMessageId` is folder-independent and gives them one local row. An
79
- * ungated probe can hand back an earlier copy's uid, and Empty Trash then
80
- * expunges by that uid. It also closes the second half of #912: an empty
76
+ * MOVE did not happen, so nothing at the destination can be this message. The
77
+ * gate survives #1122's switch to the highest matching uid and is not made
78
+ * redundant by it — highest picks the fresh copy out of several only once a
79
+ * fresh copy exists, and where the MOVE never ran every hit at the destination
80
+ * is an older copy of the same Message-ID (a sieve `fileinto` + `keep`, a
81
+ * multi-label store, a resend) that `deriveMessageId` folds into this one local
82
+ * row. An ungated probe hands back that copy's uid, and Empty Trash then
83
+ * expunges by it. The gate also closes the second half of #912: an empty
81
84
  * `uidMap` can mean the MOVE matched nothing at all.
82
85
  *
83
86
  * A row with no `messageIdHeader`, and a row that is already deleted, have
@@ -109,7 +112,7 @@ const confirmTrashMoveUid = async (
109
112
  if (!message) return { outcome: "row-gone" };
110
113
  if (!message.messageIdHeader) return { outcome: "unprobeable" };
111
114
 
112
- const probedUid = await searchMailboxByMessageId(
115
+ const probedUid = await searchMailboxForHighestMessageIdUid(
113
116
  destinationConnection,
114
117
  destinationMailboxPath,
115
118
  message.messageIdHeader,
@@ -12,7 +12,7 @@ import {
12
12
  handleMessageMove,
13
13
  MESSAGE_MOVE_MAX_ATTEMPTS,
14
14
  moveThenResync,
15
- searchMailboxByMessageId,
15
+ searchMailboxForHighestMessageIdUid,
16
16
  } from "./message-move.js";
17
17
 
18
18
  const silentLogger = (() => {
@@ -343,7 +343,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
343
343
  });
344
344
  });
345
345
 
346
- describe("searchMailboxByMessageId — the probe that binds a move to a UID (#912)", () => {
346
+ describe("searchMailboxForHighestMessageIdUid — the probe that binds a move to a UID (#912)", () => {
347
347
  const isMessageIdCriterion = (
348
348
  criterion: unknown,
349
349
  ): criterion is [string, string, string] =>
@@ -381,7 +381,7 @@ describe("searchMailboxByMessageId — the probe that binds a move to a UID (#91
381
381
  },
382
382
  } as unknown as IImapConnection;
383
383
 
384
- await searchMailboxByMessageId(
384
+ await searchMailboxForHighestMessageIdUid(
385
385
  destination,
386
386
  "Archive",
387
387
  '<a"b@example.com>\r\nUID 1',
@@ -392,14 +392,14 @@ describe("searchMailboxByMessageId — the probe that binds a move to a UID (#91
392
392
  ]);
393
393
  });
394
394
 
395
- it("answers null, not the lowest UID, when the folder holds no such message", async () => {
395
+ it("answers null, not another message's UID, when the folder holds no such message", async () => {
396
396
  const destination = buildDestination([
397
397
  { uid: 11, messageIdHeader: "<stranger-a@example.com>" },
398
398
  { uid: 12, messageIdHeader: "<stranger-b@example.com>" },
399
399
  ]);
400
400
 
401
401
  assert.strictEqual(
402
- await searchMailboxByMessageId(
402
+ await searchMailboxForHighestMessageIdUid(
403
403
  destination,
404
404
  "Archive",
405
405
  "<moved@example.com>",
@@ -415,7 +415,7 @@ describe("searchMailboxByMessageId — the probe that binds a move to a UID (#91
415
415
  ]);
416
416
 
417
417
  assert.strictEqual(
418
- await searchMailboxByMessageId(
418
+ await searchMailboxForHighestMessageIdUid(
419
419
  destination,
420
420
  "Archive",
421
421
  "<moved@example.com>",
@@ -423,4 +423,43 @@ describe("searchMailboxByMessageId — the probe that binds a move to a UID (#91
423
423
  12,
424
424
  );
425
425
  });
426
+
427
+ // Issue #1122. The folder already held an older copy of this Message-ID — a
428
+ // sieve `fileinto` + `keep`, a resend, an earlier copy of the same message —
429
+ // and the uid this probe hands back settles the row the fresh copy lives in.
430
+ // Answering 12 binds that row to mail this operation never touched and
431
+ // leaves the copy that just arrived unreachable by any later delete.
432
+ it("answers the newest copy's UID when the folder already held an older copy of it", async () => {
433
+ const destination = buildDestination([
434
+ { uid: 12, messageIdHeader: "<moved@example.com>" },
435
+ { uid: 40, messageIdHeader: "<moved@example.com>" },
436
+ ]);
437
+
438
+ assert.strictEqual(
439
+ await searchMailboxForHighestMessageIdUid(
440
+ destination,
441
+ "Archive",
442
+ "<moved@example.com>",
443
+ ),
444
+ 40,
445
+ );
446
+ });
447
+
448
+ // RFC 3501 does not order a SEARCH response, so the fresh copy is the
449
+ // highest uid returned rather than the last one returned.
450
+ it("takes the highest match whatever order the server lists them in", async () => {
451
+ const destination = buildDestination([
452
+ { uid: 40, messageIdHeader: "<moved@example.com>" },
453
+ { uid: 12, messageIdHeader: "<moved@example.com>" },
454
+ ]);
455
+
456
+ assert.strictEqual(
457
+ await searchMailboxForHighestMessageIdUid(
458
+ destination,
459
+ "Archive",
460
+ "<moved@example.com>",
461
+ ),
462
+ 40,
463
+ );
464
+ });
426
465
  });
@@ -53,11 +53,28 @@ export const emitMoveResync = async (
53
53
  };
54
54
 
55
55
  /**
56
- * SEARCH a mailbox for a message by its RFC822 Message-ID header. Read-only
57
- * (EXAMINE, not SELECT) — this is a verification probe, never a write.
58
- * Returns the first matching UID, or `null` if nothing matched.
56
+ * SEARCH a mailbox for copies of a message identified by its RFC822 Message-ID
57
+ * header and answer the HIGHEST matching uid. Read-only (EXAMINE, not SELECT) —
58
+ * this is a verification probe, never a write. `null` when nothing matched.
59
+ *
60
+ * The highest uid is the freshly delivered copy only when one exists; deciding
61
+ * that it does is the caller's job, not this function's. Every probe in this
62
+ * package wants the highest: uids ascend with arrival inside a UIDVALIDITY, so a
63
+ * copy that has just landed outranks every copy of the same Message-ID the
64
+ * folder already held. Where nothing was delivered, the highest is simply the
65
+ * newest pre-existing copy, and a caller that has not ruled that case out binds
66
+ * itself to unrelated mail.
67
+ * One Message-ID can have several server copies in one account (a sieve
68
+ * `fileinto` + `keep`, a resend, a repeated COPY — `deriveCopyMessageId` reuses
69
+ * the row, so the second copy is a second server COPY), while `deriveMessageId`
70
+ * is folder-independent and gives them one local row. Taking the lowest bound
71
+ * the fresh copy's row to an older copy's uid and orphaned the fresh one, which
72
+ * a later delete or Empty Trash then expunged by the wrong uid (issue #1122).
73
+ *
74
+ * The maximum is computed rather than read off the tail: RFC 3501 leaves the
75
+ * order of a SEARCH response unspecified, so "last returned" is not "highest".
59
76
  */
60
- export const searchMailboxByMessageId = async (
77
+ export const searchMailboxForHighestMessageIdUid = async (
61
78
  connection: IImapConnection,
62
79
  mailboxPath: string,
63
80
  messageIdHeader: string,
@@ -66,7 +83,8 @@ export const searchMailboxByMessageId = async (
66
83
  const uids = await connection.search([
67
84
  ["HEADER", "Message-ID", messageIdHeader],
68
85
  ]);
69
- return uids[0] ?? null;
86
+ if (uids.length === 0) return null;
87
+ return uids.reduce((highest, uid) => (uid > highest ? uid : highest));
70
88
  };
71
89
 
72
90
  /**
@@ -273,7 +291,7 @@ export const handleMessageMove = async (
273
291
  const newUid =
274
292
  result.uidMap.get(uid) ??
275
293
  (message.messageIdHeader
276
- ? await searchMailboxByMessageId(
294
+ ? await searchMailboxForHighestMessageIdUid(
277
295
  rawConnection,
278
296
  destinationMailboxPath,
279
297
  message.messageIdHeader,
@@ -156,6 +156,28 @@ describe("attemptMove — the IMAP push (issue #1271)", () => {
156
156
  assert.equal(outcome.newUid, 77);
157
157
  });
158
158
 
159
+ // Issue #1122. The destination already held an older copy of this
160
+ // Message-ID; the marker must bind to the copy this MOVE delivered, or
161
+ // the placement row names mail this push never touched.
162
+ it("moved: binds to the newest copy when the destination already held an older one", async () => {
163
+ const connection = buildConnection({
164
+ uidMap: new Map(),
165
+ destinationSearchUids: [15, 77],
166
+ });
167
+
168
+ const outcome = await attemptMove(
169
+ connection,
170
+ connection,
171
+ "Junk",
172
+ "INBOX",
173
+ 42,
174
+ MESSAGE_ID_HEADER,
175
+ );
176
+
177
+ assert.equal(outcome.kind, "moved");
178
+ assert.equal(outcome.newUid, 77);
179
+ });
180
+
159
181
  it("throws (never deletes) when unconfirmed at the destination but STILL present at the source", async () => {
160
182
  const connection = buildConnection({
161
183
  uidMap: new Map(),
@@ -21,7 +21,7 @@ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
21
21
  import {
22
22
  buildThreadMessageMoveUpdate,
23
23
  emitMoveResync,
24
- searchMailboxByMessageId,
24
+ searchMailboxForHighestMessageIdUid,
25
25
  } from "./message-move.js";
26
26
 
27
27
  export const getPlacementMoveMaxAttempts = (
@@ -97,9 +97,16 @@ export const attemptMove = async (
97
97
  }
98
98
 
99
99
  // Unconfirmed: either no COPYUID entry, or the server explicitly claimed
100
- // "not found" — never trust either without independent verification.
100
+ // "not found" — never trust either without independent verification. A
101
+ // message this MOVE delivered is the newest copy of that Message-ID at the
102
+ // destination, so the probe binds to the highest matching uid (#1122).
103
+ // Unlike `confirmTrashMoveUid`, this probe is ungated: where the MOVE never
104
+ // ran and an older copy filed there earlier is the only match, the marker
105
+ // binds to that unrelated mail. Pre-existing, and not narrowed by taking the
106
+ // highest — the source is not asked first here, so nothing rules the case
107
+ // out.
101
108
  if (messageIdHeader) {
102
- const destinationUid = await searchMailboxByMessageId(
109
+ const destinationUid = await searchMailboxForHighestMessageIdUid(
103
110
  destinationConnection,
104
111
  destinationMailboxPath,
105
112
  messageIdHeader,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What the account row holds after the lifecycle has written to it.
3
+ *
4
+ * The deps builder is the half that talks to the store, so it is tested against
5
+ * the real repository over the shipped SQLite schema rather than a stand-in
6
+ * that would only agree with itself: whether a stored reason survives, and
7
+ * whether one that no longer applies is gone, is decided by the repository's
8
+ * update semantics, where an absent field means "leave it alone".
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { after, before, describe, it } from "node:test";
13
+ import type { AccountItem } from "@remit/data-ports";
14
+ import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
15
+ import { AccountRepo } from "@remit/drizzle-service";
16
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
17
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
18
+ import { MailConnectionError } from "@remit/mailbox-service/types";
19
+ import type { SecretsService } from "@remit/secrets-service";
20
+ import {
21
+ type OAuthLifecycleDeps,
22
+ withOAuthLifecycle,
23
+ } from "./with-oauth-lifecycle.js";
24
+ import { buildLifecycleDeps } from "./with-oauth-lifecycle-deps.js";
25
+
26
+ const silentLogger = {
27
+ info: () => {},
28
+ warn: () => {},
29
+ error: () => {},
30
+ debug: () => {},
31
+ trace: () => {},
32
+ fatal: () => {},
33
+ child: () => silentLogger,
34
+ } as never;
35
+
36
+ const secrets = {
37
+ decrypt: async () => "decrypted",
38
+ encrypt: async () => ({}) as never,
39
+ } as unknown as SecretsService;
40
+
41
+ /**
42
+ * The real deps, with only credential resolution stubbed — minting a token is
43
+ * a call to Microsoft and not what these tests are about. `updateConnectionState`
44
+ * is the one under test and stays exactly as the handlers build it.
45
+ */
46
+ const deps = (): OAuthLifecycleDeps => ({
47
+ ...buildLifecycleDeps(secrets, repo),
48
+ resolveCredentials: async () => ({
49
+ status: "resolved",
50
+ credentials: { kind: "accessToken", accessToken: "access-token" },
51
+ }),
52
+ });
53
+
54
+ const STALE_SYNC_ERROR = "Mailbox does not exist: [NONEXISTENT]";
55
+ const REFUSAL =
56
+ "IMAP authentication failed: LOGIN failed. User is authenticated but not connected.";
57
+
58
+ let repo: AccountRepo;
59
+ let close: () => void;
60
+
61
+ const account = async (): Promise<AccountItem> =>
62
+ repo.create({
63
+ accountConfigId: "cfg-1",
64
+ username: "matthijs@example.com",
65
+ email: "matthijs@example.com",
66
+ authType: AccountAuthType.OauthMicrosoft,
67
+ oauthRefreshTokenHash: "encrypted-refresh-token",
68
+ imapHost: "outlook.office365.com",
69
+ imapPort: 993,
70
+ imapTls: true,
71
+ imapStartTls: false,
72
+ isActive: true,
73
+ connectionState: ConnectionState.Authenticated,
74
+ });
75
+
76
+ before(() => {
77
+ const db = createShippedSqliteDb();
78
+ close = db.close;
79
+ repo = new AccountRepo(db.db);
80
+ });
81
+
82
+ after(() => {
83
+ close();
84
+ });
85
+
86
+ describe("the reason an account card reads back", () => {
87
+ it("keeps the refusal the mail server gave", async () => {
88
+ const created = await account();
89
+
90
+ await withOAuthLifecycle(deps(), created, silentLogger, async () => {
91
+ throw new MailConnectionError("auth", REFUSAL);
92
+ });
93
+
94
+ const stored = await repo.get(created.accountId);
95
+ assert.equal(stored.connectionState, ConnectionState.ReauthRequired);
96
+ assert.equal(stored.lastError, REFUSAL);
97
+ });
98
+
99
+ it("drops a reason the account has moved on from", async () => {
100
+ const created = await account();
101
+ // What a failed sync leaves behind (sync-mailboxes.ts): a folder that went
102
+ // away, nothing to do with signing in.
103
+ await repo.update(created.accountId, { lastError: STALE_SYNC_ERROR });
104
+
105
+ // Hours later the refresh token is revoked. Re-authenticating is now the
106
+ // whole story, and the mailbox that went missing is not it.
107
+ await withOAuthLifecycle(
108
+ deps(),
109
+ await repo.get(created.accountId),
110
+ silentLogger,
111
+ async () => {
112
+ throw new RefreshTokenError({
113
+ kind: "reauth-required",
114
+ code: "invalid_grant",
115
+ });
116
+ },
117
+ );
118
+
119
+ const stored = await repo.get(created.accountId);
120
+ assert.equal(stored.connectionState, ConnectionState.ReauthRequired);
121
+ assert.equal(
122
+ stored.lastError,
123
+ undefined,
124
+ "a stale sync error beside a Reconnect button reads as the reason for it",
125
+ );
126
+ });
127
+
128
+ it("stores a chatty refusal at a length a card can hold", async () => {
129
+ const created = await account();
130
+
131
+ await withOAuthLifecycle(deps(), created, silentLogger, async () => {
132
+ throw new MailConnectionError("auth", "x".repeat(4000));
133
+ });
134
+
135
+ const stored = await repo.get(created.accountId);
136
+ assert.equal(stored.lastError?.length, 500);
137
+ });
138
+ });
@@ -53,10 +53,19 @@ export const buildLifecycleDeps = (
53
53
  oauthTokenUpdatedAt: updatedAt,
54
54
  });
55
55
  },
56
+ // A state flip with no reason of its own removes the stored one. Left in
57
+ // place it would describe a failure the account has moved on from — the
58
+ // mailbox a sync could not find, still on the card beside a Reconnect
59
+ // button, after the token was revoked hours later.
56
60
  updateConnectionState: async (
57
61
  accountId: string,
58
62
  state: ConnectionStateValue,
63
+ lastError?: string,
59
64
  ) => {
60
- await accountService.update(accountId, { connectionState: state });
65
+ await accountService.update(
66
+ accountId,
67
+ { connectionState: state, lastError },
68
+ lastError === undefined ? ["lastError"] : undefined,
69
+ );
61
70
  },
62
71
  });
@@ -1,8 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { describe, it, mock } from "node:test";
3
3
  import type { AccountItem } from "@remit/data-ports";
4
4
  import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
5
5
  import { RefreshTokenError } from "@remit/mail-oauth-service";
6
+ import { ImapFlowConnection } from "@remit/mailbox-service/imapflow-connection";
6
7
  import {
7
8
  MailConnectionError,
8
9
  type MailCredentials,
@@ -41,11 +42,19 @@ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
41
42
  }) as unknown as AccountItem;
42
43
 
43
44
  interface Recorded {
44
- stateUpdates: Array<{ accountId: string; state: ConnectionStateValue }>;
45
+ stateUpdates: Array<{
46
+ accountId: string;
47
+ state: ConnectionStateValue;
48
+ lastError?: string;
49
+ }>;
45
50
  resolveCalls: number;
46
51
  workCalls: number;
47
52
  }
48
53
 
54
+ /** What Microsoft says to a mailbox whose tenant has IMAP switched off. */
55
+ const REFUSAL =
56
+ "LOGIN failed. User is authenticated but not connected. SmtpClientAuthentication is disabled for the Tenant.";
57
+
49
58
  const passwordCreds: MailCredentials = {
50
59
  kind: "password",
51
60
  password: "secret",
@@ -68,8 +77,8 @@ const buildDeps = (
68
77
  },
69
78
  tokenService: { getAccessToken: async () => ({}) as never },
70
79
  persistRotatedToken: async () => {},
71
- updateConnectionState: async (accountId, state) => {
72
- recorded.stateUpdates.push({ accountId, state });
80
+ updateConnectionState: async (accountId, state, lastError) => {
81
+ recorded.stateUpdates.push({ accountId, state, lastError });
73
82
  },
74
83
  resolveCredentials:
75
84
  options.resolveCredentials ??
@@ -124,9 +133,12 @@ describe("withOAuthLifecycle", () => {
124
133
  "work must not run after resolve fails",
125
134
  );
126
135
  assert.equal(recorded.stateUpdates.length, 1);
136
+ // A revoked token has nothing to add: re-authenticating is the whole of
137
+ // what the account needs, and the card says so on its own.
127
138
  assert.deepEqual(recorded.stateUpdates[0], {
128
139
  accountId: "acc-1",
129
140
  state: ConnectionState.ReauthRequired,
141
+ lastError: undefined,
130
142
  });
131
143
  });
132
144
 
@@ -142,7 +154,48 @@ describe("withOAuthLifecycle", () => {
142
154
  assert.deepEqual(recorded.stateUpdates[0], {
143
155
  accountId: "acc-1",
144
156
  state: ConnectionState.ReauthRequired,
157
+ lastError: "auth failed",
158
+ });
159
+ });
160
+
161
+ it("keeps what the server said when it refused, so the card can repeat it", async () => {
162
+ const { deps, recorded } = buildDeps();
163
+ const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
164
+
165
+ // The error an Exchange mailbox with IMAP switched off hands back, built
166
+ // the way imapflow builds it and classified by the code that ships.
167
+ const connection = new ImapFlowConnection({
168
+ host: "outlook.office365.com",
169
+ port: 993,
170
+ user: "matthijs@example.com",
171
+ credentials: { kind: "accessToken", accessToken: "access-token" },
172
+ tls: true,
173
+ });
174
+ mock.method(
175
+ connection as unknown as { attemptConnect: () => Promise<void> },
176
+ "attemptConnect",
177
+ async () => {
178
+ throw Object.assign(new Error("Command failed"), {
179
+ authenticationFailed: true,
180
+ serverResponseCode: "AUTHENTICATIONFAILED",
181
+ responseText: REFUSAL,
182
+ });
183
+ },
184
+ );
185
+
186
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
187
+ await connection.connect();
145
188
  });
189
+
190
+ assert.equal(recorded.stateUpdates.length, 1);
191
+ assert.equal(
192
+ recorded.stateUpdates[0].state,
193
+ ConnectionState.ReauthRequired,
194
+ );
195
+ assert.ok(
196
+ recorded.stateUpdates[0].lastError?.includes(REFUSAL),
197
+ `the refusal must survive to the account row, got: ${recorded.stateUpdates[0].lastError}`,
198
+ );
146
199
  });
147
200
 
148
201
  it("on MailConnectionError auth for password account: rethrows (batch item failure, no state flip)", async () => {
@@ -221,7 +274,11 @@ describe("withOAuthLifecycle", () => {
221
274
 
222
275
  assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
223
276
  assert.deepEqual(recorded.stateUpdates, [
224
- { accountId: "acc-1", state: ConnectionState.CredentialsMissing },
277
+ {
278
+ accountId: "acc-1",
279
+ state: ConnectionState.CredentialsMissing,
280
+ lastError: undefined,
281
+ },
225
282
  ]);
226
283
  });
227
284
 
@@ -236,7 +293,11 @@ describe("withOAuthLifecycle", () => {
236
293
 
237
294
  assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
238
295
  assert.deepEqual(recorded.stateUpdates, [
239
- { accountId: "acc-1", state: ConnectionState.ReauthRequired },
296
+ {
297
+ accountId: "acc-1",
298
+ state: ConnectionState.ReauthRequired,
299
+ lastError: undefined,
300
+ },
240
301
  ]);
241
302
  });
242
303
 
@@ -16,6 +16,10 @@
16
16
  * - On a terminal auth failure (RefreshTokenError reauth-required, or
17
17
  * MailConnectionError auth), flip the account to reauth_required and return
18
18
  * WITHOUT rethrowing — this ACKs the SQS message so it is not retried.
19
+ * An IMAP refusal is stored with it, because not every refusal a mail server
20
+ * hands back is one re-authenticating clears: "SmtpClientAuthentication is
21
+ * disabled" needs a tenant setting changed, and an account card that only
22
+ * ever says "Re-authentication required" sends the user around a loop.
19
23
  * - On transient / config / network errors, rethrow so SQS retries with
20
24
  * backoff (let-it-crash).
21
25
  *
@@ -42,12 +46,17 @@ export type { ConnectionStateValue };
42
46
 
43
47
  export interface OAuthLifecycleDeps extends AccountCredentialsDeps {
44
48
  /**
45
- * Persist the account's connectionState. Called when a terminal auth
46
- * failure is detected so the account is fenced off until the user re-auths.
49
+ * Persist the account's connectionState, and with it the sentence the
50
+ * account card shows for the failure. `lastError` carries the server's own
51
+ * words when the failure came with any; it is absent when nothing was said,
52
+ * as for a credential that was never stored or a token simply revoked, and
53
+ * the stored reason is then cleared rather than left to describe an older
54
+ * failure the account has moved on from.
47
55
  */
48
56
  updateConnectionState: (
49
57
  accountId: string,
50
58
  state: ConnectionStateValue,
59
+ lastError?: string,
51
60
  ) => Promise<void>;
52
61
  /**
53
62
  * Resolve credentials for the account. Defaults to
@@ -131,12 +140,14 @@ export const withOAuthLifecycle = async (
131
140
  {
132
141
  accountId: account.accountId,
133
142
  errorKind: err.kind,
143
+ error: err.message,
134
144
  },
135
145
  "IMAP auth rejected; marking account reauth_required",
136
146
  );
137
147
  await deps.updateConnectionState(
138
148
  account.accountId,
139
149
  ConnectionState.ReauthRequired,
150
+ err.message,
140
151
  );
141
152
  return; // ACK — do not retry
142
153
  }