@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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/dist/mailbox/client.d.ts +85 -0
  3. package/dist/mailbox/client.js +201 -0
  4. package/dist/mailbox/drafts.d.ts +52 -0
  5. package/dist/mailbox/drafts.js +134 -0
  6. package/dist/mailbox/errors.d.ts +45 -0
  7. package/dist/mailbox/errors.js +88 -0
  8. package/dist/mailbox/fake/index.d.ts +34 -0
  9. package/dist/mailbox/fake/index.js +85 -0
  10. package/dist/mailbox/fake/mailbox.d.ts +65 -0
  11. package/dist/mailbox/fake/mailbox.js +402 -0
  12. package/dist/mailbox/fake/sample.d.ts +11 -0
  13. package/dist/mailbox/fake/sample.js +85 -0
  14. package/dist/mailbox/identities.d.ts +4 -0
  15. package/dist/mailbox/identities.js +18 -0
  16. package/dist/mailbox/index.d.ts +27 -0
  17. package/dist/mailbox/index.js +17 -0
  18. package/dist/mailbox/mail.css +451 -0
  19. package/dist/mailbox/mailboxes.d.ts +33 -0
  20. package/dist/mailbox/mailboxes.js +107 -0
  21. package/dist/mailbox/push.d.ts +40 -0
  22. package/dist/mailbox/push.js +127 -0
  23. package/dist/mailbox/react/Composer.d.ts +37 -0
  24. package/dist/mailbox/react/Composer.js +64 -0
  25. package/dist/mailbox/react/Mail.d.ts +8 -0
  26. package/dist/mailbox/react/Mail.js +149 -0
  27. package/dist/mailbox/react/MailboxTree.d.ts +14 -0
  28. package/dist/mailbox/react/MailboxTree.js +52 -0
  29. package/dist/mailbox/react/ThreadList.d.ts +37 -0
  30. package/dist/mailbox/react/ThreadList.js +41 -0
  31. package/dist/mailbox/react/ThreadView.d.ts +33 -0
  32. package/dist/mailbox/react/ThreadView.js +80 -0
  33. package/dist/mailbox/react/context.d.ts +11 -0
  34. package/dist/mailbox/react/context.js +28 -0
  35. package/dist/mailbox/react/hooks.d.ts +46 -0
  36. package/dist/mailbox/react/hooks.js +127 -0
  37. package/dist/mailbox/react/index.d.ts +24 -0
  38. package/dist/mailbox/react/index.js +18 -0
  39. package/dist/mailbox/search.d.ts +20 -0
  40. package/dist/mailbox/search.js +18 -0
  41. package/dist/mailbox/submit.d.ts +35 -0
  42. package/dist/mailbox/submit.js +150 -0
  43. package/dist/mailbox/thread.d.ts +61 -0
  44. package/dist/mailbox/thread.js +153 -0
  45. package/dist/mailbox/threads.d.ts +44 -0
  46. package/dist/mailbox/threads.js +156 -0
  47. package/dist/mailbox/types.d.ts +233 -0
  48. package/dist/mailbox/types.js +8 -0
  49. package/dist/mailbox/uri.d.ts +17 -0
  50. package/dist/mailbox/uri.js +26 -0
  51. package/dist/postmaster/apply.d.ts +62 -0
  52. package/dist/postmaster/apply.js +192 -0
  53. package/dist/postmaster/client.d.ts +127 -0
  54. package/dist/postmaster/client.js +235 -0
  55. package/dist/postmaster/index.d.ts +33 -0
  56. package/dist/postmaster/index.js +33 -0
  57. package/dist/postmaster/instance.d.ts +37 -0
  58. package/dist/postmaster/instance.js +21 -0
  59. package/dist/postmaster/load.d.ts +12 -0
  60. package/dist/postmaster/load.js +34 -0
  61. package/dist/postmaster/mailboxes.d.ts +23 -0
  62. package/dist/postmaster/mailboxes.js +35 -0
  63. package/dist/postmaster/objects.d.ts +47 -0
  64. package/dist/postmaster/objects.js +167 -0
  65. package/dist/postmaster/overview.d.ts +177 -0
  66. package/dist/postmaster/overview.js +112 -0
  67. package/dist/postmaster/react/controls.d.ts +18 -0
  68. package/dist/postmaster/react/controls.js +71 -0
  69. package/dist/postmaster/react/index.d.ts +13 -0
  70. package/dist/postmaster/react/index.js +12 -0
  71. package/dist/postmaster/react/panel.d.ts +42 -0
  72. package/dist/postmaster/react/panel.js +104 -0
  73. package/dist/postmaster/react/types.d.ts +10 -0
  74. package/dist/postmaster/react/types.js +1 -0
  75. package/dist/postmaster/writes.d.ts +62 -0
  76. package/dist/postmaster/writes.js +131 -0
  77. package/package.json +91 -0
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Empty, Icon, Identity, Menu, ScrollArea } from '@wtfalch/design';
3
+ import { useState } from 'react';
4
+ export function ThreadView({ thread, loading = false, onReply, onForward, onDownload, className, }) {
5
+ if (loading && !thread)
6
+ return _jsx("p", { className: "message-quiet", children: "Loading\u2026" });
7
+ if (!thread || thread.messages.length === 0) {
8
+ return (_jsx("div", { className: `thread${className ? ` ${className}` : ''}`, children: _jsx(Empty, { icon: "chat", children: "Pick a conversation to read it." }) }));
9
+ }
10
+ const last = thread.messages.length - 1;
11
+ return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "thread-head", children: [_jsx("h2", { className: "thread-title", children: thread.subject || '(no subject)' }), _jsx("p", { className: "thread-meta", children: thread.messages.length === 1 ? '1 message' : `${thread.messages.length} messages` })] }), _jsx(ScrollArea, { className: "thread-body", label: "Messages in this conversation", children: _jsx("ol", { className: "messages", children: thread.messages.map((message, index) => (_jsx("li", { children: _jsx(MessageCard, { message: message, defaultOpen: index === last, onReply: onReply, onForward: onForward, onDownload: onDownload }) }, message.id))) }) })] }));
12
+ }
13
+ function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, }) {
14
+ const [open, setOpen] = useState(defaultOpen);
15
+ const sender = message.from[0];
16
+ const when = new Date(message.sentAt ?? message.receivedAt);
17
+ return (_jsxs("article", { className: `message${open ? ' open' : ''}`, children: [_jsxs("header", { className: "message-head", children: [_jsxs("button", { type: "button", className: "message-toggle", onClick: () => setOpen((was) => !was), "aria-expanded": open, children: [sender ? (_jsx(Identity, { name: sender.name, address: sender.email, kind: open ? 'full' : 'line' })) : (_jsx("span", { className: "message-nobody", children: "Unknown sender" })), _jsx("time", { className: "message-when", dateTime: when.toISOString(), children: when.toLocaleString('en-GB', {
18
+ day: 'numeric',
19
+ month: 'short',
20
+ hour: '2-digit',
21
+ minute: '2-digit',
22
+ }) })] }), open && (onReply || onForward) && (_jsx(Menu, { label: "Message actions", trigger: _jsx(Button, { kind: "ghost", "aria-label": "Message actions", children: _jsx(Icon, { name: "settings" }) }), items: [
23
+ ...(onReply
24
+ ? [
25
+ {
26
+ id: 'reply',
27
+ label: 'Reply',
28
+ shortcut: 'R',
29
+ onAction: () => onReply(message, false),
30
+ },
31
+ {
32
+ id: 'reply-all',
33
+ label: 'Reply all',
34
+ shortcut: '⇧R',
35
+ onAction: () => onReply(message, true),
36
+ },
37
+ ]
38
+ : []),
39
+ ...(onForward
40
+ ? [
41
+ {
42
+ id: 'forward',
43
+ label: 'Forward',
44
+ shortcut: 'F',
45
+ onAction: () => onForward(message),
46
+ },
47
+ ]
48
+ : []),
49
+ ] }))] }), open && (_jsxs("div", { className: "message-body", children: [_jsx(Recipients, { message: message }), message.text !== null ? (
50
+ /* `pre-wrap`, not a paragraph split: plain-text mail is laid out
51
+ with its own line breaks and its own quote markers, and
52
+ reflowing it destroys both. */
53
+ _jsx("pre", { className: "message-text", children: message.text })) : message.html !== null ? (_jsx(HtmlNotice, { html: message.html })) : (_jsx("p", { className: "message-quiet", children: "This message has no body." })), message.isTruncated && (_jsx("p", { className: "message-quiet", children: "This message was cut short at half a megabyte. The rest has not been fetched." })), message.attachments.length > 0 && (_jsx("ul", { className: "attachments", children: message.attachments
54
+ .filter((file) => file.disposition !== 'inline')
55
+ .map((file) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "attachment", onClick: () => onDownload?.(file), disabled: !onDownload, children: [_jsx(Icon, { name: "file", className: "attachment-icon" }), _jsx("span", { className: "attachment-name", children: file.name }), _jsx("span", { className: "attachment-size", children: bytes(file.size) })] }) }, file.blobId))) }))] }))] }));
56
+ }
57
+ function Recipients({ message }) {
58
+ const line = (label, people) => people.length === 0 ? null : (_jsxs("p", { className: "message-line", children: [_jsx("span", { className: "message-line-label", children: label }), _jsx("span", { children: people.map((p) => p.name?.trim() || p.email).join(', ') })] }));
59
+ return (_jsxs("div", { className: "message-lines", children: [line('To', message.to), line('Cc', message.cc)] }));
60
+ }
61
+ /**
62
+ * A message with only an HTML body.
63
+ *
64
+ * Not rendered, and the reason is said out loud rather than left as a blank
65
+ * pane: injecting a stranger's markup into the client's own document is the
66
+ * bug this refuses to ship. The source is offered because a person who wants
67
+ * it should not have to leave to get it.
68
+ */
69
+ function HtmlNotice({ html }) {
70
+ const [shown, setShown] = useState(false);
71
+ return (_jsxs("div", { className: "message-html", children: [_jsx("p", { className: "message-quiet", children: "This message has an HTML body and no plain-text one. It is not rendered here: a mail client that puts a stranger's markup into its own page is one script tag from being read by them." }), _jsx(Button, { kind: "ghost", onClick: () => setShown((was) => !was), children: shown ? 'Hide the source' : 'Show the source' }), shown && _jsx("pre", { className: "message-source", children: html })] }));
72
+ }
73
+ /** Bytes, the way a mail client says them. */
74
+ export function bytes(size) {
75
+ if (size < 1024)
76
+ return `${size} B`;
77
+ if (size < 1024 * 1024)
78
+ return `${Math.round(size / 1024)} kB`;
79
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
80
+ }
@@ -0,0 +1,11 @@
1
+ import type { MailClient } from '../client.ts';
2
+ export declare function MailProvider({ client, children, }: {
3
+ client: MailClient;
4
+ children: React.ReactNode;
5
+ }): import("react").JSX.Element;
6
+ /** The client from the nearest provider, or `null` when there is none --
7
+ * which is a legitimate arrangement: every hook also takes a client as an
8
+ * argument, so a component tree may skip the provider entirely. */
9
+ export declare function useOptionalMailClient(): MailClient | null;
10
+ /** The client from the nearest provider. Throws when there is none. */
11
+ export declare function useMailClient(): MailClient;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ /**
4
+ * The client, so a tree of components does not thread it through every prop.
5
+ *
6
+ * Only the hooks read this. The view components take their data as props and
7
+ * know nothing about a client at all, which is what lets them be drawn in a
8
+ * gallery, in a test, and against the fake without any of those having to
9
+ * stand up a context first.
10
+ */
11
+ const Mail = createContext(null);
12
+ export function MailProvider({ client, children, }) {
13
+ return _jsx(Mail.Provider, { value: client, children: children });
14
+ }
15
+ /** The client from the nearest provider, or `null` when there is none --
16
+ * which is a legitimate arrangement: every hook also takes a client as an
17
+ * argument, so a component tree may skip the provider entirely. */
18
+ export function useOptionalMailClient() {
19
+ return useContext(Mail);
20
+ }
21
+ /** The client from the nearest provider. Throws when there is none. */
22
+ export function useMailClient() {
23
+ const client = useContext(Mail);
24
+ if (!client) {
25
+ throw new Error('useMailClient must be used inside a <MailProvider client={…}>');
26
+ }
27
+ return client;
28
+ }
@@ -0,0 +1,46 @@
1
+ import type { MailClient } from '../client.ts';
2
+ import { type MailError } from '../errors.ts';
3
+ import { type ListOptions } from '../threads.ts';
4
+ import type { ChangedState, MailIdentity, MailboxNode, ThreadDetail, ThreadPage } from '../types.ts';
5
+ /**
6
+ * What a view needs to know about something being fetched.
7
+ *
8
+ * `data` survives a reload rather than blanking to `undefined` -- a list that
9
+ * empties itself every time it refreshes flashes, and under push-driven
10
+ * refresh it would flash constantly. `refreshing` is how a view says "this is
11
+ * a moment old" without taking the content away.
12
+ */
13
+ export type Resource<T> = {
14
+ data: T | undefined;
15
+ error: MailError | undefined;
16
+ /** The first load, when there is nothing to show yet. */
17
+ loading: boolean;
18
+ /** A later load, with the previous answer still on screen. */
19
+ refreshing: boolean;
20
+ reload: () => void;
21
+ };
22
+ /** Every mailbox, as a tree. */
23
+ export declare function useMailboxes(client?: MailClient): Resource<readonly MailboxNode[]>;
24
+ export type ThreadsQuery = ListOptions & {
25
+ /** The mailbox to list. Ignored when `text` is set, unless both are given,
26
+ * in which case the search is scoped to it. */
27
+ mailboxId?: string;
28
+ /** Free text. Present, this searches instead of listing. */
29
+ text?: string;
30
+ };
31
+ /** A page of threads: a mailbox, a search, or a search within a mailbox. */
32
+ export declare function useThreads(query: ThreadsQuery, client?: MailClient): Resource<ThreadPage>;
33
+ /** One thread, with bodies and attachments. `null` fetches nothing. */
34
+ export declare function useThread(threadId: string | null, client?: MailClient): Resource<ThreadDetail>;
35
+ /** The addresses this account may send as. */
36
+ export declare function useIdentities(client?: MailClient): Resource<readonly MailIdentity[]>;
37
+ /**
38
+ * Refetch when the server says something changed.
39
+ *
40
+ * `onChange` is held in a ref rather than being a dependency, so a caller
41
+ * writing it inline does not tear the connection down and build it up again
42
+ * on every render -- which, for a stream, means never staying connected.
43
+ */
44
+ export declare function usePush(onChange: (changed: ChangedState) => void, client?: MailClient): {
45
+ connected: boolean;
46
+ };
@@ -0,0 +1,127 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toMailError } from "../errors.js";
3
+ import { identities as fetchIdentities } from "../identities.js";
4
+ import { mailboxes as fetchMailboxes } from "../mailboxes.js";
5
+ import { push } from "../push.js";
6
+ import { search as runSearch } from "../search.js";
7
+ import { thread as fetchThread } from "../thread.js";
8
+ import { listThreads } from "../threads.js";
9
+ import { useOptionalMailClient } from "./context.js";
10
+ /**
11
+ * Fetch on mount and whenever the key changes.
12
+ *
13
+ * The key rather than the function is the dependency, because every call site
14
+ * builds its function inline and a function identity changes on every render
15
+ * -- which is the standard way to write a fetch loop that never stops
16
+ * fetching.
17
+ */
18
+ function useResource(key, run) {
19
+ const [state, setState] = useState({});
20
+ const [pending, setPending] = useState(true);
21
+ const [nonce, setNonce] = useState(0);
22
+ const latest = useRef(run);
23
+ latest.current = run;
24
+ /* The key and the nonce are the triggers, not values the body reads: the
25
+ caller's function lives in a ref because every call site builds it inline,
26
+ and depending on it is how a fetch loop that never stops fetching gets
27
+ written. */
28
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the key and the nonce are the triggers; the function they run is held in a ref on purpose.
29
+ useEffect(() => {
30
+ let live = true;
31
+ setPending(true);
32
+ latest
33
+ .current()
34
+ .then((data) => {
35
+ if (live)
36
+ setState({ data });
37
+ }, (error) => {
38
+ if (live)
39
+ setState((previous) => ({ data: previous.data, error: toMailError(error) }));
40
+ })
41
+ .finally(() => {
42
+ if (live)
43
+ setPending(false);
44
+ });
45
+ return () => {
46
+ // A key that changed while a request was out must not have its answer
47
+ // written over the new one's.
48
+ live = false;
49
+ };
50
+ }, [key, nonce]);
51
+ const reload = useCallback(() => setNonce((n) => n + 1), []);
52
+ return {
53
+ data: state.data,
54
+ error: state.error,
55
+ loading: pending && state.data === undefined,
56
+ refreshing: pending && state.data !== undefined,
57
+ reload,
58
+ };
59
+ }
60
+ /** Every mailbox, as a tree. */
61
+ export function useMailboxes(client) {
62
+ const fromContext = useOptionalClient(client);
63
+ return useResource('mailboxes', () => fetchMailboxes(fromContext));
64
+ }
65
+ /** A page of threads: a mailbox, a search, or a search within a mailbox. */
66
+ export function useThreads(query, client) {
67
+ const fromContext = useOptionalClient(client);
68
+ const { mailboxId, text, position = 0, limit, calculateTotal, sort } = query;
69
+ const key = JSON.stringify([mailboxId, text, position, limit, calculateTotal, sort]);
70
+ const options = useMemo(() => ({ position, limit, calculateTotal, sort }), [position, limit, calculateTotal, sort]);
71
+ return useResource(key, () => {
72
+ if (text !== undefined && text.trim() !== '') {
73
+ return runSearch(fromContext, text, {
74
+ ...options,
75
+ ...(mailboxId ? { inMailbox: mailboxId } : {}),
76
+ });
77
+ }
78
+ if (!mailboxId)
79
+ return Promise.resolve({ items: [], position: 0, total: 0, state: '' });
80
+ return listThreads(fromContext, mailboxId, options);
81
+ });
82
+ }
83
+ /** One thread, with bodies and attachments. `null` fetches nothing. */
84
+ export function useThread(threadId, client) {
85
+ const fromContext = useOptionalClient(client);
86
+ return useResource(threadId ?? '', () => {
87
+ if (!threadId) {
88
+ return Promise.resolve({ id: '', subject: '', messages: [] });
89
+ }
90
+ return fetchThread(fromContext, threadId);
91
+ });
92
+ }
93
+ /** The addresses this account may send as. */
94
+ export function useIdentities(client) {
95
+ const fromContext = useOptionalClient(client);
96
+ return useResource('identities', () => fetchIdentities(fromContext));
97
+ }
98
+ /**
99
+ * Refetch when the server says something changed.
100
+ *
101
+ * `onChange` is held in a ref rather than being a dependency, so a caller
102
+ * writing it inline does not tear the connection down and build it up again
103
+ * on every render -- which, for a stream, means never staying connected.
104
+ */
105
+ export function usePush(onChange, client) {
106
+ const fromContext = useOptionalClient(client);
107
+ const [connected, setConnected] = useState(false);
108
+ const handler = useRef(onChange);
109
+ handler.current = onChange;
110
+ useEffect(() => {
111
+ const handle = push(fromContext, (changed) => handler.current(changed), {
112
+ onConnectionChange: setConnected,
113
+ });
114
+ return () => handle.close();
115
+ }, [fromContext]);
116
+ return { connected };
117
+ }
118
+ /** The client passed in, or the one from the provider. A prop wins, so a
119
+ * single component can be pointed at the fake without a provider anywhere. */
120
+ function useOptionalClient(client) {
121
+ const context = useOptionalMailClient();
122
+ const resolved = client ?? context;
123
+ if (!resolved) {
124
+ throw new Error('a mail hook needs a client, from its argument or a <MailProvider>');
125
+ }
126
+ return resolved;
127
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@wtfalch/mail/react` — the components that draw a mailbox.
3
+ *
4
+ * Everything but `Mail` is pure: props in, markup out, no client and no
5
+ * fetching. That is what lets each be drawn in a gallery, rendered in a test,
6
+ * and reused by an application with its own layout. `Mail` is the one piece
7
+ * that holds state and calls the hooks.
8
+ *
9
+ * On `@wtfalch/design` 0.4.0 and `@wtfalch/mail`. Import `@wtfalch/mail/mail.css`
10
+ * after the design stylesheet.
11
+ */
12
+ export { MailProvider, useMailClient, useOptionalMailClient } from './context.tsx';
13
+ export { useIdentities, useMailboxes, usePush, useThread, useThreads, } from './hooks.ts';
14
+ export type { Resource, ThreadsQuery } from './hooks.ts';
15
+ export { MailboxTree, flatMailboxes, orderForReading } from './MailboxTree.tsx';
16
+ export type { MailboxTreeProps } from './MailboxTree.tsx';
17
+ export { ThreadList, shortTime } from './ThreadList.tsx';
18
+ export type { ThreadListProps } from './ThreadList.tsx';
19
+ export { ThreadView, bytes } from './ThreadView.tsx';
20
+ export type { ThreadViewProps } from './ThreadView.tsx';
21
+ export { Composer, parseAddresses } from './Composer.tsx';
22
+ export type { ComposerProps } from './Composer.tsx';
23
+ export { Mail } from './Mail.tsx';
24
+ export type { MailProps } from './Mail.tsx';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@wtfalch/mail/react` — the components that draw a mailbox.
3
+ *
4
+ * Everything but `Mail` is pure: props in, markup out, no client and no
5
+ * fetching. That is what lets each be drawn in a gallery, rendered in a test,
6
+ * and reused by an application with its own layout. `Mail` is the one piece
7
+ * that holds state and calls the hooks.
8
+ *
9
+ * On `@wtfalch/design` 0.4.0 and `@wtfalch/mail`. Import `@wtfalch/mail/mail.css`
10
+ * after the design stylesheet.
11
+ */
12
+ export { MailProvider, useMailClient, useOptionalMailClient } from "./context.js";
13
+ export { useIdentities, useMailboxes, usePush, useThread, useThreads, } from "./hooks.js";
14
+ export { MailboxTree, flatMailboxes, orderForReading } from "./MailboxTree.js";
15
+ export { ThreadList, shortTime } from "./ThreadList.js";
16
+ export { ThreadView, bytes } from "./ThreadView.js";
17
+ export { Composer, parseAddresses } from "./Composer.js";
18
+ export { Mail } from "./Mail.js";
@@ -0,0 +1,20 @@
1
+ import type { MailClient } from './client.ts';
2
+ import type { ListOptions } from './threads.ts';
3
+ import type { MailFilter, ThreadPage } from './types.ts';
4
+ export type SearchOptions = ListOptions & {
5
+ /** Narrow the search to one mailbox. Without it the whole account is
6
+ * searched, Trash and Junk included; pass `inMailboxOtherThan` to leave
7
+ * those out. */
8
+ inMailbox?: string;
9
+ inMailboxOtherThan?: readonly string[];
10
+ /** Further conditions, ANDed with the text. */
11
+ filter?: MailFilter;
12
+ };
13
+ /**
14
+ * Threads matching free text, in the same shape a list returns.
15
+ *
16
+ * What `text` searches is the server's choice; RFC 8621 §4.4.1 lets it cover
17
+ * the headers, the body, and attachment contents it has indexed. A structured
18
+ * search (`from`, `subject`, a date range) goes in `filter`.
19
+ */
20
+ export declare function search(client: MailClient, text: string, options?: SearchOptions): Promise<ThreadPage>;
@@ -0,0 +1,18 @@
1
+ import { queryThreads } from "./threads.js";
2
+ /**
3
+ * Threads matching free text, in the same shape a list returns.
4
+ *
5
+ * What `text` searches is the server's choice; RFC 8621 §4.4.1 lets it cover
6
+ * the headers, the body, and attachment contents it has indexed. A structured
7
+ * search (`from`, `subject`, a date range) goes in `filter`.
8
+ */
9
+ export function search(client, text, options = {}) {
10
+ const { inMailbox, inMailboxOtherThan, filter, ...list } = options;
11
+ const trimmed = text.trim();
12
+ return queryThreads(client, {
13
+ ...filter,
14
+ ...(trimmed === '' ? {} : { text: trimmed }),
15
+ ...(inMailbox === undefined ? {} : { inMailbox }),
16
+ ...(inMailboxOtherThan === undefined ? {} : { inMailboxOtherThan }),
17
+ }, list);
18
+ }
@@ -0,0 +1,35 @@
1
+ import type { MailClient } from './client.ts';
2
+ import type { Draft, EmailAddress, MailIdentity, MailboxNode, Sent } from './types.ts';
3
+ /** The JMAP `Email` object a draft becomes. Exported so the shape can be
4
+ * asserted without a server. */
5
+ export declare function draftToEmail(draft: Draft, from: readonly EmailAddress[], draftsMailboxId: string): Record<string, unknown>;
6
+ /** The patch that files a sent message: out of Drafts, into Sent, no longer a
7
+ * draft. */
8
+ export declare function sentPatch(draftsMailboxId: string, sentMailboxId: string): {
9
+ [x: string]: boolean | null;
10
+ 'keywords/$draft': null;
11
+ 'keywords/$seen': boolean;
12
+ };
13
+ export type SendOptions = {
14
+ /** The identity to send as, when the caller already has it. Saves an
15
+ * `Identity/get`. */
16
+ identity?: MailIdentity;
17
+ /** The mailbox tree, when the caller already has it. Saves a
18
+ * `Mailbox/get`. */
19
+ mailboxes?: readonly MailboxNode[];
20
+ };
21
+ /**
22
+ * Send a draft.
23
+ *
24
+ * Two requests, not one, and the reason is a sharp edge in RFC 8621 §7.5:
25
+ * `onSuccessUpdateEmail` makes the server run an implicit `Email/set` and
26
+ * return it *under the same method call id* as the `EmailSubmission/set`.
27
+ * A client that reads a multi-call response into a map by id therefore loses
28
+ * the submission's own result to the implicit one. `jmap-jam`'s `requestMany`
29
+ * does exactly that, so the submission goes through the single-call path,
30
+ * which reads the first response and is unaffected.
31
+ *
32
+ * The draft is created first and separately, which also means a failed
33
+ * submission leaves the message in Drafts rather than losing it.
34
+ */
35
+ export declare function send(client: MailClient, draft: Draft, options?: SendOptions): Promise<Sent>;
@@ -0,0 +1,150 @@
1
+ import { MailError, guard } from "./errors.js";
2
+ import { identities } from "./identities.js";
3
+ import { findRole, mailboxes } from "./mailboxes.js";
4
+ /** RFC 6901 §3: `~` and `/` inside a pointer segment are escaped. Mailbox ids
5
+ * are the server's to choose, so a patch key built from one must escape. */
6
+ function pointer(segment) {
7
+ return segment.replaceAll('~', '~0').replaceAll('/', '~1');
8
+ }
9
+ function failed(operation, notCreated) {
10
+ const first = notCreated ? Object.values(notCreated)[0] : undefined;
11
+ throw new MailError(first?.description ?? `the server refused to create the ${operation}`, {
12
+ type: first?.type ?? 'unknown',
13
+ operation,
14
+ });
15
+ }
16
+ /** The JMAP `Email` object a draft becomes. Exported so the shape can be
17
+ * asserted without a server. */
18
+ export function draftToEmail(draft, from, draftsMailboxId) {
19
+ const bodyValues = {};
20
+ const textBody = [];
21
+ const htmlBody = [];
22
+ if (draft.text !== undefined) {
23
+ bodyValues.text = { value: draft.text };
24
+ textBody.push({ partId: 'text', type: 'text/plain' });
25
+ }
26
+ if (draft.html !== undefined) {
27
+ bodyValues.html = { value: draft.html };
28
+ htmlBody.push({ partId: 'html', type: 'text/html' });
29
+ }
30
+ // A message with no body at all is legal but confuses some servers'
31
+ // structure building; an empty text part is the harmless equivalent.
32
+ if (textBody.length === 0 && htmlBody.length === 0) {
33
+ bodyValues.text = { value: '' };
34
+ textBody.push({ partId: 'text', type: 'text/plain' });
35
+ }
36
+ return {
37
+ mailboxIds: { [draftsMailboxId]: true },
38
+ keywords: { $draft: true, $seen: true },
39
+ from,
40
+ to: draft.to,
41
+ ...(draft.cc?.length ? { cc: draft.cc } : {}),
42
+ ...(draft.bcc?.length ? { bcc: draft.bcc } : {}),
43
+ ...(draft.replyTo?.length ? { replyTo: draft.replyTo } : {}),
44
+ subject: draft.subject,
45
+ ...(draft.inReplyTo?.length ? { inReplyTo: draft.inReplyTo } : {}),
46
+ ...(draft.references?.length ? { references: draft.references } : {}),
47
+ bodyValues,
48
+ ...(textBody.length ? { textBody } : {}),
49
+ ...(htmlBody.length ? { htmlBody } : {}),
50
+ ...(draft.attachments?.length
51
+ ? {
52
+ attachments: draft.attachments.map((attachment) => ({
53
+ blobId: attachment.blobId,
54
+ type: attachment.type,
55
+ name: attachment.name,
56
+ disposition: attachment.disposition ?? 'attachment',
57
+ ...(attachment.cid ? { cid: attachment.cid } : {}),
58
+ })),
59
+ }
60
+ : {}),
61
+ };
62
+ }
63
+ /** The patch that files a sent message: out of Drafts, into Sent, no longer a
64
+ * draft. */
65
+ export function sentPatch(draftsMailboxId, sentMailboxId) {
66
+ return {
67
+ [`mailboxIds/${pointer(draftsMailboxId)}`]: null,
68
+ [`mailboxIds/${pointer(sentMailboxId)}`]: true,
69
+ 'keywords/$draft': null,
70
+ 'keywords/$seen': true,
71
+ };
72
+ }
73
+ /**
74
+ * Send a draft.
75
+ *
76
+ * Two requests, not one, and the reason is a sharp edge in RFC 8621 §7.5:
77
+ * `onSuccessUpdateEmail` makes the server run an implicit `Email/set` and
78
+ * return it *under the same method call id* as the `EmailSubmission/set`.
79
+ * A client that reads a multi-call response into a map by id therefore loses
80
+ * the submission's own result to the implicit one. `jmap-jam`'s `requestMany`
81
+ * does exactly that, so the submission goes through the single-call path,
82
+ * which reads the first response and is unaffected.
83
+ *
84
+ * The draft is created first and separately, which also means a failed
85
+ * submission leaves the message in Drafts rather than losing it.
86
+ */
87
+ export async function send(client, draft, options = {}) {
88
+ const { jam, accountId } = await client.connect();
89
+ const tree = options.mailboxes ?? (await mailboxes(client));
90
+ const drafts = findRole(tree, 'drafts');
91
+ const sent = findRole(tree, 'sent');
92
+ if (!drafts) {
93
+ throw new MailError('the account has no Drafts mailbox to compose in', {
94
+ type: 'notFound',
95
+ operation: 'send',
96
+ });
97
+ }
98
+ if (!sent) {
99
+ throw new MailError('the account has no Sent mailbox to file the message in', {
100
+ type: 'notFound',
101
+ operation: 'send',
102
+ });
103
+ }
104
+ let from = draft.from;
105
+ if (!from || from.length === 0) {
106
+ const identity = options.identity ?? (await identities(client)).find((one) => one.id === draft.identityId);
107
+ if (!identity) {
108
+ throw new MailError(`no identity ${draft.identityId} on this account`, {
109
+ type: 'notFound',
110
+ operation: 'send',
111
+ });
112
+ }
113
+ // RFC 8621 §7.1: a server may refuse a submission whose From header does
114
+ // not match the identity, so the header is filled from the identity
115
+ // rather than left to the server to guess.
116
+ from = [{ name: identity.name, email: identity.email }];
117
+ }
118
+ return guard('send', async () => {
119
+ const [created] = await jam.api.Email.set({
120
+ accountId,
121
+ create: { draft: draftToEmail(draft, from, drafts.id) },
122
+ });
123
+ const email = created.created?.draft;
124
+ if (!email)
125
+ failed('draft', created.notCreated);
126
+ const [submitted] = await jam.api.EmailSubmission.set({
127
+ accountId,
128
+ create: {
129
+ submission: {
130
+ identityId: draft.identityId,
131
+ emailId: email.id,
132
+ },
133
+ },
134
+ onSuccessUpdateEmail: { '#submission': sentPatch(drafts.id, sent.id) },
135
+ },
136
+ // `onSuccessUpdateEmail` runs an Email/set, so the request has to
137
+ // declare the mail capability as well as submission. jmap-jam derives
138
+ // capabilities from the method names alone and would send only
139
+ // submission and core.
140
+ { using: ['urn:ietf:params:jmap:mail'] });
141
+ const submission = submitted.created?.submission;
142
+ if (!submission)
143
+ failed('submission', submitted.notCreated);
144
+ return {
145
+ submissionId: submission.id,
146
+ emailId: email.id,
147
+ threadId: email.threadId,
148
+ };
149
+ });
150
+ }
@@ -0,0 +1,61 @@
1
+ import type { MailClient } from './client.ts';
2
+ import type { EmailAddress, Message, ThreadDetail } from './types.ts';
3
+ /** A body part as `Email/get` returns it. */
4
+ type BodyPart = {
5
+ partId?: string;
6
+ blobId?: string;
7
+ size?: number;
8
+ name?: string;
9
+ type?: string;
10
+ charset?: string;
11
+ disposition?: string;
12
+ cid?: string;
13
+ };
14
+ type BodyValue = {
15
+ value: string;
16
+ isEncodingProblem?: boolean;
17
+ isTruncated?: boolean;
18
+ };
19
+ export type RawMessage = {
20
+ id: string;
21
+ blobId: string;
22
+ threadId: string;
23
+ mailboxIds?: Record<string, boolean>;
24
+ keywords?: Record<string, boolean>;
25
+ size?: number;
26
+ receivedAt: string;
27
+ sentAt?: string;
28
+ messageId?: string[];
29
+ inReplyTo?: string[];
30
+ references?: string[];
31
+ from?: EmailAddress[];
32
+ to?: EmailAddress[];
33
+ cc?: EmailAddress[];
34
+ bcc?: EmailAddress[];
35
+ replyTo?: EmailAddress[];
36
+ subject?: string;
37
+ preview?: string;
38
+ hasAttachment?: boolean;
39
+ textBody?: BodyPart[];
40
+ htmlBody?: BodyPart[];
41
+ attachments?: BodyPart[];
42
+ bodyValues?: Record<string, BodyValue>;
43
+ };
44
+ /** How many bytes of each body part to fetch. A message with a megabyte of
45
+ * quoted history should not stall a thread view; anything cut short is
46
+ * reported as `isTruncated`. */
47
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
48
+ /** Turn one `Email/get` result into a `Message`, resolving its bodies and
49
+ * giving every attachment a download URL. */
50
+ export declare function toMessage(raw: RawMessage, downloadUrlFor: (blob: {
51
+ blobId: string;
52
+ name: string;
53
+ type: string;
54
+ }) => string): Message;
55
+ export type ThreadOptions = {
56
+ /** Bytes of each body part to fetch. */
57
+ maxBodyBytes?: number;
58
+ };
59
+ /** One thread, with every message's body and attachments, oldest first. */
60
+ export declare function thread(client: MailClient, threadId: string, options?: ThreadOptions): Promise<ThreadDetail>;
61
+ export {};