@wtfalch/email 0.4.1 → 0.5.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.
@@ -0,0 +1,51 @@
1
+ import { type MailClient } from './client.ts';
2
+ import type { MailboxNode, MailboxRole } from './types.ts';
3
+ /**
4
+ * Mark every message in a thread read, or unread.
5
+ *
6
+ * Read is what opening a conversation means, so this is called by the view
7
+ * rather than by a button -- and only when a thread actually has something
8
+ * unread in it, because a `set` that changes nothing still bumps the account's
9
+ * state string and wakes every other client's push connection for no reason.
10
+ */
11
+ export declare function setRead(client: MailClient, threadId: string, read: boolean): Promise<readonly string[]>;
12
+ /** Flag a thread, or clear it. `$flagged` is RFC 8621's keyword for the star
13
+ * every mail client draws. */
14
+ export declare function setFlagged(client: MailClient, threadId: string, flagged: boolean): Promise<readonly string[]>;
15
+ export type MoveOptions = {
16
+ /** The mailbox tree, when the caller already has it. Saves a `Mailbox/get`. */
17
+ mailboxes?: readonly MailboxNode[];
18
+ };
19
+ /**
20
+ * Move a thread into one mailbox, out of every other.
21
+ *
22
+ * **`mailboxIds` is replaced, not patched.** A message may legitimately sit in
23
+ * several mailboxes at once, and a "move" that only adds the destination is
24
+ * the bug where archiving leaves the conversation in the Inbox as well and the
25
+ * unread count never drops. The whole property is set, which is the one
26
+ * operation that means what the word means.
27
+ *
28
+ * The exception the caller has to know about: a message in Sent that is also
29
+ * in the Inbox loses the Sent filing when it is archived. That is what every
30
+ * other client does too, and the alternative -- preserving some mailboxes and
31
+ * not others -- is a rule nobody can predict.
32
+ */
33
+ export declare function moveThread(client: MailClient, threadId: string, mailboxId: string): Promise<readonly string[]>;
34
+ /**
35
+ * Move a thread to the mailbox holding a role -- `archive`, `trash`, `junk`,
36
+ * `inbox`.
37
+ *
38
+ * By role rather than by id because that is how the caller thinks: a toolbar
39
+ * has an Archive button, not a "move to the mailbox whose id is 4f2" button.
40
+ * A server that has no such mailbox says so rather than silently doing
41
+ * nothing, since a delete that quietly fails is the worst kind.
42
+ */
43
+ export declare function moveToRole(client: MailClient, threadId: string, role: MailboxRole, options?: MoveOptions): Promise<readonly string[]>;
44
+ /**
45
+ * Take a thread out of one mailbox and leave whatever else it is in.
46
+ *
47
+ * The narrow counterpart to `moveThread`, for the one case where replacement
48
+ * is wrong: removing a conversation from the Inbox when the point is to keep
49
+ * its other filings. Patches a single pointer rather than the property.
50
+ */
51
+ export declare function removeFromMailbox(client: MailClient, threadId: string, mailboxId: string): Promise<readonly string[]>;
@@ -0,0 +1,132 @@
1
+ import { JAM_FETCH } from "./client.js";
2
+ import { MailError, guard } from "./errors.js";
3
+ import { findRole, mailboxes } from "./mailboxes.js";
4
+ /**
5
+ * Changing mail rather than reading it: what is read, what is flagged, and
6
+ * which mailbox a conversation sits in.
7
+ *
8
+ * **A thread, not a message.** Every function here takes a thread id, because
9
+ * a thread is what a person selects and what they mean. Archiving "the one
10
+ * from Ada" and leaving her second message in the Inbox is not a thing anyone
11
+ * asked for; nor is a conversation that is half read. RFC 8621 has no
12
+ * `Thread/set` -- a thread is derived from its messages and cannot be written
13
+ * -- so each of these resolves the thread's members and patches all of them in
14
+ * one `Email/set`.
15
+ *
16
+ * **Two round trips, and it cannot be one.** Everywhere else in this package
17
+ * a chain of calls goes in a single request by JMAP result reference, which
18
+ * is how a page of threads costs one trip. That does not work here:
19
+ * `Email/set`'s `update` is a map *keyed by id*, and a result reference can
20
+ * fill an argument but not build the keys of one. So the member ids come back
21
+ * before the patch goes out. The cost is one extra trip per action, not one
22
+ * per message -- a twelve-message thread still patches in a single `set`.
23
+ *
24
+ * **Nothing here is optimistic.** These resolve when the server has accepted
25
+ * the change and return the ids that moved; the caller refetches. A mail
26
+ * client that draws a message as read before the server agrees is a mail
27
+ * client that shows the wrong unread count after a failure, and the count is
28
+ * the one number people trust.
29
+ */
30
+ /** RFC 6901 §3, the same escape `submit.ts` needs: `~` and `/` inside a
31
+ * pointer segment are escaped, and mailbox ids are the server's to choose. */
32
+ function pointer(segment) {
33
+ return segment.replaceAll('~', '~0').replaceAll('/', '~1');
34
+ }
35
+ /**
36
+ * A patch applied to every message in a thread.
37
+ *
38
+ * A thread that resolves to no messages is an error rather than a no-op: the
39
+ * id came from a list this client drew, so an empty answer means the thread
40
+ * was moved or expunged under the person, and reporting success for an action
41
+ * that touched nothing is how a client tells somebody their mail is filed
42
+ * when it is not.
43
+ */
44
+ async function patchThread(client, threadId, patch, operation) {
45
+ const { jam, accountId } = await client.connect();
46
+ return guard(operation, async () => {
47
+ const [thread] = await jam.api.Thread.get({ accountId, ids: [threadId] }, JAM_FETCH);
48
+ const ids = thread.list?.[0]?.emailIds ?? [];
49
+ if (ids.length === 0) {
50
+ throw new MailError(`no thread ${threadId} on this account`, {
51
+ type: 'notFound',
52
+ operation,
53
+ });
54
+ }
55
+ const update = {};
56
+ for (const id of ids)
57
+ update[id] = patch;
58
+ const [result] = await jam.api.Email.set({ accountId, update: update }, JAM_FETCH);
59
+ const refused = result.notUpdated;
60
+ const first = refused ? Object.values(refused)[0] : undefined;
61
+ if (first) {
62
+ throw new MailError(first.description ?? `the server refused to ${operation}`, {
63
+ type: first.type ?? 'unknown',
64
+ operation,
65
+ });
66
+ }
67
+ return ids;
68
+ });
69
+ }
70
+ /**
71
+ * Mark every message in a thread read, or unread.
72
+ *
73
+ * Read is what opening a conversation means, so this is called by the view
74
+ * rather than by a button -- and only when a thread actually has something
75
+ * unread in it, because a `set` that changes nothing still bumps the account's
76
+ * state string and wakes every other client's push connection for no reason.
77
+ */
78
+ export function setRead(client, threadId, read) {
79
+ return patchThread(client, threadId, { 'keywords/$seen': read ? true : null }, read ? 'mark read' : 'mark unread');
80
+ }
81
+ /** Flag a thread, or clear it. `$flagged` is RFC 8621's keyword for the star
82
+ * every mail client draws. */
83
+ export function setFlagged(client, threadId, flagged) {
84
+ return patchThread(client, threadId, { 'keywords/$flagged': flagged ? true : null }, flagged ? 'flag' : 'unflag');
85
+ }
86
+ /**
87
+ * Move a thread into one mailbox, out of every other.
88
+ *
89
+ * **`mailboxIds` is replaced, not patched.** A message may legitimately sit in
90
+ * several mailboxes at once, and a "move" that only adds the destination is
91
+ * the bug where archiving leaves the conversation in the Inbox as well and the
92
+ * unread count never drops. The whole property is set, which is the one
93
+ * operation that means what the word means.
94
+ *
95
+ * The exception the caller has to know about: a message in Sent that is also
96
+ * in the Inbox loses the Sent filing when it is archived. That is what every
97
+ * other client does too, and the alternative -- preserving some mailboxes and
98
+ * not others -- is a rule nobody can predict.
99
+ */
100
+ export function moveThread(client, threadId, mailboxId) {
101
+ return patchThread(client, threadId, { mailboxIds: { [mailboxId]: true } }, 'move');
102
+ }
103
+ /**
104
+ * Move a thread to the mailbox holding a role -- `archive`, `trash`, `junk`,
105
+ * `inbox`.
106
+ *
107
+ * By role rather than by id because that is how the caller thinks: a toolbar
108
+ * has an Archive button, not a "move to the mailbox whose id is 4f2" button.
109
+ * A server that has no such mailbox says so rather than silently doing
110
+ * nothing, since a delete that quietly fails is the worst kind.
111
+ */
112
+ export async function moveToRole(client, threadId, role, options = {}) {
113
+ const tree = options.mailboxes ?? (await mailboxes(client));
114
+ const target = findRole(tree, role);
115
+ if (!target) {
116
+ throw new MailError(`the account has no ${role} mailbox to move to`, {
117
+ type: 'notFound',
118
+ operation: 'move',
119
+ });
120
+ }
121
+ return moveThread(client, threadId, target.id);
122
+ }
123
+ /**
124
+ * Take a thread out of one mailbox and leave whatever else it is in.
125
+ *
126
+ * The narrow counterpart to `moveThread`, for the one case where replacement
127
+ * is wrong: removing a conversation from the Inbox when the point is to keep
128
+ * its other filings. Patches a single pointer rather than the property.
129
+ */
130
+ export function removeFromMailbox(client, threadId, mailboxId) {
131
+ return patchThread(client, threadId, { [`mailboxIds/${pointer(mailboxId)}`]: null }, 'move');
132
+ }
@@ -16,6 +16,11 @@ import type { Draft, EmailAddress, MailIdentity } from '../types.ts';
16
16
  * **Cc and Bcc are hidden until wanted.** Three empty address fields at the
17
17
  * top of every message is three fields to tab through to reach the subject.
18
18
  *
19
+ * **Cmd-Enter sends.** Reaching for the mouse to finish a message you typed
20
+ * is the one interruption a composer can always avoid, and every mail client
21
+ * worth using has had this key since the nineties. The button stays, because
22
+ * a shortcut nobody is told about is not an affordance.
23
+ *
19
24
  * The draft is the caller's state, so a reply arrives already filled in by
20
25
  * `replyDraft` and this is only the surface that edits it.
21
26
  */
@@ -1,5 +1,5 @@
1
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';
2
+ import { Button, Field, Icon, Identity, Input, Select, Textarea } from '@wtfalch/design';
3
3
  import { useState } from 'react';
4
4
  /** Split what somebody typed into addresses. Commas and semicolons both
5
5
  * separate, because both get typed and both get pasted. */
@@ -27,7 +27,16 @@ export function Composer({ draft, onChange, onSend, onCancel, identities, sendin
27
27
  event.preventDefault();
28
28
  if (canSend)
29
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." })] })] }));
30
+ },
31
+ /* On the form, not the textarea: it should work from the subject and
32
+ from the address chips too, which is where you notice you are done. */
33
+ onKeyDown: (event) => {
34
+ if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey))
35
+ return;
36
+ event.preventDefault();
37
+ if (canSend)
38
+ void onSend();
39
+ }, 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: [_jsxs(Button, { kind: "primary", type: "submit", disabled: !canSend, busy: sending, children: [_jsx(Icon, { name: "send", size: 15 }), " ", sending ? 'Sending…' : 'Send'] }), onCancel && (_jsx(Button, { kind: "ghost", type: "button", onClick: onCancel, disabled: sending, children: "Discard" })), _jsx("span", { className: "composer-hint", children: draft.to.length === 0 ? ('Add a recipient to send.') : (_jsxs(_Fragment, { children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: "\u21B5" }), " to send"] })) })] })] }));
31
40
  }
32
41
  function AddressField({ label, people, onChange, trail, }) {
33
42
  const [typed, setTyped] = useState('');