@remit/mailbox-service 0.0.82 → 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.82",
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,