@wtfalch/email 0.1.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/LICENSE +21 -0
- package/dist/mailbox/client.d.ts +85 -0
- package/dist/mailbox/client.js +201 -0
- package/dist/mailbox/drafts.d.ts +52 -0
- package/dist/mailbox/drafts.js +134 -0
- package/dist/mailbox/errors.d.ts +45 -0
- package/dist/mailbox/errors.js +88 -0
- package/dist/mailbox/fake/index.d.ts +34 -0
- package/dist/mailbox/fake/index.js +85 -0
- package/dist/mailbox/fake/mailbox.d.ts +65 -0
- package/dist/mailbox/fake/mailbox.js +402 -0
- package/dist/mailbox/fake/sample.d.ts +11 -0
- package/dist/mailbox/fake/sample.js +85 -0
- package/dist/mailbox/identities.d.ts +4 -0
- package/dist/mailbox/identities.js +18 -0
- package/dist/mailbox/index.d.ts +27 -0
- package/dist/mailbox/index.js +17 -0
- package/dist/mailbox/mail.css +451 -0
- package/dist/mailbox/mailboxes.d.ts +33 -0
- package/dist/mailbox/mailboxes.js +107 -0
- package/dist/mailbox/push.d.ts +40 -0
- package/dist/mailbox/push.js +127 -0
- package/dist/mailbox/react/Composer.d.ts +37 -0
- package/dist/mailbox/react/Composer.js +64 -0
- package/dist/mailbox/react/Mail.d.ts +8 -0
- package/dist/mailbox/react/Mail.js +149 -0
- package/dist/mailbox/react/MailboxTree.d.ts +14 -0
- package/dist/mailbox/react/MailboxTree.js +52 -0
- package/dist/mailbox/react/ThreadList.d.ts +37 -0
- package/dist/mailbox/react/ThreadList.js +41 -0
- package/dist/mailbox/react/ThreadView.d.ts +33 -0
- package/dist/mailbox/react/ThreadView.js +80 -0
- package/dist/mailbox/react/context.d.ts +11 -0
- package/dist/mailbox/react/context.js +28 -0
- package/dist/mailbox/react/hooks.d.ts +46 -0
- package/dist/mailbox/react/hooks.js +127 -0
- package/dist/mailbox/react/index.d.ts +24 -0
- package/dist/mailbox/react/index.js +18 -0
- package/dist/mailbox/search.d.ts +20 -0
- package/dist/mailbox/search.js +18 -0
- package/dist/mailbox/submit.d.ts +35 -0
- package/dist/mailbox/submit.js +150 -0
- package/dist/mailbox/thread.d.ts +61 -0
- package/dist/mailbox/thread.js +153 -0
- package/dist/mailbox/threads.d.ts +44 -0
- package/dist/mailbox/threads.js +156 -0
- package/dist/mailbox/types.d.ts +233 -0
- package/dist/mailbox/types.js +8 -0
- package/dist/mailbox/uri.d.ts +17 -0
- package/dist/mailbox/uri.js +26 -0
- package/dist/postmaster/apply.d.ts +62 -0
- package/dist/postmaster/apply.js +192 -0
- package/dist/postmaster/client.d.ts +127 -0
- package/dist/postmaster/client.js +235 -0
- package/dist/postmaster/index.d.ts +33 -0
- package/dist/postmaster/index.js +33 -0
- package/dist/postmaster/instance.d.ts +37 -0
- package/dist/postmaster/instance.js +21 -0
- package/dist/postmaster/load.d.ts +12 -0
- package/dist/postmaster/load.js +34 -0
- package/dist/postmaster/mailboxes.d.ts +23 -0
- package/dist/postmaster/mailboxes.js +35 -0
- package/dist/postmaster/objects.d.ts +47 -0
- package/dist/postmaster/objects.js +167 -0
- package/dist/postmaster/overview.d.ts +177 -0
- package/dist/postmaster/overview.js +112 -0
- package/dist/postmaster/react/controls.d.ts +18 -0
- package/dist/postmaster/react/controls.js +71 -0
- package/dist/postmaster/react/index.d.ts +13 -0
- package/dist/postmaster/react/index.js +12 -0
- package/dist/postmaster/react/panel.d.ts +42 -0
- package/dist/postmaster/react/panel.js +104 -0
- package/dist/postmaster/react/types.d.ts +10 -0
- package/dist/postmaster/react/types.js +1 -0
- package/dist/postmaster/writes.d.ts +62 -0
- package/dist/postmaster/writes.js +131 -0
- package/package.json +91 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push, as server-sent events.
|
|
3
|
+
*
|
|
4
|
+
* RFC 8620 §7.3 puts the event source behind the same authentication as the
|
|
5
|
+
* API, and the browser `EventSource` cannot send an `Authorization` header —
|
|
6
|
+
* it has no way to set headers at all. `jmap-jam`'s `connectEventSource`
|
|
7
|
+
* therefore only works against a server that accepts a cookie or a token in
|
|
8
|
+
* the query string, which is not what a bearer token is.
|
|
9
|
+
*
|
|
10
|
+
* So the stream is read with `fetch`, which takes the same header every other
|
|
11
|
+
* request carries, and the handful of lines of SSE framing are parsed here.
|
|
12
|
+
* This works unchanged in a browser and in Node.
|
|
13
|
+
*/
|
|
14
|
+
const FRAME_SEPARATOR = /\r?\n\r?\n/;
|
|
15
|
+
const RETRY_START_MS = 1_000;
|
|
16
|
+
const RETRY_CAP_MS = 30_000;
|
|
17
|
+
/** Parse one SSE frame's field lines into an event. */
|
|
18
|
+
function parseFrame(frame) {
|
|
19
|
+
let event = 'message';
|
|
20
|
+
let id = null;
|
|
21
|
+
const data = [];
|
|
22
|
+
// A stream may use CR, LF or CRLF line endings, so splitting on '\n' alone
|
|
23
|
+
// leaves a trailing '\r' on every field value and `event` stops matching.
|
|
24
|
+
for (const line of frame.split(/\r\n|\r|\n/)) {
|
|
25
|
+
if (line === '' || line.startsWith(':'))
|
|
26
|
+
continue;
|
|
27
|
+
const colon = line.indexOf(':');
|
|
28
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
29
|
+
// "If the line starts with a colon the field value is the rest of the
|
|
30
|
+
// line after the colon, with one leading space removed."
|
|
31
|
+
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
32
|
+
if (value.startsWith(' '))
|
|
33
|
+
value = value.slice(1);
|
|
34
|
+
if (field === 'event')
|
|
35
|
+
event = value;
|
|
36
|
+
else if (field === 'data')
|
|
37
|
+
data.push(value);
|
|
38
|
+
else if (field === 'id')
|
|
39
|
+
id = value;
|
|
40
|
+
}
|
|
41
|
+
return { event, data: data.join('\n'), id };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Watch for changes on the account.
|
|
45
|
+
*
|
|
46
|
+
* `handler` is called with the changed state strings for this account; a
|
|
47
|
+
* value that differs from the one a previous read returned means that type
|
|
48
|
+
* has changed and its list should be refetched. The stream reconnects on its
|
|
49
|
+
* own with a backoff, so the handle only has to be closed when the caller is
|
|
50
|
+
* finished with it.
|
|
51
|
+
*/
|
|
52
|
+
export function push(client, handler, options = {}) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const types = options.types === undefined || options.types === '*' ? '*' : options.types.join(',');
|
|
55
|
+
const ping = options.ping ?? 30;
|
|
56
|
+
let stopped = false;
|
|
57
|
+
let lastEventId = null;
|
|
58
|
+
const loop = async () => {
|
|
59
|
+
let delay = RETRY_START_MS;
|
|
60
|
+
while (!stopped) {
|
|
61
|
+
try {
|
|
62
|
+
const { accountId } = await client.connect();
|
|
63
|
+
const url = await client.eventSourceUrl({ types, ping });
|
|
64
|
+
const headers = {
|
|
65
|
+
Authorization: client.authorization,
|
|
66
|
+
Accept: 'text/event-stream',
|
|
67
|
+
};
|
|
68
|
+
if (lastEventId !== null)
|
|
69
|
+
headers['Last-Event-ID'] = lastEventId;
|
|
70
|
+
const response = await fetch(url, { headers, signal: controller.signal });
|
|
71
|
+
if (!response.ok || !response.body) {
|
|
72
|
+
throw new Error(`the event source answered ${response.status}`);
|
|
73
|
+
}
|
|
74
|
+
options.onConnectionChange?.(true);
|
|
75
|
+
delay = RETRY_START_MS;
|
|
76
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
77
|
+
let buffer = '';
|
|
78
|
+
while (!stopped) {
|
|
79
|
+
const { done, value } = await reader.read();
|
|
80
|
+
if (done)
|
|
81
|
+
break;
|
|
82
|
+
buffer += value;
|
|
83
|
+
// Frames are separated by a blank line; \r\n is as legal as \n.
|
|
84
|
+
let boundary = FRAME_SEPARATOR.exec(buffer);
|
|
85
|
+
while (boundary !== null) {
|
|
86
|
+
const frame = buffer.slice(0, boundary.index);
|
|
87
|
+
buffer = buffer.slice(boundary.index + boundary[0].length);
|
|
88
|
+
const parsed = parseFrame(frame);
|
|
89
|
+
if (parsed.id !== null)
|
|
90
|
+
lastEventId = parsed.id;
|
|
91
|
+
if (parsed.event === 'state' && parsed.data !== '') {
|
|
92
|
+
try {
|
|
93
|
+
const change = JSON.parse(parsed.data);
|
|
94
|
+
const mine = change.changed?.[accountId];
|
|
95
|
+
if (mine)
|
|
96
|
+
handler(mine);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// One malformed frame is not a reason to drop the stream.
|
|
100
|
+
options.onError?.(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
boundary = FRAME_SEPARATOR.exec(buffer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await reader.cancel().catch(() => { });
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (stopped || controller.signal.aborted)
|
|
110
|
+
return;
|
|
111
|
+
options.onError?.(error);
|
|
112
|
+
}
|
|
113
|
+
if (stopped)
|
|
114
|
+
return;
|
|
115
|
+
options.onConnectionChange?.(false);
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
117
|
+
delay = Math.min(delay * 2, RETRY_CAP_MS);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
void loop();
|
|
121
|
+
return {
|
|
122
|
+
close: () => {
|
|
123
|
+
stopped = true;
|
|
124
|
+
controller.abort();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Draft, EmailAddress, MailIdentity } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Writing one.
|
|
4
|
+
*
|
|
5
|
+
* **Recipients are chips, not a comma-separated string.** A text field holding
|
|
6
|
+
* `ada@example.com, charles@example.com` is a field where one missing comma
|
|
7
|
+
* sends to nobody and one stray space sends to an address that does not
|
|
8
|
+
* exist; and it cannot show you who you are about to write to. Each address
|
|
9
|
+
* becomes a chip as you commit it, and a chip can be removed without editing
|
|
10
|
+
* a sentence.
|
|
11
|
+
*
|
|
12
|
+
* **Enter, comma and semicolon all commit an address**, because people type
|
|
13
|
+
* all three and a composer that only accepts one of them silently swallows
|
|
14
|
+
* the others into the next address.
|
|
15
|
+
*
|
|
16
|
+
* **Cc and Bcc are hidden until wanted.** Three empty address fields at the
|
|
17
|
+
* top of every message is three fields to tab through to reach the subject.
|
|
18
|
+
*
|
|
19
|
+
* The draft is the caller's state, so a reply arrives already filled in by
|
|
20
|
+
* `replyDraft` and this is only the surface that edits it.
|
|
21
|
+
*/
|
|
22
|
+
export type ComposerProps = {
|
|
23
|
+
draft: Draft;
|
|
24
|
+
onChange: (draft: Draft) => void;
|
|
25
|
+
onSend: () => void | Promise<void>;
|
|
26
|
+
onCancel?: () => void;
|
|
27
|
+
identities: readonly MailIdentity[];
|
|
28
|
+
/** Set while a send is in flight, so the buttons cannot be pressed twice. */
|
|
29
|
+
sending?: boolean;
|
|
30
|
+
/** What went wrong with the last send. */
|
|
31
|
+
error?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
/** Split what somebody typed into addresses. Commas and semicolons both
|
|
35
|
+
* separate, because both get typed and both get pasted. */
|
|
36
|
+
export declare function parseAddresses(text: string): EmailAddress[];
|
|
37
|
+
export declare function Composer({ draft, onChange, onSend, onCancel, identities, sending, error, className, }: ComposerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, Field, Identity, Input, Select, Textarea } from '@wtfalch/design';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
/** Split what somebody typed into addresses. Commas and semicolons both
|
|
5
|
+
* separate, because both get typed and both get pasted. */
|
|
6
|
+
export function parseAddresses(text) {
|
|
7
|
+
return text
|
|
8
|
+
.split(/[,;]/)
|
|
9
|
+
.map((part) => part.trim())
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.map((part) => {
|
|
12
|
+
// `Ada Lovelace <ada@example.com>` as well as the bare address, because
|
|
13
|
+
// that is what a paste out of another mail client looks like.
|
|
14
|
+
const angled = part.match(/^(.*?)\s*<([^>]+)>$/);
|
|
15
|
+
if (angled?.[2]) {
|
|
16
|
+
const name = angled[1]?.replace(/^["']|["']$/g, '').trim();
|
|
17
|
+
return name ? { name, email: angled[2].trim() } : { email: angled[2].trim() };
|
|
18
|
+
}
|
|
19
|
+
return { email: part };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function Composer({ draft, onChange, onSend, onCancel, identities, sending = false, error, className, }) {
|
|
23
|
+
const [showCopies, setShowCopies] = useState((draft.cc?.length ?? 0) > 0 || (draft.bcc?.length ?? 0) > 0);
|
|
24
|
+
const set = (patch) => onChange({ ...draft, ...patch });
|
|
25
|
+
const canSend = draft.to.length > 0 && !sending;
|
|
26
|
+
return (_jsxs("form", { className: `composer${className ? ` ${className}` : ''}`, onSubmit: (event) => {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
if (canSend)
|
|
29
|
+
void onSend();
|
|
30
|
+
}, children: [identities.length > 1 && (_jsx(Field, { label: "From", children: (field) => (_jsx(Select, { id: field.id, block: true, value: draft.identityId, onChange: (event) => set({ identityId: event.target.value }), children: identities.map((identity) => (_jsx("option", { value: identity.id, children: identity.name ? `${identity.name} <${identity.email}>` : identity.email }, identity.id))) })) })), _jsx(AddressField, { label: "To", people: draft.to, onChange: (to) => set({ to }), trail: !showCopies && (_jsx(Button, { kind: "ghost", size: "sm", onClick: () => setShowCopies(true), type: "button", children: "Cc / Bcc" })) }), showCopies && (_jsxs(_Fragment, { children: [_jsx(AddressField, { label: "Cc", people: draft.cc ?? [], onChange: (cc) => set({ cc }) }), _jsx(AddressField, { label: "Bcc", people: draft.bcc ?? [], onChange: (bcc) => set({ bcc }) })] })), _jsx(Field, { label: "Subject", children: (field) => (_jsx(Input, { ...field, block: true, value: draft.subject, onChange: (event) => set({ subject: event.target.value }) })) }), _jsx(Field, { label: "Message", children: (field) => (_jsx(Textarea, { ...field, rows: 12, value: draft.text ?? '', onChange: (event) => set({ text: event.target.value }) })) }), (draft.attachments?.length ?? 0) > 0 && (_jsx("ul", { className: "composer-files", children: draft.attachments?.map((file) => (_jsxs("li", { children: [_jsx("span", { className: "composer-file", children: file.name }), _jsx(Button, { kind: "ghost", size: "sm", type: "button", onClick: () => set({ attachments: draft.attachments?.filter((f) => f.blobId !== file.blobId) }), "aria-label": `Remove ${file.name}`, children: "Remove" })] }, file.blobId))) })), error && (_jsx("p", { className: "composer-error", role: "alert", children: error })), _jsxs("div", { className: "composer-actions", children: [_jsx(Button, { kind: "primary", type: "submit", disabled: !canSend, busy: sending, children: sending ? 'Sending…' : 'Send' }), onCancel && (_jsx(Button, { kind: "ghost", type: "button", onClick: onCancel, disabled: sending, children: "Discard" })), draft.to.length === 0 && _jsx("span", { className: "composer-hint", children: "Add a recipient to send." })] })] }));
|
|
31
|
+
}
|
|
32
|
+
function AddressField({ label, people, onChange, trail, }) {
|
|
33
|
+
const [typed, setTyped] = useState('');
|
|
34
|
+
const commit = (text) => {
|
|
35
|
+
const parsed = parseAddresses(text);
|
|
36
|
+
if (parsed.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const seen = new Set(people.map((p) => p.email.toLowerCase()));
|
|
39
|
+
onChange([...people, ...parsed.filter((p) => !seen.has(p.email.toLowerCase()))]);
|
|
40
|
+
setTyped('');
|
|
41
|
+
};
|
|
42
|
+
return (_jsx(Field, { label: label, children: (field) => (_jsxs("div", { className: "composer-addresses", children: [people.map((person) => (_jsx(Identity, { kind: "chip", name: person.name, address: person.email, onRemove: () => onChange(people.filter((p) => p.email !== person.email)) }, person.email))), _jsx(Input, { ...field, className: "composer-address-input", value: typed, placeholder: people.length === 0 ? 'name@example.com' : '', onChange: (event) => {
|
|
43
|
+
const value = event.target.value;
|
|
44
|
+
// A comma or semicolon ends an address wherever it appears,
|
|
45
|
+
// which is what makes a pasted list of five become five chips.
|
|
46
|
+
if (/[,;]/.test(value))
|
|
47
|
+
commit(value);
|
|
48
|
+
else
|
|
49
|
+
setTyped(value);
|
|
50
|
+
}, onKeyDown: (event) => {
|
|
51
|
+
if (event.key === 'Enter') {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
commit(typed);
|
|
54
|
+
}
|
|
55
|
+
else if (event.key === 'Backspace' && typed === '' && people.length > 0) {
|
|
56
|
+
// Backspace on an empty field takes the last chip, which is
|
|
57
|
+
// what every chip field does and what fingers expect.
|
|
58
|
+
onChange(people.slice(0, -1));
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
/* Committed on blur too: a half-typed address left behind when
|
|
62
|
+
somebody tabs to the subject is one they meant to add. */
|
|
63
|
+
onBlur: () => commit(typed) }), trail] })) }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MailClient } from '../client.ts';
|
|
2
|
+
export type MailProps = {
|
|
3
|
+
client: MailClient;
|
|
4
|
+
/** Now, for the list's relative times. Passed in so stories hold still. */
|
|
5
|
+
now?: Date;
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function Mail({ client, now, className }: MailProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, Command, Icon, Input, Modal, SplitPane } from '@wtfalch/design';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { forwardDraft, replyDraft } from "../drafts.js";
|
|
5
|
+
import { findRole } from "../mailboxes.js";
|
|
6
|
+
import { send } from "../submit.js";
|
|
7
|
+
import { Composer } from "./Composer.js";
|
|
8
|
+
import { MailboxTree, flatMailboxes } from "./MailboxTree.js";
|
|
9
|
+
import { ThreadList } from "./ThreadList.js";
|
|
10
|
+
import { ThreadView } from "./ThreadView.js";
|
|
11
|
+
import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./hooks.js";
|
|
12
|
+
/**
|
|
13
|
+
* The whole client: mailboxes, a list, a message, and a way to write one.
|
|
14
|
+
*
|
|
15
|
+
* Everything below this is a pure component taking props, so each can be drawn
|
|
16
|
+
* in a gallery and tested without a server. This is the only piece that holds
|
|
17
|
+
* state and calls the hooks, which is what makes the rest of the package worth
|
|
18
|
+
* having on its own -- an application with its own layout can take the four
|
|
19
|
+
* views and skip this entirely.
|
|
20
|
+
*
|
|
21
|
+
* **Push moves the list, not the message.** A state change refetches the
|
|
22
|
+
* mailbox counts and the current page; it deliberately does not refetch the
|
|
23
|
+
* thread being read, because replacing the message under somebody mid-sentence
|
|
24
|
+
* is worse than showing them a copy that is thirty seconds old.
|
|
25
|
+
*
|
|
26
|
+
* **The palette is the keyboard.** Cmd-K opens it, and it carries the
|
|
27
|
+
* mailboxes as "go to" entries, so moving around never requires the mouse.
|
|
28
|
+
*/
|
|
29
|
+
const PAGE = 50;
|
|
30
|
+
export function Mail({ client, now, className }) {
|
|
31
|
+
const [mailboxId, setMailboxId] = useState(null);
|
|
32
|
+
const [threadId, setThreadId] = useState(null);
|
|
33
|
+
const [position, setPosition] = useState(0);
|
|
34
|
+
const [query, setQuery] = useState('');
|
|
35
|
+
const [text, setText] = useState('');
|
|
36
|
+
const [draft, setDraft] = useState(null);
|
|
37
|
+
const [sending, setSending] = useState(false);
|
|
38
|
+
const [sendError, setSendError] = useState();
|
|
39
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
40
|
+
const boxes = useMailboxes(client);
|
|
41
|
+
const identities = useIdentities(client);
|
|
42
|
+
/* The inbox, once, when the mailboxes first arrive. Not in an effect that
|
|
43
|
+
depends on `boxes.data`, which would fight a person who has since picked
|
|
44
|
+
another mailbox: it only fires while nothing is selected. */
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (mailboxId || !boxes.data)
|
|
47
|
+
return;
|
|
48
|
+
const inbox = findRole(boxes.data, 'inbox') ?? boxes.data[0];
|
|
49
|
+
if (inbox)
|
|
50
|
+
setMailboxId(inbox.id);
|
|
51
|
+
}, [boxes.data, mailboxId]);
|
|
52
|
+
const threads = useThreads({ mailboxId: mailboxId ?? undefined, text: query, position, limit: PAGE, calculateTotal: true }, client);
|
|
53
|
+
const thread = useThread(threadId, client);
|
|
54
|
+
const refresh = useCallback(() => {
|
|
55
|
+
boxes.reload();
|
|
56
|
+
threads.reload();
|
|
57
|
+
}, [boxes.reload, threads.reload]);
|
|
58
|
+
usePush(refresh, client);
|
|
59
|
+
const go = useCallback((mailbox) => {
|
|
60
|
+
setMailboxId(mailbox.id);
|
|
61
|
+
setThreadId(null);
|
|
62
|
+
setPosition(0);
|
|
63
|
+
setQuery('');
|
|
64
|
+
setText('');
|
|
65
|
+
}, []);
|
|
66
|
+
const openThread = useCallback((summary) => setThreadId(summary.id), []);
|
|
67
|
+
/* Cmd-K, and Escape out of a search. Bound on the window rather than on a
|
|
68
|
+
container, because the palette has to open from wherever focus is --
|
|
69
|
+
including from inside the message being read. */
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const onKey = (event) => {
|
|
72
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
setPaletteOpen(true);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
window.addEventListener('keydown', onKey);
|
|
78
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
79
|
+
}, []);
|
|
80
|
+
const identity = identities.data?.[0];
|
|
81
|
+
const startReply = (message, all) => {
|
|
82
|
+
if (!identity)
|
|
83
|
+
return;
|
|
84
|
+
setSendError(undefined);
|
|
85
|
+
setDraft(replyDraft(message, { identity, all }));
|
|
86
|
+
};
|
|
87
|
+
const startForward = (message) => {
|
|
88
|
+
if (!identity)
|
|
89
|
+
return;
|
|
90
|
+
setSendError(undefined);
|
|
91
|
+
setDraft(forwardDraft(message, { identity }));
|
|
92
|
+
};
|
|
93
|
+
const startNew = useCallback(() => {
|
|
94
|
+
if (!identity)
|
|
95
|
+
return;
|
|
96
|
+
setSendError(undefined);
|
|
97
|
+
setDraft({ identityId: identity.id, to: [], subject: '', text: '' });
|
|
98
|
+
}, [identity]);
|
|
99
|
+
const doSend = async () => {
|
|
100
|
+
if (!draft)
|
|
101
|
+
return;
|
|
102
|
+
setSending(true);
|
|
103
|
+
setSendError(undefined);
|
|
104
|
+
try {
|
|
105
|
+
await send(client, draft, {
|
|
106
|
+
...(identity ? { identity } : {}),
|
|
107
|
+
...(boxes.data ? { mailboxes: boxes.data } : {}),
|
|
108
|
+
});
|
|
109
|
+
setDraft(null);
|
|
110
|
+
refresh();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
setSendError(error instanceof Error ? error.message : 'The message was not sent.');
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
setSending(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const paletteGroups = useMemo(() => [
|
|
120
|
+
{
|
|
121
|
+
title: 'Go to',
|
|
122
|
+
commands: flatMailboxes(boxes.data ?? []).map((box) => ({
|
|
123
|
+
id: box.id,
|
|
124
|
+
label: box.name,
|
|
125
|
+
keywords: [box.role ?? ''],
|
|
126
|
+
onRun: () => go(box),
|
|
127
|
+
})),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
title: 'Message',
|
|
131
|
+
commands: [
|
|
132
|
+
{ id: 'new', label: 'New message', shortcut: 'C', onRun: startNew },
|
|
133
|
+
{ id: 'refresh', label: 'Refresh', shortcut: 'R', onRun: refresh },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
], [boxes.data, go, refresh, startNew]);
|
|
137
|
+
const current = boxes.data?.find((box) => box.id === mailboxId);
|
|
138
|
+
return (_jsxs("div", { className: `mail${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "mail-bar", children: [_jsxs(Button, { kind: "primary", onClick: startNew, disabled: !identity, children: [_jsx(Icon, { name: "chat" }), " New"] }), _jsx("form", { className: "mail-search", onSubmit: (event) => {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
setQuery(text);
|
|
141
|
+
setPosition(0);
|
|
142
|
+
setThreadId(null);
|
|
143
|
+
}, children: _jsx(Input, { block: true, value: text, placeholder: `Search${current ? ` ${current.name}` : ''}…`, "aria-label": "Search mail", onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
|
|
144
|
+
if (event.key !== 'Escape')
|
|
145
|
+
return;
|
|
146
|
+
setText('');
|
|
147
|
+
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 }) }))] }));
|
|
149
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MailboxNode } from '../types.ts';
|
|
2
|
+
export declare function orderForReading(nodes: readonly MailboxNode[]): MailboxNode[];
|
|
3
|
+
export type MailboxTreeProps = {
|
|
4
|
+
mailboxes: readonly MailboxNode[];
|
|
5
|
+
selectedId?: string | null;
|
|
6
|
+
onSelect: (mailbox: MailboxNode) => void;
|
|
7
|
+
/** Shown in place of the list while the first fetch is out. */
|
|
8
|
+
loading?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function MailboxTree({ mailboxes, selectedId, onSelect, loading, className, }: MailboxTreeProps): import("react").JSX.Element;
|
|
12
|
+
/** Every mailbox flattened, for a caller that needs the list rather than the
|
|
13
|
+
* tree -- a command palette's "go to" entries, say. */
|
|
14
|
+
export declare function flatMailboxes(nodes: readonly MailboxNode[]): MailboxNode[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Icon, ScrollArea } from '@wtfalch/design';
|
|
3
|
+
import { walk } from "../mailboxes.js";
|
|
4
|
+
/**
|
|
5
|
+
* The mailboxes, down the side.
|
|
6
|
+
*
|
|
7
|
+
* **Ordered by role, not by name.** The server sends them alphabetical --
|
|
8
|
+
* Stalwart gives all five `sortOrder: 0`, so the tie-break by name decides --
|
|
9
|
+
* which puts Deleted Items first and Inbox third. Nobody reading mail wants
|
|
10
|
+
* their trash at the top. So the roles a mail client knows about are drawn in
|
|
11
|
+
* the order a mail client draws them, and everything else follows in the
|
|
12
|
+
* server's order underneath. That reordering belongs here rather than in
|
|
13
|
+
* `mailboxes()`, which reports what the server said.
|
|
14
|
+
*
|
|
15
|
+
* **The unread count is threads, not messages**, to agree with the list beside
|
|
16
|
+
* it: a conversation with four unread replies is one bold row there and should
|
|
17
|
+
* be one in the count.
|
|
18
|
+
*/
|
|
19
|
+
/** The order a mail client draws the roles it knows. Anything else sorts
|
|
20
|
+
* after, in the order the server gave. */
|
|
21
|
+
const ROLE_ORDER = ['inbox', 'drafts', 'sent', 'archive', 'junk', 'trash'];
|
|
22
|
+
const ROLE_ICON = {
|
|
23
|
+
inbox: 'chat',
|
|
24
|
+
drafts: 'file',
|
|
25
|
+
sent: 'download',
|
|
26
|
+
archive: 'folder',
|
|
27
|
+
junk: 'warning',
|
|
28
|
+
trash: 'close',
|
|
29
|
+
};
|
|
30
|
+
export function orderForReading(nodes) {
|
|
31
|
+
const rank = (node) => {
|
|
32
|
+
const index = node.role ? ROLE_ORDER.indexOf(node.role) : -1;
|
|
33
|
+
return index === -1 ? ROLE_ORDER.length : index;
|
|
34
|
+
};
|
|
35
|
+
return [...nodes].sort((a, b) => rank(a) - rank(b));
|
|
36
|
+
}
|
|
37
|
+
export function MailboxTree({ mailboxes, selectedId, onSelect, loading = false, className, }) {
|
|
38
|
+
if (loading && mailboxes.length === 0) {
|
|
39
|
+
return (_jsx("nav", { className: `mailboxes${className ? ` ${className}` : ''}`, "aria-label": "Mailboxes", children: _jsx("p", { className: "mailboxes-quiet", children: "Loading\u2026" }) }));
|
|
40
|
+
}
|
|
41
|
+
const ordered = orderForReading(mailboxes);
|
|
42
|
+
return (_jsx(ScrollArea, { className: `mailboxes${className ? ` ${className}` : ''}`, label: "Mailboxes", children: _jsx("nav", { "aria-label": "Mailboxes", children: _jsx("ul", { className: "mailboxes-list", children: ordered.map((node) => (_jsx(MailboxRow, { node: node, depth: 0, selectedId: selectedId, onSelect: onSelect }, node.id))) }) }) }));
|
|
43
|
+
}
|
|
44
|
+
function MailboxRow({ node, depth, selectedId, onSelect, }) {
|
|
45
|
+
const unread = node.unreadThreads;
|
|
46
|
+
return (_jsxs("li", { children: [_jsxs("button", { type: "button", className: `mailbox${node.id === selectedId ? ' on' : ''}${unread > 0 ? ' unread' : ''}`, style: { '--depth': depth }, onClick: () => onSelect(node), "aria-current": node.id === selectedId ? 'true' : undefined, children: [node.role && ROLE_ICON[node.role] && (_jsx(Icon, { name: ROLE_ICON[node.role], className: "mailbox-icon" })), _jsx("span", { className: "mailbox-name", children: node.name }), unread > 0 && (_jsxs("span", { className: "mailbox-count", children: [unread, _jsx("span", { className: "sr-only", children: " unread" })] }))] }), node.children.length > 0 && (_jsx("ul", { className: "mailboxes-list", children: node.children.map((child) => (_jsx(MailboxRow, { node: child, depth: depth + 1, selectedId: selectedId, onSelect: onSelect }, child.id))) }))] }));
|
|
47
|
+
}
|
|
48
|
+
/** Every mailbox flattened, for a caller that needs the list rather than the
|
|
49
|
+
* tree -- a command palette's "go to" entries, say. */
|
|
50
|
+
export function flatMailboxes(nodes) {
|
|
51
|
+
return [...walk(orderForReading(nodes))];
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ThreadPage, ThreadSummary } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The list of conversations, and what a row of it has to say.
|
|
4
|
+
*
|
|
5
|
+
* **Unread is weight, not colour.** A blue dot beside a subject is invisible
|
|
6
|
+
* to a reader who cannot distinguish it and disappears on a projector; the
|
|
7
|
+
* whole row goes to the strong weight instead, which survives both. The dot
|
|
8
|
+
* is there as well, because two signals are better than one, and it carries
|
|
9
|
+
* the accessible text that says which rows they are.
|
|
10
|
+
*
|
|
11
|
+
* **The time is relative and titled.** "12m" is what you scan; the full
|
|
12
|
+
* timestamp is in the `title` and the `dateTime`, so hovering and a screen
|
|
13
|
+
* reader both get the real thing. A list of absolute timestamps is a list
|
|
14
|
+
* nobody reads.
|
|
15
|
+
*
|
|
16
|
+
* **A conversation says how many messages it holds**, because "3" beside a
|
|
17
|
+
* subject is the difference between one mail and a thread you are behind on.
|
|
18
|
+
*/
|
|
19
|
+
export type ThreadListProps = {
|
|
20
|
+
page: ThreadPage | undefined;
|
|
21
|
+
selectedId?: string | null;
|
|
22
|
+
onSelect: (thread: ThreadSummary) => void;
|
|
23
|
+
/** Called with the new first-item index. Absent, no pager is drawn. */
|
|
24
|
+
onPage?: (position: number) => void;
|
|
25
|
+
limit?: number;
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
/** Shown when there is nothing, instead of an empty box. */
|
|
28
|
+
empty?: React.ReactNode;
|
|
29
|
+
/** Now, for the relative times. Passed in so a story and a screenshot are
|
|
30
|
+
* stable rather than drifting with the clock. */
|
|
31
|
+
now?: Date;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
/** "12m", "3h", "Tue", "14 Mar" -- coarser the further back it is, because
|
|
35
|
+
* precision stops being useful and starts being noise. */
|
|
36
|
+
export declare function shortTime(iso: string, now?: Date): string;
|
|
37
|
+
export declare function ThreadList({ page, selectedId, onSelect, onPage, limit, loading, empty, now, className, }: ThreadListProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Empty, Identity, Pagination, Pill, ScrollArea } from '@wtfalch/design';
|
|
3
|
+
/** "12m", "3h", "Tue", "14 Mar" -- coarser the further back it is, because
|
|
4
|
+
* precision stops being useful and starts being noise. */
|
|
5
|
+
export function shortTime(iso, now = new Date()) {
|
|
6
|
+
const then = new Date(iso);
|
|
7
|
+
const minutes = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000));
|
|
8
|
+
if (minutes < 1)
|
|
9
|
+
return 'now';
|
|
10
|
+
if (minutes < 60)
|
|
11
|
+
return `${minutes}m`;
|
|
12
|
+
const hours = Math.round(minutes / 60);
|
|
13
|
+
if (hours < 24)
|
|
14
|
+
return `${hours}h`;
|
|
15
|
+
const days = Math.round(hours / 24);
|
|
16
|
+
if (days < 7)
|
|
17
|
+
return then.toLocaleDateString('en-GB', { weekday: 'short' });
|
|
18
|
+
if (then.getUTCFullYear() === now.getUTCFullYear()) {
|
|
19
|
+
return then.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
|
20
|
+
}
|
|
21
|
+
return then.toLocaleDateString('en-GB', { year: 'numeric', month: 'short' });
|
|
22
|
+
}
|
|
23
|
+
export function ThreadList({ page, selectedId, onSelect, onPage, limit = 50, loading = false, empty, now, className, }) {
|
|
24
|
+
const items = page?.items ?? [];
|
|
25
|
+
if (loading && items.length === 0) {
|
|
26
|
+
return _jsx("p", { className: "threads-quiet", children: "Loading\u2026" });
|
|
27
|
+
}
|
|
28
|
+
if (items.length === 0) {
|
|
29
|
+
return (_jsx("div", { className: `threads${className ? ` ${className}` : ''}`, children: empty ?? _jsx(Empty, { icon: "chat", children: "Nothing here." }) }));
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs("div", { className: `threads${className ? ` ${className}` : ''}`, children: [_jsx(ScrollArea, { className: "threads-scroll", label: "Conversations", children: _jsx("ul", { className: "threads-list", children: items.map((item) => (_jsx("li", { children: _jsx(ThreadRow, { thread: item, selected: item.id === selectedId, onSelect: onSelect, now: now }) }, item.id))) }) }), onPage && page && (_jsx(Pagination, { className: "threads-pager", position: page.position, limit: limit, total: page.total, count: items.length, onChange: onPage, label: "Conversation pages", unit: "conversations" }))] }));
|
|
32
|
+
}
|
|
33
|
+
function ThreadRow({ thread, selected, onSelect, now, }) {
|
|
34
|
+
const unread = thread.unreadCount > 0;
|
|
35
|
+
/* The sender a list shows is whoever wrote last, except in Sent and Drafts
|
|
36
|
+
where that is always you -- but this component does not know which
|
|
37
|
+
mailbox it is drawing, so it shows the last writer and lets the row's
|
|
38
|
+
participants carry the rest. */
|
|
39
|
+
const who = thread.from ?? thread.participants[thread.participants.length - 1] ?? null;
|
|
40
|
+
return (_jsxs("button", { type: "button", className: `thread-row${selected ? ' on' : ''}${unread ? ' unread' : ''}`, onClick: () => onSelect(thread), "aria-current": selected ? 'true' : undefined, children: [_jsxs("span", { className: "thread-top", children: [who ? (_jsx(Identity, { name: who.name, address: who.email, size: "sm", className: "thread-who" })) : (_jsx("span", { className: "thread-who thread-nobody", children: "Unknown sender" })), _jsx("time", { className: "thread-when", dateTime: thread.receivedAt, title: new Date(thread.receivedAt).toLocaleString('en-GB'), children: shortTime(thread.receivedAt, now) })] }), _jsxs("span", { className: "thread-subject", children: [unread && (_jsx("span", { className: "thread-dot", "aria-hidden": "true", children: "\u2022" })), _jsx("span", { className: "thread-subject-text", children: thread.subject || '(no subject)' }), thread.emailCount > 1 && (_jsxs("span", { className: "thread-count", children: [thread.emailCount, _jsx("span", { className: "sr-only", children: " messages" })] }))] }), _jsx("span", { className: "thread-preview", children: thread.preview }), (thread.hasAttachment || thread.isFlagged || thread.isDraft) && (_jsxs("span", { className: "thread-marks", children: [thread.isDraft && _jsx(Pill, { tone: "warn", children: "Draft" }), thread.isFlagged && _jsx(Pill, { tone: "info", children: "Flagged" }), thread.hasAttachment && _jsx(Pill, { children: "Attachment" })] })), unread && _jsxs("span", { className: "sr-only", children: [thread.unreadCount, " unread"] })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Attachment, Message, ThreadDetail } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A conversation, opened.
|
|
4
|
+
*
|
|
5
|
+
* **The newest message is expanded and the rest are collapsed**, because that
|
|
6
|
+
* is the one you came to read. A thread view that opens everything makes you
|
|
7
|
+
* scroll past mail you have already seen to reach the mail you have not.
|
|
8
|
+
*
|
|
9
|
+
* **The HTML body is not rendered.** It arrives from strangers, and a mail
|
|
10
|
+
* client that injects it into its own document is a mail client with a
|
|
11
|
+
* cross-site scripting hole in the one place attackers reliably look. Text is
|
|
12
|
+
* shown; where a message has only HTML, this says so and offers the raw
|
|
13
|
+
* source rather than silently showing nothing. Rendering it safely means a
|
|
14
|
+
* sandboxed frame with a sanitiser and a content policy, which is its own
|
|
15
|
+
* piece of work.
|
|
16
|
+
*
|
|
17
|
+
* **A truncated body says it is truncated.** `core` fetches the first half
|
|
18
|
+
* megabyte of each part; a message longer than that would otherwise appear to
|
|
19
|
+
* end mid-sentence with no explanation.
|
|
20
|
+
*/
|
|
21
|
+
export type ThreadViewProps = {
|
|
22
|
+
thread: ThreadDetail | undefined;
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
onReply?: (message: Message, all: boolean) => void;
|
|
25
|
+
onForward?: (message: Message) => void;
|
|
26
|
+
/** Fetches an attachment's bytes; the URL needs the same credential the API
|
|
27
|
+
* does, so a bare link cannot do it. */
|
|
28
|
+
onDownload?: (attachment: Attachment) => void;
|
|
29
|
+
className?: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function ThreadView({ thread, loading, onReply, onForward, onDownload, className, }: ThreadViewProps): import("react").JSX.Element;
|
|
32
|
+
/** Bytes, the way a mail client says them. */
|
|
33
|
+
export declare function bytes(size: number): string;
|