@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.
Files changed (141) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +405 -0
  3. package/dist/activity.d.ts +49 -0
  4. package/dist/activity.js +72 -0
  5. package/dist/activity.js.map +1 -0
  6. package/dist/bridge/certificate.d.ts +75 -0
  7. package/dist/bridge/certificate.js +176 -0
  8. package/dist/bridge/certificate.js.map +1 -0
  9. package/dist/bridge/connection.d.ts +99 -0
  10. package/dist/bridge/connection.js +244 -0
  11. package/dist/bridge/connection.js.map +1 -0
  12. package/dist/bridge/errors.d.ts +35 -0
  13. package/dist/bridge/errors.js +56 -0
  14. package/dist/bridge/errors.js.map +1 -0
  15. package/dist/config.d.ts +92 -0
  16. package/dist/config.js +142 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials/availability.d.ts +62 -0
  19. package/dist/credentials/availability.js +107 -0
  20. package/dist/credentials/availability.js.map +1 -0
  21. package/dist/credentials/encrypted-file.d.ts +50 -0
  22. package/dist/credentials/encrypted-file.js +156 -0
  23. package/dist/credentials/encrypted-file.js.map +1 -0
  24. package/dist/credentials/keyring.d.ts +48 -0
  25. package/dist/credentials/keyring.js +97 -0
  26. package/dist/credentials/keyring.js.map +1 -0
  27. package/dist/credentials/plain-file.d.ts +24 -0
  28. package/dist/credentials/plain-file.js +78 -0
  29. package/dist/credentials/plain-file.js.map +1 -0
  30. package/dist/credentials/probe-keyring.d.ts +11 -0
  31. package/dist/credentials/probe-keyring.js +57 -0
  32. package/dist/credentials/probe-keyring.js.map +1 -0
  33. package/dist/credentials/session.d.ts +15 -0
  34. package/dist/credentials/session.js +21 -0
  35. package/dist/credentials/session.js.map +1 -0
  36. package/dist/credentials/store.d.ts +80 -0
  37. package/dist/credentials/store.js +94 -0
  38. package/dist/credentials/store.js.map +1 -0
  39. package/dist/in-flight.d.ts +33 -0
  40. package/dist/in-flight.js +78 -0
  41. package/dist/in-flight.js.map +1 -0
  42. package/dist/mail/actions.d.ts +116 -0
  43. package/dist/mail/actions.js +265 -0
  44. package/dist/mail/actions.js.map +1 -0
  45. package/dist/mail/attachments.d.ts +39 -0
  46. package/dist/mail/attachments.js +86 -0
  47. package/dist/mail/attachments.js.map +1 -0
  48. package/dist/mail/compose.d.ts +264 -0
  49. package/dist/mail/compose.js +395 -0
  50. package/dist/mail/compose.js.map +1 -0
  51. package/dist/mail/drafts.d.ts +138 -0
  52. package/dist/mail/drafts.js +449 -0
  53. package/dist/mail/drafts.js.map +1 -0
  54. package/dist/mail/ids.d.ts +78 -0
  55. package/dist/mail/ids.js +135 -0
  56. package/dist/mail/ids.js.map +1 -0
  57. package/dist/mail/labels.d.ts +61 -0
  58. package/dist/mail/labels.js +139 -0
  59. package/dist/mail/labels.js.map +1 -0
  60. package/dist/mail/markup.d.ts +91 -0
  61. package/dist/mail/markup.js +265 -0
  62. package/dist/mail/markup.js.map +1 -0
  63. package/dist/mail/messages.d.ts +147 -0
  64. package/dist/mail/messages.js +219 -0
  65. package/dist/mail/messages.js.map +1 -0
  66. package/dist/mail/quote.d.ts +65 -0
  67. package/dist/mail/quote.js +157 -0
  68. package/dist/mail/quote.js.map +1 -0
  69. package/dist/mail/search.d.ts +66 -0
  70. package/dist/mail/search.js +107 -0
  71. package/dist/mail/search.js.map +1 -0
  72. package/dist/mail/send.d.ts +61 -0
  73. package/dist/mail/send.js +132 -0
  74. package/dist/mail/send.js.map +1 -0
  75. package/dist/mime/parse.d.ts +110 -0
  76. package/dist/mime/parse.js +194 -0
  77. package/dist/mime/parse.js.map +1 -0
  78. package/dist/server.d.ts +13 -0
  79. package/dist/server.js +387 -0
  80. package/dist/server.js.map +1 -0
  81. package/dist/session.d.ts +86 -0
  82. package/dist/session.js +220 -0
  83. package/dist/session.js.map +1 -0
  84. package/dist/settings.d.ts +52 -0
  85. package/dist/settings.js +80 -0
  86. package/dist/settings.js.map +1 -0
  87. package/dist/tools/actions.d.ts +21 -0
  88. package/dist/tools/actions.js +133 -0
  89. package/dist/tools/actions.js.map +1 -0
  90. package/dist/tools/capabilities.d.ts +52 -0
  91. package/dist/tools/capabilities.js +96 -0
  92. package/dist/tools/capabilities.js.map +1 -0
  93. package/dist/tools/confirm.d.ts +84 -0
  94. package/dist/tools/confirm.js +212 -0
  95. package/dist/tools/confirm.js.map +1 -0
  96. package/dist/tools/drafts.d.ts +21 -0
  97. package/dist/tools/drafts.js +199 -0
  98. package/dist/tools/drafts.js.map +1 -0
  99. package/dist/tools/failures.d.ts +46 -0
  100. package/dist/tools/failures.js +157 -0
  101. package/dist/tools/failures.js.map +1 -0
  102. package/dist/tools/format.d.ts +55 -0
  103. package/dist/tools/format.js +220 -0
  104. package/dist/tools/format.js.map +1 -0
  105. package/dist/tools/guidance.d.ts +38 -0
  106. package/dist/tools/guidance.js +226 -0
  107. package/dist/tools/guidance.js.map +1 -0
  108. package/dist/tools/interface.d.ts +28 -0
  109. package/dist/tools/interface.js +63 -0
  110. package/dist/tools/interface.js.map +1 -0
  111. package/dist/tools/labels.d.ts +21 -0
  112. package/dist/tools/labels.js +92 -0
  113. package/dist/tools/labels.js.map +1 -0
  114. package/dist/tools/list-folders.d.ts +14 -0
  115. package/dist/tools/list-folders.js +70 -0
  116. package/dist/tools/list-folders.js.map +1 -0
  117. package/dist/tools/messages.d.ts +9 -0
  118. package/dist/tools/messages.js +221 -0
  119. package/dist/tools/messages.js.map +1 -0
  120. package/dist/tools/send.d.ts +29 -0
  121. package/dist/tools/send.js +236 -0
  122. package/dist/tools/send.js.map +1 -0
  123. package/dist/tools/sign-in-required.d.ts +94 -0
  124. package/dist/tools/sign-in-required.js +168 -0
  125. package/dist/tools/sign-in-required.js.map +1 -0
  126. package/dist/web/layout.d.ts +75 -0
  127. package/dist/web/layout.js +590 -0
  128. package/dist/web/layout.js.map +1 -0
  129. package/dist/web/pages.d.ts +79 -0
  130. package/dist/web/pages.js +284 -0
  131. package/dist/web/pages.js.map +1 -0
  132. package/dist/web/sections.d.ts +105 -0
  133. package/dist/web/sections.js +207 -0
  134. package/dist/web/sections.js.map +1 -0
  135. package/dist/web/security.d.ts +122 -0
  136. package/dist/web/security.js +180 -0
  137. package/dist/web/security.js.map +1 -0
  138. package/dist/web/server.d.ts +153 -0
  139. package/dist/web/server.js +622 -0
  140. package/dist/web/server.js.map +1 -0
  141. 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, '&amp;')
195
+ .replace(/</g, '&lt;')
196
+ .replace(/>/g, '&gt;')
197
+ .replace(/"/g, '&quot;');
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