@wtfalch/email 0.1.0 → 0.3.0

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.
@@ -1,8 +1,52 @@
1
1
  import type { MailClient } from '../client.ts';
2
+ import type { MailError } from '../errors.ts';
3
+ /**
4
+ * Where the reader is: which mailbox, which conversation.
5
+ *
6
+ * Its own type because an application that puts this in the URL has to be
7
+ * able to name it.
8
+ */
9
+ export type MailLocation = {
10
+ mailboxId: string | null;
11
+ threadId: string | null;
12
+ };
2
13
  export type MailProps = {
3
14
  client: MailClient;
15
+ /**
16
+ * Drive the selection from outside — from the URL, almost always.
17
+ *
18
+ * Omit it and the component keeps its own, which is right for a gallery or
19
+ * an embed. Supply it with `onNavigate` and the two selections become the
20
+ * application's: `/inbox` and `/thread/abc` can be real addresses that
21
+ * survive a reload, appear in history and can be sent to someone.
22
+ *
23
+ * A partially controlled component is a trap, so this is all or nothing:
24
+ * pass `location` and you own both fields.
25
+ */
26
+ location?: MailLocation;
27
+ /** Called when something in here wants to move. Required with `location`,
28
+ * because without it the controlled selection could never change. */
29
+ onNavigate?: (to: MailLocation) => void;
30
+ /**
31
+ * The right-hand end of the header: who is signed in, and the way out.
32
+ *
33
+ * A slot rather than anything this component builds, because `Mail` does
34
+ * not know how the person got here. It has no idea whether there is a
35
+ * session, a token, an issuer or a sign-out — and a mail view that grew
36
+ * opinions about authentication would be a mail view nobody could embed.
37
+ */
38
+ account?: React.ReactNode;
39
+ /**
40
+ * Called when any of the fetches behind this view fails.
41
+ *
42
+ * The views already say "could not load" for themselves; this is for the
43
+ * application to act on a *class* of failure it alone can answer. The one
44
+ * that matters is 401: the token expired mid-session, and only the thing
45
+ * that obtained it can get another.
46
+ */
47
+ onError?: (error: MailError) => void;
4
48
  /** Now, for the list's relative times. Passed in so stories hold still. */
5
49
  now?: Date;
6
50
  className?: string;
7
51
  };
8
- export declare function Mail({ client, now, className }: MailProps): import("react").JSX.Element;
52
+ export declare function Mail({ client, location, onNavigate, account, onError, now, className, }: MailProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Command, Icon, Input, Modal, SplitPane } from '@wtfalch/design';
3
- import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { forwardDraft, replyDraft } from "../drafts.js";
5
5
  import { findRole } from "../mailboxes.js";
6
6
  import { send } from "../submit.js";
@@ -27,9 +27,25 @@ import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./h
27
27
  * mailboxes as "go to" entries, so moving around never requires the mouse.
28
28
  */
29
29
  const PAGE = 50;
30
- export function Mail({ client, now, className }) {
31
- const [mailboxId, setMailboxId] = useState(null);
32
- const [threadId, setThreadId] = useState(null);
30
+ export function Mail({ client, location, onNavigate, account, onError, now, className, }) {
31
+ const [ownMailboxId, setOwnMailboxId] = useState(null);
32
+ const [ownThreadId, setOwnThreadId] = useState(null);
33
+ /* Controlled when a location was given, and then entirely: reading one
34
+ field from the caller and the other from here is how a component ends up
35
+ with two disagreeing ideas of where it is. */
36
+ const controlled = location !== undefined;
37
+ const mailboxId = controlled ? location.mailboxId : ownMailboxId;
38
+ const threadId = controlled ? location.threadId : ownThreadId;
39
+ const navigate = useCallback((to) => {
40
+ if (controlled)
41
+ onNavigate?.(to);
42
+ else {
43
+ setOwnMailboxId(to.mailboxId);
44
+ setOwnThreadId(to.threadId);
45
+ }
46
+ }, [controlled, onNavigate]);
47
+ const setMailboxId = useCallback((next) => navigate({ mailboxId: next, threadId: null }), [navigate]);
48
+ const setThreadId = useCallback((next) => navigate({ mailboxId, threadId: next }), [navigate, mailboxId]);
33
49
  const [position, setPosition] = useState(0);
34
50
  const [query, setQuery] = useState('');
35
51
  const [text, setText] = useState('');
@@ -48,7 +64,7 @@ export function Mail({ client, now, className }) {
48
64
  const inbox = findRole(boxes.data, 'inbox') ?? boxes.data[0];
49
65
  if (inbox)
50
66
  setMailboxId(inbox.id);
51
- }, [boxes.data, mailboxId]);
67
+ }, [boxes.data, mailboxId, setMailboxId]);
52
68
  const threads = useThreads({ mailboxId: mailboxId ?? undefined, text: query, position, limit: PAGE, calculateTotal: true }, client);
53
69
  const thread = useThread(threadId, client);
54
70
  const refresh = useCallback(() => {
@@ -56,14 +72,24 @@ export function Mail({ client, now, className }) {
56
72
  threads.reload();
57
73
  }, [boxes.reload, threads.reload]);
58
74
  usePush(refresh, client);
75
+ /* Errors reach the application, but only when they change: these hooks hold
76
+ their last error while a retry is in flight, so forwarding on every
77
+ render would call `onError` sixty times for one dead token. */
78
+ const failure = boxes.error ?? threads.error ?? thread.error ?? identities.error;
79
+ const reported = useRef(undefined);
80
+ useEffect(() => {
81
+ if (!failure || reported.current === failure)
82
+ return;
83
+ reported.current = failure;
84
+ onError?.(failure);
85
+ }, [failure, onError]);
59
86
  const go = useCallback((mailbox) => {
60
- setMailboxId(mailbox.id);
61
- setThreadId(null);
87
+ navigate({ mailboxId: mailbox.id, threadId: null });
62
88
  setPosition(0);
63
89
  setQuery('');
64
90
  setText('');
65
- }, []);
66
- const openThread = useCallback((summary) => setThreadId(summary.id), []);
91
+ }, [navigate]);
92
+ const openThread = useCallback((summary) => setThreadId(summary.id), [setThreadId]);
67
93
  /* Cmd-K, and Escape out of a search. Bound on the window rather than on a
68
94
  container, because the palette has to open from wherever focus is --
69
95
  including from inside the message being read. */
@@ -145,5 +171,5 @@ export function Mail({ client, now, className }) {
145
171
  return;
146
172
  setText('');
147
173
  setQuery('');
148
- } }) }), _jsx(Button, { kind: "ghost", onClick: () => setPaletteOpen(true), "aria-label": "Open the palette", children: _jsx(Icon, { name: "bolt" }) })] }), _jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 22, min: 14, max: 40, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 38, min: 22, max: 60, storageKey: "mail-list", children: [_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: setPosition, limit: PAGE, loading: threads.loading, now: now, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }), _jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward })] })] }), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), draft && (_jsx(Modal, { title: "New message", width: "46rem", onClose: sending ? undefined : () => setDraft(null), closeDisabled: sending, children: _jsx(Composer, { draft: draft, onChange: setDraft, onSend: doSend, onCancel: () => setDraft(null), identities: identities.data ?? [], sending: sending, error: sendError }) }))] }));
174
+ } }) }), _jsx(Button, { kind: "ghost", onClick: () => setPaletteOpen(true), "aria-label": "Open the palette", children: _jsx(Icon, { name: "bolt" }) }), account] }), _jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 22, min: 14, max: 40, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 38, min: 22, max: 60, storageKey: "mail-list", children: [_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: setPosition, limit: PAGE, loading: threads.loading, now: now, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }), _jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward })] })] }), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), draft && (_jsx(Modal, { title: "New message", width: "46rem", onClose: sending ? undefined : () => setDraft(null), closeDisabled: sending, children: _jsx(Composer, { draft: draft, onChange: setDraft, onSend: doSend, onCancel: () => setDraft(null), identities: identities.data ?? [], sending: sending, error: sendError }) }))] }));
149
175
  }
@@ -21,4 +21,4 @@ export type { ThreadViewProps } from './ThreadView.tsx';
21
21
  export { Composer, parseAddresses } from './Composer.tsx';
22
22
  export type { ComposerProps } from './Composer.tsx';
23
23
  export { Mail } from './Mail.tsx';
24
- export type { MailProps } from './Mail.tsx';
24
+ export type { MailLocation, MailProps } from './Mail.tsx';
@@ -1,4 +1,3 @@
1
- import 'server-only';
2
1
  import type { Instance } from './instance.js';
3
2
  export interface Applied {
4
3
  /** For the audit row's `targetType`. */
@@ -1,4 +1,3 @@
1
- import 'server-only';
2
1
  import { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
3
2
  /**
4
3
  * The four writes `/email` makes, each as one round trip against the mail
File without changes
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * What a browser bundle gets instead of the administration client.
4
+ *
5
+ * This entry reaches a mail server with an administrator credential that, on
6
+ * Stalwart 0.16, cannot be scoped and is as powerful as the whole account. It
7
+ * must never end up in something a browser downloads.
8
+ *
9
+ * **Why the `browser` condition and not `server-only`.** `server-only` throws
10
+ * in any environment without React's `react-server` condition, which includes
11
+ * plain Node — and this entry exists partly so a provisioning CLI can use it,
12
+ * so that guard broke the legitimate caller while blocking the illegitimate
13
+ * one. The `browser` condition in the exports map is the tool that tells them
14
+ * apart: a bundler targeting a browser resolves here, while Node and a React
15
+ * Server Components build resolve the real module.
16
+ *
17
+ * It throws at import rather than exporting stubs, so the failure is at build
18
+ * or first load with a sentence saying what to do, not a runtime mystery
19
+ * about a client that never answers.
20
+ */
21
+ throw new Error('@wtfalch/email/postmaster is server-side only: it holds an administrator credential for a mail server and must not be bundled for a browser. Call it from a Server Component, a Server Action, a route handler or a Node script, and render the results with @wtfalch/email/postmaster/react.');
@@ -1,12 +1,3 @@
1
- /**
2
- * `server-only`, and it is load-bearing rather than decorative. This module
3
- * reaches a mail server with an administrator credential that, on Stalwart
4
- * 0.16, cannot be scoped and is as powerful as the whole account. Nothing
5
- * about living in its own entry stops a client component importing it —
6
- * bundlers follow imports, not entry names — so the guard that actually
7
- * fails such a build is this line.
8
- */
9
- import 'server-only';
10
1
  /**
11
2
  * Administer a Stalwart mail server over its JMAP management API: the
12
3
  * domain, the mailboxes, their aliases, their app passwords and their
@@ -1,12 +1,3 @@
1
- /**
2
- * `server-only`, and it is load-bearing rather than decorative. This module
3
- * reaches a mail server with an administrator credential that, on Stalwart
4
- * 0.16, cannot be scoped and is as powerful as the whole account. Nothing
5
- * about living in its own entry stops a client component importing it —
6
- * bundlers follow imports, not entry names — so the guard that actually
7
- * fails such a build is this line.
8
- */
9
- import 'server-only';
10
1
  /**
11
2
  * Administer a Stalwart mail server over its JMAP management API: the
12
3
  * domain, the mailboxes, their aliases, their app passwords and their
@@ -1,4 +1,3 @@
1
- import 'server-only';
2
1
  import { StalwartClient } from './client.js';
3
2
  /**
4
3
  * A mail instance and the credential this program acts on it with.
@@ -1,4 +1,3 @@
1
- import 'server-only';
2
1
  import { StalwartClient, authFor } from './client.js';
3
2
  /**
4
3
  * An instance from a URL and one opaque secret, with the scheme chosen from
@@ -1,4 +1,3 @@
1
- import 'server-only';
2
1
  import type { Instance } from './instance.js';
3
2
  import { type Overview, type Person } from './overview.js';
4
3
  /**
@@ -1,4 +1,3 @@
1
- import 'server-only';
2
1
  import { resolveMx } from 'node:dns/promises';
3
2
  import { overview, } from './overview.js';
4
3
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/email",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "./mailbox/mail.css": "./dist/mailbox/mail.css",
25
25
  "./postmaster": {
26
+ "browser": "./dist/postmaster/browser-refusal.js",
26
27
  "types": "./dist/postmaster/index.d.ts",
27
28
  "default": "./dist/postmaster/index.js"
28
29
  },
@@ -64,10 +65,6 @@
64
65
  "typescript": "^5.9.0",
65
66
  "vitest": "^4.1.6"
66
67
  },
67
- "dependencies": {
68
- "jmap-jam": "^0.13.6",
69
- "server-only": "^0.0.1"
70
- },
71
68
  "sideEffects": false,
72
69
  "publishConfig": {
73
70
  "access": "public"
@@ -82,6 +79,9 @@
82
79
  "stalwart",
83
80
  "rfc8621"
84
81
  ],
82
+ "dependencies": {
83
+ "jmap-jam": "^0.13.6"
84
+ },
85
85
  "scripts": {
86
86
  "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/mailbox/react/mail.css dist/mailbox/mail.css",
87
87
  "typecheck": "tsc --noEmit",