@rndmjoker/proton-mcp 0.4.13 → 0.5.1
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/README.md +138 -21
- package/dist/browser/channel.d.ts +76 -0
- package/dist/browser/channel.js +162 -0
- package/dist/browser/channel.js.map +1 -0
- package/dist/browser/client.d.ts +50 -0
- package/dist/browser/client.js +284 -0
- package/dist/browser/client.js.map +1 -0
- package/dist/browser/connector.d.ts +15 -0
- package/dist/browser/connector.js +26 -0
- package/dist/browser/connector.js.map +1 -0
- package/dist/browser/host.d.ts +50 -0
- package/dist/browser/host.js +258 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/messages.d.ts +163 -0
- package/dist/browser/messages.js +338 -0
- package/dist/browser/messages.js.map +1 -0
- package/dist/browser/paths.d.ts +51 -0
- package/dist/browser/paths.js +92 -0
- package/dist/browser/paths.js.map +1 -0
- package/dist/browser/setup.d.ts +42 -0
- package/dist/browser/setup.js +170 -0
- package/dist/browser/setup.js.map +1 -0
- package/dist/filters/order.d.ts +66 -0
- package/dist/filters/order.js +108 -0
- package/dist/filters/order.js.map +1 -0
- package/dist/filters/sieve.d.ts +184 -0
- package/dist/filters/sieve.js +1312 -0
- package/dist/filters/sieve.js.map +1 -0
- package/dist/instances.d.ts +58 -0
- package/dist/instances.js +145 -0
- package/dist/instances.js.map +1 -0
- package/dist/mail/compose.d.ts +10 -0
- package/dist/mail/compose.js.map +1 -1
- package/dist/mail/record.d.ts +36 -0
- package/dist/mail/record.js +74 -0
- package/dist/mail/record.js.map +1 -0
- package/dist/permissions.d.ts +139 -0
- package/dist/permissions.js +355 -0
- package/dist/permissions.js.map +1 -0
- package/dist/server.js +330 -16
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +12 -0
- package/dist/session.js +23 -0
- package/dist/session.js.map +1 -1
- package/dist/settings.d.ts +81 -11
- package/dist/settings.js +160 -25
- package/dist/settings.js.map +1 -1
- package/dist/tools/confirm.d.ts +4 -2
- package/dist/tools/confirm.js +12 -3
- package/dist/tools/confirm.js.map +1 -1
- package/dist/tools/failures.d.ts +6 -8
- package/dist/tools/failures.js +36 -17
- package/dist/tools/failures.js.map +1 -1
- package/dist/tools/filter-tools.d.ts +82 -0
- package/dist/tools/filter-tools.js +729 -0
- package/dist/tools/filter-tools.js.map +1 -0
- package/dist/tools/filters.d.ts +8 -1
- package/dist/tools/filters.js +24 -12
- package/dist/tools/filters.js.map +1 -1
- package/dist/tools/format.d.ts +15 -0
- package/dist/tools/format.js +4 -3
- package/dist/tools/format.js.map +1 -1
- package/dist/tools/guidance.js +12 -7
- package/dist/tools/guidance.js.map +1 -1
- package/dist/tools/interface.d.ts +20 -6
- package/dist/tools/interface.js +40 -16
- package/dist/tools/interface.js.map +1 -1
- package/dist/tools/send.d.ts +9 -0
- package/dist/tools/send.js +51 -9
- package/dist/tools/send.js.map +1 -1
- package/dist/tools/sign-in-required.d.ts +15 -2
- package/dist/tools/sign-in-required.js +30 -2
- package/dist/tools/sign-in-required.js.map +1 -1
- package/dist/web/layout.d.ts +12 -5
- package/dist/web/layout.js +32 -9
- package/dist/web/layout.js.map +1 -1
- package/dist/web/pages.d.ts +4 -2
- package/dist/web/pages.js +10 -9
- package/dist/web/pages.js.map +1 -1
- package/dist/web/pending.d.ts +9 -6
- package/dist/web/pending.js +9 -7
- package/dist/web/pending.js.map +1 -1
- package/dist/web/preview-server.d.ts +33 -0
- package/dist/web/preview-server.js +102 -0
- package/dist/web/preview-server.js.map +1 -0
- package/dist/web/sections.d.ts +86 -1
- package/dist/web/sections.js +290 -7
- package/dist/web/sections.js.map +1 -1
- package/dist/web/security.d.ts +48 -13
- package/dist/web/security.js +68 -16
- package/dist/web/security.js.map +1 -1
- package/dist/web/server.d.ts +81 -24
- package/dist/web/server.js +478 -117
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and checking a Sieve script before it reaches Proton (#59).
|
|
3
|
+
*
|
|
4
|
+
* A filter keeps acting on every message that arrives, long after the
|
|
5
|
+
* conversation that wrote it. So a script an assistant proposes is held to a
|
|
6
|
+
* list of what it may do, not to a list of what it may not: anything this file
|
|
7
|
+
* does not know is refused, and the refusal says why.
|
|
8
|
+
*
|
|
9
|
+
* The same code runs in two places: in this server before a proposal leaves,
|
|
10
|
+
* and in the browser extension before anything is written. Neither trusts the
|
|
11
|
+
* check of the other, which is why this file uses nothing from Node.
|
|
12
|
+
*
|
|
13
|
+
* What it refuses, as decided on 05.11.2026:
|
|
14
|
+
*
|
|
15
|
+
* - discard, expire, reject and vacation: they delete mail, turn it away or
|
|
16
|
+
* answer it in the user's name.
|
|
17
|
+
* - stop: it ends every later filter for a matching message, which switches
|
|
18
|
+
* those filters off without anyone seeing it. Proton's own assistant allows
|
|
19
|
+
* it; this one does not.
|
|
20
|
+
* - filing into Trash or Spam: it hides mail.
|
|
21
|
+
* - a script without Proton's spam guard at the top: without it the filter
|
|
22
|
+
* runs on spam as well and can file spam among normal mail.
|
|
23
|
+
* - a script that does nothing beyond the guard: an empty filter is how a rule
|
|
24
|
+
* is switched off, and switching off is the user's decision.
|
|
25
|
+
*
|
|
26
|
+
* Tightened on 06.11.2026, after two reviewers found scripts that passed and
|
|
27
|
+
* read differently in the confirmation from what they do, or that Proton's
|
|
28
|
+
* own parser may read differently from this one:
|
|
29
|
+
*
|
|
30
|
+
* - no comment but Proton's own two, the line above the spam guard and the
|
|
31
|
+
* one its filter editor writes above each rule, and no text: blocks: both
|
|
32
|
+
* are where Proton's public parser (libsieve-php) and RFC 5228 part ways,
|
|
33
|
+
* and where a discard can sit that looks commented out;
|
|
34
|
+
* - no line break, no invisible character and no lone carriage return
|
|
35
|
+
* anywhere, and addresses in plain ASCII, as for recipients since #42;
|
|
36
|
+
* - the spam guard written as Proton writes it, without escapes;
|
|
37
|
+
* - no :regex and no :eval: a pattern or a sum that fails while Proton runs
|
|
38
|
+
* the filter might end it like stop, and that is not known;
|
|
39
|
+
* - folder paths that name Trash, Spam or Junk in any spelling, and folders
|
|
40
|
+
* given by Proton's internal numbers;
|
|
41
|
+
* - arguments, tags and comparators that are not on the list, and conditions
|
|
42
|
+
* where only allof, anyof and not take them;
|
|
43
|
+
* - rules that can never match or are never reached count as doing nothing.
|
|
44
|
+
*
|
|
45
|
+
* It is not Proton's check and does not replace it. Proton reads the grammar
|
|
46
|
+
* again through mail/v4/filters/check before anything is saved.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* The longest script accepted. A filter is confirmed by reading it, so it has
|
|
50
|
+
* to stay readable. Raised from 20,000 on 06.11.2026, after a real account
|
|
51
|
+
* held a filter of 20,130 characters, a long list of senders, that an
|
|
52
|
+
* assistant could then neither change nor apply. A change to such a script is
|
|
53
|
+
* read through the difference the window shows, not line by line.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_SCRIPT_LENGTH = 50_000;
|
|
56
|
+
/** The longest filter name accepted. */
|
|
57
|
+
export const MAX_NAME_LENGTH = 100;
|
|
58
|
+
/** How deep blocks and test lists may nest before the script is refused as unreadable. */
|
|
59
|
+
const MAX_DEPTH = 32;
|
|
60
|
+
/** One of the two comments a script may carry: Proton's own line above the spam guard. */
|
|
61
|
+
export const GUARD_COMMENT = '# Generated: Do not run this script on spam messages';
|
|
62
|
+
/**
|
|
63
|
+
* The other: the comment Proton's filter editor writes above each rule, which
|
|
64
|
+
* its settings read back to show the rule in the simple editor. It holds
|
|
65
|
+
* nothing but Proton's fixed words, "@type and" or "@type or" and one
|
|
66
|
+
* "@comparator" per condition, so nothing can hide in it. Allowed since Tobi
|
|
67
|
+
* decided so on 06.11.2026: 94 of his 98 filters carry it, and refused, it
|
|
68
|
+
* kept an assistant from replacing or applying any of them. Proton writes it
|
|
69
|
+
* with Windows line ends; a plain line feed is read the same.
|
|
70
|
+
*/
|
|
71
|
+
export const EDITOR_COMMENT = /^\/\*\*(?:\r?\n \* @type (?:and|or))?(?:\r?\n \* @comparator !?(?:contains|is|starts|ends|matches|default))*\r?\n \*\/$/;
|
|
72
|
+
/**
|
|
73
|
+
* Proton's spam guard, as Proton's own filter editor and assistant write it.
|
|
74
|
+
*
|
|
75
|
+
* The comparison below is on the parsed structure and the strings as written,
|
|
76
|
+
* so a reformatted guard still counts, an escaped one does not. The text is
|
|
77
|
+
* here for the tool description and the examples, which must show the guard
|
|
78
|
+
* the same way Proton does.
|
|
79
|
+
*/
|
|
80
|
+
export const SPAM_GUARD_PROLOGUE = 'require ["include", "environment", "variables", "relational", "comparator-i;ascii-numeric", "spamtest"];\n' +
|
|
81
|
+
'\n' +
|
|
82
|
+
`${GUARD_COMMENT}\n` +
|
|
83
|
+
'if allof (environment :matches "vnd.proton.spam-threshold" "*",\n' +
|
|
84
|
+
'spamtest :value "ge" :comparator "i;ascii-numeric" "${1}")\n' +
|
|
85
|
+
'{\n' +
|
|
86
|
+
' return;\n' +
|
|
87
|
+
'}\n';
|
|
88
|
+
/**
|
|
89
|
+
* What a filter may do beyond filing and flags, each only where the user
|
|
90
|
+
* allows it: on the Permissions page of proton-mcp and, as a second lock, in
|
|
91
|
+
* the popup of the extension. All off by default. Tobi's principle of
|
|
92
|
+
* 06.11.2026: what an assistant may set up is the user's decision, and the
|
|
93
|
+
* settings are where they make it. The checks against a script that reads
|
|
94
|
+
* differently from what it does stay, whatever is allowed, and so does the
|
|
95
|
+
* spam guard.
|
|
96
|
+
*/
|
|
97
|
+
export const UNLOCKABLE = ['trash', 'discard', 'expire', 'reject', 'vacation', 'stop'];
|
|
98
|
+
/** How each is named for people, and what it costs, in the settings, in refusals and in the window. */
|
|
99
|
+
export const UNLOCKABLE_TEXT = {
|
|
100
|
+
trash: { title: 'Move mail to Trash or Spam', risk: 'Matching mail ends up where nobody looks for it.' },
|
|
101
|
+
discard: { title: 'Delete mail', risk: 'Matching mail is deleted at once and for good, before anyone sees it.' },
|
|
102
|
+
expire: { title: 'Delete mail after a time', risk: 'Matching mail is deleted after a set time, at most 730 days.' },
|
|
103
|
+
reject: { title: 'Turn mail away', risk: 'Matching mail is deleted, and its sender is told it was refused.' },
|
|
104
|
+
vacation: {
|
|
105
|
+
title: 'Answer automatically',
|
|
106
|
+
risk: 'Whoever sends matching mail gets a reply in your name, without anyone reading it first.',
|
|
107
|
+
},
|
|
108
|
+
stop: { title: 'Stop the later filters', risk: 'No filter after this one runs on matching mail.' },
|
|
109
|
+
};
|
|
110
|
+
/** Nothing allowed beyond filing and flags: how every check runs unless the user allowed more. */
|
|
111
|
+
export const NO_UNLOCKS = new Set();
|
|
112
|
+
/** A list of allowed actions as read from settings or a message, or nothing when it is not one. */
|
|
113
|
+
export function parseUnlocks(value) {
|
|
114
|
+
if (!Array.isArray(value) || value.length > UNLOCKABLE.length)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (!value.every((item) => typeof item === 'string' && UNLOCKABLE.includes(item))) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return new Set(value).size === value.length ? [...value] : undefined;
|
|
120
|
+
}
|
|
121
|
+
/** The extensions the spam guard itself needs in the require line. */
|
|
122
|
+
const GUARD_EXTENSIONS = ['include', 'environment', 'variables', 'relational', 'comparator-i;ascii-numeric', 'spamtest'];
|
|
123
|
+
/**
|
|
124
|
+
* Every extension a proposed script may require, and for the refused ones the
|
|
125
|
+
* reason. An extension that is not listed is refused as unknown.
|
|
126
|
+
*/
|
|
127
|
+
const EXTENSIONS = {
|
|
128
|
+
include: true,
|
|
129
|
+
environment: true,
|
|
130
|
+
variables: true,
|
|
131
|
+
relational: true,
|
|
132
|
+
'comparator-i;ascii-numeric': true,
|
|
133
|
+
spamtest: true,
|
|
134
|
+
fileinto: true,
|
|
135
|
+
imap4flags: true,
|
|
136
|
+
date: true,
|
|
137
|
+
envelope: true,
|
|
138
|
+
extlists: true,
|
|
139
|
+
reject: 'it turns mail away so it never arrives',
|
|
140
|
+
vacation: 'it sends replies from your address without anyone reading them first',
|
|
141
|
+
'vnd.proton.expire': 'it deletes mail after a set time',
|
|
142
|
+
regex: 'a regular expression can fail while Proton runs the filter, and whether that ends the later filters like stop is not known',
|
|
143
|
+
'vnd.proton.eval': 'arithmetic can fail while Proton runs the filter, and whether that ends the later filters like stop is not known',
|
|
144
|
+
};
|
|
145
|
+
/** The extensions that only an allowed action may require. */
|
|
146
|
+
const EXTENSION_UNLOCKS = {
|
|
147
|
+
reject: 'reject',
|
|
148
|
+
vacation: 'vacation',
|
|
149
|
+
'vnd.proton.expire': 'expire',
|
|
150
|
+
};
|
|
151
|
+
/** Commands that are refused wherever they stand, with the reason, unless the action is allowed. */
|
|
152
|
+
const REFUSED_COMMANDS = {
|
|
153
|
+
discard: 'it deletes mail, and no later filter runs on the message',
|
|
154
|
+
stop: 'it ends every later filter for a matching message, which switches those filters off without anyone seeing it',
|
|
155
|
+
reject: 'it turns mail away so it never arrives',
|
|
156
|
+
ereject: 'Proton does not list it among what its filters run',
|
|
157
|
+
vacation: 'it sends replies from your address without anyone reading them first',
|
|
158
|
+
expire: 'it deletes mail after a set time',
|
|
159
|
+
unexpire: 'it belongs to expiry',
|
|
160
|
+
// Measured on 06.11.2026 in Proton's documentation: redirect is not among
|
|
161
|
+
// what its filters run. Forwarding is a setting of its own in Proton.
|
|
162
|
+
redirect: "Proton does not run it in filters; forwarding is a setting of its own in Proton, which these tools do not touch",
|
|
163
|
+
notify: 'it sends notifications to another address',
|
|
164
|
+
include: 'Proton runs other scripts through it only as return',
|
|
165
|
+
};
|
|
166
|
+
/** The commands an allowed action unlocks. */
|
|
167
|
+
const COMMAND_UNLOCKS = {
|
|
168
|
+
discard: 'discard',
|
|
169
|
+
stop: 'stop',
|
|
170
|
+
reject: 'reject',
|
|
171
|
+
vacation: 'vacation',
|
|
172
|
+
expire: 'expire',
|
|
173
|
+
unexpire: 'expire',
|
|
174
|
+
};
|
|
175
|
+
/** How a refusal of an action that can be allowed ends, so the assistant can tell the user where. */
|
|
176
|
+
const ALLOW_HINT = 'The user can allow it on the Permissions page of proton-mcp and in the popup of the browser extension, or set it up in Proton\'s settings.';
|
|
177
|
+
/** The tags vacation takes here, as Proton documents them. :mime and :from are left out. */
|
|
178
|
+
const VACATION_TAGS = new Set(['days', 'subject', 'handle']);
|
|
179
|
+
/** The units Proton documents for expire. */
|
|
180
|
+
const EXPIRE_UNITS = new Set(['day', 'minute', 'second']);
|
|
181
|
+
/** Commands a proposed script may use, apart from the control structure. */
|
|
182
|
+
const ACTIONS = new Set(['fileinto', 'addflag', 'removeflag', 'setflag', 'keep', 'set', 'return']);
|
|
183
|
+
/** Tests a proposed script may use. A test changes nothing, but an unknown one is still refused. */
|
|
184
|
+
const TESTS = new Set([
|
|
185
|
+
'header',
|
|
186
|
+
'address',
|
|
187
|
+
'envelope',
|
|
188
|
+
'exists',
|
|
189
|
+
'size',
|
|
190
|
+
'allof',
|
|
191
|
+
'anyof',
|
|
192
|
+
'not',
|
|
193
|
+
'true',
|
|
194
|
+
'false',
|
|
195
|
+
'date',
|
|
196
|
+
'currentdate',
|
|
197
|
+
'string',
|
|
198
|
+
'hasflag',
|
|
199
|
+
'environment',
|
|
200
|
+
'spamtest',
|
|
201
|
+
'valid_ext_list',
|
|
202
|
+
]);
|
|
203
|
+
/** The only tests that take other tests. */
|
|
204
|
+
const COMBINING_TESTS = new Set(['allof', 'anyof', 'not']);
|
|
205
|
+
/** Tests that are refused with the reason, because their extension is, unless expiry is allowed. */
|
|
206
|
+
const REFUSED_TESTS = {
|
|
207
|
+
hasexpiration: 'it belongs to expiry',
|
|
208
|
+
expiration: 'it belongs to expiry',
|
|
209
|
+
};
|
|
210
|
+
/** Tags a proposed script may use, in tests and in set. */
|
|
211
|
+
const TAGS = new Set([
|
|
212
|
+
'is',
|
|
213
|
+
'contains',
|
|
214
|
+
'matches',
|
|
215
|
+
'value',
|
|
216
|
+
'count',
|
|
217
|
+
'comparator',
|
|
218
|
+
'all',
|
|
219
|
+
'localpart',
|
|
220
|
+
'domain',
|
|
221
|
+
'user',
|
|
222
|
+
'detail',
|
|
223
|
+
'over',
|
|
224
|
+
'under',
|
|
225
|
+
'zone',
|
|
226
|
+
'originalzone',
|
|
227
|
+
'list',
|
|
228
|
+
'lower',
|
|
229
|
+
'upper',
|
|
230
|
+
'lowerfirst',
|
|
231
|
+
'upperfirst',
|
|
232
|
+
'quotewildcard',
|
|
233
|
+
'length',
|
|
234
|
+
]);
|
|
235
|
+
/** Tags refused with the reason. */
|
|
236
|
+
const REFUSED_TAGS = {
|
|
237
|
+
regex: EXTENSIONS.regex,
|
|
238
|
+
eval: EXTENSIONS['vnd.proton.eval'],
|
|
239
|
+
};
|
|
240
|
+
/** The comparators Proton documents. */
|
|
241
|
+
const COMPARATORS = new Set(['i;ascii-casemap', 'i;octet', 'i;ascii-numeric', 'i;unicode-casemap']);
|
|
242
|
+
/** What :value and :count may compare with. */
|
|
243
|
+
const RELATIONS = new Set(['gt', 'ge', 'lt', 'le', 'eq', 'ne']);
|
|
244
|
+
/** The modifiers set takes. :eval is refused above. */
|
|
245
|
+
const SET_MODIFIERS = new Set(['lower', 'upper', 'lowerfirst', 'upperfirst', 'quotewildcard', 'length']);
|
|
246
|
+
/** The two flags Proton knows, written without the escaping Sieve needs for the backslash. */
|
|
247
|
+
const FLAGS = new Map([
|
|
248
|
+
['\\seen', '\\Seen'],
|
|
249
|
+
['\\flagged', '\\Flagged'],
|
|
250
|
+
]);
|
|
251
|
+
/**
|
|
252
|
+
* Folder names a filter may not file into, as path segments compared after
|
|
253
|
+
* folding: Trash and Spam hide mail, and Junk is what some clients call Spam.
|
|
254
|
+
*/
|
|
255
|
+
const HIDDEN_SEGMENTS = new Set(['trash', 'spam', 'junk']);
|
|
256
|
+
/** A script this file cannot read. The line is where the reading stopped. */
|
|
257
|
+
export class SieveSyntaxError extends Error {
|
|
258
|
+
line;
|
|
259
|
+
constructor(message, line) {
|
|
260
|
+
super(message);
|
|
261
|
+
this.line = line;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/y;
|
|
265
|
+
const NUMBER = /[0-9]+([KMGkmg])?/y;
|
|
266
|
+
/**
|
|
267
|
+
* Splits a script into tokens and comments, following RFC 5228.
|
|
268
|
+
*
|
|
269
|
+
* Identifiers and tags are lowered, because Sieve compares them without case:
|
|
270
|
+
* `FileInto` is `fileinto`, and a check that compared them as written would let
|
|
271
|
+
* `Discard` through. Comments are kept rather than dropped, so the check can
|
|
272
|
+
* refuse them.
|
|
273
|
+
*/
|
|
274
|
+
function tokenize(source) {
|
|
275
|
+
const tokens = [];
|
|
276
|
+
const comments = [];
|
|
277
|
+
let i = 0;
|
|
278
|
+
let line = 1;
|
|
279
|
+
const n = source.length;
|
|
280
|
+
while (i < n) {
|
|
281
|
+
const c = source[i];
|
|
282
|
+
if (c === '\n') {
|
|
283
|
+
line++;
|
|
284
|
+
i++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (c === ' ' || c === '\t' || c === '\r') {
|
|
288
|
+
i++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (c === '#') {
|
|
292
|
+
const start = i;
|
|
293
|
+
while (i < n && source[i] !== '\n')
|
|
294
|
+
i++;
|
|
295
|
+
comments.push({ kind: 'hash', text: source.slice(start, i).replace(/\r$/, ''), line });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (c === '/' && source[i + 1] === '*') {
|
|
299
|
+
const end = source.indexOf('*/', i + 2);
|
|
300
|
+
if (end < 0)
|
|
301
|
+
throw new SieveSyntaxError('A comment that starts with /* is never closed.', line);
|
|
302
|
+
comments.push({ kind: 'bracket', text: source.slice(i, end + 2), line });
|
|
303
|
+
for (let k = i; k < end; k++)
|
|
304
|
+
if (source[k] === '\n')
|
|
305
|
+
line++;
|
|
306
|
+
i = end + 2;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if ('[](){},;'.includes(c)) {
|
|
310
|
+
tokens.push({ type: 'punct', value: c, line });
|
|
311
|
+
i++;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (c === '"') {
|
|
315
|
+
const start = line;
|
|
316
|
+
const from = i + 1;
|
|
317
|
+
let value = '';
|
|
318
|
+
i++;
|
|
319
|
+
for (;;) {
|
|
320
|
+
if (i >= n)
|
|
321
|
+
throw new SieveSyntaxError('A quoted string is never closed.', start);
|
|
322
|
+
const d = source[i];
|
|
323
|
+
if (d === '"') {
|
|
324
|
+
i++;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
if (d === '\\') {
|
|
328
|
+
// RFC 5228, 2.4.2: a backslash takes the next character as it is.
|
|
329
|
+
const next = source[i + 1];
|
|
330
|
+
if (next === undefined)
|
|
331
|
+
throw new SieveSyntaxError('A quoted string is never closed.', start);
|
|
332
|
+
if (next === '\n')
|
|
333
|
+
line++;
|
|
334
|
+
value += next;
|
|
335
|
+
i += 2;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (d === '\n')
|
|
339
|
+
line++;
|
|
340
|
+
value += d;
|
|
341
|
+
i++;
|
|
342
|
+
}
|
|
343
|
+
tokens.push({ type: 'string', value, raw: source.slice(from, i - 1), multiLine: false, line: start });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (c === ':') {
|
|
347
|
+
IDENTIFIER.lastIndex = i + 1;
|
|
348
|
+
const match = IDENTIFIER.exec(source);
|
|
349
|
+
if (!match)
|
|
350
|
+
throw new SieveSyntaxError('A colon is not followed by the name of a tag.', line);
|
|
351
|
+
tokens.push({ type: 'tag', value: match[0].toLowerCase(), line });
|
|
352
|
+
i = IDENTIFIER.lastIndex;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (c >= '0' && c <= '9') {
|
|
356
|
+
NUMBER.lastIndex = i;
|
|
357
|
+
const match = NUMBER.exec(source);
|
|
358
|
+
const after = source[NUMBER.lastIndex];
|
|
359
|
+
if (after !== undefined && /[A-Za-z0-9_]/.test(after)) {
|
|
360
|
+
throw new SieveSyntaxError('A number runs straight into a word.', line);
|
|
361
|
+
}
|
|
362
|
+
const digits = Number.parseInt(match[0], 10);
|
|
363
|
+
const unit = match[1]?.toUpperCase();
|
|
364
|
+
const factor = unit === 'K' ? 1024 : unit === 'M' ? 1024 ** 2 : unit === 'G' ? 1024 ** 3 : 1;
|
|
365
|
+
tokens.push({ type: 'number', value: digits * factor, line });
|
|
366
|
+
i = NUMBER.lastIndex;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
IDENTIFIER.lastIndex = i;
|
|
370
|
+
const word = IDENTIFIER.exec(source);
|
|
371
|
+
if (word) {
|
|
372
|
+
const value = word[0].toLowerCase();
|
|
373
|
+
i = IDENTIFIER.lastIndex;
|
|
374
|
+
if (value === 'text' && source[i] === ':') {
|
|
375
|
+
const start = line;
|
|
376
|
+
const read = readMultiLine(source, i + 1, line);
|
|
377
|
+
tokens.push({ type: 'string', value: read.value, raw: read.value, multiLine: true, line: start });
|
|
378
|
+
i = read.end;
|
|
379
|
+
line = read.line;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
tokens.push({ type: 'identifier', value, line });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const shown = c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
|
|
386
|
+
throw new SieveSyntaxError(`The character U+${shown} cannot stand here.`, line);
|
|
387
|
+
}
|
|
388
|
+
return { tokens, comments };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Reads a multi-line string, from just after `text:` to the line that holds a
|
|
392
|
+
* single dot. A line starting with two dots loses one, as RFC 5228, 2.4.2
|
|
393
|
+
* describes. Proposals may not use them; they are read so the refusal can say
|
|
394
|
+
* where.
|
|
395
|
+
*/
|
|
396
|
+
function readMultiLine(source, from, startLine) {
|
|
397
|
+
let i = from;
|
|
398
|
+
let line = startLine;
|
|
399
|
+
while (source[i] === ' ' || source[i] === '\t')
|
|
400
|
+
i++;
|
|
401
|
+
if (source[i] === '#')
|
|
402
|
+
while (i < source.length && source[i] !== '\n')
|
|
403
|
+
i++;
|
|
404
|
+
if (source[i] === '\r')
|
|
405
|
+
i++;
|
|
406
|
+
if (source[i] !== '\n')
|
|
407
|
+
throw new SieveSyntaxError('text: has to be followed by the end of the line.', line);
|
|
408
|
+
i++;
|
|
409
|
+
line++;
|
|
410
|
+
const lines = [];
|
|
411
|
+
for (;;) {
|
|
412
|
+
if (i >= source.length)
|
|
413
|
+
throw new SieveSyntaxError('A text: block is never closed with a line holding a single dot.', startLine);
|
|
414
|
+
let end = source.indexOf('\n', i);
|
|
415
|
+
if (end < 0)
|
|
416
|
+
end = source.length;
|
|
417
|
+
let content = source.slice(i, end);
|
|
418
|
+
if (content.endsWith('\r'))
|
|
419
|
+
content = content.slice(0, -1);
|
|
420
|
+
i = end + 1;
|
|
421
|
+
line++;
|
|
422
|
+
if (content === '.')
|
|
423
|
+
break;
|
|
424
|
+
lines.push(content.startsWith('..') ? content.slice(1) : content);
|
|
425
|
+
}
|
|
426
|
+
return { value: lines.map((l) => `${l}\n`).join(''), end: i, line };
|
|
427
|
+
}
|
|
428
|
+
/** Reads tokens into commands and tests. */
|
|
429
|
+
class Parser {
|
|
430
|
+
tokens;
|
|
431
|
+
#position = 0;
|
|
432
|
+
constructor(tokens) {
|
|
433
|
+
this.tokens = tokens;
|
|
434
|
+
}
|
|
435
|
+
parse() {
|
|
436
|
+
const commands = this.#commands(0);
|
|
437
|
+
const extra = this.#peek();
|
|
438
|
+
if (extra)
|
|
439
|
+
throw new SieveSyntaxError('A closing brace has no block to close.', extra.line);
|
|
440
|
+
return commands;
|
|
441
|
+
}
|
|
442
|
+
#peek() {
|
|
443
|
+
return this.tokens[this.#position];
|
|
444
|
+
}
|
|
445
|
+
#next() {
|
|
446
|
+
return this.tokens[this.#position++];
|
|
447
|
+
}
|
|
448
|
+
#isPunct(token, value) {
|
|
449
|
+
return token?.type === 'punct' && token.value === value;
|
|
450
|
+
}
|
|
451
|
+
#lastLine() {
|
|
452
|
+
return this.tokens[this.tokens.length - 1]?.line ?? 1;
|
|
453
|
+
}
|
|
454
|
+
#commands(depth) {
|
|
455
|
+
const commands = [];
|
|
456
|
+
for (;;) {
|
|
457
|
+
const token = this.#peek();
|
|
458
|
+
if (!token || this.#isPunct(token, '}'))
|
|
459
|
+
return commands;
|
|
460
|
+
commands.push(this.#command(depth));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
#command(depth) {
|
|
464
|
+
if (depth > MAX_DEPTH)
|
|
465
|
+
throw new SieveSyntaxError('The script nests too deeply to be read.', this.#peek()?.line ?? 1);
|
|
466
|
+
const head = this.#next();
|
|
467
|
+
if (head.type !== 'identifier') {
|
|
468
|
+
throw new SieveSyntaxError('A command has to start with its name.', head.line);
|
|
469
|
+
}
|
|
470
|
+
const { args, tests } = this.#arguments(depth);
|
|
471
|
+
const end = this.#next();
|
|
472
|
+
if (this.#isPunct(end, ';'))
|
|
473
|
+
return { name: head.value, args, tests, line: head.line };
|
|
474
|
+
if (this.#isPunct(end, '{')) {
|
|
475
|
+
const block = this.#commands(depth + 1);
|
|
476
|
+
const close = this.#next();
|
|
477
|
+
if (!this.#isPunct(close, '}')) {
|
|
478
|
+
throw new SieveSyntaxError(`The block of ${head.value} is never closed.`, head.line);
|
|
479
|
+
}
|
|
480
|
+
return { name: head.value, args, tests, block, line: head.line };
|
|
481
|
+
}
|
|
482
|
+
throw new SieveSyntaxError(`${head.value} has to end with a semicolon or a block.`, end?.line ?? this.#lastLine());
|
|
483
|
+
}
|
|
484
|
+
/** The arguments of a command or test, then at most one test or one list of tests. */
|
|
485
|
+
#arguments(depth) {
|
|
486
|
+
const args = [];
|
|
487
|
+
for (;;) {
|
|
488
|
+
const token = this.#peek();
|
|
489
|
+
if (!token)
|
|
490
|
+
return { args, tests: [] };
|
|
491
|
+
if (token.type === 'string') {
|
|
492
|
+
this.#next();
|
|
493
|
+
args.push({ kind: 'strings', values: [token.value], raws: [token.raw], list: false, multiLine: token.multiLine, line: token.line });
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (token.type === 'number') {
|
|
497
|
+
this.#next();
|
|
498
|
+
args.push({ kind: 'number', value: token.value, line: token.line });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (token.type === 'tag') {
|
|
502
|
+
this.#next();
|
|
503
|
+
args.push({ kind: 'tag', name: token.value, line: token.line });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (this.#isPunct(token, '[')) {
|
|
507
|
+
args.push(this.#stringList());
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
const token = this.#peek();
|
|
513
|
+
if (token?.type === 'identifier')
|
|
514
|
+
return { args, tests: [this.#test(depth + 1)] };
|
|
515
|
+
if (this.#isPunct(token, '('))
|
|
516
|
+
return { args, tests: this.#testList(depth + 1) };
|
|
517
|
+
return { args, tests: [] };
|
|
518
|
+
}
|
|
519
|
+
#stringList() {
|
|
520
|
+
const open = this.#next();
|
|
521
|
+
const values = [];
|
|
522
|
+
const raws = [];
|
|
523
|
+
let multiLine = false;
|
|
524
|
+
for (;;) {
|
|
525
|
+
const token = this.#next();
|
|
526
|
+
if (token?.type !== 'string')
|
|
527
|
+
throw new SieveSyntaxError('A list in brackets may hold only strings.', token?.line ?? open.line);
|
|
528
|
+
values.push(token.value);
|
|
529
|
+
raws.push(token.raw);
|
|
530
|
+
multiLine ||= token.multiLine;
|
|
531
|
+
const after = this.#next();
|
|
532
|
+
if (this.#isPunct(after, ']'))
|
|
533
|
+
return { kind: 'strings', values, raws, list: true, multiLine, line: open.line };
|
|
534
|
+
if (!this.#isPunct(after, ','))
|
|
535
|
+
throw new SieveSyntaxError('A list in brackets is never closed.', open.line);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
#test(depth) {
|
|
539
|
+
if (depth > MAX_DEPTH)
|
|
540
|
+
throw new SieveSyntaxError('The script nests too deeply to be read.', this.#peek()?.line ?? 1);
|
|
541
|
+
const head = this.#next();
|
|
542
|
+
if (!head)
|
|
543
|
+
throw new SieveSyntaxError('A condition is missing at the end of the script.', this.#lastLine());
|
|
544
|
+
if (head.type !== 'identifier')
|
|
545
|
+
throw new SieveSyntaxError('A condition has to start with its name.', head.line);
|
|
546
|
+
const { args, tests } = this.#arguments(depth);
|
|
547
|
+
return { name: head.value, args, tests, line: head.line };
|
|
548
|
+
}
|
|
549
|
+
#testList(depth) {
|
|
550
|
+
const open = this.#next();
|
|
551
|
+
if (this.#isPunct(this.#peek(), ')'))
|
|
552
|
+
throw new SieveSyntaxError('A list of conditions is empty.', open.line);
|
|
553
|
+
const tests = [];
|
|
554
|
+
for (;;) {
|
|
555
|
+
tests.push(this.#test(depth));
|
|
556
|
+
const after = this.#next();
|
|
557
|
+
if (this.#isPunct(after, ')'))
|
|
558
|
+
return tests;
|
|
559
|
+
if (!this.#isPunct(after, ','))
|
|
560
|
+
throw new SieveSyntaxError('A list of conditions in parentheses is never closed.', open.line);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** Reads a script into commands. Throws SieveSyntaxError when it cannot. */
|
|
565
|
+
export function parseSieve(source) {
|
|
566
|
+
return new Parser(tokenize(source).tokens).parse();
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Characters that change how text is shown without being seen: format
|
|
570
|
+
* characters, everything Unicode marks as ignorable by default, and the line
|
|
571
|
+
* and paragraph separators. Written as properties, not as escapes.
|
|
572
|
+
*/
|
|
573
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Zl}\p{Zp}]/u;
|
|
574
|
+
/** Control characters other than tab and the two line ends. */
|
|
575
|
+
const CONTROL = /[^\P{Cc}\t\n\r]/u;
|
|
576
|
+
/** A carriage return that does not end a line. */
|
|
577
|
+
const LONE_CR = /\r(?!\n)/;
|
|
578
|
+
function describeCharacter(text, pattern) {
|
|
579
|
+
const match = pattern.exec(text);
|
|
580
|
+
if (!match)
|
|
581
|
+
return undefined;
|
|
582
|
+
const code = match[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
|
|
583
|
+
const line = text.slice(0, match.index).split('\n').length;
|
|
584
|
+
return { code: `U+${code}`, line };
|
|
585
|
+
}
|
|
586
|
+
/** Why a filter name cannot be used, or nothing when it can. */
|
|
587
|
+
export function filterNameProblem(name) {
|
|
588
|
+
const trimmed = name.trim();
|
|
589
|
+
if (!trimmed)
|
|
590
|
+
return 'The filter needs a name.';
|
|
591
|
+
if (trimmed.length > MAX_NAME_LENGTH)
|
|
592
|
+
return `The filter name is longer than ${MAX_NAME_LENGTH} characters.`;
|
|
593
|
+
if (/[\p{Cc}]/u.test(trimmed))
|
|
594
|
+
return 'The filter name contains a control character or a line break.';
|
|
595
|
+
const hidden = describeCharacter(trimmed, INVISIBLE);
|
|
596
|
+
if (hidden) {
|
|
597
|
+
return `The filter name contains an invisible character (${hidden.code}), which can make it read as something else.`;
|
|
598
|
+
}
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
/** True when the strings of an argument are exactly these, in this order, written without escapes. */
|
|
602
|
+
function isStrings(arg, ...values) {
|
|
603
|
+
return (arg?.kind === 'strings' &&
|
|
604
|
+
!arg.multiLine &&
|
|
605
|
+
arg.values.length === values.length &&
|
|
606
|
+
arg.values.every((value, index) => value === values[index] && arg.raws[index] === value));
|
|
607
|
+
}
|
|
608
|
+
function isTag(arg, name) {
|
|
609
|
+
return arg?.kind === 'tag' && arg.name === name;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* True when the command is Proton's spam guard:
|
|
613
|
+
*
|
|
614
|
+
* if allof (environment :matches "vnd.proton.spam-threshold" "*",
|
|
615
|
+
* spamtest :value "ge" :comparator "i;ascii-numeric" "${1}") { return; }
|
|
616
|
+
*
|
|
617
|
+
* Compared as structure, in the order Proton writes it, so whitespace does not
|
|
618
|
+
* matter but every part of the condition does. The strings have to stand as
|
|
619
|
+
* Proton writes them, without escapes: whether Proton resolves an escape in
|
|
620
|
+
* them the way RFC 5228 says is not known, and a guard that compares against
|
|
621
|
+
* something else lets spam through to the rules below it.
|
|
622
|
+
*/
|
|
623
|
+
function isSpamGuard(command) {
|
|
624
|
+
if (command.name !== 'if' || command.args.length !== 0 || command.tests.length !== 1)
|
|
625
|
+
return false;
|
|
626
|
+
const all = command.tests[0];
|
|
627
|
+
if (all.name !== 'allof' || all.args.length !== 0 || all.tests.length !== 2)
|
|
628
|
+
return false;
|
|
629
|
+
const [environment, spamtest] = all.tests;
|
|
630
|
+
const environmentOk = environment.name === 'environment' &&
|
|
631
|
+
environment.tests.length === 0 &&
|
|
632
|
+
environment.args.length === 3 &&
|
|
633
|
+
isTag(environment.args[0], 'matches') &&
|
|
634
|
+
isStrings(environment.args[1], 'vnd.proton.spam-threshold') &&
|
|
635
|
+
isStrings(environment.args[2], '*');
|
|
636
|
+
const spamtestOk = spamtest.name === 'spamtest' &&
|
|
637
|
+
spamtest.tests.length === 0 &&
|
|
638
|
+
spamtest.args.length === 5 &&
|
|
639
|
+
isTag(spamtest.args[0], 'value') &&
|
|
640
|
+
isStrings(spamtest.args[1], 'ge') &&
|
|
641
|
+
isTag(spamtest.args[2], 'comparator') &&
|
|
642
|
+
isStrings(spamtest.args[3], 'i;ascii-numeric') &&
|
|
643
|
+
isStrings(spamtest.args[4], '${1}');
|
|
644
|
+
const block = command.block ?? [];
|
|
645
|
+
const returnsOnly = block.length === 1 &&
|
|
646
|
+
block[0].name === 'return' &&
|
|
647
|
+
block[0].args.length === 0 &&
|
|
648
|
+
block[0].tests.length === 0 &&
|
|
649
|
+
block[0].block === undefined;
|
|
650
|
+
return environmentOk && spamtestOk && returnsOnly;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The path segments of a folder name, folded the way a lenient reader might:
|
|
654
|
+
* compatibility forms joined (fullwidth letters, the long s), case dropped,
|
|
655
|
+
* the backslashes Proton uses to escape a slash removed, empty and "." parts
|
|
656
|
+
* dropped. Whatever Proton does exactly, a name that folds to Trash, Spam or
|
|
657
|
+
* Junk here is refused.
|
|
658
|
+
*/
|
|
659
|
+
export function folderSegments(target) {
|
|
660
|
+
return target
|
|
661
|
+
.normalize('NFKC')
|
|
662
|
+
.toLowerCase()
|
|
663
|
+
.split('/')
|
|
664
|
+
.map((segment) => segment.replace(/\\/g, '').trim())
|
|
665
|
+
.filter((segment) => segment !== '' && segment !== '.');
|
|
666
|
+
}
|
|
667
|
+
/** The verdict on a condition when it does not depend on the message: true, false, or not known. */
|
|
668
|
+
function constant(test) {
|
|
669
|
+
switch (test.name) {
|
|
670
|
+
case 'true':
|
|
671
|
+
return true;
|
|
672
|
+
case 'false':
|
|
673
|
+
return false;
|
|
674
|
+
case 'not': {
|
|
675
|
+
const inner = test.tests[0] ? constant(test.tests[0]) : undefined;
|
|
676
|
+
return inner === undefined ? undefined : !inner;
|
|
677
|
+
}
|
|
678
|
+
case 'allof': {
|
|
679
|
+
const verdicts = test.tests.map(constant);
|
|
680
|
+
if (verdicts.some((verdict) => verdict === false))
|
|
681
|
+
return false;
|
|
682
|
+
return verdicts.every((verdict) => verdict === true) ? true : undefined;
|
|
683
|
+
}
|
|
684
|
+
case 'anyof': {
|
|
685
|
+
const verdicts = test.tests.map(constant);
|
|
686
|
+
if (verdicts.some((verdict) => verdict === true))
|
|
687
|
+
return true;
|
|
688
|
+
return verdicts.every((verdict) => verdict === false) ? false : undefined;
|
|
689
|
+
}
|
|
690
|
+
default:
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function addRefused(walk, what) {
|
|
695
|
+
if (!walk.refused.includes(what))
|
|
696
|
+
walk.refused.push(what);
|
|
697
|
+
}
|
|
698
|
+
function addUnlocked(walk, action, what) {
|
|
699
|
+
walk.uses.add(action);
|
|
700
|
+
if (!walk.unlocked.includes(what))
|
|
701
|
+
walk.unlocked.push(what);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Free text, such as the answer of vacation: no line break, as everywhere,
|
|
705
|
+
* and an address in it only in plain ASCII. Letters outside ASCII elsewhere
|
|
706
|
+
* in the text are fine, an answer in German has umlauts.
|
|
707
|
+
*/
|
|
708
|
+
function checkText(value, line, walk) {
|
|
709
|
+
if (/[\r\n]/.test(value)) {
|
|
710
|
+
walk.problems.push(`Line ${line}: a string runs over more than one line, which can make the script read as something it is not.`);
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
const disguised = value.split(/\s+/).find((word) => word.includes('@') && /[^\x20-\x7e]/.test(word));
|
|
714
|
+
if (disguised) {
|
|
715
|
+
walk.problems.push(`Line ${line}: ${JSON.stringify(disguised)} looks like an address with letters outside plain ASCII. They can spell ` +
|
|
716
|
+
'a familiar address that is a different one; write it in plain ASCII.');
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
if (value.includes('${')) {
|
|
720
|
+
walk.problems.push(`Line ${line}: the text has to be written out. A variable could put words in it that the check never saw.`);
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* An allowed action: its arguments as Proton documents them, and what it does
|
|
727
|
+
* in plain words for the window. Variables are refused in every text it
|
|
728
|
+
* sends or sets, so the window shows exactly what goes out.
|
|
729
|
+
*/
|
|
730
|
+
function checkUnlocked(command, walk, live) {
|
|
731
|
+
const line = command.line;
|
|
732
|
+
const strings = command.args.filter((arg) => arg.kind === 'strings');
|
|
733
|
+
switch (command.name) {
|
|
734
|
+
case 'discard':
|
|
735
|
+
case 'stop':
|
|
736
|
+
case 'unexpire': {
|
|
737
|
+
if (command.args.length > 0) {
|
|
738
|
+
walk.problems.push(`Line ${line}: ${command.name} takes no arguments.`);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (!live)
|
|
742
|
+
return;
|
|
743
|
+
walk.acts = true;
|
|
744
|
+
if (command.name === 'discard') {
|
|
745
|
+
addEffect(walk, 'deletes it at once and for good');
|
|
746
|
+
addUnlocked(walk, 'discard', 'discard');
|
|
747
|
+
}
|
|
748
|
+
else if (command.name === 'stop') {
|
|
749
|
+
addEffect(walk, 'keeps every later filter from running on it');
|
|
750
|
+
addUnlocked(walk, 'stop', 'stop');
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
addEffect(walk, 'takes away its expiry');
|
|
754
|
+
addUnlocked(walk, 'expire', 'unexpire');
|
|
755
|
+
}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
case 'expire': {
|
|
759
|
+
if (command.args.some((arg) => arg.kind !== 'strings') || strings.length !== 2 || strings.some((arg) => arg.list)) {
|
|
760
|
+
walk.problems.push(`Line ${line}: expire takes a unit and a number, each one string, such as expire "day" "30".`);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const unit = strings[0].values[0];
|
|
764
|
+
const amount = strings[1].values[0];
|
|
765
|
+
if (!EXPIRE_UNITS.has(unit)) {
|
|
766
|
+
walk.problems.push(`Line ${line}: expire takes "day", "minute" or "second" as its unit.`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (!/^\d{1,6}$/.test(amount)) {
|
|
770
|
+
walk.problems.push(`Line ${line}: expire takes the time as whole digits, written out.`);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (!live)
|
|
774
|
+
return;
|
|
775
|
+
walk.acts = true;
|
|
776
|
+
const count = Number(amount);
|
|
777
|
+
const capped = unit === 'day' && count > 730 ? ', which Proton cuts to 730 days' : '';
|
|
778
|
+
addEffect(walk, `deletes it after ${count} ${unit}${count === 1 ? '' : 's'}${capped}`);
|
|
779
|
+
addUnlocked(walk, 'expire', 'expire');
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
case 'reject': {
|
|
783
|
+
if (command.args.length !== 1 || strings.length !== 1 || strings[0].list || strings[0].values.length !== 1) {
|
|
784
|
+
walk.problems.push(`Line ${line}: reject takes one text, the reason the sender is told.`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const reason = strings[0].values[0];
|
|
788
|
+
if (!checkText(reason, line, walk))
|
|
789
|
+
return;
|
|
790
|
+
if (!live)
|
|
791
|
+
return;
|
|
792
|
+
walk.acts = true;
|
|
793
|
+
addEffect(walk, `turns it away and tells the sender ${JSON.stringify(reason)}`);
|
|
794
|
+
addUnlocked(walk, 'reject', 'reject');
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
case 'vacation': {
|
|
798
|
+
// vacation [:days number] [:subject "text"] [:handle "text"] "answer"
|
|
799
|
+
let days;
|
|
800
|
+
let subject;
|
|
801
|
+
const args = command.args;
|
|
802
|
+
let index = 0;
|
|
803
|
+
while (index < args.length - 1) {
|
|
804
|
+
const arg = args[index];
|
|
805
|
+
if (arg.kind !== 'tag')
|
|
806
|
+
break;
|
|
807
|
+
if (!VACATION_TAGS.has(arg.name)) {
|
|
808
|
+
walk.problems.push(`Line ${line}: :${arg.name} is not a tag vacation may use here. It takes :days, :subject and :handle.`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const value = args[index + 1];
|
|
812
|
+
if (arg.name === 'days') {
|
|
813
|
+
if (value?.kind !== 'number' || value.value < 1) {
|
|
814
|
+
walk.problems.push(`Line ${line}: :days takes a whole number of days, at least 1.`);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
days = value.value;
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
if (value?.kind !== 'strings' || value.list || value.values.length !== 1) {
|
|
821
|
+
walk.problems.push(`Line ${line}: :${arg.name} takes one text.`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (!checkText(value.values[0], line, walk))
|
|
825
|
+
return;
|
|
826
|
+
if (arg.name === 'subject')
|
|
827
|
+
subject = value.values[0];
|
|
828
|
+
}
|
|
829
|
+
index += 2;
|
|
830
|
+
}
|
|
831
|
+
const reason = args[index];
|
|
832
|
+
if (index !== args.length - 1 || reason?.kind !== 'strings' || reason.list || reason.values.length !== 1) {
|
|
833
|
+
walk.problems.push(`Line ${line}: vacation takes its tags, then one text: the answer.`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const answer = reason.values[0];
|
|
837
|
+
if (!checkText(answer, line, walk))
|
|
838
|
+
return;
|
|
839
|
+
if (!live)
|
|
840
|
+
return;
|
|
841
|
+
walk.acts = true;
|
|
842
|
+
addEffect(walk, `answers the sender in your name with ${JSON.stringify(answer)}` +
|
|
843
|
+
(subject !== undefined ? `, under the subject ${JSON.stringify(subject)}` : '') +
|
|
844
|
+
(days !== undefined ? `, at most once in ${days} day${days === 1 ? '' : 's'} for each sender` : ''));
|
|
845
|
+
addUnlocked(walk, 'vacation', 'vacation');
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function addEffect(walk, effect) {
|
|
851
|
+
if (!walk.effects.includes(effect))
|
|
852
|
+
walk.effects.push(effect);
|
|
853
|
+
}
|
|
854
|
+
/** Every string anywhere in a command or test: no line breaks, and addresses in plain ASCII. */
|
|
855
|
+
function checkStrings(args, line, walk) {
|
|
856
|
+
for (const arg of args) {
|
|
857
|
+
if (arg.kind !== 'strings')
|
|
858
|
+
continue;
|
|
859
|
+
if (arg.multiLine) {
|
|
860
|
+
walk.problems.push(`Line ${line}: a text: block is not taken in a proposed filter. Write the string in quotes on one line.`);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
for (const value of arg.values) {
|
|
864
|
+
if (/[\r\n]/.test(value)) {
|
|
865
|
+
walk.problems.push(`Line ${line}: a string runs over more than one line, which can make the script read as something it is not.`);
|
|
866
|
+
}
|
|
867
|
+
else if (value.includes('@') && /[^\x20-\x7e]/.test(value)) {
|
|
868
|
+
walk.problems.push(`Line ${line}: "${value}" holds an address with letters outside plain ASCII. They can spell a familiar ` +
|
|
869
|
+
'address that is a different one; write it in plain ASCII, a domain in its xn-- form.');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function checkTags(args, line, walk, allowed) {
|
|
875
|
+
for (let index = 0; index < args.length; index++) {
|
|
876
|
+
const arg = args[index];
|
|
877
|
+
if (arg.kind !== 'tag')
|
|
878
|
+
continue;
|
|
879
|
+
const refused = REFUSED_TAGS[arg.name];
|
|
880
|
+
if (refused) {
|
|
881
|
+
walk.problems.push(`Line ${line}: :${arg.name} is refused: ${refused}.`);
|
|
882
|
+
addRefused(walk, arg.name === 'regex' ? 'regular expressions' : 'arithmetic');
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (!allowed.has(arg.name)) {
|
|
886
|
+
walk.problems.push(`Line ${line}: :${arg.name} is not a tag a proposed filter may use here.`);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
const next = args[index + 1];
|
|
890
|
+
if (arg.name === 'comparator' && !(next?.kind === 'strings' && next.values.length === 1 && COMPARATORS.has(next.values[0]))) {
|
|
891
|
+
walk.problems.push(`Line ${line}: :comparator takes one of ${[...COMPARATORS].map((c) => `"${c}"`).join(', ')}.`);
|
|
892
|
+
}
|
|
893
|
+
if ((arg.name === 'value' || arg.name === 'count') && !(next?.kind === 'strings' && next.values.length === 1 && RELATIONS.has(next.values[0]))) {
|
|
894
|
+
walk.problems.push(`Line ${line}: :${arg.name} takes one of ${[...RELATIONS].map((r) => `"${r}"`).join(', ')}.`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
function walkTests(tests, walk) {
|
|
899
|
+
for (const test of tests) {
|
|
900
|
+
const refused = REFUSED_TESTS[test.name];
|
|
901
|
+
if (refused && !walk.allowed.has('expire')) {
|
|
902
|
+
walk.problems.push(`Line ${test.line}: the test ${test.name} is refused: ${refused}. ${ALLOW_HINT}`);
|
|
903
|
+
addRefused(walk, test.name);
|
|
904
|
+
}
|
|
905
|
+
else if (refused) {
|
|
906
|
+
// Reading the expiry changes nothing; its tags and strings are checked below.
|
|
907
|
+
if (test.name === 'hasexpiration' && test.args.length > 0) {
|
|
908
|
+
walk.problems.push(`Line ${test.line}: hasexpiration takes no arguments.`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
else if (!TESTS.has(test.name)) {
|
|
912
|
+
walk.problems.push(`Line ${test.line}: ${test.name} is not a test a proposed filter may use.`);
|
|
913
|
+
addRefused(walk, `the unknown test ${test.name}`);
|
|
914
|
+
}
|
|
915
|
+
if (COMBINING_TESTS.has(test.name)) {
|
|
916
|
+
if (test.args.length > 0)
|
|
917
|
+
walk.problems.push(`Line ${test.line}: ${test.name} takes conditions, nothing else.`);
|
|
918
|
+
if (test.name === 'not' && test.tests.length !== 1)
|
|
919
|
+
walk.problems.push(`Line ${test.line}: not takes exactly one condition.`);
|
|
920
|
+
if (test.tests.length === 0)
|
|
921
|
+
walk.problems.push(`Line ${test.line}: ${test.name} needs at least one condition.`);
|
|
922
|
+
}
|
|
923
|
+
else if (test.tests.length > 0) {
|
|
924
|
+
walk.problems.push(`Line ${test.line}: ${test.name} takes no other condition after its arguments.`);
|
|
925
|
+
}
|
|
926
|
+
checkStrings(test.args, test.line, walk);
|
|
927
|
+
checkTags(test.args, test.line, walk, TAGS);
|
|
928
|
+
walkTests(test.tests, walk);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/** The strings an action names, refusing a variable that could name something else after the check. */
|
|
932
|
+
function namedStrings(command, walk, what) {
|
|
933
|
+
const strings = command.args.filter((arg) => arg.kind === 'strings');
|
|
934
|
+
if (command.args.some((arg) => arg.kind !== 'strings') ||
|
|
935
|
+
strings.length !== 1 ||
|
|
936
|
+
command.tests.length > 0) {
|
|
937
|
+
walk.problems.push(`Line ${command.line}: ${command.name} takes exactly one ${what} here, written out, and nothing else.`);
|
|
938
|
+
return undefined;
|
|
939
|
+
}
|
|
940
|
+
const values = strings[0].values;
|
|
941
|
+
if (values.some((value) => value.includes('${'))) {
|
|
942
|
+
walk.problems.push(`Line ${command.line}: the ${what} of ${command.name} has to be written out. A variable could ` +
|
|
943
|
+
'name something the check never saw.');
|
|
944
|
+
if (command.name === 'fileinto')
|
|
945
|
+
addRefused(walk, 'filing into a folder named by a variable');
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
return values;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Walks commands in order. `reachable` says whether these commands can run at
|
|
952
|
+
* all: an action after a plain return, or inside a rule whose condition can
|
|
953
|
+
* never be true, does not count as something the filter does.
|
|
954
|
+
*/
|
|
955
|
+
function walkCommands(commands, walk, reachable) {
|
|
956
|
+
let previous;
|
|
957
|
+
let chainSettled = false;
|
|
958
|
+
let live = reachable;
|
|
959
|
+
for (const command of commands) {
|
|
960
|
+
const name = command.name;
|
|
961
|
+
if (name === 'require') {
|
|
962
|
+
walk.problems.push(`Line ${command.line}: require belongs at the top of the script, before everything else.`);
|
|
963
|
+
}
|
|
964
|
+
else if (name === 'if' || name === 'elsif' || name === 'else') {
|
|
965
|
+
if ((name === 'elsif' || name === 'else') && previous !== 'if' && previous !== 'elsif') {
|
|
966
|
+
walk.problems.push(`Line ${command.line}: ${name} has to follow an if or an elsif.`);
|
|
967
|
+
}
|
|
968
|
+
if (command.args.length > 0)
|
|
969
|
+
walk.problems.push(`Line ${command.line}: ${name} takes a condition and a block, nothing else.`);
|
|
970
|
+
if (name === 'else' && command.tests.length > 0) {
|
|
971
|
+
walk.problems.push(`Line ${command.line}: else takes no condition.`);
|
|
972
|
+
}
|
|
973
|
+
if (name !== 'else' && command.tests.length !== 1) {
|
|
974
|
+
walk.problems.push(`Line ${command.line}: ${name} needs exactly one condition.`);
|
|
975
|
+
}
|
|
976
|
+
if (!command.block)
|
|
977
|
+
walk.problems.push(`Line ${command.line}: ${name} needs a block in braces.`);
|
|
978
|
+
walkTests(command.tests, walk);
|
|
979
|
+
if (name === 'if')
|
|
980
|
+
chainSettled = false;
|
|
981
|
+
const verdict = name === 'else' ? true : command.tests[0] ? constant(command.tests[0]) : undefined;
|
|
982
|
+
const runs = live && !chainSettled && verdict !== false;
|
|
983
|
+
walkCommands(command.block ?? [], walk, runs);
|
|
984
|
+
// A branch that always runs leaves the rest of the chain unreachable.
|
|
985
|
+
if (verdict === true)
|
|
986
|
+
chainSettled = true;
|
|
987
|
+
// A rule that always runs and returns ends the script for every message,
|
|
988
|
+
// and one that always stops ends every filter.
|
|
989
|
+
if (runs &&
|
|
990
|
+
verdict === true &&
|
|
991
|
+
name === 'if' &&
|
|
992
|
+
(command.block ?? []).some((inner) => inner.name === 'return' || inner.name === 'stop')) {
|
|
993
|
+
live = false;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
else if (REFUSED_COMMANDS[name] && COMMAND_UNLOCKS[name] && walk.allowed.has(COMMAND_UNLOCKS[name])) {
|
|
997
|
+
if (command.block)
|
|
998
|
+
walk.problems.push(`Line ${command.line}: ${name} takes no block.`);
|
|
999
|
+
if (command.tests.length > 0)
|
|
1000
|
+
walk.problems.push(`Line ${command.line}: ${name} takes no condition.`);
|
|
1001
|
+
checkUnlocked(command, walk, live);
|
|
1002
|
+
// stop ends the filters, so nothing after it runs.
|
|
1003
|
+
if (name === 'stop')
|
|
1004
|
+
live = false;
|
|
1005
|
+
}
|
|
1006
|
+
else if (REFUSED_COMMANDS[name]) {
|
|
1007
|
+
walk.problems.push(`Line ${command.line}: ${name} is refused: ${REFUSED_COMMANDS[name]}.` +
|
|
1008
|
+
(COMMAND_UNLOCKS[name] ? ` ${ALLOW_HINT}` : " Only the user can add it, in Proton's settings."));
|
|
1009
|
+
addRefused(walk, name);
|
|
1010
|
+
}
|
|
1011
|
+
else if (!ACTIONS.has(name)) {
|
|
1012
|
+
walk.problems.push(`Line ${command.line}: ${name} is not a command a proposed filter may use.`);
|
|
1013
|
+
addRefused(walk, `the unknown command ${name}`);
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
if (command.block)
|
|
1017
|
+
walk.problems.push(`Line ${command.line}: ${name} takes no block.`);
|
|
1018
|
+
if (command.tests.length > 0)
|
|
1019
|
+
walk.problems.push(`Line ${command.line}: ${name} takes no condition.`);
|
|
1020
|
+
checkStrings(command.args, command.line, walk);
|
|
1021
|
+
checkAction(command, walk, live);
|
|
1022
|
+
if (name === 'return')
|
|
1023
|
+
live = false;
|
|
1024
|
+
}
|
|
1025
|
+
previous = name;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
function checkAction(command, walk, live) {
|
|
1029
|
+
switch (command.name) {
|
|
1030
|
+
case 'fileinto': {
|
|
1031
|
+
const values = namedStrings(command, walk, 'folder or label');
|
|
1032
|
+
if (!values)
|
|
1033
|
+
return;
|
|
1034
|
+
if (values.length !== 1) {
|
|
1035
|
+
walk.problems.push(`Line ${command.line}: fileinto takes one folder or label at a time.`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const target = values[0];
|
|
1039
|
+
const segments = folderSegments(target);
|
|
1040
|
+
const hidden = segments.find((segment) => HIDDEN_SEGMENTS.has(segment));
|
|
1041
|
+
if (hidden && walk.allowed.has('trash')) {
|
|
1042
|
+
if (!live)
|
|
1043
|
+
return;
|
|
1044
|
+
walk.acts = true;
|
|
1045
|
+
const place = hidden === 'trash' ? 'Trash' : 'Spam';
|
|
1046
|
+
// Named as what it may well be, with the name as written.
|
|
1047
|
+
addEffect(walk, `moves it to ${place}${target === place ? '' : `, written ${JSON.stringify(target)}`}`);
|
|
1048
|
+
addUnlocked(walk, 'trash', `filing into ${place}`);
|
|
1049
|
+
walk.movesAway = true;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (hidden) {
|
|
1053
|
+
walk.problems.push(`Line ${command.line}: filing into ${JSON.stringify(target)} is refused: it names ${hidden === 'trash' ? 'Trash' : 'Spam'}, which hides mail. ` +
|
|
1054
|
+
ALLOW_HINT);
|
|
1055
|
+
addRefused(walk, hidden === 'trash' ? 'filing into Trash' : 'filing into Spam');
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (/^\d+$/.test(segments.join(''))) {
|
|
1059
|
+
walk.problems.push(`Line ${command.line}: filing into ${JSON.stringify(target)} is refused: Proton numbers its own folders, 3 is Trash ` +
|
|
1060
|
+
'and 4 is Spam, so a folder given as a number is not taken.');
|
|
1061
|
+
addRefused(walk, 'filing into a folder given as a number');
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (segments.length === 0) {
|
|
1065
|
+
walk.problems.push(`Line ${command.line}: fileinto names no folder.`);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (!live)
|
|
1069
|
+
return;
|
|
1070
|
+
walk.acts = true;
|
|
1071
|
+
if (segments.length === 1 && segments[0] === 'inbox') {
|
|
1072
|
+
addEffect(walk, 'moves it into the inbox');
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
// Labels and folders cannot be told apart here: both are filed with
|
|
1076
|
+
// fileinto, and only the account knows which name is which.
|
|
1077
|
+
addEffect(walk, `files it into ${JSON.stringify(target)}`);
|
|
1078
|
+
walk.movesAway = true;
|
|
1079
|
+
}
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
case 'addflag':
|
|
1083
|
+
case 'removeflag':
|
|
1084
|
+
case 'setflag': {
|
|
1085
|
+
const values = namedStrings(command, walk, 'flag');
|
|
1086
|
+
if (!values)
|
|
1087
|
+
return;
|
|
1088
|
+
const flags = [];
|
|
1089
|
+
// RFC 5232 allows several flags in one string, separated by spaces.
|
|
1090
|
+
for (const value of values.flatMap((v) => v.split(/\s+/)).filter(Boolean)) {
|
|
1091
|
+
const flag = FLAGS.get(value.toLowerCase());
|
|
1092
|
+
if (!flag) {
|
|
1093
|
+
walk.problems.push(`Line ${command.line}: "${value}" is not a flag Proton knows. Only \\Seen and \\Flagged are.`);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
flags.push(flag);
|
|
1097
|
+
}
|
|
1098
|
+
if (flags.length === 0 && command.name !== 'setflag') {
|
|
1099
|
+
walk.problems.push(`Line ${command.line}: ${command.name} names no flag.`);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (!live)
|
|
1103
|
+
return;
|
|
1104
|
+
walk.acts = true;
|
|
1105
|
+
const seen = flags.includes('\\Seen');
|
|
1106
|
+
const flagged = flags.includes('\\Flagged');
|
|
1107
|
+
if (command.name === 'removeflag') {
|
|
1108
|
+
if (seen)
|
|
1109
|
+
addEffect(walk, 'marks it as unread');
|
|
1110
|
+
if (flagged)
|
|
1111
|
+
addEffect(walk, 'removes its star');
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
if (seen) {
|
|
1115
|
+
addEffect(walk, 'marks it as read');
|
|
1116
|
+
walk.marksRead = true;
|
|
1117
|
+
}
|
|
1118
|
+
if (flagged)
|
|
1119
|
+
addEffect(walk, 'stars it');
|
|
1120
|
+
if (command.name === 'setflag') {
|
|
1121
|
+
if (!seen)
|
|
1122
|
+
addEffect(walk, 'marks it as unread');
|
|
1123
|
+
if (!flagged)
|
|
1124
|
+
addEffect(walk, 'removes its star');
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
case 'keep':
|
|
1130
|
+
case 'return':
|
|
1131
|
+
if (command.args.length > 0)
|
|
1132
|
+
walk.problems.push(`Line ${command.line}: ${command.name} takes no arguments.`);
|
|
1133
|
+
return;
|
|
1134
|
+
case 'set': {
|
|
1135
|
+
// set [modifiers] "name" "value". Variables change nothing on their own;
|
|
1136
|
+
// the actions that could use one to name a target refuse variables.
|
|
1137
|
+
checkTags(command.args, command.line, walk, SET_MODIFIERS);
|
|
1138
|
+
const strings = command.args.filter((arg) => arg.kind === 'strings');
|
|
1139
|
+
const misplaced = command.args.findIndex((arg) => arg.kind === 'strings') < command.args.findLastIndex((arg) => arg.kind === 'tag');
|
|
1140
|
+
if (strings.length !== 2 ||
|
|
1141
|
+
strings.some((arg) => arg.list) ||
|
|
1142
|
+
command.args.some((arg) => arg.kind === 'number') ||
|
|
1143
|
+
misplaced) {
|
|
1144
|
+
walk.problems.push(`Line ${command.line}: set takes its modifiers, then a name and a value, each one string.`);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(strings[0].values[0])) {
|
|
1148
|
+
walk.problems.push(`Line ${command.line}: "${strings[0].values[0]}" is not a variable name.`);
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Checks a proposed script against everything above.
|
|
1156
|
+
*
|
|
1157
|
+
* Every problem is listed, not just the first, so an assistant can correct
|
|
1158
|
+
* the script in one go rather than one refusal at a time.
|
|
1159
|
+
*/
|
|
1160
|
+
export function reviewSieve(source, allowed = NO_UNLOCKS) {
|
|
1161
|
+
const problems = [];
|
|
1162
|
+
if (source.length > MAX_SCRIPT_LENGTH) {
|
|
1163
|
+
return {
|
|
1164
|
+
problems: [`The script is longer than ${MAX_SCRIPT_LENGTH} characters, too long to be read before it is confirmed.`],
|
|
1165
|
+
effects: [],
|
|
1166
|
+
warnings: [],
|
|
1167
|
+
refused: ['a script too long to be checked here'],
|
|
1168
|
+
unlocked: [],
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
const hidden = describeCharacter(source, INVISIBLE);
|
|
1172
|
+
if (hidden) {
|
|
1173
|
+
problems.push(`Line ${hidden.line}: the script contains an invisible character (${hidden.code}). ` +
|
|
1174
|
+
'It can make the script read differently from what it does, so it is refused.');
|
|
1175
|
+
}
|
|
1176
|
+
const control = describeCharacter(source, CONTROL);
|
|
1177
|
+
if (control)
|
|
1178
|
+
problems.push(`Line ${control.line}: the script contains a control character (${control.code}).`);
|
|
1179
|
+
const lone = describeCharacter(source, LONE_CR);
|
|
1180
|
+
if (lone)
|
|
1181
|
+
problems.push(`Line ${lone.line}: a carriage return stands without a line feed, which readers split differently.`);
|
|
1182
|
+
if (problems.length > 0) {
|
|
1183
|
+
return { problems, effects: [], warnings: [], refused: ['characters that hide what the script does'], unlocked: [] };
|
|
1184
|
+
}
|
|
1185
|
+
let commands;
|
|
1186
|
+
let comments;
|
|
1187
|
+
try {
|
|
1188
|
+
const read = tokenize(source);
|
|
1189
|
+
comments = read.comments;
|
|
1190
|
+
commands = new Parser(read.tokens).parse();
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
if (error instanceof SieveSyntaxError) {
|
|
1194
|
+
return {
|
|
1195
|
+
problems: [`Line ${error.line}: ${error.message}`],
|
|
1196
|
+
effects: [],
|
|
1197
|
+
warnings: [],
|
|
1198
|
+
refused: ['a script this check cannot read'],
|
|
1199
|
+
unlocked: [],
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
// Two comments are Proton's own: the line above the guard and the one its
|
|
1205
|
+
// filter editor writes. Any other can make the script read as something
|
|
1206
|
+
// else, and comments are where parsers part ways.
|
|
1207
|
+
for (const comment of comments) {
|
|
1208
|
+
if (comment.kind === 'hash' && comment.text.trim() === GUARD_COMMENT)
|
|
1209
|
+
continue;
|
|
1210
|
+
if (comment.kind === 'bracket' && EDITOR_COMMENT.test(comment.text))
|
|
1211
|
+
continue;
|
|
1212
|
+
problems.push(`Line ${comment.line}: comments are not taken in a proposed filter, except Proton's own two: the line above ` +
|
|
1213
|
+
"the spam guard and the one Proton's filter editor writes above a rule. A comment can make the script read " +
|
|
1214
|
+
'differently in the confirmation from what it does.');
|
|
1215
|
+
}
|
|
1216
|
+
// The require lines come first, as Sieve demands.
|
|
1217
|
+
const required = new Set();
|
|
1218
|
+
const refusedExtensions = [];
|
|
1219
|
+
let index = 0;
|
|
1220
|
+
while (commands[index]?.name === 'require') {
|
|
1221
|
+
const command = commands[index];
|
|
1222
|
+
const lists = command.args.filter((arg) => arg.kind === 'strings');
|
|
1223
|
+
if (lists.length !== 1 || command.args.length !== 1 || command.tests.length > 0 || command.block) {
|
|
1224
|
+
problems.push(`Line ${command.line}: require takes one name or one list of names.`);
|
|
1225
|
+
}
|
|
1226
|
+
for (const list of lists) {
|
|
1227
|
+
for (const [position, value] of list.values.entries()) {
|
|
1228
|
+
const extension = value.toLowerCase();
|
|
1229
|
+
const verdict = EXTENSIONS[extension];
|
|
1230
|
+
if (verdict === undefined || list.raws[position] !== value || list.multiLine) {
|
|
1231
|
+
problems.push(`Line ${command.line}: the extension "${value}" is not one a proposed filter may use.`);
|
|
1232
|
+
}
|
|
1233
|
+
else if (verdict !== true && !(EXTENSION_UNLOCKS[extension] && allowed.has(EXTENSION_UNLOCKS[extension]))) {
|
|
1234
|
+
problems.push(`Line ${command.line}: the extension "${value}" is refused: ${verdict}. ` +
|
|
1235
|
+
(EXTENSION_UNLOCKS[extension] ? ALLOW_HINT : "Only the user can add it, in Proton's settings."));
|
|
1236
|
+
refusedExtensions.push(extension === 'vnd.proton.expire'
|
|
1237
|
+
? 'expire'
|
|
1238
|
+
: extension === 'regex'
|
|
1239
|
+
? 'regular expressions'
|
|
1240
|
+
: extension === 'vnd.proton.eval'
|
|
1241
|
+
? 'arithmetic'
|
|
1242
|
+
: extension);
|
|
1243
|
+
}
|
|
1244
|
+
required.add(extension);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
index++;
|
|
1248
|
+
}
|
|
1249
|
+
const guard = commands[index];
|
|
1250
|
+
const after = commands[index + 1];
|
|
1251
|
+
if (!guard || !isSpamGuard(guard) || after?.name === 'elsif' || after?.name === 'else') {
|
|
1252
|
+
problems.push("Proton's spam guard has to come right after require, unchanged and written without escapes, so the " +
|
|
1253
|
+
'filter never runs on spam. Start the script with this, and put your rules below it:\n\n' +
|
|
1254
|
+
SPAM_GUARD_PROLOGUE);
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
const missing = GUARD_EXTENSIONS.filter((extension) => !required.has(extension));
|
|
1258
|
+
if (missing.length > 0) {
|
|
1259
|
+
problems.push(`The spam guard needs these in require as well: ${missing.map((m) => `"${m}"`).join(', ')}.`);
|
|
1260
|
+
}
|
|
1261
|
+
index++;
|
|
1262
|
+
}
|
|
1263
|
+
const walk = {
|
|
1264
|
+
problems,
|
|
1265
|
+
effects: [],
|
|
1266
|
+
refused: [],
|
|
1267
|
+
marksRead: false,
|
|
1268
|
+
movesAway: false,
|
|
1269
|
+
acts: false,
|
|
1270
|
+
allowed,
|
|
1271
|
+
unlocked: [],
|
|
1272
|
+
uses: new Set(),
|
|
1273
|
+
};
|
|
1274
|
+
for (const extension of refusedExtensions)
|
|
1275
|
+
addRefused(walk, extension);
|
|
1276
|
+
walkCommands(commands.slice(index), walk, true);
|
|
1277
|
+
if (problems.length === 0 && !walk.acts) {
|
|
1278
|
+
problems.push('The script does nothing to a message beyond the spam guard: no rule in it can ever act. A filter that ' +
|
|
1279
|
+
'does nothing is how a rule is switched off, and that is left to the user.');
|
|
1280
|
+
}
|
|
1281
|
+
const warnings = [];
|
|
1282
|
+
if (walk.marksRead && walk.movesAway) {
|
|
1283
|
+
warnings.push('Matching mail is marked as read and moved out of the inbox, so it can arrive without you ever noticing it.');
|
|
1284
|
+
}
|
|
1285
|
+
else if (walk.marksRead) {
|
|
1286
|
+
warnings.push('Matching mail is marked as read, so it does not show up as new.');
|
|
1287
|
+
}
|
|
1288
|
+
// Each allowed action that can run is named once more, with what it costs.
|
|
1289
|
+
for (const action of UNLOCKABLE) {
|
|
1290
|
+
if (walk.uses.has(action))
|
|
1291
|
+
warnings.push(UNLOCKABLE_TEXT[action].risk);
|
|
1292
|
+
}
|
|
1293
|
+
if (walk.uses.has('vacation')) {
|
|
1294
|
+
warnings.push('Proton sends automatic answers only on paid plans, and none to mail in Spam.');
|
|
1295
|
+
}
|
|
1296
|
+
return { problems, effects: walk.effects, warnings, refused: walk.refused, unlocked: walk.unlocked };
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Whether two scripts are the same filter as far as a person could tell:
|
|
1300
|
+
* the same characters, with line ends and trailing spaces evened out. Proton
|
|
1301
|
+
* may store a script with other line ends than it was sent with.
|
|
1302
|
+
*/
|
|
1303
|
+
export function sameScript(a, b) {
|
|
1304
|
+
const even = (script) => script
|
|
1305
|
+
.replace(/\r\n?/g, '\n')
|
|
1306
|
+
.split('\n')
|
|
1307
|
+
.map((line) => line.replace(/[ \t]+$/, ''))
|
|
1308
|
+
.join('\n')
|
|
1309
|
+
.trimEnd();
|
|
1310
|
+
return even(a) === even(b);
|
|
1311
|
+
}
|
|
1312
|
+
//# sourceMappingURL=sieve.js.map
|