@wtfalch/email 0.4.0 → 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.
- package/dist/mailbox/fake/mailbox.js +61 -1
- package/dist/mailbox/fake/sample.d.ts +0 -9
- package/dist/mailbox/fake/sample.js +224 -4
- package/dist/mailbox/index.d.ts +2 -0
- package/dist/mailbox/index.js +1 -0
- package/dist/mailbox/mail.css +693 -47
- package/dist/mailbox/mutate.d.ts +51 -0
- package/dist/mailbox/mutate.js +132 -0
- package/dist/mailbox/react/Composer.d.ts +5 -0
- package/dist/mailbox/react/Composer.js +11 -2
- package/dist/mailbox/react/Mail.js +308 -32
- package/dist/mailbox/react/MailboxTree.d.ts +3 -1
- package/dist/mailbox/react/MailboxTree.js +43 -13
- package/dist/mailbox/react/ThreadList.d.ts +46 -14
- package/dist/mailbox/react/ThreadList.js +109 -8
- package/dist/mailbox/react/ThreadView.d.ts +29 -4
- package/dist/mailbox/react/ThreadView.js +85 -18
- package/dist/mailbox/react/index.d.ts +5 -3
- package/dist/mailbox/react/index.js +3 -2
- package/dist/mailbox/react/layout.d.ts +31 -0
- package/dist/mailbox/react/layout.js +42 -0
- package/dist/postmaster/objects.d.ts +22 -3
- package/dist/postmaster/objects.js +2 -2
- package/package.json +3 -3
|
@@ -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
|
-
},
|
|
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('');
|