@remit/mailbox-service 0.0.81 → 0.0.83

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/mailbox-service",
3
- "version": "0.0.81",
3
+ "version": "0.0.83",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -16,6 +16,10 @@
16
16
  "./account-credentials": {
17
17
  "types": "./src/account-credentials.ts",
18
18
  "default": "./src/account-credentials.ts"
19
+ },
20
+ "./types": {
21
+ "types": "./src/types.ts",
22
+ "default": "./src/types.ts"
19
23
  }
20
24
  },
21
25
  "scripts": {
@@ -19,6 +19,83 @@ import {
19
19
  } from "@remit/secrets-service";
20
20
  import type { MailCredentials } from "./types.js";
21
21
 
22
+ /** ConnectionState is a const object (not a TS enum); this is its value type. */
23
+ export type ConnectionStateValue =
24
+ (typeof ConnectionState)[keyof typeof ConnectionState];
25
+
26
+ /**
27
+ * The fields that say whether an account holds a credential at all. Narrower
28
+ * than AccountItem so a caller holding only an account summary can ask.
29
+ */
30
+ export type StoredCredentialFields = Partial<
31
+ Pick<AccountItem, "authType" | "passwordHash" | "oauthRefreshTokenHash">
32
+ >;
33
+
34
+ /**
35
+ * No credential of the kind this account's authType needs is stored. An
36
+ * imported account holds none by design until its owner types one, so this is
37
+ * a normal state of the world rather than a fault: no retry can change it, and
38
+ * it is distinct from a credential the mail server rejected.
39
+ *
40
+ * `terminalState` is the connectionState that describes the account while it
41
+ * stays this way, decided here so nothing downstream branches on authType.
42
+ */
43
+ export interface MissingCredential {
44
+ readonly status: "missing";
45
+ readonly terminalState: ConnectionStateValue;
46
+ readonly reason: string;
47
+ }
48
+
49
+ export type CredentialResolution =
50
+ | { readonly status: "resolved"; readonly credentials: MailCredentials }
51
+ | MissingCredential;
52
+
53
+ type StoredCredential =
54
+ | { readonly status: "password"; readonly passwordHash: string }
55
+ | { readonly status: "oauthMicrosoft"; readonly refreshTokenHash: string }
56
+ | MissingCredential;
57
+
58
+ const readStoredCredential = (
59
+ account: StoredCredentialFields,
60
+ ): StoredCredential => {
61
+ const authType = account.authType ?? AccountAuthType.Password;
62
+
63
+ if (authType === AccountAuthType.OauthMicrosoft) {
64
+ if (!account.oauthRefreshTokenHash) {
65
+ return {
66
+ status: "missing",
67
+ terminalState: ConnectionState.ReauthRequired,
68
+ reason: "authType=oauthMicrosoft but no oauthRefreshTokenHash",
69
+ };
70
+ }
71
+ return {
72
+ status: "oauthMicrosoft",
73
+ refreshTokenHash: account.oauthRefreshTokenHash,
74
+ };
75
+ }
76
+
77
+ if (!account.passwordHash) {
78
+ return {
79
+ status: "missing",
80
+ terminalState: ConnectionState.CredentialsMissing,
81
+ reason: "authType=password but no passwordHash",
82
+ };
83
+ }
84
+ return { status: "password", passwordHash: account.passwordHash };
85
+ };
86
+
87
+ /**
88
+ * Whether the account holds the credential its authType requires.
89
+ *
90
+ * Keyed on the stored credential, never on `connectionState`: PATCH
91
+ * /accounts/{id} writes a password without clearing `credentials_missing` —
92
+ * only a successful connect clears it, via markAuthenticated — so a
93
+ * state-based check would go on skipping an account that can sync perfectly
94
+ * well.
95
+ */
96
+ export const hasStoredCredential = (account: StoredCredentialFields): boolean =>
97
+ readStoredCredential(account).status !== "missing";
98
+
22
99
  export interface AccountCredentialsDeps {
23
100
  secrets: Pick<SecretsService, "decrypt" | "encrypt">;
24
101
  tokenService: Pick<MailOAuthService, "getAccessToken">;
@@ -42,6 +119,9 @@ export interface AccountCredentialsDeps {
42
119
  * tokenService. If the provider rotated the refresh token, persist the
43
120
  * new token via `deps.persistRotatedToken` BEFORE returning credentials.
44
121
  *
122
+ * Returns `{ status: "missing" }` when the account stores no credential —
123
+ * an expected outcome every caller settles terminally, never retries.
124
+ *
45
125
  * Throws `RefreshTokenError` on OAuth failures; callers should handle:
46
126
  * - `kind === "reauth-required"` → set connectionState to reauth_required, ACK
47
127
  * - `kind === "transient"` → rethrow (SQS retry)
@@ -50,49 +130,55 @@ export interface AccountCredentialsDeps {
50
130
  export const resolveConnectionCredentials = async (
51
131
  account: AccountItem,
52
132
  deps: AccountCredentialsDeps,
53
- ): Promise<MailCredentials> => {
54
- const authType = account.authType ?? AccountAuthType.Password;
133
+ ): Promise<CredentialResolution> => {
134
+ const stored = readStoredCredential(account);
55
135
 
56
- if (authType === AccountAuthType.OauthMicrosoft) {
57
- return resolveOauthCredentials(account, deps);
136
+ if (stored.status === "missing") {
137
+ return stored;
138
+ }
139
+
140
+ if (stored.status === "oauthMicrosoft") {
141
+ return {
142
+ status: "resolved",
143
+ credentials: await resolveOauthCredentials(
144
+ account.accountId,
145
+ stored.refreshTokenHash,
146
+ deps,
147
+ ),
148
+ };
58
149
  }
59
150
 
60
- // Default: password auth
61
- return resolvePasswordCredentials(account, deps.secrets);
151
+ return {
152
+ status: "resolved",
153
+ credentials: await resolvePasswordCredentials(
154
+ stored.passwordHash,
155
+ deps.secrets,
156
+ ),
157
+ };
62
158
  };
63
159
 
64
160
  const resolvePasswordCredentials = async (
65
- account: AccountItem,
161
+ passwordHash: string,
66
162
  secrets: Pick<SecretsService, "decrypt">,
67
163
  ): Promise<MailCredentials> => {
68
- if (!account.passwordHash) {
69
- throw new Error(
70
- `Account ${account.accountId} has authType=password but no passwordHash`,
71
- );
72
- }
73
164
  const password = await secrets.decrypt(
74
- deserializeEncryptedPayload(JSON.parse(account.passwordHash)),
165
+ deserializeEncryptedPayload(JSON.parse(passwordHash)),
75
166
  );
76
167
  return { kind: "password", password };
77
168
  };
78
169
 
79
170
  const resolveOauthCredentials = async (
80
- account: AccountItem,
171
+ accountId: string,
172
+ refreshTokenHash: string,
81
173
  deps: AccountCredentialsDeps,
82
174
  ): Promise<MailCredentials> => {
83
- if (!account.oauthRefreshTokenHash) {
84
- throw new Error(
85
- `Account ${account.accountId} has authType=oauthMicrosoft but no oauthRefreshTokenHash`,
86
- );
87
- }
88
-
89
175
  const refreshToken = await deps.secrets.decrypt(
90
- deserializeEncryptedPayload(JSON.parse(account.oauthRefreshTokenHash)),
176
+ deserializeEncryptedPayload(JSON.parse(refreshTokenHash)),
91
177
  );
92
178
 
93
179
  // getAccessToken throws RefreshTokenError on failure
94
180
  const tokenSet = await deps.tokenService.getAccessToken(
95
- account.accountId,
181
+ accountId,
96
182
  refreshToken,
97
183
  );
98
184
 
@@ -103,11 +189,7 @@ const resolveOauthCredentials = async (
103
189
  const encryptedHash = JSON.stringify(
104
190
  serializeEncryptedPayload(encryptedPayload),
105
191
  );
106
- await deps.persistRotatedToken(
107
- account.accountId,
108
- encryptedHash,
109
- Date.now(),
110
- );
192
+ await deps.persistRotatedToken(accountId, encryptedHash, Date.now());
111
193
  }
112
194
 
113
195
  return { kind: "accessToken", accessToken: tokenSet.accessToken };
package/src/index.ts CHANGED
@@ -6,8 +6,13 @@
6
6
 
7
7
  export {
8
8
  type AccountCredentialsDeps,
9
+ type ConnectionStateValue,
10
+ type CredentialResolution,
9
11
  encryptRefreshToken,
12
+ hasStoredCredential,
13
+ type MissingCredential,
10
14
  resolveConnectionCredentials,
15
+ type StoredCredentialFields,
11
16
  } from "./account-credentials.js";
12
17
  export {
13
18
  hasChildren,
@@ -49,6 +49,12 @@ const JUNK = mailboxAt("INBOX/Rubbish");
49
49
  const TRASH = mailboxAt("Trash");
50
50
  const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
51
51
 
52
+ /**
53
+ * `pending` is what an ordinary inbound row carries: `upsertWithStatus` is
54
+ * called without a `syncStatus` and the repository defaults to it, and nothing
55
+ * on the sync path ever promotes it (#1096). The fixture states the reachable
56
+ * state, not the one the gate used to demand.
57
+ */
52
58
  const storedIn = (
53
59
  mailbox: MailboxItem,
54
60
  overrides: Partial<MessageItem> = {},
@@ -57,7 +63,7 @@ const storedIn = (
57
63
  mailboxId: mailbox.mailboxId,
58
64
  uid: 7,
59
65
  status: MessageStatus.active,
60
- syncStatus: MessageSyncStatus.synced,
66
+ syncStatus: MessageSyncStatus.pending,
61
67
  ...overrides,
62
68
  }) as MessageItem;
63
69
 
@@ -310,6 +316,26 @@ describe("repointsOnSighting", () => {
310
316
  );
311
317
  });
312
318
 
319
+ it("accepts an ordinary inbound row the sync path left pending", () => {
320
+ assert.equal(
321
+ repointsOnSighting(
322
+ JUNK,
323
+ storedIn(INBOX, { syncStatus: MessageSyncStatus.pending }),
324
+ ),
325
+ true,
326
+ );
327
+ });
328
+
329
+ it("accepts a row a settled mutation marked synced", () => {
330
+ assert.equal(
331
+ repointsOnSighting(
332
+ JUNK,
333
+ storedIn(INBOX, { syncStatus: MessageSyncStatus.synced }),
334
+ ),
335
+ true,
336
+ );
337
+ });
338
+
313
339
  it("refuses a row whose move failed and has not been re-tried", () => {
314
340
  assert.equal(
315
341
  repointsOnSighting(
@@ -319,4 +345,30 @@ describe("repointsOnSighting", () => {
319
345
  false,
320
346
  );
321
347
  });
348
+
349
+ it("refuses a row whose own move is still in flight", () => {
350
+ assert.equal(
351
+ repointsOnSighting(
352
+ JUNK,
353
+ storedIn(INBOX, {
354
+ status: MessageStatus.moving,
355
+ syncStatus: MessageSyncStatus.pending,
356
+ }),
357
+ ),
358
+ false,
359
+ );
360
+ });
361
+
362
+ it("refuses a row whose own delete is still in flight", () => {
363
+ assert.equal(
364
+ repointsOnSighting(
365
+ JUNK,
366
+ storedIn(INBOX, {
367
+ status: MessageStatus.deleting,
368
+ syncStatus: MessageSyncStatus.pending,
369
+ }),
370
+ ),
371
+ false,
372
+ );
373
+ });
322
374
  });
@@ -145,8 +145,17 @@ export const addressSightingIn = (
145
145
  * filing locations; following one would empty every other folder's listing. And
146
146
  * a row whose own mutation has not settled is in flight, not stale
147
147
  * (imap-mutations R1): the server's answer predates the move reader is still
148
- * waiting on, so taking it would undo the optimistic write. That sighting is
149
- * reconciled by the next one after the mutation settles.
148
+ * waiting on, so taking it would undo the optimistic write. The sync path
149
+ * reconciles rather than waits (imap-mutations R2): it never blocks on a
150
+ * mutation, it re-reads the row on the next sighting after the move settles.
151
+ *
152
+ * In flight is read off `status`, never off `syncStatus` (#1096). Every
153
+ * outbound mutation writes `moving` or `deleting` and only settling returns the
154
+ * row to `active`, while an ordinary inbound row stays `pending` for its whole
155
+ * life because nothing on the sync path promotes it — so demanding `synced`
156
+ * here meant ordinary mail never re-pointed at all. `failed` is still refused:
157
+ * a mutation that gave up leaves it set on a row put back to `active`, and
158
+ * nothing routine settles that row afterwards.
150
159
  */
151
160
  export const repointsOnSighting = (
152
161
  mailbox: MailboxItem,
@@ -154,10 +163,8 @@ export const repointsOnSighting = (
154
163
  ): boolean => {
155
164
  if (message.mailboxId === mailbox.mailboxId) return false;
156
165
  if (isVirtualCopyMailbox(mailbox)) return false;
157
- return (
158
- message.status === MessageStatus.active &&
159
- message.syncStatus === MessageSyncStatus.synced
160
- );
166
+ if (message.status !== MessageStatus.active) return false;
167
+ return message.syncStatus !== MessageSyncStatus.failed;
161
168
  };
162
169
 
163
170
  /**