@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,153 @@
|
|
|
1
|
+
import { MailError, guard } from "./errors.js";
|
|
2
|
+
import { expandTemplate } from "./uri.js";
|
|
3
|
+
const MESSAGE_PROPERTIES = [
|
|
4
|
+
'id',
|
|
5
|
+
'blobId',
|
|
6
|
+
'threadId',
|
|
7
|
+
'mailboxIds',
|
|
8
|
+
'keywords',
|
|
9
|
+
'size',
|
|
10
|
+
'receivedAt',
|
|
11
|
+
'sentAt',
|
|
12
|
+
'messageId',
|
|
13
|
+
'inReplyTo',
|
|
14
|
+
'references',
|
|
15
|
+
'from',
|
|
16
|
+
'to',
|
|
17
|
+
'cc',
|
|
18
|
+
'bcc',
|
|
19
|
+
'replyTo',
|
|
20
|
+
'subject',
|
|
21
|
+
'preview',
|
|
22
|
+
'hasAttachment',
|
|
23
|
+
'textBody',
|
|
24
|
+
'htmlBody',
|
|
25
|
+
'attachments',
|
|
26
|
+
'bodyValues',
|
|
27
|
+
];
|
|
28
|
+
const BODY_PROPERTIES = [
|
|
29
|
+
'partId',
|
|
30
|
+
'blobId',
|
|
31
|
+
'size',
|
|
32
|
+
'name',
|
|
33
|
+
'type',
|
|
34
|
+
'charset',
|
|
35
|
+
'disposition',
|
|
36
|
+
'cid',
|
|
37
|
+
];
|
|
38
|
+
/** How many bytes of each body part to fetch. A message with a megabyte of
|
|
39
|
+
* quoted history should not stall a thread view; anything cut short is
|
|
40
|
+
* reported as `isTruncated`. */
|
|
41
|
+
export const DEFAULT_MAX_BODY_BYTES = 512 * 1024;
|
|
42
|
+
function joinParts(parts, values) {
|
|
43
|
+
if (!parts || parts.length === 0 || !values)
|
|
44
|
+
return { text: null, truncated: false };
|
|
45
|
+
const pieces = [];
|
|
46
|
+
let truncated = false;
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
if (part.partId === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
const value = values[part.partId];
|
|
51
|
+
if (!value)
|
|
52
|
+
continue;
|
|
53
|
+
pieces.push(value.value);
|
|
54
|
+
if (value.isTruncated)
|
|
55
|
+
truncated = true;
|
|
56
|
+
}
|
|
57
|
+
if (pieces.length === 0)
|
|
58
|
+
return { text: null, truncated };
|
|
59
|
+
return { text: pieces.join('\n'), truncated };
|
|
60
|
+
}
|
|
61
|
+
/** Turn one `Email/get` result into a `Message`, resolving its bodies and
|
|
62
|
+
* giving every attachment a download URL. */
|
|
63
|
+
export function toMessage(raw, downloadUrlFor) {
|
|
64
|
+
const text = joinParts(raw.textBody, raw.bodyValues);
|
|
65
|
+
const html = joinParts(raw.htmlBody, raw.bodyValues);
|
|
66
|
+
const keywords = raw.keywords ?? {};
|
|
67
|
+
const attachments = (raw.attachments ?? [])
|
|
68
|
+
// A part with no blob cannot be fetched, so it is not an attachment a
|
|
69
|
+
// reader can do anything with.
|
|
70
|
+
.filter((part) => typeof part.blobId === 'string')
|
|
71
|
+
.map((part) => {
|
|
72
|
+
const name = part.name ?? 'attachment';
|
|
73
|
+
const type = part.type ?? 'application/octet-stream';
|
|
74
|
+
return {
|
|
75
|
+
blobId: part.blobId,
|
|
76
|
+
name,
|
|
77
|
+
type,
|
|
78
|
+
size: part.size ?? 0,
|
|
79
|
+
disposition: part.disposition === 'inline' ? 'inline' : 'attachment',
|
|
80
|
+
cid: part.cid ? part.cid.replace(/^<|>$/g, '') : null,
|
|
81
|
+
downloadUrl: downloadUrlFor({ blobId: part.blobId, name, type }),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
id: raw.id,
|
|
86
|
+
blobId: raw.blobId,
|
|
87
|
+
threadId: raw.threadId,
|
|
88
|
+
subject: raw.subject ?? '',
|
|
89
|
+
from: raw.from ?? [],
|
|
90
|
+
to: raw.to ?? [],
|
|
91
|
+
cc: raw.cc ?? [],
|
|
92
|
+
bcc: raw.bcc ?? [],
|
|
93
|
+
replyTo: raw.replyTo ?? [],
|
|
94
|
+
sentAt: raw.sentAt ?? null,
|
|
95
|
+
receivedAt: raw.receivedAt,
|
|
96
|
+
size: raw.size ?? 0,
|
|
97
|
+
preview: raw.preview ?? '',
|
|
98
|
+
messageId: raw.messageId ?? [],
|
|
99
|
+
inReplyTo: raw.inReplyTo ?? [],
|
|
100
|
+
references: raw.references ?? [],
|
|
101
|
+
text: text.text,
|
|
102
|
+
html: html.text,
|
|
103
|
+
isTruncated: text.truncated || html.truncated,
|
|
104
|
+
attachments,
|
|
105
|
+
keywords,
|
|
106
|
+
mailboxIds: Object.keys(raw.mailboxIds ?? {}),
|
|
107
|
+
isUnread: keywords.$seen !== true,
|
|
108
|
+
isFlagged: keywords.$flagged === true,
|
|
109
|
+
isDraft: keywords.$draft === true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** One thread, with every message's body and attachments, oldest first. */
|
|
113
|
+
export async function thread(client, threadId, options = {}) {
|
|
114
|
+
const { jam, accountId, doc } = await client.connect();
|
|
115
|
+
return guard('thread', async () => {
|
|
116
|
+
const [results] = await jam.requestMany((t) => {
|
|
117
|
+
const threads = t.Thread.get({ accountId, ids: [threadId] });
|
|
118
|
+
const messages = t.Email.get({
|
|
119
|
+
accountId,
|
|
120
|
+
ids: threads.$ref('/list/*/emailIds'),
|
|
121
|
+
properties: MESSAGE_PROPERTIES,
|
|
122
|
+
bodyProperties: [...BODY_PROPERTIES],
|
|
123
|
+
fetchTextBodyValues: true,
|
|
124
|
+
fetchHTMLBodyValues: true,
|
|
125
|
+
maxBodyValueBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
126
|
+
});
|
|
127
|
+
return { threads, messages };
|
|
128
|
+
});
|
|
129
|
+
const found = results.threads.list;
|
|
130
|
+
if (found.length === 0) {
|
|
131
|
+
throw new MailError(`no thread ${threadId} in this account`, {
|
|
132
|
+
type: 'notFound',
|
|
133
|
+
operation: 'thread',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const template = doc.downloadUrl;
|
|
137
|
+
const raw = results.messages.list;
|
|
138
|
+
const messages = raw
|
|
139
|
+
.map((message) => toMessage(message, (blob) => expandTemplate(template, {
|
|
140
|
+
accountId,
|
|
141
|
+
blobId: blob.blobId,
|
|
142
|
+
name: blob.name,
|
|
143
|
+
type: blob.type,
|
|
144
|
+
})))
|
|
145
|
+
// A thread reads oldest first, whatever order the server listed it in.
|
|
146
|
+
.sort((a, b) => (a.receivedAt < b.receivedAt ? -1 : a.receivedAt > b.receivedAt ? 1 : 0));
|
|
147
|
+
return {
|
|
148
|
+
id: threadId,
|
|
149
|
+
subject: messages.find((message) => message.subject)?.subject ?? '',
|
|
150
|
+
messages,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MailClient } from './client.ts';
|
|
2
|
+
import type { EmailAddress, MailFilter, MailSort, ThreadPage, ThreadSummary } from './types.ts';
|
|
3
|
+
type SummaryEmail = {
|
|
4
|
+
id: string;
|
|
5
|
+
threadId: string;
|
|
6
|
+
mailboxIds: Record<string, boolean>;
|
|
7
|
+
keywords: Record<string, boolean>;
|
|
8
|
+
from?: EmailAddress[];
|
|
9
|
+
to?: EmailAddress[];
|
|
10
|
+
subject?: string;
|
|
11
|
+
receivedAt: string;
|
|
12
|
+
preview?: string;
|
|
13
|
+
hasAttachment?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type ListOptions = {
|
|
16
|
+
/** Index of the first thread to return. */
|
|
17
|
+
position?: number;
|
|
18
|
+
/** How many threads to return. Servers cap this; Stalwart's default cap is
|
|
19
|
+
* generous but a list view should ask for what it draws. */
|
|
20
|
+
limit?: number;
|
|
21
|
+
sort?: readonly MailSort[];
|
|
22
|
+
/** Ask the server how many threads match. It is allowed to refuse, in
|
|
23
|
+
* which case `total` comes back undefined. */
|
|
24
|
+
calculateTotal?: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Fold a thread's messages into one list row.
|
|
28
|
+
*
|
|
29
|
+
* `latest` is the message the query returned for the thread, which is the one
|
|
30
|
+
* the sort put first; `members` is every message in the thread, in whatever
|
|
31
|
+
* order the server listed them.
|
|
32
|
+
*/
|
|
33
|
+
export declare function summarise(threadId: string, latest: SummaryEmail, members: readonly SummaryEmail[]): ThreadSummary;
|
|
34
|
+
/**
|
|
35
|
+
* One page of threads matching a filter.
|
|
36
|
+
*
|
|
37
|
+
* Four method calls chained by result reference, so the whole page costs one
|
|
38
|
+
* round trip: query the newest message per thread, fetch those messages,
|
|
39
|
+
* fetch their threads' member ids, fetch the members.
|
|
40
|
+
*/
|
|
41
|
+
export declare function queryThreads(client: MailClient, filter: MailFilter, options?: ListOptions): Promise<ThreadPage>;
|
|
42
|
+
/** A page of the threads in one mailbox, newest first. */
|
|
43
|
+
export declare function listThreads(client: MailClient, mailboxId: string, options?: ListOptions): Promise<ThreadPage>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { guard } from "./errors.js";
|
|
2
|
+
/** What a list row needs from the newest message in each thread. */
|
|
3
|
+
const SUMMARY_PROPERTIES = [
|
|
4
|
+
'id',
|
|
5
|
+
'threadId',
|
|
6
|
+
'mailboxIds',
|
|
7
|
+
'keywords',
|
|
8
|
+
'from',
|
|
9
|
+
'to',
|
|
10
|
+
'subject',
|
|
11
|
+
'receivedAt',
|
|
12
|
+
'preview',
|
|
13
|
+
'hasAttachment',
|
|
14
|
+
];
|
|
15
|
+
/** What every other message in the thread contributes: the unread count, the
|
|
16
|
+
* participant list, and whether anything in the thread is flagged. */
|
|
17
|
+
const MEMBER_PROPERTIES = [
|
|
18
|
+
'id',
|
|
19
|
+
'threadId',
|
|
20
|
+
'mailboxIds',
|
|
21
|
+
'keywords',
|
|
22
|
+
'from',
|
|
23
|
+
'subject',
|
|
24
|
+
'receivedAt',
|
|
25
|
+
'hasAttachment',
|
|
26
|
+
];
|
|
27
|
+
const DEFAULT_SORT = [{ property: 'receivedAt', isAscending: false }];
|
|
28
|
+
function keywords(email, keyword) {
|
|
29
|
+
return Boolean(email?.keywords?.[keyword]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fold a thread's messages into one list row.
|
|
33
|
+
*
|
|
34
|
+
* `latest` is the message the query returned for the thread, which is the one
|
|
35
|
+
* the sort put first; `members` is every message in the thread, in whatever
|
|
36
|
+
* order the server listed them.
|
|
37
|
+
*/
|
|
38
|
+
export function summarise(threadId, latest, members) {
|
|
39
|
+
// A thread whose members could not be fetched still has the one message the
|
|
40
|
+
// query returned, and a row drawn from it is right in every field but the
|
|
41
|
+
// counts.
|
|
42
|
+
const all = members.length > 0 ? members : [latest];
|
|
43
|
+
const chronological = [...all].sort((a, b) => (a.receivedAt < b.receivedAt ? -1 : 1));
|
|
44
|
+
const participants = [];
|
|
45
|
+
const seenAddresses = new Set();
|
|
46
|
+
for (const message of chronological) {
|
|
47
|
+
for (const address of message.from ?? []) {
|
|
48
|
+
const key = address.email.toLowerCase();
|
|
49
|
+
if (seenAddresses.has(key))
|
|
50
|
+
continue;
|
|
51
|
+
seenAddresses.add(key);
|
|
52
|
+
participants.push(address);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const mailboxIds = new Set();
|
|
56
|
+
for (const message of all)
|
|
57
|
+
for (const id of Object.keys(message.mailboxIds ?? {}))
|
|
58
|
+
mailboxIds.add(id);
|
|
59
|
+
// The thread's subject is the one it started with: a reply may drop or
|
|
60
|
+
// re-prefix it, and a list keyed on the newest subject reads as a new
|
|
61
|
+
// thread. `Email/get` omits `subject` entirely for a message with no
|
|
62
|
+
// Subject header, so the message the query returned is the last resort.
|
|
63
|
+
const subject = chronological.find((message) => message.subject)?.subject ?? latest.subject ?? '';
|
|
64
|
+
return {
|
|
65
|
+
id: threadId,
|
|
66
|
+
latestEmailId: latest.id,
|
|
67
|
+
subject,
|
|
68
|
+
preview: latest.preview ?? '',
|
|
69
|
+
receivedAt: latest.receivedAt,
|
|
70
|
+
participants,
|
|
71
|
+
from: latest.from?.[0] ?? null,
|
|
72
|
+
to: latest.to ?? [],
|
|
73
|
+
emailCount: all.length,
|
|
74
|
+
unreadCount: all.filter((message) => !keywords(message, '$seen')).length,
|
|
75
|
+
hasAttachment: all.some((message) => message.hasAttachment === true),
|
|
76
|
+
isFlagged: all.some((message) => keywords(message, '$flagged')),
|
|
77
|
+
isDraft: keywords(latest, '$draft'),
|
|
78
|
+
mailboxIds: [...mailboxIds],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One page of threads matching a filter.
|
|
83
|
+
*
|
|
84
|
+
* Four method calls chained by result reference, so the whole page costs one
|
|
85
|
+
* round trip: query the newest message per thread, fetch those messages,
|
|
86
|
+
* fetch their threads' member ids, fetch the members.
|
|
87
|
+
*/
|
|
88
|
+
export async function queryThreads(client, filter, options = {}) {
|
|
89
|
+
const { jam, accountId } = await client.connect();
|
|
90
|
+
const position = options.position ?? 0;
|
|
91
|
+
// `MailFilter` is deliberately readonly; the wire type is not, so the one
|
|
92
|
+
// array field is copied rather than cast away.
|
|
93
|
+
const { inMailboxOtherThan, ...conditions } = filter;
|
|
94
|
+
const condition = {
|
|
95
|
+
...conditions,
|
|
96
|
+
...(inMailboxOtherThan === undefined ? {} : { inMailboxOtherThan: [...inMailboxOtherThan] }),
|
|
97
|
+
};
|
|
98
|
+
return guard('threads.query', async () => {
|
|
99
|
+
const [results] = await jam.requestMany((t) => {
|
|
100
|
+
const query = t.Email.query({
|
|
101
|
+
accountId,
|
|
102
|
+
filter: condition,
|
|
103
|
+
collapseThreads: true,
|
|
104
|
+
sort: options.sort ?? DEFAULT_SORT,
|
|
105
|
+
position,
|
|
106
|
+
...(options.limit === undefined ? {} : { limit: options.limit }),
|
|
107
|
+
calculateTotal: options.calculateTotal ?? false,
|
|
108
|
+
});
|
|
109
|
+
const latest = t.Email.get({
|
|
110
|
+
accountId,
|
|
111
|
+
ids: query.$ref('/ids'),
|
|
112
|
+
properties: SUMMARY_PROPERTIES,
|
|
113
|
+
});
|
|
114
|
+
const threads = t.Thread.get({
|
|
115
|
+
accountId,
|
|
116
|
+
ids: latest.$ref('/list/*/threadId'),
|
|
117
|
+
});
|
|
118
|
+
const members = t.Email.get({
|
|
119
|
+
accountId,
|
|
120
|
+
ids: threads.$ref('/list/*/emailIds'),
|
|
121
|
+
properties: MEMBER_PROPERTIES,
|
|
122
|
+
});
|
|
123
|
+
return { query, latest, threads, members };
|
|
124
|
+
});
|
|
125
|
+
const query = results.query;
|
|
126
|
+
const latest = results.latest;
|
|
127
|
+
const threads = results.threads;
|
|
128
|
+
const members = results.members;
|
|
129
|
+
const latestById = new Map(latest.list.map((email) => [email.id, email]));
|
|
130
|
+
const memberById = new Map(members.list.map((email) => [email.id, email]));
|
|
131
|
+
const threadById = new Map(threads.list.map((thread) => [thread.id, thread]));
|
|
132
|
+
const items = [];
|
|
133
|
+
for (const emailId of query.ids) {
|
|
134
|
+
// The query can name a message `Email/get` did not return, if it was
|
|
135
|
+
// destroyed between the two calls in the same request.
|
|
136
|
+
const email = latestById.get(emailId);
|
|
137
|
+
if (!email)
|
|
138
|
+
continue;
|
|
139
|
+
const thread = threadById.get(email.threadId);
|
|
140
|
+
const memberEmails = (thread?.emailIds ?? [])
|
|
141
|
+
.map((id) => memberById.get(id))
|
|
142
|
+
.filter((member) => member !== undefined);
|
|
143
|
+
items.push(summarise(email.threadId, email, memberEmails));
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
items,
|
|
147
|
+
position: query.position ?? position,
|
|
148
|
+
total: query.total,
|
|
149
|
+
state: latest.state,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** A page of the threads in one mailbox, newest first. */
|
|
154
|
+
export function listThreads(client, mailboxId, options = {}) {
|
|
155
|
+
return queryThreads(client, { inMailbox: mailboxId }, options);
|
|
156
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's public shapes. Nothing here comes from `jmap-jam` or
|
|
3
|
+
* `jmap-rfc-types`: those ship raw TypeScript with `.ts` import specifiers,
|
|
4
|
+
* which only compiles under `allowImportingTsExtensions`, and a consumer
|
|
5
|
+
* should not have to turn that on to use this package. The JMAP wire types
|
|
6
|
+
* stay behind the client.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* A mailbox address, with the display name when the message carried one.
|
|
10
|
+
*
|
|
11
|
+
* `name` may be `null` as well as absent: Stalwart sends `null` for a header
|
|
12
|
+
* field the message did not have, rather than leaving the property out, and
|
|
13
|
+
* a type that promised only `string | undefined` would be lying about what
|
|
14
|
+
* arrives.
|
|
15
|
+
*/
|
|
16
|
+
export type EmailAddress = {
|
|
17
|
+
name?: string | null;
|
|
18
|
+
email: string;
|
|
19
|
+
};
|
|
20
|
+
/** The purposes RFC 8621 §2 names, whatever a mailbox is called. */
|
|
21
|
+
export type MailboxRole = 'all' | 'archive' | 'drafts' | 'flagged' | 'important' | 'inbox' | 'junk' | 'sent' | 'subscribed' | 'trash';
|
|
22
|
+
/** What the session document says about the signed-in person. */
|
|
23
|
+
export type MailSession = {
|
|
24
|
+
/** The login name the token resolved to. */
|
|
25
|
+
username: string;
|
|
26
|
+
/** The account this package reads and writes. */
|
|
27
|
+
accountId: string;
|
|
28
|
+
/** The account's display name. */
|
|
29
|
+
accountName: string;
|
|
30
|
+
isReadOnly: boolean;
|
|
31
|
+
/** True when the account may submit mail, so a composer can be hidden
|
|
32
|
+
* rather than failing at send. */
|
|
33
|
+
maySubmit: boolean;
|
|
34
|
+
};
|
|
35
|
+
/** A mailbox with its children, as the tree the server's flat list describes. */
|
|
36
|
+
export type MailboxNode = {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
parentId: string | null;
|
|
40
|
+
role: MailboxRole | null;
|
|
41
|
+
sortOrder: number;
|
|
42
|
+
totalEmails: number;
|
|
43
|
+
unreadEmails: number;
|
|
44
|
+
totalThreads: number;
|
|
45
|
+
unreadThreads: number;
|
|
46
|
+
isSubscribed: boolean;
|
|
47
|
+
/** The names from the root down to and including this one, e.g.
|
|
48
|
+
* `['Archive', '2026']`. A stable label, and a sort key. */
|
|
49
|
+
path: readonly string[];
|
|
50
|
+
children: readonly MailboxNode[];
|
|
51
|
+
};
|
|
52
|
+
/** One row of a thread list: what a list view draws without opening anything. */
|
|
53
|
+
export type ThreadSummary = {
|
|
54
|
+
/** The thread id, which is the row's identity. */
|
|
55
|
+
id: string;
|
|
56
|
+
/** The most recent message, for opening straight to it. */
|
|
57
|
+
latestEmailId: string;
|
|
58
|
+
subject: string;
|
|
59
|
+
preview: string;
|
|
60
|
+
/** The most recent message's arrival time, which orders the list. */
|
|
61
|
+
receivedAt: string;
|
|
62
|
+
/** Everyone who wrote in the thread, oldest first, no duplicates. */
|
|
63
|
+
participants: readonly EmailAddress[];
|
|
64
|
+
/** The most recent message's sender. */
|
|
65
|
+
from: EmailAddress | null;
|
|
66
|
+
to: readonly EmailAddress[];
|
|
67
|
+
emailCount: number;
|
|
68
|
+
/** Messages in the thread without the `$seen` keyword. */
|
|
69
|
+
unreadCount: number;
|
|
70
|
+
hasAttachment: boolean;
|
|
71
|
+
isFlagged: boolean;
|
|
72
|
+
isDraft: boolean;
|
|
73
|
+
/** Every mailbox the thread's messages sit in. */
|
|
74
|
+
mailboxIds: readonly string[];
|
|
75
|
+
};
|
|
76
|
+
/** A page of a thread list. */
|
|
77
|
+
export type ThreadPage = {
|
|
78
|
+
items: readonly ThreadSummary[];
|
|
79
|
+
/** The index of the first item within the whole result. The server may
|
|
80
|
+
* clamp what was asked for, so this is the position actually returned. */
|
|
81
|
+
position: number;
|
|
82
|
+
/** How many threads match, when the server was asked to count. */
|
|
83
|
+
total: number | undefined;
|
|
84
|
+
/** The `Email` type's state at the time of the query. */
|
|
85
|
+
state: string;
|
|
86
|
+
};
|
|
87
|
+
/** An attachment, with the URL that fetches its bytes. */
|
|
88
|
+
export type Attachment = {
|
|
89
|
+
blobId: string;
|
|
90
|
+
name: string;
|
|
91
|
+
type: string;
|
|
92
|
+
size: number;
|
|
93
|
+
/** `inline` for a part the body references, `attachment` otherwise. */
|
|
94
|
+
disposition: 'inline' | 'attachment';
|
|
95
|
+
/** The `Content-ID` an inline part is referenced by, without the angle
|
|
96
|
+
* brackets, so an HTML body's `cid:` URLs can be rewritten. */
|
|
97
|
+
cid: string | null;
|
|
98
|
+
/** An absolute URL. It needs the same `Authorization` header as the API,
|
|
99
|
+
* so a browser cannot use it as a bare `<img src>`: fetch it and make an
|
|
100
|
+
* object URL. */
|
|
101
|
+
downloadUrl: string;
|
|
102
|
+
};
|
|
103
|
+
/** One message, with its body resolved. */
|
|
104
|
+
export type Message = {
|
|
105
|
+
id: string;
|
|
106
|
+
blobId: string;
|
|
107
|
+
threadId: string;
|
|
108
|
+
subject: string;
|
|
109
|
+
from: readonly EmailAddress[];
|
|
110
|
+
to: readonly EmailAddress[];
|
|
111
|
+
cc: readonly EmailAddress[];
|
|
112
|
+
bcc: readonly EmailAddress[];
|
|
113
|
+
replyTo: readonly EmailAddress[];
|
|
114
|
+
sentAt: string | null;
|
|
115
|
+
receivedAt: string;
|
|
116
|
+
size: number;
|
|
117
|
+
preview: string;
|
|
118
|
+
/** The `Message-ID` header, which a reply quotes in `In-Reply-To`. */
|
|
119
|
+
messageId: readonly string[];
|
|
120
|
+
inReplyTo: readonly string[];
|
|
121
|
+
references: readonly string[];
|
|
122
|
+
/** The plain-text body, parts joined. `null` when the message has only an
|
|
123
|
+
* HTML body. */
|
|
124
|
+
text: string | null;
|
|
125
|
+
/** The HTML body, parts joined. `null` when the message is plain text. */
|
|
126
|
+
html: string | null;
|
|
127
|
+
/** True when a body came back cut short at `maxBodyBytes`; the rest needs
|
|
128
|
+
* another fetch. */
|
|
129
|
+
isTruncated: boolean;
|
|
130
|
+
attachments: readonly Attachment[];
|
|
131
|
+
keywords: Readonly<Record<string, boolean>>;
|
|
132
|
+
mailboxIds: readonly string[];
|
|
133
|
+
isUnread: boolean;
|
|
134
|
+
isFlagged: boolean;
|
|
135
|
+
isDraft: boolean;
|
|
136
|
+
};
|
|
137
|
+
/** A thread, opened. */
|
|
138
|
+
export type ThreadDetail = {
|
|
139
|
+
id: string;
|
|
140
|
+
/** The subject of the first message that has one, which names the thread. */
|
|
141
|
+
subject: string;
|
|
142
|
+
/** Oldest first, as a conversation reads. */
|
|
143
|
+
messages: readonly Message[];
|
|
144
|
+
};
|
|
145
|
+
/** A sending identity: an address this account may send as. */
|
|
146
|
+
export type MailIdentity = {
|
|
147
|
+
id: string;
|
|
148
|
+
name: string;
|
|
149
|
+
email: string;
|
|
150
|
+
replyTo: readonly EmailAddress[];
|
|
151
|
+
bcc: readonly EmailAddress[];
|
|
152
|
+
textSignature: string;
|
|
153
|
+
htmlSignature: string;
|
|
154
|
+
mayDelete: boolean;
|
|
155
|
+
};
|
|
156
|
+
/** A message to send. Addresses are objects and bodies are strings; the wire
|
|
157
|
+
* shape (body parts, body values, the envelope) is this package's business. */
|
|
158
|
+
export type Draft = {
|
|
159
|
+
/** The identity to send as. Its address becomes the envelope's `MAIL FROM`
|
|
160
|
+
* and, unless `from` says otherwise, the `From` header. */
|
|
161
|
+
identityId: string;
|
|
162
|
+
from?: readonly EmailAddress[];
|
|
163
|
+
to: readonly EmailAddress[];
|
|
164
|
+
cc?: readonly EmailAddress[];
|
|
165
|
+
bcc?: readonly EmailAddress[];
|
|
166
|
+
replyTo?: readonly EmailAddress[];
|
|
167
|
+
subject: string;
|
|
168
|
+
text?: string;
|
|
169
|
+
html?: string;
|
|
170
|
+
/** `Message-ID`s this message replies to. */
|
|
171
|
+
inReplyTo?: readonly string[];
|
|
172
|
+
/** The `References` chain, oldest first. */
|
|
173
|
+
references?: readonly string[];
|
|
174
|
+
/** Blobs to attach: freshly uploaded, or carried from a message being
|
|
175
|
+
* forwarded. */
|
|
176
|
+
attachments?: readonly DraftAttachment[];
|
|
177
|
+
};
|
|
178
|
+
export type DraftAttachment = {
|
|
179
|
+
blobId: string;
|
|
180
|
+
name: string;
|
|
181
|
+
type: string;
|
|
182
|
+
/** `inline` needs a `cid` the HTML body references. */
|
|
183
|
+
disposition?: 'inline' | 'attachment';
|
|
184
|
+
cid?: string;
|
|
185
|
+
};
|
|
186
|
+
/** What `send` reports back. */
|
|
187
|
+
export type Sent = {
|
|
188
|
+
/** The submission, which `EmailSubmission/get` reports delivery for. */
|
|
189
|
+
submissionId: string;
|
|
190
|
+
/** The id the message has once it is in Sent. */
|
|
191
|
+
emailId: string;
|
|
192
|
+
threadId: string;
|
|
193
|
+
};
|
|
194
|
+
/** The per-type state strings a push event carries. A value that differs
|
|
195
|
+
* from the one a previous call returned means that type changed. */
|
|
196
|
+
export type ChangedState = {
|
|
197
|
+
Mailbox?: string;
|
|
198
|
+
Thread?: string;
|
|
199
|
+
Email?: string;
|
|
200
|
+
EmailSubmission?: string;
|
|
201
|
+
Identity?: string;
|
|
202
|
+
[type: string]: string | undefined;
|
|
203
|
+
};
|
|
204
|
+
/** The conditions a thread query filters on. Every field set is required to
|
|
205
|
+
* match, which is what RFC 8621 §4.4 says a single filter condition means. */
|
|
206
|
+
export type MailFilter = {
|
|
207
|
+
/** Only threads with a message in this mailbox. */
|
|
208
|
+
inMailbox?: string;
|
|
209
|
+
/** Exclude threads whose only messages are in these mailboxes, e.g. Trash. */
|
|
210
|
+
inMailboxOtherThan?: readonly string[];
|
|
211
|
+
/** Received strictly before this UTC timestamp. */
|
|
212
|
+
before?: string;
|
|
213
|
+
/** Received at or after this UTC timestamp. */
|
|
214
|
+
after?: string;
|
|
215
|
+
minSize?: number;
|
|
216
|
+
maxSize?: number;
|
|
217
|
+
hasKeyword?: string;
|
|
218
|
+
notKeyword?: string;
|
|
219
|
+
hasAttachment?: boolean;
|
|
220
|
+
/** Free text across the parts the server chooses to index. */
|
|
221
|
+
text?: string;
|
|
222
|
+
from?: string;
|
|
223
|
+
to?: string;
|
|
224
|
+
cc?: string;
|
|
225
|
+
bcc?: string;
|
|
226
|
+
subject?: string;
|
|
227
|
+
body?: string;
|
|
228
|
+
};
|
|
229
|
+
/** How to order a thread list. `receivedAt` newest-first is the default. */
|
|
230
|
+
export type MailSort = {
|
|
231
|
+
property: 'receivedAt' | 'sentAt' | 'size' | 'subject' | 'from' | 'to';
|
|
232
|
+
isAscending?: boolean;
|
|
233
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's public shapes. Nothing here comes from `jmap-jam` or
|
|
3
|
+
* `jmap-rfc-types`: those ship raw TypeScript with `.ts` import specifiers,
|
|
4
|
+
* which only compiles under `allowImportingTsExtensions`, and a consumer
|
|
5
|
+
* should not have to turn that on to use this package. The JMAP wire types
|
|
6
|
+
* stay behind the client.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session document's `downloadUrl`, `uploadUrl` and `eventSourceUrl` are
|
|
3
|
+
* RFC 6570 level-1 templates, and RFC 8620 §6.2 is explicit that a client
|
|
4
|
+
* must URI-encode the values it substitutes. jmap-jam's own expander does a
|
|
5
|
+
* plain `replaceAll`, so an attachment called `Q3 report (final).pdf`, or any
|
|
6
|
+
* blob id with a `/` in it, produces a broken or wrong URL.
|
|
7
|
+
*
|
|
8
|
+
* Stalwart's download template is
|
|
9
|
+
* `https://email.<apex>/jmap/download/{accountId}/{blobId}/{name}?type={type}`,
|
|
10
|
+
* where an unencoded `/` in `name` would silently change the path.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Expand an RFC 6570 level-1 template, percent-encoding every value.
|
|
14
|
+
*
|
|
15
|
+
* @throws {Error} when the template names a variable `params` does not supply.
|
|
16
|
+
*/
|
|
17
|
+
export declare function expandTemplate(template: string, params: Record<string, string>): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session document's `downloadUrl`, `uploadUrl` and `eventSourceUrl` are
|
|
3
|
+
* RFC 6570 level-1 templates, and RFC 8620 §6.2 is explicit that a client
|
|
4
|
+
* must URI-encode the values it substitutes. jmap-jam's own expander does a
|
|
5
|
+
* plain `replaceAll`, so an attachment called `Q3 report (final).pdf`, or any
|
|
6
|
+
* blob id with a `/` in it, produces a broken or wrong URL.
|
|
7
|
+
*
|
|
8
|
+
* Stalwart's download template is
|
|
9
|
+
* `https://email.<apex>/jmap/download/{accountId}/{blobId}/{name}?type={type}`,
|
|
10
|
+
* where an unencoded `/` in `name` would silently change the path.
|
|
11
|
+
*/
|
|
12
|
+
const VARIABLE = /\{([^{}]+)\}/g;
|
|
13
|
+
/**
|
|
14
|
+
* Expand an RFC 6570 level-1 template, percent-encoding every value.
|
|
15
|
+
*
|
|
16
|
+
* @throws {Error} when the template names a variable `params` does not supply.
|
|
17
|
+
*/
|
|
18
|
+
export function expandTemplate(template, params) {
|
|
19
|
+
return template.replace(VARIABLE, (_match, name) => {
|
|
20
|
+
const value = params[name];
|
|
21
|
+
if (value === undefined) {
|
|
22
|
+
throw new Error(`the URI template needs a value for {${name}}: ${template}`);
|
|
23
|
+
}
|
|
24
|
+
return encodeURIComponent(value);
|
|
25
|
+
});
|
|
26
|
+
}
|