@rndmjoker/proton-mcp 0.4.4
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 +20 -0
- package/README.md +405 -0
- package/dist/activity.d.ts +49 -0
- package/dist/activity.js +72 -0
- package/dist/activity.js.map +1 -0
- package/dist/bridge/certificate.d.ts +75 -0
- package/dist/bridge/certificate.js +176 -0
- package/dist/bridge/certificate.js.map +1 -0
- package/dist/bridge/connection.d.ts +99 -0
- package/dist/bridge/connection.js +244 -0
- package/dist/bridge/connection.js.map +1 -0
- package/dist/bridge/errors.d.ts +35 -0
- package/dist/bridge/errors.js +56 -0
- package/dist/bridge/errors.js.map +1 -0
- package/dist/config.d.ts +92 -0
- package/dist/config.js +142 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials/availability.d.ts +62 -0
- package/dist/credentials/availability.js +107 -0
- package/dist/credentials/availability.js.map +1 -0
- package/dist/credentials/encrypted-file.d.ts +50 -0
- package/dist/credentials/encrypted-file.js +156 -0
- package/dist/credentials/encrypted-file.js.map +1 -0
- package/dist/credentials/keyring.d.ts +48 -0
- package/dist/credentials/keyring.js +97 -0
- package/dist/credentials/keyring.js.map +1 -0
- package/dist/credentials/plain-file.d.ts +24 -0
- package/dist/credentials/plain-file.js +78 -0
- package/dist/credentials/plain-file.js.map +1 -0
- package/dist/credentials/probe-keyring.d.ts +11 -0
- package/dist/credentials/probe-keyring.js +57 -0
- package/dist/credentials/probe-keyring.js.map +1 -0
- package/dist/credentials/session.d.ts +15 -0
- package/dist/credentials/session.js +21 -0
- package/dist/credentials/session.js.map +1 -0
- package/dist/credentials/store.d.ts +80 -0
- package/dist/credentials/store.js +94 -0
- package/dist/credentials/store.js.map +1 -0
- package/dist/in-flight.d.ts +33 -0
- package/dist/in-flight.js +78 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/mail/actions.d.ts +116 -0
- package/dist/mail/actions.js +265 -0
- package/dist/mail/actions.js.map +1 -0
- package/dist/mail/attachments.d.ts +39 -0
- package/dist/mail/attachments.js +86 -0
- package/dist/mail/attachments.js.map +1 -0
- package/dist/mail/compose.d.ts +264 -0
- package/dist/mail/compose.js +395 -0
- package/dist/mail/compose.js.map +1 -0
- package/dist/mail/drafts.d.ts +138 -0
- package/dist/mail/drafts.js +449 -0
- package/dist/mail/drafts.js.map +1 -0
- package/dist/mail/ids.d.ts +78 -0
- package/dist/mail/ids.js +135 -0
- package/dist/mail/ids.js.map +1 -0
- package/dist/mail/labels.d.ts +61 -0
- package/dist/mail/labels.js +139 -0
- package/dist/mail/labels.js.map +1 -0
- package/dist/mail/markup.d.ts +91 -0
- package/dist/mail/markup.js +265 -0
- package/dist/mail/markup.js.map +1 -0
- package/dist/mail/messages.d.ts +147 -0
- package/dist/mail/messages.js +219 -0
- package/dist/mail/messages.js.map +1 -0
- package/dist/mail/quote.d.ts +65 -0
- package/dist/mail/quote.js +157 -0
- package/dist/mail/quote.js.map +1 -0
- package/dist/mail/search.d.ts +66 -0
- package/dist/mail/search.js +107 -0
- package/dist/mail/search.js.map +1 -0
- package/dist/mail/send.d.ts +61 -0
- package/dist/mail/send.js +132 -0
- package/dist/mail/send.js.map +1 -0
- package/dist/mime/parse.d.ts +110 -0
- package/dist/mime/parse.js +194 -0
- package/dist/mime/parse.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +387 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +86 -0
- package/dist/session.js +220 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +52 -0
- package/dist/settings.js +80 -0
- package/dist/settings.js.map +1 -0
- package/dist/tools/actions.d.ts +21 -0
- package/dist/tools/actions.js +133 -0
- package/dist/tools/actions.js.map +1 -0
- package/dist/tools/capabilities.d.ts +52 -0
- package/dist/tools/capabilities.js +96 -0
- package/dist/tools/capabilities.js.map +1 -0
- package/dist/tools/confirm.d.ts +84 -0
- package/dist/tools/confirm.js +212 -0
- package/dist/tools/confirm.js.map +1 -0
- package/dist/tools/drafts.d.ts +21 -0
- package/dist/tools/drafts.js +199 -0
- package/dist/tools/drafts.js.map +1 -0
- package/dist/tools/failures.d.ts +46 -0
- package/dist/tools/failures.js +157 -0
- package/dist/tools/failures.js.map +1 -0
- package/dist/tools/format.d.ts +55 -0
- package/dist/tools/format.js +220 -0
- package/dist/tools/format.js.map +1 -0
- package/dist/tools/guidance.d.ts +38 -0
- package/dist/tools/guidance.js +226 -0
- package/dist/tools/guidance.js.map +1 -0
- package/dist/tools/interface.d.ts +28 -0
- package/dist/tools/interface.js +63 -0
- package/dist/tools/interface.js.map +1 -0
- package/dist/tools/labels.d.ts +21 -0
- package/dist/tools/labels.js +92 -0
- package/dist/tools/labels.js.map +1 -0
- package/dist/tools/list-folders.d.ts +14 -0
- package/dist/tools/list-folders.js +70 -0
- package/dist/tools/list-folders.js.map +1 -0
- package/dist/tools/messages.d.ts +9 -0
- package/dist/tools/messages.js +221 -0
- package/dist/tools/messages.js.map +1 -0
- package/dist/tools/send.d.ts +29 -0
- package/dist/tools/send.js +236 -0
- package/dist/tools/send.js.map +1 -0
- package/dist/tools/sign-in-required.d.ts +94 -0
- package/dist/tools/sign-in-required.js +168 -0
- package/dist/tools/sign-in-required.js.map +1 -0
- package/dist/web/layout.d.ts +75 -0
- package/dist/web/layout.js +590 -0
- package/dist/web/layout.js.map +1 -0
- package/dist/web/pages.d.ts +79 -0
- package/dist/web/pages.js +284 -0
- package/dist/web/pages.js.map +1 -0
- package/dist/web/sections.d.ts +105 -0
- package/dist/web/sections.js +207 -0
- package/dist/web/sections.js.map +1 -0
- package/dist/web/security.d.ts +122 -0
- package/dist/web/security.js +180 -0
- package/dist/web/security.js.map +1 -0
- package/dist/web/server.d.ts +153 -0
- package/dist/web/server.js +622 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building a message, and the address handling that goes with it.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from drafts.ts and from sending because both need exactly the
|
|
5
|
+
* same thing and must not drift: the recipients a confirmation shows have to
|
|
6
|
+
* be the recipients that are written or sent, or the confirmation is theatre.
|
|
7
|
+
*
|
|
8
|
+
* No attachment can be read from disk here, and that is deliberate rather than
|
|
9
|
+
* unfinished. An attachment taken from a path would mean the model names a
|
|
10
|
+
* file, the server reads it, and the next confirmation the user waves through
|
|
11
|
+
* carries it outside. Which paths may be reached is its own decision, not a
|
|
12
|
+
* side effect of being able to write a draft.
|
|
13
|
+
*/
|
|
14
|
+
import { type MarkupUrl } from './markup.js';
|
|
15
|
+
/** An address, optionally with a display name. */
|
|
16
|
+
export interface Recipient {
|
|
17
|
+
name?: string;
|
|
18
|
+
address: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Draft {
|
|
21
|
+
from: Recipient;
|
|
22
|
+
to: Recipient[];
|
|
23
|
+
cc: Recipient[];
|
|
24
|
+
bcc: Recipient[];
|
|
25
|
+
subject: string;
|
|
26
|
+
text: string;
|
|
27
|
+
/** Message-ID of the message being answered, for In-Reply-To. */
|
|
28
|
+
inReplyTo?: string;
|
|
29
|
+
/** The chain, oldest first. Mail readers use it to build the thread. */
|
|
30
|
+
references?: string[];
|
|
31
|
+
/** Our own Message-ID. Minted here so there is a handle straight away. */
|
|
32
|
+
messageId: string;
|
|
33
|
+
/** A whole message carried along, used when forwarding. */
|
|
34
|
+
attachedMessage?: {
|
|
35
|
+
filename: string;
|
|
36
|
+
raw: Buffer;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The message as markup, when it has any.
|
|
40
|
+
*
|
|
41
|
+
* `text` stays filled either way and is what the confirmation reads, because
|
|
42
|
+
* a person confirming a send has to be shown something they can actually
|
|
43
|
+
* read. When only markup is given, the text is derived from it with the same
|
|
44
|
+
* conversion this server uses for received mail, which puts every link target
|
|
45
|
+
* beside the text it belongs to.
|
|
46
|
+
*
|
|
47
|
+
* Only one of the two is delivered. Proton keeps the markup and drops the
|
|
48
|
+
* text half of a message that carries both, measured, so composing both would
|
|
49
|
+
* mean the recipient never sees the part that was confirmed.
|
|
50
|
+
*/
|
|
51
|
+
html?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The quoted original, already prepared, kept apart from `html` on purpose.
|
|
54
|
+
*
|
|
55
|
+
* The separation is the boundary itself rather than a convenience. What the
|
|
56
|
+
* writer composed goes through the permitted set, because it is written under
|
|
57
|
+
* this account's name and a person is answering for it. What is quoted does
|
|
58
|
+
* not, because it is a message that already arrived and cutting it down to
|
|
59
|
+
* what this server would write itself was measured to need a median of 87
|
|
60
|
+
* removals, which demolishes it. quote.ts drops the elements that would reach
|
|
61
|
+
* out of the quote, and nothing else.
|
|
62
|
+
*/
|
|
63
|
+
quotedHtml?: string;
|
|
64
|
+
/** Files referenced from the markup by content id. */
|
|
65
|
+
inlineParts?: InlinePart[];
|
|
66
|
+
}
|
|
67
|
+
/** A file carried inside the message and referenced from the markup. */
|
|
68
|
+
export interface InlinePart {
|
|
69
|
+
/** Matches a `cid:` in the markup. */
|
|
70
|
+
contentId: string;
|
|
71
|
+
filename: string;
|
|
72
|
+
contentType: string;
|
|
73
|
+
content: Buffer;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Checks an address well enough to catch the mistakes that matter.
|
|
77
|
+
*
|
|
78
|
+
* Not a full RFC 5322 parser, and not trying to be: the point is to refuse a
|
|
79
|
+
* string that would end up somewhere unintended, not to adjudicate exotic but
|
|
80
|
+
* legal forms. A newline is the one that has to be caught, because a header
|
|
81
|
+
* injected through an address field would let the sender of a mail the model
|
|
82
|
+
* just read add a recipient the confirmation never showed.
|
|
83
|
+
*/
|
|
84
|
+
export declare function parseRecipient(input: string): Recipient;
|
|
85
|
+
export declare function parseRecipients(list: string[] | undefined): Recipient[];
|
|
86
|
+
/** Every address a message would go to, in the order a person reads them. */
|
|
87
|
+
export declare function allRecipients(draft: Draft): Recipient[];
|
|
88
|
+
/** Mints a Message-ID. Measured: the Bridge keeps ours rather than replacing it. */
|
|
89
|
+
export declare function mintMessageId(from: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* Builds the MIME message.
|
|
92
|
+
*
|
|
93
|
+
* A message carries either text or markup, never both. Measured: Proton keeps
|
|
94
|
+
* the markup and drops the text half of a message that has both, so composing
|
|
95
|
+
* both would deliver the half nobody confirmed.
|
|
96
|
+
*
|
|
97
|
+
* `keepBcc` decides whether the blind copies appear as a header in the built
|
|
98
|
+
* message, and the two cases are opposites for a good reason. A message that
|
|
99
|
+
* goes out must not carry them: the header would tell every recipient who was
|
|
100
|
+
* copied in secretly, which is the one thing a blind copy is for. A draft that
|
|
101
|
+
* is stored must carry them, or the addresses are gone by the time anyone comes
|
|
102
|
+
* back to send it. So drafts keep it and sending does not.
|
|
103
|
+
*
|
|
104
|
+
* Declared async so that a refusal comes back as a rejected promise like every
|
|
105
|
+
* other failure here, rather than being thrown before the promise exists.
|
|
106
|
+
*/
|
|
107
|
+
export declare function buildMessage(draft: Draft, options?: {
|
|
108
|
+
keepBcc?: boolean;
|
|
109
|
+
}): Promise<Buffer>;
|
|
110
|
+
/** Subject with a prefix, unless it already carries one. */
|
|
111
|
+
export declare function prefixSubject(subject: string, prefix: 'Re' | 'Fwd'): string;
|
|
112
|
+
/**
|
|
113
|
+
* Quotes a message the way mail readers do.
|
|
114
|
+
*
|
|
115
|
+
* The quoted text is content written by a stranger. It is not filtered and no
|
|
116
|
+
* attempt is made to detect instructions inside it, in line with the rest of
|
|
117
|
+
* this server: the boundary is the confirmation before sending, not a filter
|
|
118
|
+
* that pretends to recognise an attack.
|
|
119
|
+
*/
|
|
120
|
+
export declare function quote(original: {
|
|
121
|
+
from: Recipient[];
|
|
122
|
+
date: Date | undefined;
|
|
123
|
+
subject: string;
|
|
124
|
+
text: string;
|
|
125
|
+
}, limit?: number): string;
|
|
126
|
+
/** Turns text into markup that says exactly what the text said. */
|
|
127
|
+
export declare function escapeHtml(text: string): string;
|
|
128
|
+
/**
|
|
129
|
+
* Quotes a message inside a formatted reply.
|
|
130
|
+
*
|
|
131
|
+
* **The quote is built from the original's text, never from its markup**, and
|
|
132
|
+
* that is the whole design rather than a shortcut. Two reasons, and the second
|
|
133
|
+
* is the one that matters:
|
|
134
|
+
*
|
|
135
|
+
* 1. Carrying the original's markup would mean checking a stranger's HTML
|
|
136
|
+
* against the permitted set and refusing the reply when it fails. Ordinary
|
|
137
|
+
* mail is full of `<style>` blocks, so replying to real messages would
|
|
138
|
+
* mostly not work.
|
|
139
|
+
* 2. It would put markup written by somebody else into a message sent under
|
|
140
|
+
* this account's name. A link in the quote could show one thing and go
|
|
141
|
+
* somewhere else, and it would ride along into the reply without anybody
|
|
142
|
+
* having written it.
|
|
143
|
+
*
|
|
144
|
+
* The text comes from the same conversion this server uses to read a message,
|
|
145
|
+
* so a link in the original appears as its address in the quote. A phishing
|
|
146
|
+
* mail quoted in a reply arrives with its targets in plain sight. It is then
|
|
147
|
+
* escaped, because text that happens to contain angle brackets must stay text.
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* The quoted body alone, without the "wrote:" line.
|
|
151
|
+
*
|
|
152
|
+
* A forward writes its own header, so it needs the body on its own. The two
|
|
153
|
+
* paths share this rather than each preparing the original, which is how they
|
|
154
|
+
* would come to differ in what they let through.
|
|
155
|
+
*/
|
|
156
|
+
export declare function quoteBody(original: {
|
|
157
|
+
from: Recipient[];
|
|
158
|
+
date: Date | undefined;
|
|
159
|
+
text: string;
|
|
160
|
+
html?: string;
|
|
161
|
+
}, limit?: number): string;
|
|
162
|
+
export declare function quoteAsHtml(original: {
|
|
163
|
+
from: Recipient[];
|
|
164
|
+
date: Date | undefined;
|
|
165
|
+
text: string;
|
|
166
|
+
html?: string;
|
|
167
|
+
}, limit?: number): string;
|
|
168
|
+
/** One address as a person reads it. */
|
|
169
|
+
export declare function showRecipient(r: Recipient): string;
|
|
170
|
+
/**
|
|
171
|
+
* The recipients in plain text, separated into To, Cc and Bcc.
|
|
172
|
+
*
|
|
173
|
+
* Shared by the draft answers and by the confirmation shown before sending, on
|
|
174
|
+
* purpose: if those two ever described a message differently, the confirmation
|
|
175
|
+
* would be showing something other than what goes out, and a confirmation that
|
|
176
|
+
* does not show the truth is worse than none.
|
|
177
|
+
*/
|
|
178
|
+
export declare function describeRecipients(draft: {
|
|
179
|
+
to: Recipient[];
|
|
180
|
+
cc: Recipient[];
|
|
181
|
+
bcc: Recipient[];
|
|
182
|
+
}): string;
|
|
183
|
+
/** The first lines of a body, for a confirmation that has to stay readable. */
|
|
184
|
+
export declare function firstLines(text: string, lines?: number, chars?: number): string;
|
|
185
|
+
/**
|
|
186
|
+
* Written into the description of every tool that takes text from a caller.
|
|
187
|
+
*
|
|
188
|
+
* It exists because of an observed failure rather than a hypothetical one: an
|
|
189
|
+
* assistant using this server wrote German with "ae", "oe", "ue" and "ss" in
|
|
190
|
+
* place of the umlauts, in a message whose whole point was to carry them. A
|
|
191
|
+
* model that is unsure whether an interface survives non-ASCII will write around
|
|
192
|
+
* it, and the result is text that is wrong in a way nothing downstream can
|
|
193
|
+
* repair.
|
|
194
|
+
*
|
|
195
|
+
* The claim is measured, not assumed: a subject arrives MIME-encoded and decodes
|
|
196
|
+
* back to the characters it was given, a body carries `charset=utf-8`, and an
|
|
197
|
+
* attachment filename survives as an encoded word. Checked end to end through
|
|
198
|
+
* the Bridge on 31.07.2026.
|
|
199
|
+
*/
|
|
200
|
+
export declare const UTF8_NOTE: string;
|
|
201
|
+
/**
|
|
202
|
+
* Written into the description of every tool that takes a message body.
|
|
203
|
+
*
|
|
204
|
+
* Also measured rather than assumed. HTML placed in the body field is sent as
|
|
205
|
+
* `text/plain` and arrives as visible characters: the recipient reads
|
|
206
|
+
* `<b>Fett</b>`, tags and all. Nothing in the interface said so, and "the
|
|
207
|
+
* transport carries HTML" is true of the Bridge while being false of these
|
|
208
|
+
* tools, which is exactly the kind of gap a model falls into.
|
|
209
|
+
*
|
|
210
|
+
* The note says what to do instead, because a prohibition without an
|
|
211
|
+
* alternative is an invitation to try anyway.
|
|
212
|
+
*/
|
|
213
|
+
export declare const MARKUP_NOTE: string;
|
|
214
|
+
export declare const PLAIN_TEXT_NOTE: string;
|
|
215
|
+
/**
|
|
216
|
+
* The message as a person will read it.
|
|
217
|
+
*
|
|
218
|
+
* For a plain text message that is the text itself. For markup it is the same
|
|
219
|
+
* conversion this server applies to received mail, which is the point rather
|
|
220
|
+
* than a convenience: it writes every link target beside the text it belongs
|
|
221
|
+
* to, and that is exactly the difference a person confirming a send has to see.
|
|
222
|
+
*/
|
|
223
|
+
export declare function readableBody(draft: Draft): string;
|
|
224
|
+
/**
|
|
225
|
+
* The part the sender wrote, without the quote.
|
|
226
|
+
*
|
|
227
|
+
* What a confirmation shows as the body. Mixing the quote into the excerpt
|
|
228
|
+
* would mean a person reads eight lines of a message they already received
|
|
229
|
+
* instead of the message they are about to send, and the excerpt is cut long
|
|
230
|
+
* before their own words appear.
|
|
231
|
+
*/
|
|
232
|
+
export declare function composedBody(draft: Draft): string;
|
|
233
|
+
/** What the quote points at, counted rather than listed. */
|
|
234
|
+
export interface QuoteSummary {
|
|
235
|
+
links: number;
|
|
236
|
+
images: number;
|
|
237
|
+
/** Text a recipient can read that the readable body leaves out. */
|
|
238
|
+
hiddenText: string[];
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The quote, summarised for the confirmation.
|
|
242
|
+
*
|
|
243
|
+
* Counted rather than listed, and that is a judgement worth stating. The
|
|
244
|
+
* composed part's addresses are shown in full because the sender is answering
|
|
245
|
+
* for them. A quoted newsletter carries dozens, measured: four images per
|
|
246
|
+
* message on average and up to thirty links. Listing those would bury the two
|
|
247
|
+
* addresses that matter under thirty that arrived in the sender's mailbox
|
|
248
|
+
* anyway, and a confirmation nobody reads to the end protects nobody.
|
|
249
|
+
*/
|
|
250
|
+
export declare function summariseQuote(draft: Draft): QuoteSummary | undefined;
|
|
251
|
+
/**
|
|
252
|
+
* Everything the message points at or carries, for the confirmation.
|
|
253
|
+
*
|
|
254
|
+
* Kept whole and never shortened. The body excerpt in a confirmation is cut
|
|
255
|
+
* after a few lines, so an address on line thirty would otherwise never be
|
|
256
|
+
* shown, which is precisely where one would be put to avoid being read.
|
|
257
|
+
*/
|
|
258
|
+
export declare function describeAttachments(draft: Draft): string[];
|
|
259
|
+
/** The addresses in a message's markup, with the text they are shown as. */
|
|
260
|
+
export declare function describeUrls(draft: Draft): MarkupUrl[];
|
|
261
|
+
/** Text a recipient can read that the readable body leaves out. */
|
|
262
|
+
export declare function hiddenTextOf(draft: Draft): string[];
|
|
263
|
+
/** A fingerprint of a file, so a confirmation can be bound to its contents. */
|
|
264
|
+
export declare function fingerprintPart(content: Buffer): string;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building a message, and the address handling that goes with it.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from drafts.ts and from sending because both need exactly the
|
|
5
|
+
* same thing and must not drift: the recipients a confirmation shows have to
|
|
6
|
+
* be the recipients that are written or sent, or the confirmation is theatre.
|
|
7
|
+
*
|
|
8
|
+
* No attachment can be read from disk here, and that is deliberate rather than
|
|
9
|
+
* unfinished. An attachment taken from a path would mean the model names a
|
|
10
|
+
* file, the server reads it, and the next confirmation the user waves through
|
|
11
|
+
* carries it outside. Which paths may be reached is its own decision, not a
|
|
12
|
+
* side effect of being able to write a draft.
|
|
13
|
+
*/
|
|
14
|
+
import MailComposer from 'nodemailer/lib/mail-composer/index.js';
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
16
|
+
import { BridgeError } from '../bridge/errors.js';
|
|
17
|
+
import { assertSendableMarkup, readMarkup } from './markup.js';
|
|
18
|
+
import { prepareQuotedMarkup } from './quote.js';
|
|
19
|
+
import { htmlToText } from '../mime/parse.js';
|
|
20
|
+
/**
|
|
21
|
+
* Checks an address well enough to catch the mistakes that matter.
|
|
22
|
+
*
|
|
23
|
+
* Not a full RFC 5322 parser, and not trying to be: the point is to refuse a
|
|
24
|
+
* string that would end up somewhere unintended, not to adjudicate exotic but
|
|
25
|
+
* legal forms. A newline is the one that has to be caught, because a header
|
|
26
|
+
* injected through an address field would let the sender of a mail the model
|
|
27
|
+
* just read add a recipient the confirmation never showed.
|
|
28
|
+
*/
|
|
29
|
+
export function parseRecipient(input) {
|
|
30
|
+
const raw = input.trim();
|
|
31
|
+
if (!raw)
|
|
32
|
+
throw new BridgeError('An empty recipient cannot be used.');
|
|
33
|
+
if (/[\r\n]/.test(raw)) {
|
|
34
|
+
throw new BridgeError(`The recipient "${raw.replace(/[\r\n]+/g, ' ')}" contains a line break. A line break in an ` +
|
|
35
|
+
'address is how a header is smuggled in, so it is refused rather than stripped.');
|
|
36
|
+
}
|
|
37
|
+
const angled = /^(.*)<([^<>]+)>$/.exec(raw);
|
|
38
|
+
const name = angled ? angled[1].trim().replace(/^"|"$/g, '') : undefined;
|
|
39
|
+
const address = (angled ? angled[2] : raw).trim();
|
|
40
|
+
// One @, something on each side, a dot in the domain, no spaces anywhere.
|
|
41
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
|
|
42
|
+
throw new BridgeError(`"${address}" is not a usable email address. Expected something of the form name@example.com, ` +
|
|
43
|
+
'optionally as "Display Name <name@example.com>".');
|
|
44
|
+
}
|
|
45
|
+
return name ? { name, address } : { address };
|
|
46
|
+
}
|
|
47
|
+
export function parseRecipients(list) {
|
|
48
|
+
return (list ?? []).map(parseRecipient);
|
|
49
|
+
}
|
|
50
|
+
/** Every address a message would go to, in the order a person reads them. */
|
|
51
|
+
export function allRecipients(draft) {
|
|
52
|
+
return [...draft.to, ...draft.cc, ...draft.bcc];
|
|
53
|
+
}
|
|
54
|
+
/** Mints a Message-ID. Measured: the Bridge keeps ours rather than replacing it. */
|
|
55
|
+
export function mintMessageId(from) {
|
|
56
|
+
const domain = from.split('@')[1] ?? 'proton-mcp.local';
|
|
57
|
+
return `<${randomUUID()}@${domain}>`;
|
|
58
|
+
}
|
|
59
|
+
/** Brings a Message-ID into the shape headers use. */
|
|
60
|
+
function angled(id) {
|
|
61
|
+
const bare = id.trim().replace(/^<|>$/g, '');
|
|
62
|
+
return `<${bare}>`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Builds the MIME message.
|
|
66
|
+
*
|
|
67
|
+
* A message carries either text or markup, never both. Measured: Proton keeps
|
|
68
|
+
* the markup and drops the text half of a message that has both, so composing
|
|
69
|
+
* both would deliver the half nobody confirmed.
|
|
70
|
+
*
|
|
71
|
+
* `keepBcc` decides whether the blind copies appear as a header in the built
|
|
72
|
+
* message, and the two cases are opposites for a good reason. A message that
|
|
73
|
+
* goes out must not carry them: the header would tell every recipient who was
|
|
74
|
+
* copied in secretly, which is the one thing a blind copy is for. A draft that
|
|
75
|
+
* is stored must carry them, or the addresses are gone by the time anyone comes
|
|
76
|
+
* back to send it. So drafts keep it and sending does not.
|
|
77
|
+
*
|
|
78
|
+
* Declared async so that a refusal comes back as a rejected promise like every
|
|
79
|
+
* other failure here, rather than being thrown before the promise exists.
|
|
80
|
+
*/
|
|
81
|
+
export async function buildMessage(draft, options = {}) {
|
|
82
|
+
if (/[\r\n]/.test(draft.subject)) {
|
|
83
|
+
throw new BridgeError('The subject contains a line break. That is how an extra header gets added to a message, ' +
|
|
84
|
+
'so it is refused rather than stripped.');
|
|
85
|
+
}
|
|
86
|
+
if (draft.to.length === 0 && draft.cc.length === 0 && draft.bcc.length === 0) {
|
|
87
|
+
throw new BridgeError('A message needs at least one recipient.');
|
|
88
|
+
}
|
|
89
|
+
const fields = {
|
|
90
|
+
from: draft.from,
|
|
91
|
+
to: draft.to,
|
|
92
|
+
subject: draft.subject,
|
|
93
|
+
messageId: draft.messageId,
|
|
94
|
+
date: new Date(),
|
|
95
|
+
};
|
|
96
|
+
if (draft.html === undefined) {
|
|
97
|
+
fields.text = draft.text;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Checked here rather than only at the tool, so that no path into this
|
|
101
|
+
// function can produce a message whose markup was never read.
|
|
102
|
+
const reading = assertSendableMarkup(draft.html);
|
|
103
|
+
// Every content id the markup points at has to have arrived with it. A
|
|
104
|
+
// reference to a part that is not there shows the recipient a broken image
|
|
105
|
+
// where the sender saw a picture, and neither of them finds out why.
|
|
106
|
+
const provided = new Set((draft.inlineParts ?? []).map((part) => part.contentId));
|
|
107
|
+
const missing = reading.contentIds.filter((id) => !provided.has(id));
|
|
108
|
+
if (missing.length) {
|
|
109
|
+
throw new BridgeError(`The markup refers to ${missing.map((id) => `"cid:${id}"`).join(', ')}, but no such file ` +
|
|
110
|
+
'was given. Either provide the file or point the image at a full address instead.');
|
|
111
|
+
}
|
|
112
|
+
// Only the composed part is checked. The quote is appended afterwards,
|
|
113
|
+
// already prepared, and deliberately not held to the same list.
|
|
114
|
+
fields.html = draft.html + (draft.quotedHtml ?? '');
|
|
115
|
+
}
|
|
116
|
+
if (draft.cc.length)
|
|
117
|
+
fields.cc = draft.cc;
|
|
118
|
+
if (draft.bcc.length)
|
|
119
|
+
fields.bcc = draft.bcc;
|
|
120
|
+
if (draft.inReplyTo)
|
|
121
|
+
fields.inReplyTo = angled(draft.inReplyTo);
|
|
122
|
+
if (draft.references?.length)
|
|
123
|
+
fields.references = draft.references.map(angled);
|
|
124
|
+
const attachments = [];
|
|
125
|
+
if (draft.attachedMessage) {
|
|
126
|
+
attachments.push({
|
|
127
|
+
filename: draft.attachedMessage.filename,
|
|
128
|
+
content: draft.attachedMessage.raw,
|
|
129
|
+
contentType: 'message/rfc822',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const part of draft.inlineParts ?? []) {
|
|
133
|
+
attachments.push({
|
|
134
|
+
filename: part.filename,
|
|
135
|
+
content: part.content,
|
|
136
|
+
contentType: part.contentType,
|
|
137
|
+
cid: part.contentId,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (attachments.length)
|
|
141
|
+
fields.attachments = attachments;
|
|
142
|
+
// keepBcc is not an option MailComposer understands. It lives on the MimeNode
|
|
143
|
+
// that compile() hands back and is read when that node is built, so it has to
|
|
144
|
+
// be set in between. Passing it in with the other fields does nothing at all,
|
|
145
|
+
// silently, which is how a stored draft would lose its blind copies.
|
|
146
|
+
const node = new MailComposer(fields).compile();
|
|
147
|
+
node.keepBcc = options.keepBcc === true;
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
node.build((error, message) => {
|
|
150
|
+
if (error)
|
|
151
|
+
reject(new BridgeError(`The message could not be built: ${error.message}`));
|
|
152
|
+
else
|
|
153
|
+
resolve(message);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Subject with a prefix, unless it already carries one. */
|
|
158
|
+
export function prefixSubject(subject, prefix) {
|
|
159
|
+
const trimmed = subject.trim();
|
|
160
|
+
const already = prefix === 'Re' ? /^(re|aw)\s*:/i : /^(fwd?|wg)\s*:/i;
|
|
161
|
+
if (already.test(trimmed))
|
|
162
|
+
return trimmed;
|
|
163
|
+
return `${prefix}: ${trimmed || '(no subject)'}`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Quotes a message the way mail readers do.
|
|
167
|
+
*
|
|
168
|
+
* The quoted text is content written by a stranger. It is not filtered and no
|
|
169
|
+
* attempt is made to detect instructions inside it, in line with the rest of
|
|
170
|
+
* this server: the boundary is the confirmation before sending, not a filter
|
|
171
|
+
* that pretends to recognise an attack.
|
|
172
|
+
*/
|
|
173
|
+
export function quote(original, limit = 4000) {
|
|
174
|
+
const who = original.from.map((f) => (f.name ? `${f.name} <${f.address}>` : f.address)).join(', ');
|
|
175
|
+
const when = original.date ? original.date.toISOString().slice(0, 16).replace('T', ' ') : 'an unknown date';
|
|
176
|
+
const body = original.text.length > limit ? `${original.text.slice(0, limit)}\n[...]` : original.text;
|
|
177
|
+
const quoted = body
|
|
178
|
+
.split('\n')
|
|
179
|
+
.map((line) => `> ${line}`)
|
|
180
|
+
.join('\n');
|
|
181
|
+
return `\n\nOn ${when}, ${who || 'someone'} wrote:\n${quoted}`;
|
|
182
|
+
}
|
|
183
|
+
/** Who wrote the quoted message and when, escaped because both are foreign. */
|
|
184
|
+
function quoteHeader(original) {
|
|
185
|
+
const who = original.from.map(showRecipient).join(', ') || 'someone';
|
|
186
|
+
const when = original.date
|
|
187
|
+
? original.date.toISOString().slice(0, 16).replace('T', ' ')
|
|
188
|
+
: 'an unknown date';
|
|
189
|
+
return `On ${escapeHtml(when)}, ${escapeHtml(who)} wrote:`;
|
|
190
|
+
}
|
|
191
|
+
/** Turns text into markup that says exactly what the text said. */
|
|
192
|
+
export function escapeHtml(text) {
|
|
193
|
+
return text
|
|
194
|
+
.replace(/&/g, '&')
|
|
195
|
+
.replace(/</g, '<')
|
|
196
|
+
.replace(/>/g, '>')
|
|
197
|
+
.replace(/"/g, '"');
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Quotes a message inside a formatted reply.
|
|
201
|
+
*
|
|
202
|
+
* **The quote is built from the original's text, never from its markup**, and
|
|
203
|
+
* that is the whole design rather than a shortcut. Two reasons, and the second
|
|
204
|
+
* is the one that matters:
|
|
205
|
+
*
|
|
206
|
+
* 1. Carrying the original's markup would mean checking a stranger's HTML
|
|
207
|
+
* against the permitted set and refusing the reply when it fails. Ordinary
|
|
208
|
+
* mail is full of `<style>` blocks, so replying to real messages would
|
|
209
|
+
* mostly not work.
|
|
210
|
+
* 2. It would put markup written by somebody else into a message sent under
|
|
211
|
+
* this account's name. A link in the quote could show one thing and go
|
|
212
|
+
* somewhere else, and it would ride along into the reply without anybody
|
|
213
|
+
* having written it.
|
|
214
|
+
*
|
|
215
|
+
* The text comes from the same conversion this server uses to read a message,
|
|
216
|
+
* so a link in the original appears as its address in the quote. A phishing
|
|
217
|
+
* mail quoted in a reply arrives with its targets in plain sight. It is then
|
|
218
|
+
* escaped, because text that happens to contain angle brackets must stay text.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* The quoted body alone, without the "wrote:" line.
|
|
222
|
+
*
|
|
223
|
+
* A forward writes its own header, so it needs the body on its own. The two
|
|
224
|
+
* paths share this rather than each preparing the original, which is how they
|
|
225
|
+
* would come to differ in what they let through.
|
|
226
|
+
*/
|
|
227
|
+
export function quoteBody(original, limit = 4000) {
|
|
228
|
+
if (original.html) {
|
|
229
|
+
const prepared = prepareQuotedMarkup(original.html);
|
|
230
|
+
if (prepared.html) {
|
|
231
|
+
return (`<blockquote style="margin:0 0 0 12px; padding-left:12px; border-left:3px solid #cccccc">` +
|
|
232
|
+
`${prepared.html}</blockquote>`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const body = original.text.length > limit ? `${original.text.slice(0, limit)}\n[...]` : original.text;
|
|
236
|
+
return (`<blockquote style="margin:0 0 0 12px; padding-left:12px; border-left:3px solid #cccccc; color:#555555">` +
|
|
237
|
+
`${escapeHtml(body).split('\n').join('<br>')}</blockquote>`);
|
|
238
|
+
}
|
|
239
|
+
export function quoteAsHtml(original, limit = 4000) {
|
|
240
|
+
// When the original was formatted, it is quoted as it was written. Only the
|
|
241
|
+
// document-level elements are dropped, because those would apply to the reply
|
|
242
|
+
// above the quote rather than to the quote. The reasoning and the numbers
|
|
243
|
+
// behind it are in quote.ts.
|
|
244
|
+
return `<p>${quoteHeader(original)}</p>${quoteBody(original, limit)}`;
|
|
245
|
+
}
|
|
246
|
+
/** One address as a person reads it. */
|
|
247
|
+
export function showRecipient(r) {
|
|
248
|
+
return r.name ? `${r.name} <${r.address}>` : r.address;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The recipients in plain text, separated into To, Cc and Bcc.
|
|
252
|
+
*
|
|
253
|
+
* Shared by the draft answers and by the confirmation shown before sending, on
|
|
254
|
+
* purpose: if those two ever described a message differently, the confirmation
|
|
255
|
+
* would be showing something other than what goes out, and a confirmation that
|
|
256
|
+
* does not show the truth is worse than none.
|
|
257
|
+
*/
|
|
258
|
+
export function describeRecipients(draft) {
|
|
259
|
+
const lines = [`To: ${draft.to.map(showRecipient).join(', ') || '(none)'}`];
|
|
260
|
+
if (draft.cc.length)
|
|
261
|
+
lines.push(`Cc: ${draft.cc.map(showRecipient).join(', ')}`);
|
|
262
|
+
if (draft.bcc.length) {
|
|
263
|
+
lines.push(`Bcc: ${draft.bcc.map(showRecipient).join(', ')} <- hidden from the other recipients, but they do receive it`);
|
|
264
|
+
}
|
|
265
|
+
return lines.join('\n');
|
|
266
|
+
}
|
|
267
|
+
/** The first lines of a body, for a confirmation that has to stay readable. */
|
|
268
|
+
export function firstLines(text, lines = 8, chars = 600) {
|
|
269
|
+
const head = text.split('\n').slice(0, lines).join('\n');
|
|
270
|
+
const cut = head.length > chars ? `${head.slice(0, chars)}...` : head;
|
|
271
|
+
return cut.trim() || '(the message has no text)';
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Written into the description of every tool that takes text from a caller.
|
|
275
|
+
*
|
|
276
|
+
* It exists because of an observed failure rather than a hypothetical one: an
|
|
277
|
+
* assistant using this server wrote German with "ae", "oe", "ue" and "ss" in
|
|
278
|
+
* place of the umlauts, in a message whose whole point was to carry them. A
|
|
279
|
+
* model that is unsure whether an interface survives non-ASCII will write around
|
|
280
|
+
* it, and the result is text that is wrong in a way nothing downstream can
|
|
281
|
+
* repair.
|
|
282
|
+
*
|
|
283
|
+
* The claim is measured, not assumed: a subject arrives MIME-encoded and decodes
|
|
284
|
+
* back to the characters it was given, a body carries `charset=utf-8`, and an
|
|
285
|
+
* attachment filename survives as an encoded word. Checked end to end through
|
|
286
|
+
* the Bridge on 31.07.2026.
|
|
287
|
+
*/
|
|
288
|
+
export const UTF8_NOTE = 'Text is UTF-8 everywhere: subject, body and addresses. Write characters as the language ' +
|
|
289
|
+
'actually spells them, including umlauts, accents, sharp s, quotation marks and any other ' +
|
|
290
|
+
// The two examples are escaped rather than written out. Everything public in
|
|
291
|
+
// this repository is English, and the check that enforces that reads a
|
|
292
|
+
// literal umlaut as German prose. Here they are specimens rather than
|
|
293
|
+
// language, and the string a caller receives is the same either way.
|
|
294
|
+
'non-ASCII character. Do not transliterate them away (no "ae" for "\u00e4", no "ss" for ' +
|
|
295
|
+
'"\u00df"): the subject is encoded for transport and decoded again on arrival, and the body ' +
|
|
296
|
+
'carries its character set, both verified against the Bridge. A transliterated message is ' +
|
|
297
|
+
'simply a message with the wrong words in it.';
|
|
298
|
+
/**
|
|
299
|
+
* Written into the description of every tool that takes a message body.
|
|
300
|
+
*
|
|
301
|
+
* Also measured rather than assumed. HTML placed in the body field is sent as
|
|
302
|
+
* `text/plain` and arrives as visible characters: the recipient reads
|
|
303
|
+
* `<b>Fett</b>`, tags and all. Nothing in the interface said so, and "the
|
|
304
|
+
* transport carries HTML" is true of the Bridge while being false of these
|
|
305
|
+
* tools, which is exactly the kind of gap a model falls into.
|
|
306
|
+
*
|
|
307
|
+
* The note says what to do instead, because a prohibition without an
|
|
308
|
+
* alternative is an invitation to try anyway.
|
|
309
|
+
*/
|
|
310
|
+
export const MARKUP_NOTE = 'A message may instead be written as markup, in the html field, which is delivered as ' +
|
|
311
|
+
'formatting rather than as visible tags. Give one or the other, not both: a message that ' +
|
|
312
|
+
'carries markup has its plain text half dropped, so the half that was confirmed would never ' +
|
|
313
|
+
'arrive. Permitted are headings, paragraphs, line breaks, rules, quotes, emphasis, lists, ' +
|
|
314
|
+
'tables, links and images, with colour, font, alignment, spacing and borders. Anything else ' +
|
|
315
|
+
'is refused rather than quietly removed, and the answer names what and why. Note that the ' +
|
|
316
|
+
'confirmation shows every address in the message in full, including the ones behind links and ' +
|
|
317
|
+
'images, and every alt text, because those are what a recipient reads.';
|
|
318
|
+
export const PLAIN_TEXT_NOTE = 'The body is plain text and markup is not interpreted. HTML written here is delivered as ' +
|
|
319
|
+
'visible characters, so the recipient would read the tags rather than see formatting. ' +
|
|
320
|
+
'Give a message its shape with line breaks, blank lines, indentation and plain lists, and ' +
|
|
321
|
+
'write a link as the bare address so that what is read is what is followed.';
|
|
322
|
+
/**
|
|
323
|
+
* The message as a person will read it.
|
|
324
|
+
*
|
|
325
|
+
* For a plain text message that is the text itself. For markup it is the same
|
|
326
|
+
* conversion this server applies to received mail, which is the point rather
|
|
327
|
+
* than a convenience: it writes every link target beside the text it belongs
|
|
328
|
+
* to, and that is exactly the difference a person confirming a send has to see.
|
|
329
|
+
*/
|
|
330
|
+
export function readableBody(draft) {
|
|
331
|
+
return draft.html === undefined
|
|
332
|
+
? draft.text
|
|
333
|
+
: htmlToText(draft.html + (draft.quotedHtml ?? ''));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The part the sender wrote, without the quote.
|
|
337
|
+
*
|
|
338
|
+
* What a confirmation shows as the body. Mixing the quote into the excerpt
|
|
339
|
+
* would mean a person reads eight lines of a message they already received
|
|
340
|
+
* instead of the message they are about to send, and the excerpt is cut long
|
|
341
|
+
* before their own words appear.
|
|
342
|
+
*/
|
|
343
|
+
export function composedBody(draft) {
|
|
344
|
+
return draft.html === undefined ? draft.text : htmlToText(draft.html);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The quote, summarised for the confirmation.
|
|
348
|
+
*
|
|
349
|
+
* Counted rather than listed, and that is a judgement worth stating. The
|
|
350
|
+
* composed part's addresses are shown in full because the sender is answering
|
|
351
|
+
* for them. A quoted newsletter carries dozens, measured: four images per
|
|
352
|
+
* message on average and up to thirty links. Listing those would bury the two
|
|
353
|
+
* addresses that matter under thirty that arrived in the sender's mailbox
|
|
354
|
+
* anyway, and a confirmation nobody reads to the end protects nobody.
|
|
355
|
+
*/
|
|
356
|
+
export function summariseQuote(draft) {
|
|
357
|
+
if (!draft.quotedHtml)
|
|
358
|
+
return undefined;
|
|
359
|
+
const reading = readMarkup(draft.quotedHtml);
|
|
360
|
+
return {
|
|
361
|
+
links: reading.urls.filter((u) => u.kind === 'link').length,
|
|
362
|
+
images: reading.urls.filter((u) => u.kind === 'image').length,
|
|
363
|
+
hiddenText: reading.hiddenText,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Everything the message points at or carries, for the confirmation.
|
|
368
|
+
*
|
|
369
|
+
* Kept whole and never shortened. The body excerpt in a confirmation is cut
|
|
370
|
+
* after a few lines, so an address on line thirty would otherwise never be
|
|
371
|
+
* shown, which is precisely where one would be put to avoid being read.
|
|
372
|
+
*/
|
|
373
|
+
export function describeAttachments(draft) {
|
|
374
|
+
const lines = [];
|
|
375
|
+
if (draft.attachedMessage) {
|
|
376
|
+
lines.push(`${draft.attachedMessage.filename} (the forwarded message, ${draft.attachedMessage.raw.length} bytes)`);
|
|
377
|
+
}
|
|
378
|
+
for (const part of draft.inlineParts ?? []) {
|
|
379
|
+
lines.push(`${part.filename} (${part.contentType}, ${part.content.length} bytes, in the message body)`);
|
|
380
|
+
}
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
/** The addresses in a message's markup, with the text they are shown as. */
|
|
384
|
+
export function describeUrls(draft) {
|
|
385
|
+
return draft.html === undefined ? [] : assertSendableMarkup(draft.html).urls;
|
|
386
|
+
}
|
|
387
|
+
/** Text a recipient can read that the readable body leaves out. */
|
|
388
|
+
export function hiddenTextOf(draft) {
|
|
389
|
+
return draft.html === undefined ? [] : assertSendableMarkup(draft.html).hiddenText;
|
|
390
|
+
}
|
|
391
|
+
/** A fingerprint of a file, so a confirmation can be bound to its contents. */
|
|
392
|
+
export function fingerprintPart(content) {
|
|
393
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
394
|
+
}
|
|
395
|
+
//# sourceMappingURL=compose.js.map
|