@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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoting a message inside a reply, with its formatting intact.
|
|
3
|
+
*
|
|
4
|
+
* The obvious way to quote is to convert the original to text. It is safe and
|
|
5
|
+
* it is what this server did first, and measured against a real mailbox it
|
|
6
|
+
* turned out to be too expensive: 94 percent of ordinary mail is formatted, so
|
|
7
|
+
* every reply would throw away the look of nearly everything it answers.
|
|
8
|
+
*
|
|
9
|
+
* The other obvious way is to keep the original's markup and cut it down to
|
|
10
|
+
* what this server permits itself to write. Also measured, on the same
|
|
11
|
+
* mailbox: **83 percent of real messages would need work, with a median of 87
|
|
12
|
+
* removals each.** That is not a plainer quote, it is a demolished one, and the
|
|
13
|
+
* pieces that carry the look are the first to go.
|
|
14
|
+
*
|
|
15
|
+
* So the quote keeps the original **byte for byte**, with one exception that
|
|
16
|
+
* the measurement made unavoidable.
|
|
17
|
+
*
|
|
18
|
+
* ## The exception, and why it is not negotiable
|
|
19
|
+
*
|
|
20
|
+
* A `<style>` block belongs to a document, not to the element it sits in. Embed
|
|
21
|
+
* one inside a quote and its rules apply to the whole message, including the
|
|
22
|
+
* reply written above it. Measured on the same sample:
|
|
23
|
+
*
|
|
24
|
+
* | | |
|
|
25
|
+
* | :--- | :--- |
|
|
26
|
+
* | Formatted messages carrying a `<style>` block | 66 percent |
|
|
27
|
+
* | Of those, rules aimed at bare elements rather than classes | 56 percent |
|
|
28
|
+
* | The selectors, in order | `body`, `a`, `table`, `p`, `img`, `td`, `blockquote` |
|
|
29
|
+
*
|
|
30
|
+
* `blockquote` appears in thirty-six of them, which is the element this quote
|
|
31
|
+
* is wrapped in. So in more than half of all replies, the quoted message's
|
|
32
|
+
* stylesheet would reach out and restyle the sender's own words. A rule as
|
|
33
|
+
* short as `p { display: none }` would make the reply disappear while leaving
|
|
34
|
+
* the quote in place, and the person who confirmed the send would never know.
|
|
35
|
+
*
|
|
36
|
+
* Everything else stays. Inline styles, classes, tables, colours, alignment,
|
|
37
|
+
* images, links, spacing: untouched, character for character, because the parts
|
|
38
|
+
* that are kept are copied out of the original string rather than rebuilt from
|
|
39
|
+
* a parse tree. A re-serialised quote is a quote somebody rewrote.
|
|
40
|
+
*
|
|
41
|
+
* ## Images
|
|
42
|
+
*
|
|
43
|
+
* Measured: **none** of the 687 images in the sample were embedded by content
|
|
44
|
+
* id; every one was an ordinary address. So a quote needs no files carried
|
|
45
|
+
* along and its pictures simply work. Content ids are handled anyway, because
|
|
46
|
+
* "none in this sample" is not "never", and a quote with a broken image would
|
|
47
|
+
* be worse than one without.
|
|
48
|
+
*/
|
|
49
|
+
export interface PreparedQuote {
|
|
50
|
+
/** The original's markup, minus what reaches outside the quote. */
|
|
51
|
+
html: string;
|
|
52
|
+
/** Which elements were dropped, for the answer and for the confirmation. */
|
|
53
|
+
removed: string[];
|
|
54
|
+
/** Content ids the quote still refers to, which have to travel with it. */
|
|
55
|
+
contentIds: string[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Prepares a message's markup for use as a quote.
|
|
59
|
+
*
|
|
60
|
+
* Works by cutting ranges out of the original string rather than by rebuilding
|
|
61
|
+
* it. Every character that is not inside a removed element survives exactly as
|
|
62
|
+
* it was written, which is the difference between quoting a message and
|
|
63
|
+
* paraphrasing it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function prepareQuotedMarkup(html: string): PreparedQuote;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoting a message inside a reply, with its formatting intact.
|
|
3
|
+
*
|
|
4
|
+
* The obvious way to quote is to convert the original to text. It is safe and
|
|
5
|
+
* it is what this server did first, and measured against a real mailbox it
|
|
6
|
+
* turned out to be too expensive: 94 percent of ordinary mail is formatted, so
|
|
7
|
+
* every reply would throw away the look of nearly everything it answers.
|
|
8
|
+
*
|
|
9
|
+
* The other obvious way is to keep the original's markup and cut it down to
|
|
10
|
+
* what this server permits itself to write. Also measured, on the same
|
|
11
|
+
* mailbox: **83 percent of real messages would need work, with a median of 87
|
|
12
|
+
* removals each.** That is not a plainer quote, it is a demolished one, and the
|
|
13
|
+
* pieces that carry the look are the first to go.
|
|
14
|
+
*
|
|
15
|
+
* So the quote keeps the original **byte for byte**, with one exception that
|
|
16
|
+
* the measurement made unavoidable.
|
|
17
|
+
*
|
|
18
|
+
* ## The exception, and why it is not negotiable
|
|
19
|
+
*
|
|
20
|
+
* A `<style>` block belongs to a document, not to the element it sits in. Embed
|
|
21
|
+
* one inside a quote and its rules apply to the whole message, including the
|
|
22
|
+
* reply written above it. Measured on the same sample:
|
|
23
|
+
*
|
|
24
|
+
* | | |
|
|
25
|
+
* | :--- | :--- |
|
|
26
|
+
* | Formatted messages carrying a `<style>` block | 66 percent |
|
|
27
|
+
* | Of those, rules aimed at bare elements rather than classes | 56 percent |
|
|
28
|
+
* | The selectors, in order | `body`, `a`, `table`, `p`, `img`, `td`, `blockquote` |
|
|
29
|
+
*
|
|
30
|
+
* `blockquote` appears in thirty-six of them, which is the element this quote
|
|
31
|
+
* is wrapped in. So in more than half of all replies, the quoted message's
|
|
32
|
+
* stylesheet would reach out and restyle the sender's own words. A rule as
|
|
33
|
+
* short as `p { display: none }` would make the reply disappear while leaving
|
|
34
|
+
* the quote in place, and the person who confirmed the send would never know.
|
|
35
|
+
*
|
|
36
|
+
* Everything else stays. Inline styles, classes, tables, colours, alignment,
|
|
37
|
+
* images, links, spacing: untouched, character for character, because the parts
|
|
38
|
+
* that are kept are copied out of the original string rather than rebuilt from
|
|
39
|
+
* a parse tree. A re-serialised quote is a quote somebody rewrote.
|
|
40
|
+
*
|
|
41
|
+
* ## Images
|
|
42
|
+
*
|
|
43
|
+
* Measured: **none** of the 687 images in the sample were embedded by content
|
|
44
|
+
* id; every one was an ordinary address. So a quote needs no files carried
|
|
45
|
+
* along and its pictures simply work. Content ids are handled anyway, because
|
|
46
|
+
* "none in this sample" is not "never", and a quote with a broken image would
|
|
47
|
+
* be worse than one without.
|
|
48
|
+
*/
|
|
49
|
+
import { Parser } from 'htmlparser2';
|
|
50
|
+
/**
|
|
51
|
+
* Elements dropped from a quote, with everything inside them.
|
|
52
|
+
*
|
|
53
|
+
* All of them are document-level: they say something about the message as a
|
|
54
|
+
* whole rather than about the place they stand in, so inside a quote they act
|
|
55
|
+
* outside it. That is the entire criterion, and it is why the list is short.
|
|
56
|
+
*/
|
|
57
|
+
const DOCUMENT_LEVEL = new Set([
|
|
58
|
+
'style', 'script', 'head', 'meta', 'link', 'title', 'base', 'noscript',
|
|
59
|
+
]);
|
|
60
|
+
/** Wrappers whose contents are kept and whose tags are not. */
|
|
61
|
+
const UNWRAP = new Set(['html', 'body']);
|
|
62
|
+
/**
|
|
63
|
+
* Prepares a message's markup for use as a quote.
|
|
64
|
+
*
|
|
65
|
+
* Works by cutting ranges out of the original string rather than by rebuilding
|
|
66
|
+
* it. Every character that is not inside a removed element survives exactly as
|
|
67
|
+
* it was written, which is the difference between quoting a message and
|
|
68
|
+
* paraphrasing it.
|
|
69
|
+
*/
|
|
70
|
+
export function prepareQuotedMarkup(html) {
|
|
71
|
+
/** Ranges to cut, as [start, end) in the original string. */
|
|
72
|
+
const cuts = [];
|
|
73
|
+
const removed = [];
|
|
74
|
+
const contentIds = [];
|
|
75
|
+
/** Set while inside a dropped element, so nested tags are not counted twice. */
|
|
76
|
+
let dropDepth = 0;
|
|
77
|
+
let dropStart = 0;
|
|
78
|
+
let dropName = '';
|
|
79
|
+
const parser = new Parser({
|
|
80
|
+
onopentag(name, attributes) {
|
|
81
|
+
const tag = name.toLowerCase();
|
|
82
|
+
if (dropDepth > 0) {
|
|
83
|
+
if (tag === dropName)
|
|
84
|
+
dropDepth += 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (DOCUMENT_LEVEL.has(tag)) {
|
|
88
|
+
dropDepth = 1;
|
|
89
|
+
dropName = tag;
|
|
90
|
+
dropStart = parser.startIndex;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (UNWRAP.has(tag)) {
|
|
94
|
+
cuts.push([parser.startIndex, parser.endIndex + 1]);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (tag === 'img' && typeof attributes.src === 'string') {
|
|
98
|
+
const src = attributes.src.trim();
|
|
99
|
+
if (src.toLowerCase().startsWith('cid:'))
|
|
100
|
+
contentIds.push(src.slice(4));
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
oncomment() {
|
|
104
|
+
// Comments are cut out of a quote, and it is not tidiness. Outlook acts
|
|
105
|
+
// on conditional comments, so a `<style>` block inside
|
|
106
|
+
// `<!--[if mso]>` is a stylesheet that a parser reads as a comment and
|
|
107
|
+
// a mail client executes. Measured on a real mailbox: three of 151
|
|
108
|
+
// messages hid a style block exactly there, and one hid a `<noscript>`.
|
|
109
|
+
// Every unit test in this module passed while that hole was open.
|
|
110
|
+
//
|
|
111
|
+
// Matching on `[if mso]` would close those four and leave the next
|
|
112
|
+
// variant open. Dropping comments closes the shape of the hole: nothing
|
|
113
|
+
// renders a comment except this one client, and what it renders is a
|
|
114
|
+
// layout fallback inside a quote.
|
|
115
|
+
if (dropDepth === 0)
|
|
116
|
+
cuts.push([parser.startIndex, parser.endIndex + 1]);
|
|
117
|
+
},
|
|
118
|
+
onclosetag(name) {
|
|
119
|
+
const tag = name.toLowerCase();
|
|
120
|
+
if (dropDepth > 0 && tag === dropName) {
|
|
121
|
+
dropDepth -= 1;
|
|
122
|
+
if (dropDepth === 0) {
|
|
123
|
+
cuts.push([dropStart, parser.endIndex + 1]);
|
|
124
|
+
removed.push(`<${tag}>`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (dropDepth === 0 && UNWRAP.has(tag)) {
|
|
129
|
+
cuts.push([parser.startIndex, parser.endIndex + 1]);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}, {
|
|
133
|
+
decodeEntities: false,
|
|
134
|
+
lowerCaseTags: true,
|
|
135
|
+
lowerCaseAttributeNames: true,
|
|
136
|
+
recognizeSelfClosing: true,
|
|
137
|
+
});
|
|
138
|
+
parser.write(html);
|
|
139
|
+
parser.end();
|
|
140
|
+
// An element left open at the end of the document still has to be cut, or
|
|
141
|
+
// its content would ride along inside a quote.
|
|
142
|
+
if (dropDepth > 0) {
|
|
143
|
+
cuts.push([dropStart, html.length]);
|
|
144
|
+
removed.push(`<${dropName}>`);
|
|
145
|
+
}
|
|
146
|
+
cuts.sort((a, b) => a[0] - b[0]);
|
|
147
|
+
let out = '';
|
|
148
|
+
let at = 0;
|
|
149
|
+
for (const [start, end] of cuts) {
|
|
150
|
+
if (start > at)
|
|
151
|
+
out += html.slice(at, start);
|
|
152
|
+
at = Math.max(at, end);
|
|
153
|
+
}
|
|
154
|
+
out += html.slice(at);
|
|
155
|
+
return { html: out.trim(), removed: [...new Set(removed)], contentIds };
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=quote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quote.js","sourceRoot":"","sources":["../../src/mail/quote.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Searching messages.
|
|
3
|
+
*
|
|
4
|
+
* Measured against a mailbox of 26816 messages: a
|
|
5
|
+
* full-text search takes between 1.7 and 3.3 seconds, a search that finds
|
|
6
|
+
* nothing takes just as long. The Bridge walks its local database linearly,
|
|
7
|
+
* there is no index, so the time grows with the size of the mailbox.
|
|
8
|
+
*
|
|
9
|
+
* That is slow but usable, which is why this server builds no index of its own.
|
|
10
|
+
* An index would mean mirroring the decrypted mailbox to disk, undoing locally
|
|
11
|
+
* what Proton does for you.
|
|
12
|
+
*
|
|
13
|
+
* Metadata queries are cheap by comparison: unread came back in 3 ms, a size
|
|
14
|
+
* filter in 437 ms.
|
|
15
|
+
*/
|
|
16
|
+
import type { SearchObject } from 'imapflow';
|
|
17
|
+
import type { Connection } from '../bridge/connection.js';
|
|
18
|
+
import { type MessageHeader, type OrderingCost } from './messages.js';
|
|
19
|
+
export interface SearchCriteria {
|
|
20
|
+
/** Free text, searched in the body and in headers. The expensive part. */
|
|
21
|
+
text?: string;
|
|
22
|
+
/** Substring of the subject. */
|
|
23
|
+
subject?: string;
|
|
24
|
+
/** Substring of the sender address or display name. */
|
|
25
|
+
from?: string;
|
|
26
|
+
/** Substring of a recipient address. */
|
|
27
|
+
to?: string;
|
|
28
|
+
/** Only messages on or after this date. */
|
|
29
|
+
since?: Date;
|
|
30
|
+
/** Only messages before this date. */
|
|
31
|
+
before?: Date;
|
|
32
|
+
/** true for unread only, false for read only. */
|
|
33
|
+
seen?: boolean;
|
|
34
|
+
/** Only starred messages. */
|
|
35
|
+
flagged?: boolean;
|
|
36
|
+
/** Minimum size in bytes. */
|
|
37
|
+
largerThan?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface SearchResult {
|
|
40
|
+
path: string;
|
|
41
|
+
/** Number of matches, independent of paging. */
|
|
42
|
+
total: number;
|
|
43
|
+
offset: number;
|
|
44
|
+
headers: MessageHeader[];
|
|
45
|
+
/** How long the search itself took, so a caller can judge the cost. */
|
|
46
|
+
elapsedMs: number;
|
|
47
|
+
/** What putting the hits in order cost, separate from the search. */
|
|
48
|
+
ordering: OrderingCost;
|
|
49
|
+
/** True when the search included a full-text term, which is the slow case. */
|
|
50
|
+
fullText: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** Translates the criteria into what imapflow expects. */
|
|
53
|
+
export declare function buildQuery(criteria: SearchCriteria): SearchObject;
|
|
54
|
+
/** Whether a query contains the expensive full-text part. */
|
|
55
|
+
export declare function isFullText(criteria: SearchCriteria): boolean;
|
|
56
|
+
export interface SearchOptions extends SearchCriteria {
|
|
57
|
+
limit?: number;
|
|
58
|
+
offset?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Searches one mailbox and returns headers, never bodies.
|
|
62
|
+
*
|
|
63
|
+
* "All Mail" is the mailbox to search when the caller does not care where a
|
|
64
|
+
* message lives, since it holds everything including trash.
|
|
65
|
+
*/
|
|
66
|
+
export declare function searchMessages(connection: Connection, path: string, options: SearchOptions): Promise<SearchResult>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Searching messages.
|
|
3
|
+
*
|
|
4
|
+
* Measured against a mailbox of 26816 messages: a
|
|
5
|
+
* full-text search takes between 1.7 and 3.3 seconds, a search that finds
|
|
6
|
+
* nothing takes just as long. The Bridge walks its local database linearly,
|
|
7
|
+
* there is no index, so the time grows with the size of the mailbox.
|
|
8
|
+
*
|
|
9
|
+
* That is slow but usable, which is why this server builds no index of its own.
|
|
10
|
+
* An index would mean mirroring the decrypted mailbox to disk, undoing locally
|
|
11
|
+
* what Proton does for you.
|
|
12
|
+
*
|
|
13
|
+
* Metadata queries are cheap by comparison: unread came back in 3 ms, a size
|
|
14
|
+
* filter in 437 ms.
|
|
15
|
+
*/
|
|
16
|
+
import { BridgeError } from '../bridge/errors.js';
|
|
17
|
+
import { fetchDates, fetchHeaders, orderNewestFirst, MAX_LIST_LIMIT, } from './messages.js';
|
|
18
|
+
/** Translates the criteria into what imapflow expects. */
|
|
19
|
+
export function buildQuery(criteria) {
|
|
20
|
+
const query = {};
|
|
21
|
+
if (criteria.text)
|
|
22
|
+
query.text = criteria.text;
|
|
23
|
+
if (criteria.subject)
|
|
24
|
+
query.header = { subject: criteria.subject };
|
|
25
|
+
if (criteria.from)
|
|
26
|
+
query.from = criteria.from;
|
|
27
|
+
if (criteria.to)
|
|
28
|
+
query.to = criteria.to;
|
|
29
|
+
if (criteria.since)
|
|
30
|
+
query.since = criteria.since;
|
|
31
|
+
if (criteria.before)
|
|
32
|
+
query.before = criteria.before;
|
|
33
|
+
if (criteria.seen !== undefined)
|
|
34
|
+
query.seen = criteria.seen;
|
|
35
|
+
if (criteria.flagged !== undefined)
|
|
36
|
+
query.flagged = criteria.flagged;
|
|
37
|
+
if (criteria.largerThan !== undefined)
|
|
38
|
+
query.larger = criteria.largerThan;
|
|
39
|
+
// An empty query would match everything, which is list_messages rather than
|
|
40
|
+
// a search. Saying so is better than silently returning the whole mailbox.
|
|
41
|
+
if (Object.keys(query).length === 0) {
|
|
42
|
+
throw new BridgeError('A search needs at least one criterion. Pass text, subject, from, to, since, before, ' +
|
|
43
|
+
'unread, starred or largerThan. To see everything in a mailbox use list_messages instead.');
|
|
44
|
+
}
|
|
45
|
+
return query;
|
|
46
|
+
}
|
|
47
|
+
/** Whether a query contains the expensive full-text part. */
|
|
48
|
+
export function isFullText(criteria) {
|
|
49
|
+
return Boolean(criteria.text);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Searches one mailbox and returns headers, never bodies.
|
|
53
|
+
*
|
|
54
|
+
* "All Mail" is the mailbox to search when the caller does not care where a
|
|
55
|
+
* message lives, since it holds everything including trash.
|
|
56
|
+
*/
|
|
57
|
+
export async function searchMessages(connection, path, options) {
|
|
58
|
+
const { limit: rawLimit, offset: rawOffset, ...criteria } = options;
|
|
59
|
+
const limit = Math.min(Math.max(rawLimit ?? 25, 1), MAX_LIST_LIMIT);
|
|
60
|
+
const offset = Math.max(rawOffset ?? 0, 0);
|
|
61
|
+
// Built before opening the mailbox, so an empty query fails fast.
|
|
62
|
+
const query = buildQuery(criteria);
|
|
63
|
+
const started = process.hrtime.bigint();
|
|
64
|
+
const uids = await connection.withMailbox(path, async (client, status) => {
|
|
65
|
+
if (status.messages === 0)
|
|
66
|
+
return [];
|
|
67
|
+
const hits = await client.search(query, { uid: true });
|
|
68
|
+
// imapflow answers with false rather than an empty array when a search
|
|
69
|
+
// yields nothing, and `?? []` would let that through.
|
|
70
|
+
return Array.isArray(hits) ? hits : [];
|
|
71
|
+
});
|
|
72
|
+
const elapsedMs = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
|
|
73
|
+
if (uids.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
path,
|
|
76
|
+
total: 0,
|
|
77
|
+
offset,
|
|
78
|
+
headers: [],
|
|
79
|
+
elapsedMs,
|
|
80
|
+
ordering: { messages: 0, elapsedMs: 0 },
|
|
81
|
+
fullText: isFullText(criteria),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Ordering reads the date of every hit, because a page can only be cut out of
|
|
85
|
+
// a list that is already in order. Only the dates: the full header fetch below
|
|
86
|
+
// still covers one page, which is what keeps this affordable. A common word
|
|
87
|
+
// matched 6803 messages in the measured mailbox.
|
|
88
|
+
const orderStarted = process.hrtime.bigint();
|
|
89
|
+
const ordered = await connection.withMailbox(path, async (client) => orderNewestFirst(await fetchDates(client, uids)));
|
|
90
|
+
const ordering = {
|
|
91
|
+
messages: uids.length,
|
|
92
|
+
elapsedMs: Math.round(Number(process.hrtime.bigint() - orderStarted) / 1e6),
|
|
93
|
+
};
|
|
94
|
+
const page = ordered.slice(offset, offset + limit);
|
|
95
|
+
// Same helper the listing uses, so both produce identical entries.
|
|
96
|
+
const headers = await connection.withMailbox(path, (client) => fetchHeaders(client, page));
|
|
97
|
+
return {
|
|
98
|
+
path,
|
|
99
|
+
total: ordered.length,
|
|
100
|
+
offset,
|
|
101
|
+
headers,
|
|
102
|
+
elapsedMs,
|
|
103
|
+
ordering,
|
|
104
|
+
fullText: isFullText(criteria),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/mail/search.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending over the Bridge's SMTP interface.
|
|
3
|
+
*
|
|
4
|
+
* This is the only code in the server that can make something leave the
|
|
5
|
+
* machine, so it is deliberately small and says no in more places than it says
|
|
6
|
+
* yes. The confirmation itself is not here: it belongs to the tool layer, where
|
|
7
|
+
* the protocol's round trip lives. What is here is the part that must be true
|
|
8
|
+
* regardless of who asks.
|
|
9
|
+
*
|
|
10
|
+
* ## The certificate
|
|
11
|
+
*
|
|
12
|
+
* The Bridge password goes over SMTP exactly as it goes over IMAP, so the
|
|
13
|
+
* connection is pinned the same way. Measured on 31.07.2026: **the Bridge
|
|
14
|
+
* presents the same certificate on both ports**, fingerprint for fingerprint,
|
|
15
|
+
* so the entry recorded for IMAP is what SMTP is verified against and no second
|
|
16
|
+
* pin is needed. That is also why nothing here disables the check: if Proton
|
|
17
|
+
* ever gives the two ports different certificates, sending stops with a
|
|
18
|
+
* verification failure instead of quietly handing the password to whatever
|
|
19
|
+
* answered.
|
|
20
|
+
*
|
|
21
|
+
* ## No verification afterwards
|
|
22
|
+
*
|
|
23
|
+
* Measured on 31.07.2026: SMTP took about six and a half seconds to accept a
|
|
24
|
+
* message and answer `250 2.0.0 OK: queued`, and the message showed up over
|
|
25
|
+
* IMAP about three seconds after that. Nothing here looks for what it just
|
|
26
|
+
* sent. What SMTP accepted is what is reported, and the answer says what that
|
|
27
|
+
* does and does not mean.
|
|
28
|
+
*
|
|
29
|
+
* ## What Proton keeps
|
|
30
|
+
*
|
|
31
|
+
* Also measured, and it is the reason replying exists as a sending tool rather
|
|
32
|
+
* than only as a draft tool: **a sent message keeps its `In-Reply-To`.** A
|
|
33
|
+
* stored draft does not, and gets Proton's internal thread id in `References`
|
|
34
|
+
* instead. `References` is normalised on the way out, keeping the entries
|
|
35
|
+
* Proton can resolve and dropping the ones it cannot, which is reasonable
|
|
36
|
+
* behaviour and leaves the threading intact.
|
|
37
|
+
*
|
|
38
|
+
* The blind copies were verified to be absent from the delivered headers while
|
|
39
|
+
* the blind recipient still received the message. That is the whole reason the
|
|
40
|
+
* envelope is stated separately below.
|
|
41
|
+
*/
|
|
42
|
+
import type { Config, BridgeCredentials } from '../config.js';
|
|
43
|
+
import { type Draft } from './compose.js';
|
|
44
|
+
export interface SendOutcome {
|
|
45
|
+
messageId: string;
|
|
46
|
+
/** Addresses the Bridge took responsibility for. */
|
|
47
|
+
accepted: string[];
|
|
48
|
+
/** Addresses it refused. A message can be half delivered. */
|
|
49
|
+
rejected: string[];
|
|
50
|
+
/** What the Bridge said, for the record. */
|
|
51
|
+
response: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Sends a message.
|
|
55
|
+
*
|
|
56
|
+
* The exact bytes that were built are handed over as the message, with the
|
|
57
|
+
* envelope stated separately. That is not a detail: the envelope is what
|
|
58
|
+
* carries the blind copies, and the built message must not, so the two have to
|
|
59
|
+
* be given apart from one another.
|
|
60
|
+
*/
|
|
61
|
+
export declare function sendMessage(config: Config, getCredentials: () => Promise<BridgeCredentials | undefined>, readOnly: boolean, draft: Draft): Promise<SendOutcome>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending over the Bridge's SMTP interface.
|
|
3
|
+
*
|
|
4
|
+
* This is the only code in the server that can make something leave the
|
|
5
|
+
* machine, so it is deliberately small and says no in more places than it says
|
|
6
|
+
* yes. The confirmation itself is not here: it belongs to the tool layer, where
|
|
7
|
+
* the protocol's round trip lives. What is here is the part that must be true
|
|
8
|
+
* regardless of who asks.
|
|
9
|
+
*
|
|
10
|
+
* ## The certificate
|
|
11
|
+
*
|
|
12
|
+
* The Bridge password goes over SMTP exactly as it goes over IMAP, so the
|
|
13
|
+
* connection is pinned the same way. Measured on 31.07.2026: **the Bridge
|
|
14
|
+
* presents the same certificate on both ports**, fingerprint for fingerprint,
|
|
15
|
+
* so the entry recorded for IMAP is what SMTP is verified against and no second
|
|
16
|
+
* pin is needed. That is also why nothing here disables the check: if Proton
|
|
17
|
+
* ever gives the two ports different certificates, sending stops with a
|
|
18
|
+
* verification failure instead of quietly handing the password to whatever
|
|
19
|
+
* answered.
|
|
20
|
+
*
|
|
21
|
+
* ## No verification afterwards
|
|
22
|
+
*
|
|
23
|
+
* Measured on 31.07.2026: SMTP took about six and a half seconds to accept a
|
|
24
|
+
* message and answer `250 2.0.0 OK: queued`, and the message showed up over
|
|
25
|
+
* IMAP about three seconds after that. Nothing here looks for what it just
|
|
26
|
+
* sent. What SMTP accepted is what is reported, and the answer says what that
|
|
27
|
+
* does and does not mean.
|
|
28
|
+
*
|
|
29
|
+
* ## What Proton keeps
|
|
30
|
+
*
|
|
31
|
+
* Also measured, and it is the reason replying exists as a sending tool rather
|
|
32
|
+
* than only as a draft tool: **a sent message keeps its `In-Reply-To`.** A
|
|
33
|
+
* stored draft does not, and gets Proton's internal thread id in `References`
|
|
34
|
+
* instead. `References` is normalised on the way out, keeping the entries
|
|
35
|
+
* Proton can resolve and dropping the ones it cannot, which is reasonable
|
|
36
|
+
* behaviour and leaves the threading intact.
|
|
37
|
+
*
|
|
38
|
+
* The blind copies were verified to be absent from the delivered headers while
|
|
39
|
+
* the blind recipient still received the message. That is the whole reason the
|
|
40
|
+
* envelope is stated separately below.
|
|
41
|
+
*/
|
|
42
|
+
import { createTransport } from 'nodemailer';
|
|
43
|
+
import { BridgeError } from '../bridge/errors.js';
|
|
44
|
+
import { ensurePinned } from '../bridge/certificate.js';
|
|
45
|
+
import { buildMessage, allRecipients } from './compose.js';
|
|
46
|
+
/**
|
|
47
|
+
* Opens an authenticated SMTP connection to the Bridge.
|
|
48
|
+
*
|
|
49
|
+
* Built per send rather than held open. Sending is rare, and a connection that
|
|
50
|
+
* sits idle holding the Bridge password is a worse trade than a handshake.
|
|
51
|
+
*/
|
|
52
|
+
async function transport(config, credentials) {
|
|
53
|
+
// The pin is recorded against the IMAP port because that is where the first
|
|
54
|
+
// connection happens. Measured to be the same certificate on the SMTP port,
|
|
55
|
+
// so this is the material to verify against; a difference fails the
|
|
56
|
+
// handshake rather than being waved through.
|
|
57
|
+
const pinned = await ensurePinned(config.host, config.imapPort);
|
|
58
|
+
return createTransport({
|
|
59
|
+
host: config.host,
|
|
60
|
+
port: config.smtpPort,
|
|
61
|
+
// Plain connection upgraded via STARTTLS, which is what the Bridge offers.
|
|
62
|
+
secure: false,
|
|
63
|
+
// Not "if available": without this a server that drops STARTTLS from its
|
|
64
|
+
// greeting would get the password in the clear.
|
|
65
|
+
requireTLS: true,
|
|
66
|
+
auth: { user: credentials.user, pass: credentials.pass },
|
|
67
|
+
tls: {
|
|
68
|
+
rejectUnauthorized: true,
|
|
69
|
+
ca: [pinned.pem],
|
|
70
|
+
// The certificate does not carry 127.0.0.1 as a subject alternative
|
|
71
|
+
// name, so the hostname check has to go. It adds nothing on top of
|
|
72
|
+
// pinning: matching one specific certificate says more than matching a
|
|
73
|
+
// name written inside it.
|
|
74
|
+
checkServerIdentity: () => undefined,
|
|
75
|
+
},
|
|
76
|
+
logger: false,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function assertWritable(readOnly) {
|
|
80
|
+
if (readOnly) {
|
|
81
|
+
throw new BridgeError('The server is running read-only, so sending is refused. This is set by ' +
|
|
82
|
+
'PROTON_MCP_READ_ONLY and the web interface reports it as the current mode.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sends a message.
|
|
87
|
+
*
|
|
88
|
+
* The exact bytes that were built are handed over as the message, with the
|
|
89
|
+
* envelope stated separately. That is not a detail: the envelope is what
|
|
90
|
+
* carries the blind copies, and the built message must not, so the two have to
|
|
91
|
+
* be given apart from one another.
|
|
92
|
+
*/
|
|
93
|
+
export async function sendMessage(config, getCredentials, readOnly, draft) {
|
|
94
|
+
assertWritable(readOnly);
|
|
95
|
+
const credentials = await getCredentials();
|
|
96
|
+
if (!credentials) {
|
|
97
|
+
throw new BridgeError('No Bridge credentials are available, so there is nothing to send with. Sign in through ' +
|
|
98
|
+
'the configuration page first.');
|
|
99
|
+
}
|
|
100
|
+
const recipients = allRecipients(draft);
|
|
101
|
+
if (recipients.length === 0) {
|
|
102
|
+
throw new BridgeError('A message needs at least one recipient.');
|
|
103
|
+
}
|
|
104
|
+
// Built without the blind copies in the headers. They travel in the envelope
|
|
105
|
+
// below, where the other recipients never see them.
|
|
106
|
+
const raw = await buildMessage(draft, { keepBcc: false });
|
|
107
|
+
const mailer = await transport(config, credentials);
|
|
108
|
+
try {
|
|
109
|
+
const info = await mailer.sendMail({
|
|
110
|
+
// The bytes, not the fields: what was confirmed is what goes out.
|
|
111
|
+
raw,
|
|
112
|
+
envelope: {
|
|
113
|
+
from: draft.from.address,
|
|
114
|
+
to: recipients.map((r) => r.address),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
messageId: draft.messageId,
|
|
119
|
+
accepted: (info.accepted ?? []).map(String),
|
|
120
|
+
rejected: (info.rejected ?? []).map(String),
|
|
121
|
+
response: info.response ?? '',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw new BridgeError(`The Bridge did not accept the message: ${error.message}. ` +
|
|
126
|
+
'Nothing was sent, and it can be sent again once the Bridge is reachable.', 'unreachable', { cause: error });
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
mailer.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=send.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"send.js","sourceRoot":"","sources":["../../src/mail/send.ts"],"names":[],"mappings":"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"}
|