@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.
- package/dist/mailbox/react/Mail.d.ts +45 -1
- package/dist/mailbox/react/Mail.js +36 -10
- package/dist/mailbox/react/index.d.ts +1 -1
- package/dist/postmaster/apply.d.ts +0 -1
- package/dist/postmaster/apply.js +0 -1
- package/dist/postmaster/browser-refusal.d.ts +0 -0
- package/dist/postmaster/browser-refusal.js +21 -0
- package/dist/postmaster/index.d.ts +0 -9
- package/dist/postmaster/index.js +0 -9
- package/dist/postmaster/instance.d.ts +0 -1
- package/dist/postmaster/instance.js +0 -1
- package/dist/postmaster/load.d.ts +0 -1
- package/dist/postmaster/load.js +0 -1
- package/package.json +5 -5
|
@@ -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 [
|
|
32
|
-
const [
|
|
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
|
-
|
|
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';
|
package/dist/postmaster/apply.js
CHANGED
|
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
|
package/dist/postmaster/index.js
CHANGED
|
@@ -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
|
package/dist/postmaster/load.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/email",
|
|
3
|
-
"version": "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",
|