@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.
Files changed (95) hide show
  1. package/README.md +138 -21
  2. package/dist/browser/channel.d.ts +76 -0
  3. package/dist/browser/channel.js +162 -0
  4. package/dist/browser/channel.js.map +1 -0
  5. package/dist/browser/client.d.ts +50 -0
  6. package/dist/browser/client.js +284 -0
  7. package/dist/browser/client.js.map +1 -0
  8. package/dist/browser/connector.d.ts +15 -0
  9. package/dist/browser/connector.js +26 -0
  10. package/dist/browser/connector.js.map +1 -0
  11. package/dist/browser/host.d.ts +50 -0
  12. package/dist/browser/host.js +258 -0
  13. package/dist/browser/host.js.map +1 -0
  14. package/dist/browser/messages.d.ts +163 -0
  15. package/dist/browser/messages.js +338 -0
  16. package/dist/browser/messages.js.map +1 -0
  17. package/dist/browser/paths.d.ts +51 -0
  18. package/dist/browser/paths.js +92 -0
  19. package/dist/browser/paths.js.map +1 -0
  20. package/dist/browser/setup.d.ts +42 -0
  21. package/dist/browser/setup.js +170 -0
  22. package/dist/browser/setup.js.map +1 -0
  23. package/dist/filters/order.d.ts +66 -0
  24. package/dist/filters/order.js +108 -0
  25. package/dist/filters/order.js.map +1 -0
  26. package/dist/filters/sieve.d.ts +184 -0
  27. package/dist/filters/sieve.js +1312 -0
  28. package/dist/filters/sieve.js.map +1 -0
  29. package/dist/instances.d.ts +58 -0
  30. package/dist/instances.js +145 -0
  31. package/dist/instances.js.map +1 -0
  32. package/dist/mail/compose.d.ts +10 -0
  33. package/dist/mail/compose.js.map +1 -1
  34. package/dist/mail/record.d.ts +36 -0
  35. package/dist/mail/record.js +74 -0
  36. package/dist/mail/record.js.map +1 -0
  37. package/dist/permissions.d.ts +139 -0
  38. package/dist/permissions.js +355 -0
  39. package/dist/permissions.js.map +1 -0
  40. package/dist/server.js +330 -16
  41. package/dist/server.js.map +1 -1
  42. package/dist/session.d.ts +12 -0
  43. package/dist/session.js +23 -0
  44. package/dist/session.js.map +1 -1
  45. package/dist/settings.d.ts +81 -11
  46. package/dist/settings.js +160 -25
  47. package/dist/settings.js.map +1 -1
  48. package/dist/tools/confirm.d.ts +4 -2
  49. package/dist/tools/confirm.js +12 -3
  50. package/dist/tools/confirm.js.map +1 -1
  51. package/dist/tools/failures.d.ts +6 -8
  52. package/dist/tools/failures.js +36 -17
  53. package/dist/tools/failures.js.map +1 -1
  54. package/dist/tools/filter-tools.d.ts +82 -0
  55. package/dist/tools/filter-tools.js +729 -0
  56. package/dist/tools/filter-tools.js.map +1 -0
  57. package/dist/tools/filters.d.ts +8 -1
  58. package/dist/tools/filters.js +24 -12
  59. package/dist/tools/filters.js.map +1 -1
  60. package/dist/tools/format.d.ts +15 -0
  61. package/dist/tools/format.js +4 -3
  62. package/dist/tools/format.js.map +1 -1
  63. package/dist/tools/guidance.js +12 -7
  64. package/dist/tools/guidance.js.map +1 -1
  65. package/dist/tools/interface.d.ts +20 -6
  66. package/dist/tools/interface.js +40 -16
  67. package/dist/tools/interface.js.map +1 -1
  68. package/dist/tools/send.d.ts +9 -0
  69. package/dist/tools/send.js +51 -9
  70. package/dist/tools/send.js.map +1 -1
  71. package/dist/tools/sign-in-required.d.ts +15 -2
  72. package/dist/tools/sign-in-required.js +30 -2
  73. package/dist/tools/sign-in-required.js.map +1 -1
  74. package/dist/web/layout.d.ts +12 -5
  75. package/dist/web/layout.js +32 -9
  76. package/dist/web/layout.js.map +1 -1
  77. package/dist/web/pages.d.ts +4 -2
  78. package/dist/web/pages.js +10 -9
  79. package/dist/web/pages.js.map +1 -1
  80. package/dist/web/pending.d.ts +9 -6
  81. package/dist/web/pending.js +9 -7
  82. package/dist/web/pending.js.map +1 -1
  83. package/dist/web/preview-server.d.ts +33 -0
  84. package/dist/web/preview-server.js +102 -0
  85. package/dist/web/preview-server.js.map +1 -0
  86. package/dist/web/sections.d.ts +86 -1
  87. package/dist/web/sections.js +290 -7
  88. package/dist/web/sections.js.map +1 -1
  89. package/dist/web/security.d.ts +48 -13
  90. package/dist/web/security.js +68 -16
  91. package/dist/web/security.js.map +1 -1
  92. package/dist/web/server.d.ts +81 -24
  93. package/dist/web/server.js +478 -117
  94. package/dist/web/server.js.map +1 -1
  95. package/package.json +1 -1
@@ -11,13 +11,13 @@
11
11
  * for what each one defends against.
12
12
  */
13
13
  var _a;
14
- import { createServer } from 'node:http';
14
+ import { createServer, request as httpRequest, } from 'node:http';
15
15
  import { STORE_DESCRIPTIONS } from '../credentials/store.js';
16
16
  import { listStores, recommend, recommendationReason } from '../credentials/availability.js';
17
- import { createSession, checkRequest, csrfToken, securityHeaders } from './security.js';
17
+ import { createSession, checkRequest, csrfToken, securityHeaders, hashPin, pinMatches, pinProblem, AttemptLimiter, } from './security.js';
18
18
  import { loginPage, rejectionPage, unlockPage } from './pages.js';
19
- import { overviewSection, bridgeSection, credentialsSection, activitySection, mailboxSection, } from './sections.js';
20
- import { pendingPage, noPendingPage } from './pending.js';
19
+ import { overviewSection, bridgeSection, credentialsSection, permissionsSection, activitySection, mailboxSection, } from './sections.js';
20
+ import { UNLOCKABLE } from '../filters/sieve.js';
21
21
  /**
22
22
  * Upper bound for a request body.
23
23
  *
@@ -39,26 +39,71 @@ const SECTION_PATHS = {
39
39
  '/mailboxes': 'mailboxes',
40
40
  '/bridge': 'bridge',
41
41
  '/credentials': 'credentials',
42
+ '/permissions': 'permissions',
42
43
  '/activity': 'activity',
43
44
  };
45
+ /**
46
+ * Where an answer to each form lands, so a wrong PIN is reported on the page the
47
+ * user was on. Anything not listed lands on the overview.
48
+ */
49
+ const FORM_SECTIONS = {
50
+ '/settings': 'bridge',
51
+ '/test-connection': 'bridge',
52
+ '/sign-out': 'credentials',
53
+ '/pin': 'permissions',
54
+ '/permissions/levels': 'permissions',
55
+ '/permissions/limits': 'permissions',
56
+ '/permissions/record': 'permissions',
57
+ '/permissions/filter-actions': 'permissions',
58
+ };
59
+ /**
60
+ * Posts that change nothing worth a PIN. Testing the connection only reads, and
61
+ * the notice about not being Proton's software only hides a paragraph.
62
+ */
63
+ const WITHOUT_PIN = new Set(['/test-connection', '/dismiss-notice']);
64
+ /**
65
+ * What `/health` answers, so a server that finds the port taken can tell a
66
+ * running proton-mcp from some other program. Says nothing else on purpose.
67
+ */
68
+ const HEALTH_BODY = JSON.stringify({ app: 'proton-mcp' });
44
69
  /** The same names as a set, for checking a section that arrived in a form. */
45
70
  const SECTIONS = new Set(Object.values(SECTION_PATHS));
46
71
  /**
47
- * The port tried first.
72
+ * The one address of the interface, shared by every running server.
48
73
  *
49
- * A fixed default rather than a random one, because the interface runs for as
50
- * long as the server does and is meant to be opened again and again. A port that
51
- * moved on every start would make that needlessly awkward.
74
+ * Fixed rather than random, and since 2026-11-05 also not walked upwards when
75
+ * taken: every client session starts its own server, and the interface they
76
+ * share has to be at the same address for all of them. The first server to
77
+ * start serves it; the others take over when it stops.
52
78
  */
53
79
  export const DEFAULT_WEB_PORT = 7345;
80
+ /** How often a server that does not serve the interface tries to take it over. */
81
+ export const TAKEOVER_INTERVAL_MS = 10_000;
82
+ /**
83
+ * How much of an answer the probe reads: the health answer of a current
84
+ * proton-mcp, and the head of an older one's page, whose title comes within
85
+ * the first 300 bytes.
86
+ */
87
+ const PROBE_BYTES = 1024;
54
88
  /**
55
- * How many consecutive ports to try.
89
+ * Names the occupant from the start of its answer to /health.
56
90
  *
57
- * Several clients may each start their own server, and the second one must not
58
- * fail over a port the first one took. Failing outright would be worse than
59
- * moving up by one.
91
+ * A current proton-mcp answers with a small JSON object. An older one has no
92
+ * such address and answers every request without its token with a page of its
93
+ * own, titled "Not available - proton-mcp": measured with 0.4.13, and the
94
+ * title has had this form since 0.3.0. Anything else is another program.
60
95
  */
61
- export const PORT_ATTEMPTS = 10;
96
+ function classifyOccupant(start) {
97
+ try {
98
+ const body = JSON.parse(start);
99
+ if (body.app === 'proton-mcp')
100
+ return 'proton-mcp';
101
+ }
102
+ catch {
103
+ // Not JSON, so not a current proton-mcp. Perhaps an older one.
104
+ }
105
+ return /<title>[^<]* - proton-mcp<\/title>/.test(start) ? 'older' : 'other';
106
+ }
62
107
  /**
63
108
  * Reads a form body, refusing anything oversized.
64
109
  *
@@ -105,88 +150,176 @@ export class WebInterface {
105
150
  #server;
106
151
  #secrets = createSession();
107
152
  #port = 0;
153
+ /** Who held the port when this server last found it taken. */
154
+ #occupant = 'none';
155
+ #takeover;
156
+ #limiter = new AttemptLimiter();
108
157
  #options;
109
158
  #notify;
110
159
  constructor(options) {
111
160
  this.#options = options;
112
161
  this.#notify = options.notify ?? ((m) => process.stderr.write(`proton-mcp: ${m}\n`));
113
162
  }
114
- /** The URL the user has to open, including the access token. */
163
+ /**
164
+ * The address of the interface. The same for every running server, whichever
165
+ * of them serves it, and nothing in it is secret.
166
+ */
115
167
  get url() {
116
- return `http://${BIND_ADDRESS}:${this.#port}/?token=${this.#secrets.accessToken}`;
168
+ const port = this.#port || this.#options.port || DEFAULT_WEB_PORT;
169
+ return `http://${BIND_ADDRESS}:${port}/`;
117
170
  }
118
171
  get port() {
119
172
  return this.#port;
120
173
  }
174
+ /** True while this server serves the interface itself. */
121
175
  get running() {
122
176
  return this.#server?.listening === true;
123
177
  }
124
- async start() {
125
- if (this.#server?.listening)
126
- return this.url;
127
- const build = () => {
128
- const created = createServer((request, response) => {
129
- void this.#handle(request, response).catch(() => {
130
- // A handler that throws must not take the process down, and must not
131
- // leak anything either.
132
- this.#send(response, 500, 'text/plain; charset=utf-8', 'Internal error.');
133
- });
178
+ /**
179
+ * Whether the interface can be reached at `url`, from this server or from
180
+ * another running proton-mcp. False when something else holds the port.
181
+ */
182
+ get reachable() {
183
+ return this.running || this.#occupant === 'proton-mcp';
184
+ }
185
+ /** Who held the port when this server last found it taken. */
186
+ get occupant() {
187
+ return this.running ? 'none' : this.#occupant;
188
+ }
189
+ #build() {
190
+ const created = createServer((request, response) => {
191
+ void this.#handle(request, response).catch(() => {
192
+ // A handler that throws must not take the process down, and must not
193
+ // leak anything either.
194
+ this.#send(response, 500, 'text/plain; charset=utf-8', 'Internal error.');
134
195
  });
135
- // Slow clients must not be able to tie up the server indefinitely.
136
- created.headersTimeout = 10_000;
137
- created.requestTimeout = 30_000;
138
- return created;
139
- };
140
- const wanted = this.#options.port ?? DEFAULT_WEB_PORT;
141
- // Port 0 means "any free port" and needs no retry loop.
142
- const attempts = wanted === 0 ? 1 : PORT_ATTEMPTS;
143
- let server;
144
- let lastError;
145
- for (let i = 0; i < attempts; i++) {
146
- const candidate = wanted === 0 ? 0 : wanted + i;
147
- // A fresh instance per attempt: after a failed listen() the old one is not
148
- // reliably reusable.
149
- const attempt = build();
150
- try {
151
- await new Promise((resolve, reject) => {
152
- const onError = (error) => reject(error);
153
- attempt.once('error', onError);
154
- // Explicitly loopback: binding to 0.0.0.0 would put a page that stores
155
- // credentials onto the network.
156
- attempt.listen(candidate, BIND_ADDRESS, () => {
157
- attempt.removeListener('error', onError);
158
- resolve();
159
- });
196
+ });
197
+ // Slow clients must not be able to tie up the server indefinitely.
198
+ created.headersTimeout = 10_000;
199
+ created.requestTimeout = 30_000;
200
+ return created;
201
+ }
202
+ /** One attempt at the port. Resolves with the error instead of throwing. */
203
+ async #listen(port) {
204
+ const attempt = this.#build();
205
+ try {
206
+ await new Promise((resolve, reject) => {
207
+ const onError = (error) => reject(error);
208
+ attempt.once('error', onError);
209
+ // Explicitly loopback: binding to 0.0.0.0 would put a page that stores
210
+ // credentials onto the network.
211
+ attempt.listen(port, BIND_ADDRESS, () => {
212
+ attempt.removeListener('error', onError);
213
+ resolve();
160
214
  });
161
- server = attempt;
162
- lastError = undefined;
163
- break;
164
- }
165
- catch (error) {
166
- attempt.close();
167
- lastError = error;
168
- if (error.code !== 'EADDRINUSE')
169
- break;
170
- // Taken, most likely by another instance of this server. Try the next.
171
- }
215
+ });
172
216
  }
173
- if (!server || lastError) {
174
- const code = lastError?.code;
175
- throw new Error(code === 'EADDRINUSE'
176
- ? `Ports ${wanted} to ${wanted + attempts - 1} are all in use, so the web interface ` +
177
- 'could not start. Set PROTON_MCP_WEB_PORT to a free port.'
178
- : `The web interface could not start: ${lastError?.message ?? 'unknown reason'}`);
217
+ catch (error) {
218
+ attempt.close();
219
+ return error;
179
220
  }
180
- const address = server.address();
221
+ const address = attempt.address();
181
222
  this.#port = typeof address === 'object' && address ? address.port : 0;
182
- this.#server = server;
223
+ this.#server = attempt;
224
+ this.#occupant = 'none';
225
+ this.#options.onServingChange?.(true);
226
+ return undefined;
227
+ }
228
+ /**
229
+ * Asks whoever holds the port whether it is a proton-mcp.
230
+ *
231
+ * The answer decides between "another session serves the interface, all is
232
+ * well" and "something else sits on this port", which the user has to hear
233
+ * about, because then nobody serves the interface at all. An older proton-mcp
234
+ * is the third answer: no interface either, but the remedy is to restart the
235
+ * sessions that still run it, not to move every server to another port.
236
+ */
237
+ static async probe(port, timeoutMs = 1500) {
238
+ return new Promise((resolve) => {
239
+ const request = httpRequest({ host: BIND_ADDRESS, port, path: '/health', method: 'GET', timeout: timeoutMs }, (response) => {
240
+ // The start of the answer, however long the whole is. Collecting
241
+ // only answers that were short in total missed an older proton-mcp,
242
+ // whose page runs to a few kilobytes and arrives in one piece.
243
+ const chunks = [];
244
+ let kept = 0;
245
+ response.on('data', (chunk) => {
246
+ if (kept >= PROBE_BYTES)
247
+ return;
248
+ const part = chunk.subarray(0, PROBE_BYTES - kept);
249
+ chunks.push(part);
250
+ kept += part.length;
251
+ });
252
+ response.on('end', () => resolve(classifyOccupant(Buffer.concat(chunks).toString('utf8'))));
253
+ });
254
+ request.on('timeout', () => request.destroy());
255
+ request.on('error', (error) => {
256
+ resolve(error.code === 'ECONNREFUSED' ? 'none' : 'other');
257
+ });
258
+ request.end();
259
+ });
260
+ }
261
+ /**
262
+ * Serves the interface, or leaves it to the server that already does.
263
+ *
264
+ * Never throws over a taken port. Another proton-mcp on it is the ordinary
265
+ * case with several sessions; this server then tries every few seconds to
266
+ * take over, so the interface survives the session that started it. Anything
267
+ * else on the port is reported once, and taken over too if it ever leaves.
268
+ */
269
+ async start() {
270
+ if (this.running)
271
+ return this.url;
272
+ const wanted = this.#options.port ?? DEFAULT_WEB_PORT;
273
+ const failure = await this.#listen(wanted);
274
+ if (!failure)
275
+ return this.url;
276
+ if (failure.code !== 'EADDRINUSE' || wanted === 0) {
277
+ throw new Error(`The web interface could not start: ${failure.message}`);
278
+ }
279
+ this.#occupant = await _a.probe(wanted);
280
+ if (this.#occupant === 'other') {
281
+ this.#notify(`port ${wanted} is held by a program that is not proton-mcp, so the web interface is not ` +
282
+ 'available. Set PROTON_MCP_WEB_PORT to a free port for every proton-mcp, or stop that ' +
283
+ 'program. This server takes the port as soon as it is free.');
284
+ }
285
+ else if (this.#occupant === 'older') {
286
+ this.#notify(`port ${wanted} is held by an older proton-mcp, from a session started before the update. ` +
287
+ 'It serves an interface of its own and knows nothing of the shared one, so the web ' +
288
+ 'interface is not available until that session ends. This server takes the port over ' +
289
+ 'then; restarting the sessions that still run the older version makes that happen now.');
290
+ }
291
+ this.#scheduleTakeover(wanted);
183
292
  return this.url;
184
293
  }
294
+ #scheduleTakeover(port) {
295
+ if (this.#takeover)
296
+ return;
297
+ this.#takeover = setInterval(() => {
298
+ void (async () => {
299
+ if (this.running)
300
+ return;
301
+ if (await this.#listen(port)) {
302
+ // Still taken. Find out by whom, so the status stays honest.
303
+ this.#occupant = await _a.probe(port);
304
+ return;
305
+ }
306
+ clearInterval(this.#takeover);
307
+ this.#takeover = undefined;
308
+ this.#notify(`took over the web interface at ${this.url}`);
309
+ })();
310
+ }, this.#options.takeoverIntervalMs ?? TAKEOVER_INTERVAL_MS);
311
+ // Waiting to take over must not keep the process alive on its own.
312
+ this.#takeover.unref();
313
+ }
185
314
  async stop() {
315
+ if (this.#takeover)
316
+ clearInterval(this.#takeover);
317
+ this.#takeover = undefined;
186
318
  const server = this.#server;
187
319
  this.#server = undefined;
188
320
  if (!server)
189
321
  return;
322
+ this.#options.onServingChange?.(false);
190
323
  // close() alone only stops accepting new connections and then waits for the
191
324
  // existing ones. A browser keeps its connection alive, so a single open tab
192
325
  // would hold the shutdown forever and the MCP server would never exit.
@@ -194,11 +327,6 @@ export class WebInterface {
194
327
  server.closeAllConnections();
195
328
  await new Promise((resolve) => server.close(() => resolve()));
196
329
  }
197
- /** Invalidates the current link and issues a new one. */
198
- rotateToken() {
199
- this.#secrets = createSession();
200
- return this.url;
201
- }
202
330
  #send(response, status, contentType, body) {
203
331
  response.writeHead(status, { ...securityHeaders(), 'Content-Type': contentType });
204
332
  response.end(body);
@@ -217,9 +345,9 @@ export class WebInterface {
217
345
  this.#reject(response, 405);
218
346
  return;
219
347
  }
220
- // Answered before the token check and without a log line. Browsers ask for
221
- // it on every page view, and each one would otherwise be recorded as a
222
- // refused request, which buries the refusals that mean something.
348
+ // Answered before the checks and without a log line. Browsers ask for it on
349
+ // every page view, and each one would otherwise be recorded as a refused
350
+ // request, which buries the refusals that mean something.
223
351
  if (path === '/favicon.ico') {
224
352
  response.writeHead(404, { ...securityHeaders(), 'Content-Length': '0' });
225
353
  response.end();
@@ -240,7 +368,6 @@ export class WebInterface {
240
368
  request,
241
369
  secrets: this.#secrets,
242
370
  port: this.#port,
243
- presentedToken: url.searchParams.get('token') ?? undefined,
244
371
  writing,
245
372
  ...(writing ? { csrf: { purpose, presented: body.fields.csrf } } : {}),
246
373
  });
@@ -252,19 +379,49 @@ export class WebInterface {
252
379
  return;
253
380
  }
254
381
  if (method === 'GET') {
382
+ if (path === '/health') {
383
+ response.writeHead(200, { ...securityHeaders(), 'Content-Type': 'application/json' });
384
+ response.end(HEALTH_BODY);
385
+ return;
386
+ }
255
387
  const section = SECTION_PATHS[path];
256
388
  if (section) {
257
389
  await this.#render(section, response);
258
390
  return;
259
391
  }
260
- // The message waiting for an answer. Its own path rather than a section,
261
- // because it exists only while a question is open and is reached from a
262
- // link in that question.
263
- if (path.startsWith('/pending/')) {
264
- await this.#renderPending(response, path.slice('/pending/'.length));
392
+ }
393
+ // The PIN, for every change. A wrong one is the user's mistake and gets a
394
+ // page that says so, unlike the refusals above, which say nothing.
395
+ if (writing && !WITHOUT_PIN.has(path)) {
396
+ const refusal = await this.#checkPin(body.fields.pin);
397
+ if (refusal) {
398
+ await this.#render(FORM_SECTIONS[path] ?? 'overview', response, {
399
+ error: refusal,
400
+ ...(path === '/sign-in' && body.fields.address ? { address: body.fields.address } : {}),
401
+ });
265
402
  return;
266
403
  }
267
404
  }
405
+ if (writing && path === '/pin') {
406
+ await this.#handlePin(response, body.fields);
407
+ return;
408
+ }
409
+ if (writing && path === '/permissions/levels') {
410
+ await this.#handleLevels(response, body.fields);
411
+ return;
412
+ }
413
+ if (writing && path === '/permissions/limits') {
414
+ await this.#handleLimits(response, body.fields);
415
+ return;
416
+ }
417
+ if (writing && path === '/permissions/record') {
418
+ await this.#handleRecord(response, body.fields);
419
+ return;
420
+ }
421
+ if (writing && path === '/permissions/filter-actions') {
422
+ await this.#handleFilterActions(response, body.fields);
423
+ return;
424
+ }
268
425
  if (writing && path === '/dismiss-notice') {
269
426
  await this.#handleDismissNotice(response, body.fields);
270
427
  return;
@@ -313,11 +470,11 @@ export class WebInterface {
313
470
  async #render(section, response, outcome = {}) {
314
471
  const { error, notice, address, store, showAdvanced } = outcome;
315
472
  const status = await this.#options.getStatus();
316
- const token = this.#secrets.accessToken;
473
+ const pinRequired = this.#options.getPin?.() !== undefined;
317
474
  const disclaimer = this.#disclaimerFor(section);
318
475
  if (!status.connected && status.locked) {
319
476
  const page = unlockPage({
320
- token,
477
+ pinRequired,
321
478
  disclaimer,
322
479
  csrf: csrfToken(this.#secrets, '/unlock'),
323
480
  csrfDiscard: csrfToken(this.#secrets, '/discard-encrypted'),
@@ -330,7 +487,7 @@ export class WebInterface {
330
487
  if (!status.connected) {
331
488
  const stores = await listStores();
332
489
  const page = loginPage({
333
- token,
490
+ pinRequired,
334
491
  disclaimer,
335
492
  csrf: csrfToken(this.#secrets, '/sign-in'),
336
493
  stores,
@@ -350,6 +507,7 @@ export class WebInterface {
350
507
  }
351
508
  const banners = {
352
509
  disclaimer,
510
+ pinRequired,
353
511
  ...(error ? { error } : {}),
354
512
  ...(notice ? { notice } : {}),
355
513
  };
@@ -359,11 +517,10 @@ export class WebInterface {
359
517
  let page;
360
518
  switch (section) {
361
519
  case 'mailboxes':
362
- page = await this.#renderMailboxes(token, banners);
520
+ page = await this.#renderMailboxes(banners);
363
521
  break;
364
522
  case 'bridge':
365
523
  page = bridgeSection({
366
- token,
367
524
  csrfSettings: csrfToken(this.#secrets, '/settings'),
368
525
  csrfTest: csrfToken(this.#secrets, '/test-connection'),
369
526
  bridgeHost: status.bridgeHost,
@@ -377,7 +534,6 @@ export class WebInterface {
377
534
  break;
378
535
  case 'credentials':
379
536
  page = credentialsSection({
380
- token,
381
537
  csrfSignOut: csrfToken(this.#secrets, '/sign-out'),
382
538
  ...(status.address ? { address: status.address } : {}),
383
539
  ...(status.storeKind ? { storeKind: status.storeKind } : {}),
@@ -385,15 +541,46 @@ export class WebInterface {
385
541
  ...banners,
386
542
  });
387
543
  break;
544
+ case 'permissions': {
545
+ const permissions = this.#options.getPermissions?.();
546
+ page = permissionsSection({
547
+ csrfPin: csrfToken(this.#secrets, '/pin'),
548
+ csrfLevels: csrfToken(this.#secrets, '/permissions/levels'),
549
+ csrfLimits: csrfToken(this.#secrets, '/permissions/limits'),
550
+ csrfRecord: csrfToken(this.#secrets, '/permissions/record'),
551
+ csrfFilterActions: csrfToken(this.#secrets, '/permissions/filter-actions'),
552
+ ...(permissions ? { tools: permissions.tools, limits: permissions.limits } : {}),
553
+ ...(this.#options.getRecord ? { record: this.#options.getRecord() } : {}),
554
+ ...(this.#options.getFilterActions ? { filterActions: this.#options.getFilterActions() } : {}),
555
+ ...banners,
556
+ });
557
+ break;
558
+ }
388
559
  case 'activity': {
389
560
  const activity = this.#options.getActivity?.() ?? { running: [], recent: [] };
390
- page = activitySection({ token, ...activity, ...banners });
561
+ page = activitySection({ ...activity, ...banners });
391
562
  break;
392
563
  }
393
564
  default:
394
565
  page = overviewSection({
395
- token,
396
566
  connected: true,
567
+ ...(this.#options.getServers ? { servers: this.#options.getServers() } : {}),
568
+ ...(() => {
569
+ const record = this.#options.getRecord?.();
570
+ return record?.address ? { record: { address: record.address, mode: record.mode ?? 'bcc' } } : {};
571
+ })(),
572
+ ...(this.#options.getFilterActions ? { filterActions: this.#options.getFilterActions().allowed } : {}),
573
+ ...(this.#options.getPermissions
574
+ ? {
575
+ departures: this.#options
576
+ .getPermissions()
577
+ .tools.filter((tool) => tool.departs)
578
+ .map((tool) => ({
579
+ tool: tool.tool,
580
+ level: { off: 'off', ask: 'ask first', act: 'act on its own' }[tool.level],
581
+ })),
582
+ }
583
+ : {}),
397
584
  ...(status.address ? { address: status.address } : {}),
398
585
  ...storeTitle,
399
586
  ...(status.mailboxCount !== undefined ? { mailboxCount: status.mailboxCount } : {}),
@@ -411,35 +598,209 @@ export class WebInterface {
411
598
  this.#send(response, 200, 'text/html; charset=utf-8', page);
412
599
  }
413
600
  /**
414
- * Shows a message that is waiting to be sent.
601
+ * Checks the PIN of a change. Returns why it was refused, or nothing.
415
602
  *
416
- * Deliberately not a section: it is reached from the confirmation, exists
417
- * only while that question is unanswered, and belongs to no part of the
418
- * navigation. A digest nobody is holding gives the empty page rather than a
419
- * refusal, because the ordinary reason to land here is having answered
420
- * already.
603
+ * No PIN set means nothing to check: then anything on this machine can make
604
+ * changes, which the pages say. Too many wrong attempts lock out every attempt
605
+ * for a while, the right one included, so the lock cannot be used to tell
606
+ * when a guess was right.
607
+ */
608
+ async #checkPin(presented) {
609
+ const stored = this.#options.getPin?.();
610
+ if (!stored)
611
+ return undefined;
612
+ if (!this.#limiter.allowed()) {
613
+ return 'Too many wrong PINs. Changes are refused for a minute, whichever PIN is entered.';
614
+ }
615
+ if (presented && (await pinMatches(presented, stored))) {
616
+ this.#limiter.reset();
617
+ return undefined;
618
+ }
619
+ this.#limiter.fail();
620
+ this.#notify('web interface refused a change with a wrong or missing PIN');
621
+ return presented ? 'The PIN is not right.' : 'This change needs the PIN.';
622
+ }
623
+ /**
624
+ * Takes the levels from the form.
421
625
  *
422
- * **No answer is given here.** The page carries no button and must not grow
423
- * one: the token in this address reaches the assistant, so a control here
424
- * could be operated by the thing being supervised.
626
+ * Only tools the page offered, only levels each one offers, and nothing the
627
+ * environment fixed: the form is untrusted input like any other, and a value
628
+ * it should not carry is refused rather than quietly dropped.
425
629
  */
426
- async #renderPending(response, digest) {
427
- const status = await this.#options.getStatus();
428
- const token = this.#secrets.accessToken;
429
- const disclaimer = this.#disclaimerFor('none');
430
- // Nobody signed in means nothing can be waiting, and the sign-in form is
431
- // the more useful answer than an empty preview.
432
- if (!status.connected) {
433
- await this.#render('overview', response);
630
+ async #handleLevels(response, fields) {
631
+ const view = this.#options.getPermissions?.();
632
+ if (!view || !this.#options.onLevels) {
633
+ await this.#render('permissions', response, { error: 'This server cannot store levels.' });
434
634
  return;
435
635
  }
436
- const waiting = /^[0-9a-f]{64}$/.test(digest)
437
- ? this.#options.getPending?.(digest)
438
- : undefined;
439
- const page = waiting
440
- ? pendingPage({ ...waiting, token, disclaimer })
441
- : noPendingPage(token, disclaimer);
442
- this.#send(response, 200, 'text/html; charset=utf-8', page);
636
+ const levels = {};
637
+ for (const tool of view.tools) {
638
+ if (tool.fixed)
639
+ continue;
640
+ const value = fields[`level:${tool.tool}`];
641
+ if (value === undefined)
642
+ continue;
643
+ if (!tool.options.includes(value)) {
644
+ await this.#render('permissions', response, {
645
+ error: `"${value}" is not a level ${tool.tool} offers.`,
646
+ });
647
+ return;
648
+ }
649
+ levels[tool.tool] = value;
650
+ }
651
+ const failure = await this.#options.onLevels(levels);
652
+ await this.#render('permissions', response, failure ? { error: failure } : { notice: 'The levels are saved.' });
653
+ }
654
+ /** Takes the bounds on sending without asking from the form, each one checked. */
655
+ async #handleLimits(response, fields) {
656
+ if (!this.#options.onLimits) {
657
+ await this.#render('permissions', response, { error: 'This server cannot store bounds.' });
658
+ return;
659
+ }
660
+ const limits = {};
661
+ const allowed = (fields.allowed ?? '')
662
+ .split(/[\s,]+/)
663
+ .map((entry) => entry.trim())
664
+ .filter(Boolean);
665
+ const malformed = allowed.find((entry) => !/^[^\s@]*@[^\s@]+\.[^\s@]+$/.test(entry));
666
+ if (malformed) {
667
+ await this.#render('permissions', response, {
668
+ error: `"${malformed}" is neither an address nor a domain written as @example.com.`,
669
+ });
670
+ return;
671
+ }
672
+ if (allowed.length > 0)
673
+ limits.allowed = allowed;
674
+ const max = (fields.maxPerHour ?? '').trim();
675
+ if (max) {
676
+ const value = Number(max);
677
+ if (!Number.isInteger(value) || value < 1 || value > 10_000) {
678
+ await this.#render('permissions', response, {
679
+ error: `"${max}" is not a usable number of messages per hour.`,
680
+ });
681
+ return;
682
+ }
683
+ limits.maxPerHour = value;
684
+ }
685
+ const until = (fields.until ?? '').trim();
686
+ if (until) {
687
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`))) {
688
+ await this.#render('permissions', response, { error: `"${until}" is not a day.` });
689
+ return;
690
+ }
691
+ limits.until = until;
692
+ }
693
+ const failure = await this.#options.onLimits(limits);
694
+ await this.#render('permissions', response, failure ? { error: failure } : { notice: 'The bounds are saved.' });
695
+ }
696
+ /**
697
+ * Takes what filters may do from the form: only the actions the page
698
+ * offers, each only as a ticked box, and nothing the environment fixed.
699
+ */
700
+ async #handleFilterActions(response, fields) {
701
+ if (!this.#options.onFilterActions || !this.#options.getFilterActions) {
702
+ await this.#render('permissions', response, { error: 'This server cannot store what filters may do.' });
703
+ return;
704
+ }
705
+ if (this.#options.getFilterActions().fixed) {
706
+ await this.#render('permissions', response, {
707
+ error: 'What filters may do is set in the environment (PROTON_MCP_FILTER_ACTIONS), so it is not changed here.',
708
+ });
709
+ return;
710
+ }
711
+ const named = Object.keys(fields)
712
+ .filter((key) => key.startsWith('action:'))
713
+ .map((key) => key.slice('action:'.length));
714
+ const unknown = named.find((name) => !UNLOCKABLE.includes(name));
715
+ if (unknown !== undefined) {
716
+ await this.#render('permissions', response, { error: `"${unknown}" is not an action a filter can be allowed.` });
717
+ return;
718
+ }
719
+ const odd = named.find((name) => fields[`action:${name}`] !== 'on');
720
+ if (odd !== undefined) {
721
+ await this.#render('permissions', response, { error: `The box for ${odd} came back with a value it does not have.` });
722
+ return;
723
+ }
724
+ const failure = await this.#options.onFilterActions(UNLOCKABLE.filter((action) => named.includes(action)));
725
+ await this.#render('permissions', response, failure ? { error: failure } : { notice: 'What filters may do is saved.' });
726
+ }
727
+ /** Takes the copy recipient from the form. An empty address removes it. */
728
+ async #handleRecord(response, fields) {
729
+ if (!this.#options.onRecord) {
730
+ await this.#render('permissions', response, { error: 'This server cannot store a copy recipient.' });
731
+ return;
732
+ }
733
+ // The page shows no form when the environment sets the copy, but a
734
+ // request does not need the page. Storing it anyway would change nothing,
735
+ // since the environment wins, and the notice would claim otherwise.
736
+ if (this.#options.getRecord?.().fixed) {
737
+ await this.#render('permissions', response, {
738
+ error: 'The copy is set in the environment (PROTON_MCP_COPY_TO), so it is not changed here.',
739
+ });
740
+ return;
741
+ }
742
+ const address = (fields.address ?? '').trim();
743
+ if (!address) {
744
+ const failure = await this.#options.onRecord(undefined);
745
+ await this.#render('permissions', response, failure ? { error: failure } : { notice: 'No copy is added to messages any more.' });
746
+ return;
747
+ }
748
+ const mode = fields.mode === 'cc' ? 'cc' : 'bcc';
749
+ if (fields.mode !== undefined && fields.mode !== 'cc' && fields.mode !== 'bcc') {
750
+ await this.#render('permissions', response, { error: `"${fields.mode}" is neither cc nor bcc.` });
751
+ return;
752
+ }
753
+ const failure = await this.#options.onRecord({ address, mode });
754
+ await this.#render('permissions', response, failure ? { error: failure } : { notice: `Every message sent now also goes to ${address} as ${mode === 'cc' ? 'Cc' : 'Bcc'}.` });
755
+ }
756
+ /**
757
+ * Sets, changes or removes the PIN.
758
+ *
759
+ * Changing and removing have passed the PIN check before this runs, like
760
+ * every other change. Setting one where none exists needs nothing, because
761
+ * until then anything could have changed anything anyway.
762
+ */
763
+ async #handlePin(response, fields) {
764
+ if (!this.#options.onPinChange) {
765
+ await this.#render('permissions', response, {
766
+ error: 'This server cannot store a PIN.',
767
+ });
768
+ return;
769
+ }
770
+ const action = fields.action;
771
+ const existing = this.#options.getPin?.();
772
+ if (action === 'remove') {
773
+ const failure = await this.#options.onPinChange(undefined);
774
+ await this.#render('permissions', response, failure
775
+ ? { error: failure }
776
+ : { notice: 'The PIN is removed. Anything on this machine can change these settings again.' });
777
+ return;
778
+ }
779
+ if (action !== 'set' && action !== 'change') {
780
+ await this.#render('permissions', response);
781
+ return;
782
+ }
783
+ if (action === 'set' && existing) {
784
+ await this.#render('permissions', response, {
785
+ error: 'A PIN is set already. Change it with the current one.',
786
+ });
787
+ return;
788
+ }
789
+ const next = fields.next ?? '';
790
+ const problem = pinProblem(next);
791
+ if (problem) {
792
+ await this.#render('permissions', response, { error: problem });
793
+ return;
794
+ }
795
+ if (next !== (fields.nextRepeat ?? '')) {
796
+ await this.#render('permissions', response, { error: 'The two PINs do not match.' });
797
+ return;
798
+ }
799
+ const failure = await this.#options.onPinChange(await hashPin(next));
800
+ this.#limiter.reset();
801
+ await this.#render('permissions', response, failure
802
+ ? { error: failure }
803
+ : { notice: action === 'set' ? 'The PIN is set.' : 'The PIN is changed.' });
443
804
  }
444
805
  /**
445
806
  * Whether the notice is shown, and whether it carries a button.
@@ -475,7 +836,7 @@ export class WebInterface {
475
836
  * caller has already established that somebody is signed in, so the only
476
837
  * question left here is whether the Bridge answers.
477
838
  */
478
- async #renderMailboxes(token, banners) {
839
+ async #renderMailboxes(banners) {
479
840
  let mailboxes = [];
480
841
  let error = banners.error;
481
842
  if (this.#options.getMailboxes) {
@@ -487,8 +848,8 @@ export class WebInterface {
487
848
  }
488
849
  }
489
850
  return mailboxSection({
490
- token,
491
851
  mailboxes,
852
+ pinRequired: banners.pinRequired,
492
853
  disclaimer: banners.disclaimer,
493
854
  ...(error ? { error } : {}),
494
855
  ...(banners.notice ? { notice: banners.notice } : {}),