@wtfalch/email 0.3.0 → 0.4.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.
@@ -31,13 +31,11 @@ export type MailClientOptions = {
31
31
  /** Use a different account than the session's primary one. */
32
32
  accountId?: string;
33
33
  };
34
- /**
35
- * A connection to one JMAP account.
36
- *
37
- * Every other module in this package takes one of these. It owns the session
38
- * document, the account id, and the `jmap-jam` client the wire calls go
39
- * through; `jmap-jam` itself is not part of this package's public surface.
40
- */
34
+ /** Passed to every `jmap-jam` call, whose `fetchInit` is spread over the
35
+ * request it builds. */
36
+ export declare const JAM_FETCH: {
37
+ fetchInit: RequestInit;
38
+ };
41
39
  export declare class MailClient {
42
40
  #private;
43
41
  constructor(options: MailClientOptions);
@@ -1,5 +1,5 @@
1
1
  import { JamClient } from 'jmap-jam';
2
- import { MailError, guard, toMailError } from "./errors.js";
2
+ import { MailError, guard } from "./errors.js";
3
3
  import { expandTemplate } from "./uri.js";
4
4
  export const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
5
5
  export const SUBMISSION_CAPABILITY = 'urn:ietf:params:jmap:submission';
@@ -16,6 +16,26 @@ function looksLikeSession(value) {
16
16
  * document, the account id, and the `jmap-jam` client the wire calls go
17
17
  * through; `jmap-jam` itself is not part of this package's public surface.
18
18
  */
19
+ /**
20
+ * Never let the browser answer an authentication challenge for us.
21
+ *
22
+ * Every request here carries its own `Authorization` header. If one is
23
+ * refused, Stalwart answers with both `WWW-Authenticate: Bearer` and
24
+ * `WWW-Authenticate: Basic`, and a browser that is allowed to use
25
+ * credentials honours the second by opening its native password prompt —
26
+ * a modal that hides whatever the application was about to say about the
27
+ * refusal. `omit` declines that on our behalf: the 401 comes back as an
28
+ * ordinary response and the application gets to explain it.
29
+ *
30
+ * Outside a browser this is inert.
31
+ */
32
+ const NO_BROWSER_CREDENTIALS = { credentials: 'omit' };
33
+ /** Passed to every `jmap-jam` call, whose `fetchInit` is spread over the
34
+ * request it builds. */
35
+ export const JAM_FETCH = { fetchInit: NO_BROWSER_CREDENTIALS };
36
+ /** Handed to `JamClient`'s constructor so its eager session request resolves
37
+ * in-process instead of going to the server. See `#open`. */
38
+ const NO_SESSION_URL = 'data:application/json,{}';
19
39
  export class MailClient {
20
40
  #sessionUrl;
21
41
  #wantedAccountId;
@@ -60,37 +80,34 @@ export class MailClient {
60
80
  async #open() {
61
81
  const authorization = this.#authorization;
62
82
  const isBearer = authorization.startsWith('Bearer ');
63
- // `JamClient` can only build a `Bearer` header from the string it is
64
- // given, so a credential that is not a bearer token (an app password,
65
- // which Stalwart takes as Basic) needs the header replaced afterwards.
66
- // The constructor has already fired its own session request by then, so
67
- // that path reads the session here and discards what the constructor
68
- // fetched; the bearer path, which is what the product uses, costs one
69
- // request as usual.
83
+ // `JamClient`'s constructor fires a session request of its own, and we
84
+ // want neither the request nor its result. It calls `.json()` without
85
+ // looking at the status, so a refusal arrives as an unreadable parse
86
+ // error and, worse, it is a plain same-origin fetch, so a 401 carrying
87
+ // `WWW-Authenticate: Basic` makes the browser put up its own password
88
+ // prompt. To a person that is indistinguishable from being asked to sign
89
+ // in again, on the right domain, when in fact they are already signed in
90
+ // and the token is the problem.
91
+ //
92
+ // So point the constructor at a `data:` URL, which resolves in-process
93
+ // and never touches the network, and read the session ourselves with the
94
+ // status checked. `jam.session` is overwritten with that document, and
95
+ // it is the document — not the URL — that carries `apiUrl`, so nothing
96
+ // downstream notices.
70
97
  const jam = new JamClient({
71
- sessionUrl: this.#sessionUrl,
98
+ sessionUrl: NO_SESSION_URL,
72
99
  bearerToken: isBearer ? authorization.slice('Bearer '.length) : '',
73
100
  });
74
- let doc;
75
- if (isBearer) {
76
- let loaded;
77
- try {
78
- loaded = await jam.session;
79
- }
80
- catch (cause) {
81
- // `JamClient.loadSession` calls `.json()` without checking the
82
- // status, so a 401 with an HTML body arrives here as a parse error.
83
- throw await this.#sessionFailure(authorization, cause);
84
- }
85
- if (!looksLikeSession(loaded))
86
- throw await this.#sessionFailure(authorization, loaded);
87
- doc = loaded;
88
- }
89
- else {
101
+ // Nothing ever reads the promise the constructor made, and in Node an
102
+ // unobserved rejection takes the process down. Say so explicitly.
103
+ void jam.session.catch(() => { });
104
+ // `JamClient` can only build a `Bearer` header from the string it is
105
+ // given, so a credential that is not a bearer token — an app password,
106
+ // which Stalwart takes as Basic — needs the header replaced.
107
+ if (!isBearer)
90
108
  jam.authHeader = authorization;
91
- doc = await this.#fetchSession(authorization);
92
- jam.session = Promise.resolve(doc);
93
- }
109
+ const doc = await this.#fetchSession(authorization);
110
+ jam.session = Promise.resolve(doc);
94
111
  const accountId = this.#wantedAccountId ?? doc.primaryAccounts?.[MAIL_CAPABILITY];
95
112
  if (!accountId) {
96
113
  throw new MailError(`the session document names no primary account for ${MAIL_CAPABILITY}; the server may not offer JMAP Mail to this login`, { operation: 'client.connect' });
@@ -110,6 +127,7 @@ export class MailClient {
110
127
  response = await fetch(this.#sessionUrl, {
111
128
  headers: { Authorization: authorization, Accept: 'application/json' },
112
129
  cache: 'no-cache',
130
+ ...NO_BROWSER_CREDENTIALS,
113
131
  });
114
132
  }
115
133
  catch (cause) {
@@ -134,18 +152,6 @@ export class MailClient {
134
152
  }
135
153
  return parsed;
136
154
  }
137
- /** Turn a failed or unrecognisable session read into an error that says
138
- * what happened, by asking again with the status checked. */
139
- async #sessionFailure(authorization, cause) {
140
- try {
141
- await this.#fetchSession(authorization);
142
- }
143
- catch (error) {
144
- return toMailError(error, 'client.connect');
145
- }
146
- // The second read succeeded, so the first was a transient failure.
147
- return toMailError(cause, 'client.connect');
148
- }
149
155
  /** The signed-in person and the account being read. */
150
156
  async session() {
151
157
  const { doc, accountId } = await this.connect();
@@ -185,6 +191,7 @@ export class MailClient {
185
191
  return guard('client.upload', async () => {
186
192
  const result = await jam.uploadBlob(accountId, body, {
187
193
  headers: { Authorization: this.#authorization, 'Content-Type': type },
194
+ ...NO_BROWSER_CREDENTIALS,
188
195
  });
189
196
  return { blobId: result.blobId, type: result.type, size: result.size };
190
197
  });
@@ -1,4 +1,4 @@
1
- import type { MailClient } from './client.ts';
1
+ import { type MailClient } from './client.ts';
2
2
  import type { MailIdentity } from './types.ts';
3
3
  /** Every address this account may send as. */
4
4
  export declare function identities(client: MailClient): Promise<readonly MailIdentity[]>;
@@ -1,9 +1,10 @@
1
+ import { JAM_FETCH } from "./client.js";
1
2
  import { guard } from "./errors.js";
2
3
  /** Every address this account may send as. */
3
4
  export async function identities(client) {
4
5
  const { jam, accountId } = await client.connect();
5
6
  return guard('identities', async () => {
6
- const [result] = await jam.api.Identity.get({ accountId });
7
+ const [result] = await jam.api.Identity.get({ accountId }, JAM_FETCH);
7
8
  return result.list.map((identity) => ({
8
9
  id: identity.id,
9
10
  name: identity.name ?? '',
@@ -1,4 +1,4 @@
1
- import type { MailClient } from './client.ts';
1
+ import { type MailClient } from './client.ts';
2
2
  import type { MailboxNode, MailboxRole } from './types.ts';
3
3
  /** A mailbox as the server returns it, before the tree is built. */
4
4
  export type FlatMailbox = {
@@ -1,3 +1,4 @@
1
+ import { JAM_FETCH } from "./client.js";
1
2
  import { guard } from "./errors.js";
2
3
  /** RFC 8621 §2: order by `sortOrder`, and break ties by name. */
3
4
  function compare(a, b) {
@@ -101,7 +102,7 @@ export async function mailboxes(client) {
101
102
  const [result] = await jam.api.Mailbox.get({
102
103
  accountId,
103
104
  properties: MAILBOX_PROPERTIES,
104
- });
105
+ }, JAM_FETCH);
105
106
  return buildTree(result.list);
106
107
  });
107
108
  }
@@ -1,4 +1,4 @@
1
- import type { MailClient } from './client.ts';
1
+ import { type MailClient } from './client.ts';
2
2
  import type { Draft, EmailAddress, MailIdentity, MailboxNode, Sent } from './types.ts';
3
3
  /** The JMAP `Email` object a draft becomes. Exported so the shape can be
4
4
  * asserted without a server. */
@@ -1,3 +1,4 @@
1
+ import { JAM_FETCH } from "./client.js";
1
2
  import { MailError, guard } from "./errors.js";
2
3
  import { identities } from "./identities.js";
3
4
  import { findRole, mailboxes } from "./mailboxes.js";
@@ -119,7 +120,7 @@ export async function send(client, draft, options = {}) {
119
120
  const [created] = await jam.api.Email.set({
120
121
  accountId,
121
122
  create: { draft: draftToEmail(draft, from, drafts.id) },
122
- });
123
+ }, JAM_FETCH);
123
124
  const email = created.created?.draft;
124
125
  if (!email)
125
126
  failed('draft', created.notCreated);
@@ -137,7 +138,7 @@ export async function send(client, draft, options = {}) {
137
138
  // declare the mail capability as well as submission. jmap-jam derives
138
139
  // capabilities from the method names alone and would send only
139
140
  // submission and core.
140
- { using: ['urn:ietf:params:jmap:mail'] });
141
+ { using: ['urn:ietf:params:jmap:mail'], ...JAM_FETCH });
141
142
  const submission = submitted.created?.submission;
142
143
  if (!submission)
143
144
  failed('submission', submitted.notCreated);
@@ -1,4 +1,4 @@
1
- import type { MailClient } from './client.ts';
1
+ import { type MailClient } from './client.ts';
2
2
  import type { EmailAddress, Message, ThreadDetail } from './types.ts';
3
3
  /** A body part as `Email/get` returns it. */
4
4
  type BodyPart = {
@@ -1,3 +1,4 @@
1
+ import { JAM_FETCH } from "./client.js";
1
2
  import { MailError, guard } from "./errors.js";
2
3
  import { expandTemplate } from "./uri.js";
3
4
  const MESSAGE_PROPERTIES = [
@@ -125,7 +126,7 @@ export async function thread(client, threadId, options = {}) {
125
126
  maxBodyValueBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
126
127
  });
127
128
  return { threads, messages };
128
- });
129
+ }, JAM_FETCH);
129
130
  const found = results.threads.list;
130
131
  if (found.length === 0) {
131
132
  throw new MailError(`no thread ${threadId} in this account`, {
@@ -1,4 +1,4 @@
1
- import type { MailClient } from './client.ts';
1
+ import { type MailClient } from './client.ts';
2
2
  import type { EmailAddress, MailFilter, MailSort, ThreadPage, ThreadSummary } from './types.ts';
3
3
  type SummaryEmail = {
4
4
  id: string;
@@ -1,3 +1,4 @@
1
+ import { JAM_FETCH } from "./client.js";
1
2
  import { guard } from "./errors.js";
2
3
  /** What a list row needs from the newest message in each thread. */
3
4
  const SUMMARY_PROPERTIES = [
@@ -121,7 +122,7 @@ export async function queryThreads(client, filter, options = {}) {
121
122
  properties: MEMBER_PROPERTIES,
122
123
  });
123
124
  return { query, latest, threads, members };
124
- });
125
+ }, JAM_FETCH);
125
126
  const query = results.query;
126
127
  const latest = results.latest;
127
128
  const threads = results.threads;
@@ -13,13 +13,32 @@ export interface MailObjectsInput {
13
13
  /**
14
14
  * The project's identity provider, once the mail applications are
15
15
  * registered there (docs/plans/email.md E3, phase-4.md slice 1): tokens
16
- * from `issuerUrl` for `audience` (the ZITADEL project id) sign people in,
17
- * on the domain and as the server's default. Absent, the domain keeps the
18
- * internal directory and people have passwords.
16
+ * from `issuerUrl` for `audience` (the provider's project or resource id)
17
+ * sign people in, on the domain and as the server's default. Absent, the
18
+ * domain keeps the internal directory and people have passwords.
19
19
  */
20
20
  oidc?: {
21
21
  issuerUrl: string;
22
22
  audience: string;
23
+ /**
24
+ * Scopes an access token must carry, on top of the audience check.
25
+ *
26
+ * **Empty by default, and that is the safe default rather than a lax
27
+ * one.** Whether an access token carries a `scope` claim at all is the
28
+ * provider's choice: OIDC Core keeps the `profile` and `email` claims at
29
+ * the userinfo endpoint rather than in the token, and a provider that
30
+ * follows it mints access tokens with no `scope` claim to check. Stalwart
31
+ * reads this against the token's own claims when it validates a JWT
32
+ * offline, so a requirement here refuses *every* token such a provider
33
+ * issues — 401 on a credential that is real, current and for this
34
+ * audience, which is a dead end with nothing on screen to explain it.
35
+ *
36
+ * The audience is what binds a token to this server. Set this only for a
37
+ * provider known to put scopes in the token.
38
+ */
39
+ requireScopes?: Record<string, boolean>;
40
+ /** The claim holding the login name. The OIDC standard one by default. */
41
+ claimUsername?: string;
23
42
  };
24
43
  }
25
44
  /** A value that names an object created earlier in the same plan. */
@@ -19,8 +19,8 @@ export function mailObjects(i) {
19
19
  description: oidcDescription,
20
20
  issuerUrl: i.oidc.issuerUrl,
21
21
  requireAudience: i.oidc.audience,
22
- requireScopes: { openid: true, email: true },
23
- claimUsername: 'preferred_username',
22
+ requireScopes: i.oidc.requireScopes ?? {},
23
+ claimUsername: i.oidc.claimUsername ?? 'preferred_username',
24
24
  usernameDomain: i.apex,
25
25
  claimName: 'name',
26
26
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/email",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,11 +33,14 @@
33
33
  },
34
34
  "./package.json": "./package.json"
35
35
  },
36
- "files": [
37
- "dist",
38
- "LICENSE",
39
- "README.md"
40
- ],
36
+ "files": ["dist", "LICENSE", "README.md"],
37
+ "scripts": {
38
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/mailbox/react/mail.css dist/mailbox/mail.css",
39
+ "prepack": "pnpm build",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run",
42
+ "record-fixtures": "node scripts/record-fixtures.mjs"
43
+ },
41
44
  "peerDependencies": {
42
45
  "@wtfalch/design": ">=0.4.0",
43
46
  "react": "^19.0.0",
@@ -72,20 +75,8 @@
72
75
  "engines": {
73
76
  "node": ">=22.0.0"
74
77
  },
75
- "keywords": [
76
- "jmap",
77
- "email",
78
- "mail",
79
- "stalwart",
80
- "rfc8621"
81
- ],
78
+ "keywords": ["jmap", "email", "mail", "stalwart", "rfc8621"],
82
79
  "dependencies": {
83
80
  "jmap-jam": "^0.13.6"
84
- },
85
- "scripts": {
86
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/mailbox/react/mail.css dist/mailbox/mail.css",
87
- "typecheck": "tsc --noEmit",
88
- "test": "vitest run",
89
- "record-fixtures": "node scripts/record-fixtures.mjs"
90
81
  }
91
- }
82
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 William Tallis Falch
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.