Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/mail.js

165 KiB, 7 runs

created by r2519314175:1395, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* mail.js — Daimond's mailboxes.
2 *
3 * A browser has no TCP socket, so Daimond cannot speak IMAP. The gateway makes the
4 * connection and hands back the raw RFC 5322 bytes; everything else happens
5 * here. The mail is written into the workspace as a Maildir, which is to say:
6 * as ordinary files, in ordinary folders, that the agents' existing file tools
7 * already read. Nothing about mail is a special case downstream of the socket.
8 *
9 * mail/<address>/INBOX/cur/<uid>.<uidvalidity>.daimond:2,<flags>
10 * mail/<address>/INBOX/index.md a digest, so an agent can see the shape
11 * of an inbox without reading every message
12 *
13 * The credential is an app password. It is wrapped under the user's passphrase
14 * with the same key that wraps their API key, and it is sent to the gateway only
15 * as part of a sync — the gateway holds it for one IMAP conversation and then
16 * forgets it. Daimond stores no mail server-side, and never sees the passphrase.
17 *
18 * THAT IS THE TRANSPORT THIS RELEASE SHIPS, and the tunnel section below is NOT
19 * yet carrying anything. TLS in the page is built and tested — the socket, the
20 * handshake, the STARTTLS promotion, the close codes and their sentences — and it
21 * is inert, because the two protocol exports it would run over (`mail_imap` and
22 * `mail_smtp_send`) do not exist and cannot until `fe2o3_net`'s IMAP client is
23 * split sans-io. `syncOne`, `loadFolders` and `sendDraft` therefore still post to
24 * `/api/mail/sync`, `/api/mail/folders` and `/api/mail/send`, password and all.
25 * Nothing in this file may claim otherwise until those three call sites move, and
26 * the privacy page may not claim it either.
27 *
28 * The UID is the thing that makes an incremental sync possible: `since_uid` is
29 * the last message already held, so a sync asks only for what arrived after it.
30 * `uidvalidity` is the mailbox's generation — if the server changes it, every
31 * UID held locally is meaningless and the mailbox is rebuilt from scratch.
32 */
33(function () {
34 'use strict';
35
36 /// What the app says. The table lives in i18n/en.js; this is the one name
37 /// the rest of this file uses. Guarded, because a page that failed to load
38 /// the engine should still show its mail rather than nothing.
39 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
40 function tn(k, n, v) { return window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k; }
41
42 /// The same, with English standing in for a key the tables do not carry yet.
43 ///
44 /// The string tables are not this phase's to edit, and a key added here
45 /// before the translator reaches it would otherwise put `mail.every.off` on
46 /// screen. `t` answers a missing key with the key itself, which is the test.
47 /// Same device as gateway.js's `pauseWords`, for the same reason.
48 function tf(k, english, v) {
49 var s = t(k, v);
50 if (s !== k) return s;
51 return String(english).replace(/\{(\w+)\}/g, function (_, n) {
52 return (v && v[n] != null) ? String(v[n]) : '';
53 });
54 }
55
56 var LS = 'daimond-mail';
57 // Mailboxes removed on purpose, by address. The parcel merges by union, so an
58 // account deleted here and still held on the other device would be handed
59 // straight back on the next pull — password and all — and the seat given up at
60 // the gateway would be taken again. See the sync section below.
61 var TOMBS = 'daimond-mail-tombs';
62 var deps = null; // { writeBytes, openFile, refreshFiles, runTool, showDoc }
63 // runTool answers { text, outcome } -- see readText below
64
65 // The wasm module, resolved against THIS script rather than the document, so the
66 // app still finds it when served from a sub-path. Same idiom as tools.js and
67 // graph.js, and for the same reason: mail.js is a classic script and cannot
68 // `import` at the top. It is NOT handed over in `deps` because the TLS client
69 // arrived after daimond.js's `DaimondMail.init` call was written, and one
70 // dynamic import of a URL the page has already loaded costs nothing and is not a
71 // second copy of the wasm — the module registry keys on the URL.
72 var SELF = (document.currentScript && document.currentScript.src) || '';
73 var PKG = SELF ? new URL('../pkg/oxedyne_daimond.js', SELF).href
74 : '../pkg/oxedyne_daimond.js';
75 var pkgP = null;
76
77 /// Read a message file's BYTES, as bytes.
78 ///
79 /// NOT `run_tool('file_read')`. That is the model-facing rendering: it prefixes
80 /// every line with its number and a TAB, and everything under `mail/` is an
81 /// untrusted path, so the result also arrives wrapped in an envelope. Handed to
82 /// `parseHeaders`, `1\tFrom: …` matches nothing — which is why every message in
83 /// the panel read "(unknown)" and "(no subject)".
84 ///
85 /// The Doc panel had exactly this fault and was fixed by reading through
86 /// `Wasm.read_file`; mail was not, and stayed broken. `Wasm` is an ES module
87 /// import inside daimond.js and unreachable from here, so it arrives as
88 /// `deps.readText`. If it is absent the panel says so rather than quietly
89 /// showing the numbered rendering as if it were the message.
90 ///
91 /// It answers `{ text, outcome }`, the same shape `deps.runTool` does, so every caller
92 /// below asks ONE question of both doors: did this call come back with something.
93 ///
94 /// It used to hand back a bare string, and the callers tested that string for the
95 /// word `Error` -- a rule that could only ever be true of the tool door, and not even
96 /// of all of it, since a refusal opens `Refused`. `Wasm.read_file` REJECTS instead, so
97 /// after mail was switched to it those branches were unreachable and the rejection
98 /// escaped: on a device with no `mail/` directory -- a phone that has never opened the
99 /// Email panel -- two unhandled rejections on every boot, which is what the first
100 /// usable trail from an iPhone showed.
101 ///
102 /// `refused` cannot arise here: this door is the raw byte reader, and only the tool
103 /// layer has a fence to refuse anything. Two of the three words is not a second
104 /// vocabulary -- it is this door's share of the one there is.
105 async function readText(path) {
106 if (deps && typeof deps.readText === 'function') {
107 try {
108 return { text: await deps.readText(path), outcome: 'done' };
109 } catch (e) {
110 return { text: String((e && (e.message || e)) || 'unreadable'), outcome: 'failed' };
111 }
112 }
113 console.error('mail: deps.readText is missing, so message headers cannot be read; '
114 + 'see DaimondMail.init in daimond.js');
115 return { text: '', outcome: 'failed' };
116 }
117
118 var els = {};
119 var state = {
120 accounts: [], // [{address, host, port, user, pass (wrapped), folder, folders:{}}]
121 sel: null, // the selected address
122 msgs: [], // the digest of the selected mailbox
123 drafts: [], // unsent messages held for the selected mailbox
124 // address -> { list: [{name, dir, label, role, selectable, delimiter}], err, busy }
125 // The folder list is the SERVER's answer, cached per account for as long
126 // as the page lives. Nothing is stored: a folder that was renamed on the
127 // server should not go on being offered after a reload.
128 folders: {},
129 unlocked: null, // null = not yet asked the gateway
130 // The cap is the gateway's to state — it is the only place it means anything — so this
131 // is what the panel says before the gateway has answered, and it must not promise more
132 // than the unlock actually covers.
133 cap: 3,
134 price: null, // minor units, from the gateway's catalogue
135 busy: false,
136 draining: false, // a "fetch all" is walking the mailbox down
137 note: '',
138 err: '',
139 };
140
141 /// What each provider calls its IMAP server, and what it demands instead of
142 /// a password. Guessed from the address so the user is asked for as little
143 /// as possible; every field stays editable, because a guess is not a fact.
144 /// Reading a mailbox and posting from it are two different servers, so a preset names
145 /// both. Submission runs on 587 (which starts in the clear and upgrades) or 465 (which
146 /// is encrypted from the first byte); the gateway dials no other port.
147 /// The guidance a preset carries is a `note` KEY, not a sentence: the dialog
148 /// is built when it opens, so it reads the table then and gets whatever
149 /// language is in force at that moment.
150 var PRESETS = {
151 'gmail.com': { host: 'imap.gmail.com', port: 993, smtpHost: 'smtp.gmail.com', smtpPort: 587, note: 'mail.preset.gmail' },
152 'googlemail.com': { host: 'imap.gmail.com', port: 993, smtpHost: 'smtp.gmail.com', smtpPort: 587, note: 'mail.preset.gmail_short' },
153 'outlook.com': { host: 'outlook.office365.com', port: 993, smtpHost: 'smtp.office365.com', smtpPort: 587, note: 'mail.preset.outlook' },
154 'hotmail.com': { host: 'outlook.office365.com', port: 993, smtpHost: 'smtp.office365.com', smtpPort: 587, note: 'mail.preset.outlook' },
155 'live.com': { host: 'outlook.office365.com', port: 993, smtpHost: 'smtp.office365.com', smtpPort: 587, note: '' },
156 'yahoo.com': { host: 'imap.mail.yahoo.com', port: 993, smtpHost: 'smtp.mail.yahoo.com', smtpPort: 465, note: 'mail.preset.yahoo' },
157 'icloud.com': { host: 'imap.mail.me.com', port: 993, smtpHost: 'smtp.mail.me.com', smtpPort: 587, note: 'mail.preset.icloud' },
158 'me.com': { host: 'imap.mail.me.com', port: 993, smtpHost: 'smtp.mail.me.com', smtpPort: 587, note: 'mail.preset.icloud' },
159 'fastmail.com': { host: 'imap.fastmail.com', port: 993, smtpHost: 'smtp.fastmail.com', smtpPort: 465, note: 'mail.preset.fastmail' },
160 'fastmail.fm': { host: 'imap.fastmail.com', port: 993, smtpHost: 'smtp.fastmail.com', smtpPort: 465, note: '' },
161 'zoho.com': { host: 'imap.zoho.com', port: 993, smtpHost: 'smtp.zoho.com', smtpPort: 587, note: '' },
162 'aol.com': { host: 'imap.aol.com', port: 993, smtpHost: 'smtp.aol.com', smtpPort: 465, note: '' },
163 };
164
165 /// Providers that have no IMAP server anyone else can reach. Saying so is
166 /// the honest thing; letting the user type a password into a form that
167 /// cannot work is not. The value is a key, as with the presets above.
168 var UNREACHABLE = {
169 'proton.me': 'mail.unreachable.proton',
170 'protonmail.com': 'mail.unreachable.proton',
171 'pm.me': 'mail.unreachable.proton',
172 'tutanota.com': 'mail.unreachable.tuta',
173 'tuta.io': 'mail.unreachable.tuta',
174 };
175
176 function esc(s) {
177 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
178 return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
179 });
180 }
181 function domainOf(addr) {
182 var i = String(addr || '').lastIndexOf('@');
183 return i < 0 ? '' : addr.slice(i + 1).toLowerCase().trim();
184 }
185 function load() {
186 try {
187 var j = JSON.parse(localStorage.getItem(LS) || '{}');
188 state.accounts = Array.isArray(j.accounts) ? j.accounts : [];
189 state.accounts.forEach(liftFolders);
190 state.sel = j.sel || (state.accounts[0] && state.accounts[0].address) || null;
191 } catch (e) { state.accounts = []; }
192 }
193
194 /// An account's sync watermarks used to be the account's, because there was
195 /// one mailbox and it was the inbox. They belong to a FOLDER — `uidvalidity`
196 /// is per-mailbox and so is every UID under it — so an older record has its
197 /// four numbers lifted into the inbox's slot rather than being discarded,
198 /// which would re-download an inbox that is already on disk.
199 function liftFolders(a) {
200 if (!a.folder) a.folder = 'INBOX';
201 if (a.folders && a.folders[a.folder]) return;
202 a.folders = a.folders || {};
203 a.folders.INBOX = a.folders.INBOX || {
204 dir: 'INBOX',
205 uidValidity: a.uidValidity || 0,
206 lastUid: a.lastUid || 0,
207 firstUid: a.firstUid || 0,
208 heldBack: a.heldBack || 0,
209 limit: a.limit || 0,
210 lastSync: a.lastSync || 0,
211 };
212 if (!a.folders[a.folder]) a.folders[a.folder] = blankFolder(a.folder);
213 }
214
215 function blankFolder(name) {
216 return {
217 dir: dirFor(name), uidValidity: 0, lastUid: 0, firstUid: 0,
218 // `count` is how many messages this folder held at `lastSync`, which is
219 // the only honest reading of a number on a folder row. Zero and
220 // never-synced are different states and the row says so.
221 heldBack: 0, limit: 0, lastSync: 0, lastTry: 0, count: 0,
222 };
223 }
224
225 /// The per-folder record for an account, made if this is the first time the
226 /// folder has been looked at.
227 function fld(a, name) {
228 if (!a) return null;
229 name = name || a.folder || 'INBOX';
230 a.folders = a.folders || {};
231 if (!a.folders[name]) a.folders[name] = blankFolder(name);
232 return a.folders[name];
233 }
234 // ── Surviving a passphrase change ──────────────────────────────
235 //
236 // Every mailbox password is sealed under a key derived from the passphrase.
237 // Changing the passphrase therefore made all of them unopenable, silently:
238 // nothing re-wrapped them, and the first sign was a mailbox that had stopped
239 // working for no stated reason. Found 2026-08-14 by the lane that added the
240 // forge voice, which would have inherited the same hole.
241 //
242 // The plaintexts are held here, in this module, for the length of the change
243 // and no longer. The caller learns how many are held, never what they are.
244
245 /// Passwords in the clear, keyed by address so that a mailbox added or
246 /// removed mid-change cannot put a password onto the wrong account.
247 var rekey = null;
248
249 /// Read every mailbox password out from under the CURRENT passphrase.
250 ///
251 /// Must be called BEFORE `DaimondIdentity.changePassphrase` swaps the key:
252 /// afterwards nothing can open them at all.
253 async function unsealForRekey() {
254 // Fresh every time, and assigned BEFORE the loop, so a throw part-way
255 // through leaves a hold the caller's `forgetRekey` can still clear rather
256 // than an unreachable object holding passwords in the clear.
257 rekey = {};
258 var failed = [];
259 for (var i = 0; i < state.accounts.length; i++) {
260 var a = state.accounts[i];
261 if (!a || !a.address || !a.pass) continue;
262 try { rekey[a.address] = await DaimondIdentity.unwrap(a.pass); }
263 catch (e) { failed.push(a.address); }
264 }
265 return { ok: !failed.length, held: Object.keys(rekey).length, failed: failed };
266 }
267
268 /// Put them back under the NEW passphrase, and forget them either way.
269 ///
270 /// A mailbox whose password could not be re-sealed is NAMED rather than
271 /// counted, because "one mailbox needs its password again" is actionable and
272 /// "something went wrong" is not.
273 async function resealAfterRekey() {
274 if (!rekey) return { ok: true, failed: [] };
275 var failed = [];
276 try {
277 for (var i = 0; i < state.accounts.length; i++) {
278 var a = state.accounts[i];
279 if (!a || !a.address) continue;
280 var plain = rekey[a.address];
281 if (typeof plain !== 'string' || !plain) continue;
282 try { a.pass = await DaimondIdentity.wrap(plain); }
283 catch (e) { failed.push(a.address); }
284 }
285 save();
286 } finally { rekey = null; } // in the clear; never held past here
287 return { ok: !failed.length, failed: failed };
288 }
289
290 /// Drop the plaintexts unused, for a change that did not happen.
291 function forgetRekey() { rekey = null; }
292
293 /// Take part in a passphrase change, registered HERE beside the seal rather
294 /// than named in `doChangePassphrase`.
295 ///
296 /// That is the whole of the 2026-08-14 fix: this module's passwords were
297 /// missing from a hand-written list in another file, and nothing anywhere
298 /// said so. A registration next to the sealing code is visible to whoever
299 /// writes the next seal, and `dev/verify_rekey.mjs` fails the build for a
300 /// module that seals without one.
301 ///
302 /// Both phases, because a password is held ONLY sealed: read out under the
303 /// old key, put back under the new one, forgotten either way.
304 if (window.DaimondRekey) {
305 DaimondRekey.register({
306 name: 'mail',
307 read: unsealForRekey,
308 reseal: resealAfterRekey,
309 forget: forgetRekey,
310 /// The mailboxes named, never counted. `list` is addresses, which is
311 /// what the user knows them by and what they will retype the password
312 /// into.
313 sentence: function (kind, list) {
314 return t(kind === 'unread' ? 'changepass.mail_not_unsealed'
315 : 'changepass.mail_not_resealed', { list: list.join(', ') });
316 },
317 });
318 }
319
320 function save() {
321 localStorage.setItem(LS, JSON.stringify({ accounts: state.accounts, sel: state.sel }));
322 // A mailbox added or removed outside a turn must travel like any edit.
323 // The engine coalesces and skips an unchanged parcel, so this is cheap.
324 try { if (window.DaimondSync && DaimondSync.nudge) DaimondSync.nudge(); } catch (e) { /* not up yet */ }
325 }
326 function acct(address) {
327 return state.accounts.find(function (a) { return a.address === address; }) || null;
328 }
329
330 // ── The pause tree, as mail sees it ─────────────────────────────
331 // The ids are DaimondPause's and are built with its own escaper: a folder
332 // called `INBOX/Sub` would otherwise invent a level in the tree.
333 //
334 // root/mail/<address> the mailbox branch
335 // root/mail/<address>/self its own polling LEAF
336 // root/mail/<address>/<folder> one folder LEAF
337 //
338 // Nothing here draws a control. The widget is daimond.js's, asked for through
339 // `pptw()` below, so there is one drawing of it in the app.
340
341 function pauseId() {
342 if (!window.DaimondPause) return Array.prototype.join.call(arguments, '/');
343 return DaimondPause.id.apply(null, arguments);
344 }
345 function mailNode() { return pauseId('root', 'mail'); }
346 function boxNode(address) { return pauseId('root', 'mail', address); }
347 function selfNode(address) { return pauseId('root', 'mail', address, 'self'); }
348 function folderNode(address, name) { return pauseId('root', 'mail', address, name); }
349
350 function heldNode(node) {
351 return !!(node && window.DaimondPause && DaimondPause.isPaused(node));
352 }
353
354 /// Which node refuses a poll of this folder, or '' when none does. The
355 /// folder's own leaf answers first, then the mailbox's.
356 ///
357 /// The tree does NOT derive this. `self` is a SIBLING of the folders, not
358 /// their ancestor, so a mailbox whose `self` is held and whose folders play
359 /// is 'mixed' to `stateOf` and 'not paused' to `isPaused` on any folder leaf.
360 /// "A paused mailbox must not go on reaching the server one folder at a time"
361 /// is a rule laid OVER the tree rather than read out of it, which is why it
362 /// is written twice: here, so a held folder is never scheduled, and at the
363 /// wire in gateway.js:275, so one that is scheduled anyway never leaves.
364 function pollStop(address, name) {
365 var f = folderNode(address, name);
366 if (heldNode(f)) return f;
367 var s = selfNode(address);
368 if (heldNode(s)) return s;
369 return '';
370 }
371
372 /// The refusal, in words: what was not done, and where the control is.
373 /// The key is gateway.js's, so the sentence is translated once.
374 function pausedWords(node) {
375 return tf('pause.refused.mail',
376 '{node} is paused. The mailbox was not contacted and nothing was spent. '
377 + 'Press play on it to resume.', { node: node });
378 }
379
380 /// One pause control, from the module that owns the drawing of it.
381 ///
382 /// THE MOUNT POINT for the shared widget. `DaimondUI.pauseWidget(nodeId,
383 /// name)` returns a painted `<span class="pptw">` — a light and two verb
384 /// buttons — that repaints itself on
385 /// `daimond:pause`; mail.js only says where one goes. Absent — the widget is
386 /// another phase's — the slot stays empty and everything else in the panel
387 /// still works, which is the whole reason it is asked for rather than drawn.
388 function pptw(nodeId, name) {
389 var slot = document.createElement('span');
390 slot.className = 'pptw-slot';
391 var mk = window.DaimondUI && DaimondUI.pauseWidget;
392 if (typeof mk !== 'function') return slot;
393 try { slot.appendChild(mk(nodeId, name)); } catch (e) { /* leave it empty */ }
394 return slot;
395 }
396
397 // ── How often a folder refreshes itself ─────────────────────────
398 //
399 // WHERE THE SETTING LIVES, and why it is not in `a.folders`.
400 //
401 // `a.folders[name]` is this DEVICE's account of what is on this device's
402 // disk — uidvalidity, watermarks, the last sync — and the sync parcel
403 // deliberately carries none of it (see the section below). A refresh
404 // frequency is the opposite kind of fact: it is what the user asked for, it
405 // is true of the mailbox rather than of the disk, and a person who sets
406 // their inbox to fifteen minutes on the laptop means it on the phone too.
407 // So it lives in its own map on the account, `a.refresh`, and it travels.
408 //
409 // TRAVELLING MEANS STABLE BYTES. sync.js skips a push when the parcel
410 // stringifies to what it last sent, so a map serialised in enumeration order
411 // would make this device always have news and two devices would push at each
412 // other for ever. That has happened here twice; `dev/verify_parcelstable.mjs`
413 // is the check. Hence `sortedRefresh`: sorted keys, integer seconds, no
414 // stamp of its own — the account's existing `touched` decides the merge, and
415 // it moves only when the user changes something.
416 //
417 // Seconds, not minutes, because the unit a test needs is not the unit a
418 // person picks from and the store should not make them the same choice.
419
420 /// The frequencies the dialog offers, in seconds. 0 is "manual only", which
421 /// is what every folder is until someone says otherwise: a mailbox that
422 /// started polling on its own the moment it was added would spend the user's
423 /// credits on a decision they never made.
424 var EVERY = [0, 300, 900, 1800, 3600, 14400, 43200, 86400];
425
426 function refreshMap(a) {
427 if (!a) return {};
428 if (!a.refresh || typeof a.refresh !== 'object') a.refresh = {};
429 return a.refresh;
430 }
431
432 /// How often this folder refreshes itself, in seconds; 0 for manual only.
433 function refreshOf(a, name) {
434 var v = refreshMap(a)[name];
435 return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0;
436 }
437
438 /// Set it. `touched` moves because this is a statement about the mailbox and
439 /// has to win the cross-device merge against a device that has not heard it.
440 function setRefresh(address, name, secs) {
441 var a = acct(address);
442 if (!a || !name) return false;
443 secs = (typeof secs === 'number' && isFinite(secs) && secs > 0) ? Math.floor(secs) : 0;
444 var m = refreshMap(a);
445 if (refreshOf(a, name) === secs) return false;
446 if (secs) m[name] = secs; else delete m[name];
447 // The folder needs a record for its watermarks and for the pause tree,
448 // which daimond.js builds out of this map (daimond.js:6819). A folder
449 // scheduled but never opened would otherwise have no leaf, and pausing
450 // the mailbox would walk straight past it.
451 fld(a, name);
452 a.touched = Math.max(Date.now(), ms(a.touched) + 1);
453 save();
454 arm();
455 render();
456 return true;
457 }
458
459 /// The map as it travels: sorted keys, integer seconds, nothing else.
460 function sortedRefresh(a) {
461 var m = refreshMap(a), out = {};
462 Object.keys(m).sort().forEach(function (k) {
463 var v = refreshOf(a, k);
464 if (v) out[k] = v;
465 });
466 return out;
467 }
468
469 // ── The schedule ────────────────────────────────────────────────
470 // One timer for the whole app, re-armed to the next folder that falls due.
471 // Not one timer per folder: a dozen folders across three mailboxes would be
472 // a dozen timers to cancel on every account change, and the one thing this
473 // must never do is go on polling a mailbox that has been removed.
474
475 var timer = null;
476 var TICK_MIN = 250; // never busier than this, whatever a folder asks for
477 var TICK_MAX = 60000; // and never asleep longer, so a resume is felt
478
479 /// When this folder is next due, in epoch ms; 0 when it is never due.
480 /// A folder with a frequency and no attempt behind it is due NOW, which is
481 /// what setting one means.
482 ///
483 /// The clock runs from the last ATTEMPT, not the last success. A sync that
484 /// failed leaves `lastSync` where it was, so scheduling off that alone would
485 /// find the folder overdue on the very next tick and hammer a server that is
486 /// down four times a second. An interval is how often to try.
487 function dueAt(a, name) {
488 var secs = refreshOf(a, name);
489 if (!secs) return 0;
490 var f = a.folders && a.folders[name];
491 var last = Math.max((f && ms(f.lastSync)) || 0, (f && ms(f.lastTry)) || 0);
492 return last ? last + secs * 1000 : 1;
493 }
494
495 /// Can anything be polled at all? A locked device cannot unwrap the
496 /// password, and an account without the entitlement has no mailbox to poll.
497 function canPoll() {
498 if (state.unlocked === false) return false;
499 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return false;
500 return true;
501 }
502
503 /// Poll the one folder that is furthest overdue, then re-arm.
504 ///
505 /// One per tick on purpose: `syncAccount` refuses to run while another sync
506 /// is in flight, so firing six at once would drop five of them silently and
507 /// leave their watermarks unmoved. Re-arming after each is the queue.
508 async function tick() {
509 timer = null;
510 var now = Date.now(), pick = null, worst = 0;
511 state.accounts.forEach(function (a) {
512 Object.keys(refreshMap(a)).sort().forEach(function (n) {
513 var d = dueAt(a, n);
514 if (!d || d > now) return;
515 if (pollStop(a.address, n)) return; // held: not polled, not stamped
516 if (!pick || d < worst) { pick = { address: a.address, name: n }; worst = d; }
517 });
518 });
519 if (pick && !state.busy && !state.draining) {
520 await syncAccount(pick.address, false, pick.name, true);
521 }
522 arm();
523 }
524
525 /// Re-arm the timer to whichever folder falls due first.
526 ///
527 /// Called from `render()`, so every state change that could move a due time —
528 /// a sync finishing, a frequency changing, an account arriving in a parcel,
529 /// the device unlocking — re-arms without each of them having to remember to.
530 function arm() {
531 if (timer) { clearTimeout(timer); timer = null; }
532 if (typeof setTimeout !== 'function') return;
533 var scheduled = false, soonest = 0, now = Date.now();
534 state.accounts.forEach(function (a) {
535 Object.keys(refreshMap(a)).forEach(function (n) {
536 var d = dueAt(a, n);
537 if (!d) return;
538 scheduled = true;
539 if (pollStop(a.address, n)) return;
540 if (!soonest || d < soonest) soonest = d;
541 });
542 });
543 if (!scheduled) return;
544 // A schedule exists but nothing can act on it — the device is locked, or
545 // every folder is held. Look again shortly rather than never: the unlock
546 // and the resume both happen outside this module.
547 if (!soonest || !canPoll()) {
548 timer = setTimeout(function () { tick(); }, TICK_MAX);
549 return;
550 }
551 // A sync already in flight makes every overdue folder look due on the next
552 // tick, and `syncAccount` refuses a second one. Look again in a second
553 // rather than spinning at the floor while a large fetch runs.
554 var floor = (state.busy || state.draining) ? 1000 : TICK_MIN;
555 var wait = Math.max(floor, Math.min(TICK_MAX, soonest - now));
556 timer = setTimeout(function () { tick(); }, wait);
557 }
558
559 // A resume has to be felt without waiting out the current sleep.
560 try { if (window.DaimondPause) DaimondPause.subscribe(arm); } catch (e) { /* not up */ }
561
562 // ── Travelling in the sync parcel ───────────────────────────────
563 // A user who has linked two devices has one account, and mail configured on
564 // one of them and not the other is half a mailbox. So the accounts ride in the
565 // parcel beside the chats, the Diamonds and the provider keys.
566 //
567 // WHAT TRAVELS, and why:
568 //
569 // * The server configuration — address, host, port, SMTP host and port, and
570 // the login user. Facts about the mailbox, true wherever it is read.
571 // * The WRAPPED password. Both paired devices hold the same identity, so the
572 // ciphertext opens on both; the gateway in the middle can open neither, and
573 // the parcel is sealed again over the top of it. It is exactly the trust
574 // model the sealed provider keys already travel under, and it is what makes
575 // the second device WORK rather than merely list a mailbox it cannot read.
576 // The plaintext password exists only for the length of one request and is
577 // never stored, so there is nothing readable here to carry.
578 // * `sel`, which mailbox is being looked at — a small courtesy, and cheap.
579 // * `refresh`, how often each folder polls itself. A statement about the
580 // mailbox, not about this device's disk: a person who sets their inbox to
581 // fifteen minutes on the laptop means it on the phone. Sorted keys and
582 // integer seconds, for the determinism rule at the foot of this comment.
583 // * NOT `folders`, and nothing under it. Every UID, uidvalidity, watermark
584 // and lastSync in there describes what is on THIS device's disk. Carrying
585 // it would tell the other device it already holds mail it has never
586 // downloaded, and the merge would have to reconcile two independent
587 // Maildirs. Rebuilding is one sync per folder and cannot be wrong.
588 // * NOT `folder`, the folder on screen, for the same reason as the rest of
589 // the per-folder state: a fresh device starts in INBOX, which is right.
590 //
591 // DETERMINISM IS A REQUIREMENT. sync.js skips a push when the parcel
592 // stringifies to what it last sent, so an export whose field or account order
593 // followed enumeration would make the app push for ever. Accounts are sorted
594 // by address and every row is assembled in a fixed field order.
595
596 /// A millisecond stamp, or 0 when there is none to be had. NOT `n | 0`: a
597 /// bitwise operator coerces to 32 bits and an epoch-ms value is far past that,
598 /// so the truncation would be not merely wrong but inconsistently wrong — a
599 /// fresher stamp can truncate below an older one, and the freshest side then
600 /// loses the merge.
601 function ms(v) {
602 return (typeof v === 'number' && isFinite(v) && v > 0) ? Math.floor(v) : 0;
603 }
604
605 /// A refresh map off the wire, reduced to what this module will act on:
606 /// string keys, whole positive seconds, sorted. A parcel is another device's
607 /// word for it, and a `-1` in there would arm a timer that fires for ever.
608 function cleanRefresh(m) {
609 var out = {};
610 if (!m || typeof m !== 'object') return out;
611 Object.keys(m).sort().forEach(function (k) {
612 var v = m[k];
613 if (!k) return;
614 if (typeof v === 'number' && isFinite(v) && v > 0) out[k] = Math.floor(v);
615 });
616 return out;
617 }
618
619 /// The mailboxes deleted on purpose, by address, with anything past its TTL
620 /// pruned. The map, the TTL and the union rule are DaimondCore's — one deletion
621 /// policy for chats, Diamonds, providers and mailboxes rather than four.
622 function tombs() {
623 return (window.DaimondCore && DaimondCore.tombs) ? DaimondCore.tombs(TOMBS) : {};
624 }
625 function tombstone(address) {
626 if (window.DaimondCore && DaimondCore.tombstone) DaimondCore.tombstone(TOMBS, address);
627 }
628 function mergeTombs(incoming) {
629 return (window.DaimondCore && DaimondCore.mergeTombs)
630 ? DaimondCore.mergeTombs(TOMBS, incoming) : tombs();
631 }
632
633 /// The tombstone map with sorted keys, for the same reason the accounts are
634 /// sorted: enumeration order must never reach the wire.
635 function sortedTombs() {
636 var t = tombs(), out = {};
637 Object.keys(t).sort().forEach(function (addr) { out[addr] = ms(t[addr]); });
638 return out;
639 }
640
641 /// The mailboxes as they should travel: JSON-safe, deterministic, and carrying
642 /// no per-device state.
643 function exportSync() {
644 var out = { v: 1, sel: state.sel || '', accounts: [], tombs: sortedTombs() };
645 state.accounts.slice().sort(function (x, y) {
646 return String(x.address).localeCompare(String(y.address));
647 }).forEach(function (a) {
648 if (!a || !a.address) return;
649 var row = {
650 address: String(a.address),
651 host: String(a.host || ''),
652 port: a.port | 0,
653 smtpHost: String(a.smtpHost || ''),
654 smtpPort: a.smtpPort | 0,
655 user: String(a.user || ''),
656 pass: String(a.pass || ''), // wrapped; see above
657 // Always present, even empty: absent has to keep meaning "that
658 // device predates the setting", or clearing the last schedule
659 // could never travel.
660 refresh: sortedRefresh(a),
661 touched: ms(a.touched),
662 };
663 // Only where it has been set: it decides whether the gateway opens the
664 // connection in the clear and upgrades, so losing it would change how
665 // the mailbox is dialled on the other device.
666 if (a.security) row.security = String(a.security);
667 out.accounts.push(row);
668 });
669 return out;
670 }
671
672 /// Merge another device's mailboxes into this one.
673 ///
674 /// A union, never a replacement: a mailbox only this device has is left alone,
675 /// one only the other device has arrives whole and working, and where both have
676 /// the same address the later `touched` decides — strictly, so an unchanged
677 /// account is not rewritten on every pull.
678 ///
679 /// A deletion travels as a tombstone, and beats any copy of the account stamped
680 /// before it; an account re-added after the deletion carries a later stamp and
681 /// wins in its turn.
682 ///
683 /// The arriving account brings no folder state, so it is given the blank INBOX a
684 /// new mailbox starts with and fills it from the server on its first sync. A
685 /// parcel with no `mail` section — a device that predates this — is a no-op.
686 async function applySync(remote) {
687 if (!remote || typeof remote !== 'object') return { added: 0, updated: 0, removed: 0 };
688 var added = 0, updated = 0, removed = 0;
689 var dead = mergeTombs(remote.tombs);
690 state.accounts = state.accounts.filter(function (a) {
691 if (!dead[a.address]) return true;
692 if (ms(a.touched) > ms(dead[a.address])) return true; // re-added here since
693 removed++;
694 delete state.folders[a.address];
695 return false;
696 });
697 (Array.isArray(remote.accounts) ? remote.accounts : []).forEach(function (r) {
698 if (!r || !r.address) return;
699 if (dead[r.address] && !(ms(r.touched) > ms(dead[r.address]))) return; // buried
700 var mine = acct(r.address);
701 if (!mine) {
702 var fresh = {
703 address: String(r.address),
704 host: String(r.host || ''),
705 port: r.port | 0,
706 smtpHost: String(r.smtpHost || ''),
707 smtpPort: r.smtpPort | 0,
708 user: String(r.user || r.address),
709 pass: String(r.pass || ''),
710 touched: ms(r.touched),
711 refresh: cleanRefresh(r.refresh),
712 // This device's own view of the mailbox, built fresh: the mail
713 // itself is fetched here rather than carried.
714 folder: 'INBOX',
715 folders: { INBOX: blankFolder('INBOX') },
716 lastSync: 0,
717 };
718 if (r.security) fresh.security = String(r.security);
719 state.accounts.push(fresh);
720 added++;
721 return;
722 }
723 if (!(ms(r.touched) > ms(mine.touched))) return; // ours is newer, or the same
724 mine.host = String(r.host || mine.host || '');
725 mine.port = (r.port | 0) || mine.port;
726 mine.smtpHost = String(r.smtpHost || mine.smtpHost || '');
727 mine.smtpPort = (r.smtpPort | 0) || mine.smtpPort;
728 mine.user = String(r.user || mine.user || r.address);
729 // An empty password on the other side is not an instruction to forget
730 // the one that works here: it means that device never had one.
731 if (r.pass) mine.pass = String(r.pass);
732 if (r.security) mine.security = String(r.security);
733 // Absent means the other device predates the setting and has nothing
734 // to say about it; an empty map is a real answer and clears ours.
735 if (r.refresh && typeof r.refresh === 'object') mine.refresh = cleanRefresh(r.refresh);
736 mine.touched = ms(r.touched);
737 updated++;
738 });
739 // The selection is this device's, as long as it still names something real;
740 // only then does the other device's choice get a say.
741 if (!state.sel || !acct(state.sel)) {
742 state.sel = (remote.sel && acct(remote.sel)) ? remote.sel
743 : ((state.accounts[0] && state.accounts[0].address) || null);
744 state.msgs = [];
745 }
746 if (!added && !updated && !removed) return { added: 0, updated: 0, removed: 0 };
747 save();
748 // Show what landed, but only where there is a panel to show it in: init()
749 // has not run on a page whose Mail panel was never opened, and the digest
750 // cannot be read before the file tools exist.
751 if (els.state) {
752 if (state.sel) {
753 try { await Promise.all([loadDigest(state.sel, folderOf(state.sel)), refreshDrafts()]); }
754 catch (e) { /* the panel still draws what it has */ }
755 }
756 render();
757 if (state.sel) loadFolders(state.sel);
758 }
759 return { added: added, updated: updated, removed: removed };
760 }
761
762 /// The folder an account is looking at, defaulting to the inbox.
763 function folderOf(address) {
764 var a = acct(address);
765 return (a && a.folder) || 'INBOX';
766 }
767
768 // ── RFC 5322, enough of it ──────────────────────────────────────
769 // Enough to show a message to a person: the headers that matter, and the
770 // readable part of the body. An agent gets the raw file and can do better.
771
772 /// Unfold the header block (a header may continue on an indented line) and
773 /// return it as an ordered list of [name, value].
774 function parseHeaders(text) {
775 var end = text.search(/\r?\n\r?\n/);
776 var block = end < 0 ? text : text.slice(0, end);
777 var lines = block.split(/\r?\n/);
778 var out = [], cur = null;
779 lines.forEach(function (l) {
780 if (/^[ \t]/.test(l) && cur) { cur[1] += ' ' + l.trim(); return; }
781 var i = l.indexOf(':');
782 if (i < 0) return;
783 cur = [l.slice(0, i).trim().toLowerCase(), l.slice(i + 1).trim()];
784 out.push(cur);
785 });
786 return out;
787 }
788 function header(hs, name) {
789 var h = hs.find(function (x) { return x[0] === name; });
790 return h ? h[1] : '';
791 }
792 function bodyOf(text) {
793 var m = text.match(/\r?\n\r?\n/);
794 return m ? text.slice(m.index + m[0].length) : '';
795 }
796
797 /// Decode an RFC 2047 encoded-word (`=?utf-8?B?...?=`), which is how a
798 /// subject line carries anything that is not ASCII.
799 function decodeWords(s) {
800 return String(s || '').replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, function (_, cs, enc, txt) {
801 try {
802 var bytes;
803 if (enc.toLowerCase() === 'b') {
804 bytes = Uint8Array.from(atob(txt), function (c) { return c.charCodeAt(0); });
805 } else {
806 var q = txt.replace(/_/g, ' ');
807 var arr = [];
808 for (var i = 0; i < q.length; i++) {
809 if (q[i] === '=' && /[0-9a-f]{2}/i.test(q.substr(i + 1, 2))) {
810 arr.push(parseInt(q.substr(i + 1, 2), 16)); i += 2;
811 } else { arr.push(q.charCodeAt(i)); }
812 }
813 bytes = new Uint8Array(arr);
814 }
815 return new TextDecoder(cs.toLowerCase().replace(/^utf8$/, 'utf-8')).decode(bytes);
816 } catch (e) { return txt; }
817 }).replace(/\?=\s*=\?/g, '');
818 }
819
820 /// A date the reader can read, in the language the interface is speaking.
821 /// `toDateString` is English whatever the locale, which is what this quoted
822 /// a reply's date in for every user in the world.
823 function longDate(d) {
824 var loc = window.DaimondI18n ? DaimondI18n.locale() : undefined;
825 try {
826 return d.toLocaleDateString(loc,
827 { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
828 } catch (e) { return d.toDateString(); }
829 }
830
831 /// Thousands separators, because "69635 older messages" is a number the eye has to count.
832 function fmtCount(n) {
833 return String(n || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
834 }
835
836 function decodeQP(s) {
837 return s.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, function (_, h) {
838 return String.fromCharCode(parseInt(h, 16));
839 });
840 }
841 function decodeB64(s) {
842 try { return atob(s.replace(/\s+/g, '')); } catch (e) { return s; }
843 }
844
845 /// Re-read a decoded byte-string as UTF-8. `atob` and quoted-printable both
846 /// yield one character per byte, so a multi-byte character arrives as
847 /// mojibake unless it is decoded again.
848 function asUtf8(bytes, charset) {
849 try {
850 var arr = Uint8Array.from(bytes, function (c) { return c.charCodeAt(0) & 0xff; });
851 var cs = (charset || 'utf-8').toLowerCase().replace(/^utf8$/, 'utf-8');
852 return new TextDecoder(cs, { fatal: false }).decode(arr);
853 } catch (e) { return bytes; }
854 }
855
856 /// The readable text of a message: the `text/plain` part of a multipart, or
857 /// the body itself, decoded out of whatever transfer encoding it arrived in.
858 function readableText(raw) {
859 var hs = parseHeaders(raw);
860 var ctype = header(hs, 'content-type') || 'text/plain';
861 var body = bodyOf(raw);
862
863 var mb = ctype.match(/boundary="?([^";]+)"?/i);
864 if (/multipart/i.test(ctype) && mb) {
865 var parts = body.split('--' + mb[1]);
866 var plain = null, html = null;
867 parts.forEach(function (p) {
868 var phs = parseHeaders(p.replace(/^\r?\n/, ''));
869 var pct = header(phs, 'content-type') || '';
870 var pte = (header(phs, 'content-transfer-encoding') || '').toLowerCase();
871 var pb = bodyOf(p.replace(/^\r?\n/, ''));
872 if (!pb) return;
873 if (pte === 'base64') pb = decodeB64(pb);
874 else if (pte === 'quoted-printable') pb = decodeQP(pb);
875 var pcs = (pct.match(/charset="?([^";]+)"?/i) || [])[1];
876 pb = asUtf8(pb, pcs);
877 if (/text\/plain/i.test(pct) && plain === null) plain = pb;
878 else if (/text\/html/i.test(pct) && html === null) html = pb;
879 else if (/multipart/i.test(pct) && plain === null) {
880 // One level of nesting: multipart/alternative inside
881 // multipart/mixed is the common shape of a message with an
882 // attachment, and the text is inside the inner part.
883 var inner = readableText('content-type: ' + pct + '\r\n\r\n' + pb);
884 if (inner) plain = inner;
885 }
886 });
887 if (plain) return plain.trim();
888 if (html) return stripHtml(html).trim();
889 return '';
890 }
891
892 var te = (header(hs, 'content-transfer-encoding') || '').toLowerCase();
893 if (te === 'base64') body = decodeB64(body);
894 else if (te === 'quoted-printable') body = decodeQP(body);
895 var cs = (ctype.match(/charset="?([^";]+)"?/i) || [])[1];
896 body = asUtf8(body, cs);
897 if (/text\/html/i.test(ctype)) return stripHtml(body).trim();
898 return body.trim();
899 }
900
901 /// Reduce HTML to its text. The message is never inserted as markup: a mail
902 /// body is the least trustworthy string in the application.
903 function stripHtml(html) {
904 var bare = String(html)
905 .replace(/<style[\s\S]*?<\/style>/gi, '')
906 .replace(/<script[\s\S]*?<\/script>/gi, '')
907 .replace(/<\/(p|div|tr|h[1-6]|li)>/gi, '\n')
908 .replace(/<br\s*\/?>/gi, '\n')
909 .replace(/<[^>]+>/g, '');
910 var d = document.createElement('textarea');
911 d.innerHTML = bare; // entity decode only
912 return d.value.replace(/\n{3,}/g, '\n\n');
913 }
914
915 // ── Maildir ─────────────────────────────────────────────────────
916
917 /// A Maildir filename: `<unique>:2,<flags>`, flags in ASCII order. The
918 /// unique part is derived from the UID and the mailbox generation rather
919 /// than from the clock, so syncing the same message twice overwrites one
920 /// file instead of making two.
921 function maildirName(uid, uidValidity, flags) {
922 var f = '';
923 var has = function (n) { return (flags || []).some(function (x) { return x.toLowerCase() === n; }); };
924 if (has('\\draft')) f += 'D';
925 if (has('\\flagged')) f += 'F';
926 if (has('\\answered')) f += 'R';
927 if (has('\\seen')) f += 'S';
928 if (has('\\deleted')) f += 'T';
929 return uid + '.' + uidValidity + '.daimond:2,' + f;
930 }
931 /// Where one account's mail sits: `mail/<address>`, with anything a directory name
932 /// cannot carry flattened out of the address.
933 ///
934 /// A MAILBOX DOES NOT FOLLOW THE WORKSPACE FOLDER, and the engine is what makes that
935 /// true rather than anything here: `mail/` is one of Daimond's own roots
936 /// (`is_store_path`, src/tools.rs), so every path this module hands to a file tool
937 /// resolves in the browser's own storage whichever folder the user has open. It used
938 /// not to, and mail is per ACCOUNT rather than per piece of work, so the same mailbox
939 /// landed inside whichever folder was open, disappeared when none was, and was written
940 /// somewhere else again after a switch. A real folder would not take the names either —
941 /// a Maildir file carries a colon, which nothing outside the sandbox accepts.
942 ///
943 /// Messages an older build left in a folder are copied home on the next activation, and
944 /// the folder's copies are left where they are (`bring_mail_home`, src/wasm/diamond.rs).
945 function mailDir(address) {
946 return 'mail/' + String(address || '').replace(/[^A-Za-z0-9@._-]/g, '_');
947 }
948
949 /// A folder name as one path segment.
950 ///
951 /// A server names its folders in its own alphabet, with its own separator:
952 /// `[Gmail]/All Mail`, `Работа`, `INBOX.Sent`. None of that can be a
953 /// directory name here, so it is flattened — and, because flattening can
954 /// collide (`A/B` and `A_B` both give `A_B`), anything that had to be
955 /// changed carries a short hash of the ORIGINAL name. The server's own
956 /// spelling is what a sync sends; this is only where the files sit.
957 function dirFor(name) {
958 name = String(name == null ? '' : name);
959 if (name === 'INBOX') return 'INBOX'; // the shape already on disk
960 var safe = name.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
961 // A name of nothing but dots is `.` or `..`, which are not folder names
962 // but instructions to a filesystem. They never reach one from here.
963 if (/^\.+$/.test(safe)) safe = '';
964 if (safe === name && safe) return safe;
965 return (safe || 'folder') + '-' + hash36(name);
966 }
967
968 /// A short, stable hash of a string. Not a security property: it is a
969 /// suffix that keeps two different folder names in two different folders.
970 function hash36(s) {
971 var h = 5381;
972 for (var i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
973 return h.toString(36);
974 }
975
976 function mailboxDir(address, folder) {
977 var a = acct(address);
978 var name = folder || (a && a.folder) || 'INBOX';
979 var f = a ? fld(a, name) : null;
980 return mailDir(address) + '/' + ((f && f.dir) || dirFor(name));
981 }
982
983 // ── Writing a message ───────────────────────────────────────────
984 // RFC 5322 in the other direction. The gateway posts bytes rather than
985 // intentions — it opens one submission conversation with the user's provider and
986 // hands over a finished document — so the document is built here, in full, and
987 // nothing server-side decides what a message says or who it goes to.
988
989 function utf8(s) {
990 return new TextEncoder().encode(String(s == null ? '' : s));
991 }
992 /// Base64 a byte array, in chunks: `String.fromCharCode` blows the argument
993 /// limit on an attachment of any size.
994 function b64(bytes) {
995 var s = '', CH = 0x8000;
996 for (var i = 0; i < bytes.length; i += CH) {
997 s += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
998 }
999 return btoa(s);
1000 }
1001 function isAscii(s) {
1002 return !/[^\x20-\x7e]/.test(String(s == null ? '' : s));
1003 }
1004
1005 /// A header value with anything but plain ASCII in it, as RFC 2047 encoded-words.
1006 ///
1007 /// The words are chunked so no line runs past the 76-character limit, and the chunk
1008 /// boundary is taken at a *character*, never inside a multi-byte one — split a
1009 /// character across two encoded-words and the recipient decodes rubbish.
1010 function encodeWord(s) {
1011 s = String(s == null ? '' : s);
1012 if (isAscii(s)) return s;
1013 var out = [], chunk = '', bytes = 0;
1014 for (var i = 0; i < s.length; i++) {
1015 var ch = s[i];
1016 // A surrogate pair is one character and must not be halved.
1017 if (/[\uD800-\uDBFF]/.test(ch) && i + 1 < s.length) ch += s[++i];
1018 var n = utf8(ch).length;
1019 if (bytes + n > 39 && chunk) {
1020 out.push('=?utf-8?B?' + b64(utf8(chunk)) + '?=');
1021 chunk = ''; bytes = 0;
1022 }
1023 chunk += ch; bytes += n;
1024 }
1025 if (chunk) out.push('=?utf-8?B?' + b64(utf8(chunk)) + '?=');
1026 return out.join('\r\n ');
1027 }
1028
1029 /// One address as a header writes it: `Name <addr>`, with the name encoded if it
1030 /// needs it and quoted if it holds a character that would otherwise punctuate.
1031 function encodeAddr(a) {
1032 if (typeof a === 'string') a = splitAddr(a);
1033 if (!a || !a.addr) return '';
1034 if (!a.name) return a.addr;
1035 var nm = isAscii(a.name)
1036 ? (/[(),:;<>@\[\]".]/.test(a.name) ? '"' + a.name.replace(/(["\\])/g, '\\$1') + '"' : a.name)
1037 : encodeWord(a.name);
1038 return nm + ' <' + a.addr + '>';
1039 }
1040 /// Split a header's worth of addresses on the commas that separate them, ignoring
1041 /// the ones inside a quoted display name.
1042 function addrList(s) {
1043 var out = [], cur = '', q = false;
1044 String(s || '').split('').forEach(function (c) {
1045 if (c === '"') q = !q;
1046 if (c === ',' && !q) { out.push(cur); cur = ''; return; }
1047 cur += c;
1048 });
1049 out.push(cur);
1050 return out.map(function (x) { return x.trim(); }).filter(Boolean);
1051 }
1052 /// Just the addresses, which is what the envelope carries: a display name is for
1053 /// the reader, and the provider is not the reader.
1054 function addrsOf(s) {
1055 return addrList(s).map(function (x) { return splitAddr(x).addr; }).filter(Boolean);
1056 }
1057
1058 /// Quoted-printable, over the UTF-8 bytes.
1059 ///
1060 /// The rules that bite: a space or tab at the end of a line is invisible and would be
1061 /// stripped in transit, so it is encoded; a line is folded with a soft break before it
1062 /// reaches 76 characters; and a line beginning `From ` is escaped, because some
1063 /// software still treats one as the start of a new message.
1064 function encodeQP(text) {
1065 var bytes = utf8(String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
1066 var lines = [], line = '', held = '';
1067 function flush() { lines.push(line); line = ''; }
1068 function push(tok) {
1069 if (line.length + tok.length > 75) { lines.push(line + '='); line = ''; }
1070 line += tok;
1071 }
1072 for (var i = 0; i < bytes.length; i++) {
1073 var b = bytes[i];
1074 if (b === 0x0a) { // end of line
1075 if (held) { push(held === ' ' ? '=20' : '=09'); held = ''; }
1076 flush();
1077 continue;
1078 }
1079 if (held) { push(held); held = ''; }
1080 if (b === 0x20) { held = ' '; continue; }
1081 if (b === 0x09) { held = '\t'; continue; }
1082 if (b >= 33 && b <= 126 && b !== 61) push(String.fromCharCode(b));
1083 else push('=' + ('0' + b.toString(16).toUpperCase()).slice(-2));
1084 if (line === 'From' && i + 1 < bytes.length && bytes[i + 1] === 0x20) {
1085 line = '=46rom'; // a line may not begin "From "
1086 }
1087 }
1088 if (held) push(held === ' ' ? '=20' : '=09');
1089 flush();
1090 return lines.join('\r\n');
1091 }
1092 /// Base64, wrapped to the 76-character line a MIME body is allowed.
1093 function b64Lines(bytes) {
1094 return (b64(bytes).match(/.{1,76}/g) || []).join('\r\n');
1095 }
1096
1097 /// The date, as a mail header spells it. Built by hand rather than through
1098 /// `toLocaleString`, because the format is fixed and English and the user's locale
1099 /// is neither.
1100 function mailDate(d) {
1101 var DAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1102 var MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1103 var pad = function (n) { return ('0' + n).slice(-2); };
1104 var off = -d.getTimezoneOffset();
1105 var sign = off < 0 ? '-' : '+';
1106 off = Math.abs(off);
1107 return DAY[d.getDay()] + ', ' + d.getDate() + ' ' + MON[d.getMonth()] + ' ' + d.getFullYear()
1108 + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
1109 + ' ' + sign + pad(Math.floor(off / 60)) + pad(off % 60);
1110 }
1111 function rand(n) {
1112 var a = new Uint8Array(n || 8);
1113 crypto.getRandomValues(a);
1114 return Array.from(a).map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
1115 }
1116 function messageId(from) {
1117 return '<' + rand(10) + '.' + Date.now() + '@' + (domainOf(from) || 'daimond.local') + '>';
1118 }
1119
1120 /// Build the RFC 5322 document a draft describes.
1121 ///
1122 /// A draft with no attachment is one `text/plain` part; a draft with attachments is a
1123 /// `multipart/mixed` whose first part is that text. Nothing here is optional
1124 /// decoration: the `Message-ID` is what a reply to this message will point back at,
1125 /// and `In-Reply-To` / `References` are what make a reply *thread* in the recipient's
1126 /// client rather than arrive as an unrelated message with a similar subject.
1127 function buildMessage(d) {
1128 var from = { name: d.fromName || '', addr: d.from };
1129 var id = d.messageId || messageId(d.from);
1130 var h = [];
1131 h.push('Message-ID: ' + id);
1132 h.push('Date: ' + mailDate(new Date()));
1133 h.push('From: ' + encodeAddr(from));
1134 h.push('To: ' + addrList(d.to).map(encodeAddr).join(', '));
1135 if (String(d.cc || '').trim()) h.push('Cc: ' + addrList(d.cc).map(encodeAddr).join(', '));
1136 h.push('Subject: ' + encodeWord(d.subject || ''));
1137 if (d.inReplyTo) {
1138 h.push('In-Reply-To: ' + d.inReplyTo);
1139 h.push('References: ' + (d.references || d.inReplyTo));
1140 }
1141 h.push('MIME-Version: 1.0');
1142 h.push('User-Agent: Daimond');
1143
1144 var atts = d.attachments || [];
1145 if (!atts.length) {
1146 h.push('Content-Type: text/plain; charset=utf-8');
1147 h.push('Content-Transfer-Encoding: quoted-printable');
1148 return h.join('\r\n') + '\r\n\r\n' + encodeQP(d.body || '') + '\r\n';
1149 }
1150
1151 var bnd = '=_daimond_' + rand(12);
1152 h.push('Content-Type: multipart/mixed; boundary="' + bnd + '"');
1153 var out = h.join('\r\n') + '\r\n\r\n'
1154 + 'This is a message in MIME format.\r\n'
1155 + '--' + bnd + '\r\n'
1156 + 'Content-Type: text/plain; charset=utf-8\r\n'
1157 + 'Content-Transfer-Encoding: quoted-printable\r\n\r\n'
1158 + encodeQP(d.body || '') + '\r\n';
1159 atts.forEach(function (att) {
1160 var name = att.name || 'attachment';
1161 out += '--' + bnd + '\r\n'
1162 + 'Content-Type: ' + (att.type || 'application/octet-stream') + '\r\n'
1163 + 'Content-Transfer-Encoding: base64\r\n'
1164 + 'Content-Disposition: attachment; filename="' + encodeWord(name).replace(/"/g, '') + '"\r\n\r\n'
1165 + b64Lines(att.bytes) + '\r\n';
1166 });
1167 out += '--' + bnd + '--\r\n';
1168 return out;
1169 }
1170
1171 /// Where a message is posted from, which is not where it was read from: submission is
1172 /// a different server on a different port, and a preset knows both. An account the
1173 /// user configured by hand wins over the guess.
1174 function smtpFor(a) {
1175 var p = PRESETS[domainOf(a.address)] || {};
1176 var host = a.smtpHost || p.smtpHost || ('smtp.' + domainOf(a.address));
1177 var port = parseInt(a.smtpPort || p.smtpPort || 587, 10);
1178 // 465 is encrypted from the first byte; 587 starts in the clear and must upgrade
1179 // before the password is spoken. A mailbox may say otherwise — a test server on
1180 // loopback speaks neither — and what the account says wins over what the port implies.
1181 return {
1182 host: host,
1183 port: port,
1184 security: a.smtpSecurity || (port === 465 ? 'tls' : 'starttls'),
1185 };
1186 }
1187
1188 // ── Drafts ──────────────────────────────────────────────────────
1189 // A draft is a file: `mail/<address>/drafts/<id>.eml`, the same RFC 5322 bytes that
1190 // would go on the wire. That makes it legible to every file tool the agent already
1191 // has — which is the whole of the agent's access to sending. It may WRITE a draft
1192 // here for the user to read, correct and send; it has no tool that puts a message on
1193 // the wire, and it is not going to be given one. Only a person pressing Send sends.
1194 //
1195 // A draft is also the one thing here that exists NOWHERE ELSE. A synced message can be
1196 // fetched again from the server; a draft is on no server and in no gateway, which is why
1197 // the mail migration copies rather than moves and never deletes anything.
1198
1199 function draftsDir(address) { return mailDir(address) + '/drafts'; }
1200 function sentDir(address) { return mailDir(address) + '/sent'; }
1201
1202 async function saveDraft(d) {
1203 if (!d.from) throw new Error(t('mail.err.draft_needs_mailbox'));
1204 d.id = d.id || ('draft-' + Date.now() + '-' + rand(3));
1205 d.messageId = d.messageId || messageId(d.from);
1206 var path = draftsDir(d.from) + '/' + d.id + '.eml';
1207 await deps.writeBytes(path, utf8(buildMessage(d)));
1208 if (deps.refreshFiles) deps.refreshFiles();
1209 return path;
1210 }
1211
1212 /// Every draft held for a mailbox, newest first — including any an agent wrote.
1213 async function listDrafts(address) {
1214 var dir = draftsDir(address);
1215 var listing;
1216 try { listing = await deps.runTool('file_list', { path: dir }); }
1217 catch (e) { return []; }
1218 // A LISTING THAT DID NOT HAPPEN IS NOT AN EMPTY FOLDER. The test read the
1219 // sentence for `Error`, which a refusal does not open with, so a drafts folder
1220 // the fence had closed was parsed for `.eml` names and reported as no drafts.
1221 if (!listing || listing.outcome !== 'done') return [];
1222 var names = listing.text.split('\n').map(function (l) {
1223 var m = l.match(/^\s*(?:[-*]\s*)?(\S.*?)(?:\s+\(\d+.*\))?\s*$/);
1224 return m ? m[1].trim().replace(/\/$/, '') : '';
1225 }).filter(function (n) { return /\.eml$/i.test(n); });
1226
1227 var out = [];
1228 for (var i = 0; i < names.length; i++) {
1229 var path = dir + '/' + names[i];
1230 var raw = await readText(path);
1231 if (raw.outcome !== 'done') continue;
1232 var hs = parseHeaders(raw.text);
1233 out.push({
1234 path: path,
1235 id: names[i].replace(/\.eml$/i, ''),
1236 to: decodeWords(header(hs, 'to')),
1237 subject: decodeWords(header(hs, 'subject')) || t('mail.no_subject'),
1238 date: header(hs, 'date'),
1239 });
1240 }
1241 out.sort(function (x, y) { return (Date.parse(y.date) || 0) - (Date.parse(x.date) || 0); });
1242 return out;
1243 }
1244
1245 /// Read a draft file back into the thing the compose panel edits. A draft an agent
1246 /// wrote is an ordinary message file, so it opens the same way.
1247 async function readDraft(address, path) {
1248 var raw = await readText(path);
1249 if (raw.outcome !== 'done') {
1250 throw new Error(t('mail.err.draft_unreadable'));
1251 }
1252 var hs = parseHeaders(raw.text);
1253 var mime = parseMime(raw.text, 0);
1254 var f = splitAddr(header(hs, 'from'));
1255 return {
1256 id: (path.split('/').pop() || '').replace(/\.eml$/i, ''),
1257 path: path,
1258 from: f.addr || address,
1259 fromName: f.name,
1260 to: decodeWords(header(hs, 'to')),
1261 cc: decodeWords(header(hs, 'cc')),
1262 subject: decodeWords(header(hs, 'subject')),
1263 body: mime.plain || (mime.html ? stripHtml(mime.html) : ''),
1264 inReplyTo: header(hs, 'in-reply-to'),
1265 references: header(hs, 'references'),
1266 messageId: header(hs, 'message-id'),
1267 attachments: mime.attachments,
1268 };
1269 }
1270
1271 async function discardDraft(d) {
1272 if (!d.path && !d.id) return;
1273 var path = d.path || (draftsDir(d.from) + '/' + d.id + '.eml');
1274 try { await deps.runTool('file_delete', { path: path }); } catch (e) { /* never existed */ }
1275 if (deps.refreshFiles) deps.refreshFiles();
1276 }
1277
1278 // ── Sending ─────────────────────────────────────────────────────
1279
1280 /// Post a draft through the user's own provider.
1281 ///
1282 /// The envelope recipients are the addresses in To and Cc, and they are named to the
1283 /// gateway explicitly: a `To:` header is text a person reads, and the envelope is the
1284 /// instruction the provider acts on. Keeping them one list built here means the two
1285 /// cannot drift apart.
1286 async function sendDraft(d) {
1287 var a = acct(d.from);
1288 if (!a) throw new Error(t('mail.err.send_from_added'));
1289 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
1290 throw new Error(t('mail.err.unlock_first'));
1291 }
1292 var rcpt = addrsOf(d.to).concat(addrsOf(d.cc));
1293 if (!rcpt.length) throw new Error(t('mail.err.no_recipients'));
1294
1295 var smtp = smtpFor(a);
1296 var raw = buildMessage(d);
1297 var payload = b64(utf8(raw));
1298 var password = await DaimondIdentity.unwrap(a.pass);
1299
1300 var j = await post('/api/mail/send', {
1301 address: a.address,
1302 host: smtp.host,
1303 port: smtp.port,
1304 security: smtp.security,
1305 user: a.user || a.address,
1306 password: password,
1307 rcpt: rcpt,
1308 raw: payload,
1309 });
1310
1311 // A sent message is a file too, so "what did I send them" is answerable by the
1312 // same agent, with the same tools, as "what did they send me".
1313 try {
1314 await deps.writeBytes(sentDir(a.address) + '/' + (d.id || rand(6)) + '.eml', utf8(raw));
1315 } catch (e) { /* the mail is gone whatever the local copy did */ }
1316 await discardDraft(d);
1317 return j;
1318 }
1319
1320 // ── The sync ────────────────────────────────────────────────────
1321
1322 /// Sync a mailbox.
1323 ///
1324 /// A sync normally walks *forwards*: it asks for what arrived after the newest message already
1325 /// held. With `older` set it reaches *backwards* instead, for the batch just below the oldest
1326 /// message held — which is the only way to reach mail older than the first batch, since a
1327 /// mailbox is never pulled down whole.
1328 /// `auto` marks a poll the schedule asked for rather than the user. It changes
1329 /// nothing about what is fetched — only how loudly the panel narrates it, and
1330 /// whether a refusal is worth saying out loud to somebody who did not ask.
1331 /// What is running now, so a second sync can wait for it instead of vanishing.
1332 ///
1333 /// `state.busy` used to make `syncAccount` return at once, and every caller is
1334 /// fire-and-forget -- so a fetch asked for while another was in flight simply did
1335 /// not happen, silently, with the panel showing whatever was already on disk.
1336 /// That is how `selectFolder` opened a folder for the first time and never
1337 /// fetched it: it fires its first-batch sync without awaiting, and the folder
1338 /// LIST refresh that runs beside it is enough to be holding `busy`.
1339 ///
1340 /// Phase G made it much worse rather than causing it, by adding a background
1341 /// poll that holds `busy` on its own schedule.
1342 var syncTurn = Promise.resolve();
1343
1344 /// Fetch one folder.
1345 ///
1346 /// A sync the USER asked for waits its turn; one the SCHEDULE asked for is still
1347 /// dropped when something else is running, because the schedule comes round again
1348 /// and a queue of automatic polls is a queue of bills. `auto` already carries
1349 /// exactly that distinction.
1350 function syncAccount(address, older, folder, auto) {
1351 if (auto && state.busy) return Promise.resolve();
1352 // `finally` on both arms: a sync that threw must not stop the next one, and
1353 // a rejected chain would strand every later fetch for the life of the tab.
1354 var next = syncTurn.then(
1355 function () { return syncOne(address, older, folder, auto); },
1356 function () { return syncOne(address, older, folder, auto); });
1357 syncTurn = next.then(function () {}, function () {});
1358 return next;
1359 }
1360
1361 async function syncOne(address, older, folder, auto) {
1362 var a = acct(address);
1363 if (!a) return;
1364 var name = folder || a.folder || 'INBOX';
1365 var f = fld(a, name);
1366 if (older && !f.firstUid) return; // nothing held, so nothing to reach back from
1367
1368 // REFUSED WHERE THE REQUEST IS MADE. `gwFetch` refuses it again at the
1369 // wire (gateway.js:269), which is what makes the hold real; this is the
1370 // half that keeps a held folder from starting a sync it cannot finish,
1371 // and that names the control to press. A scheduler that respected a pause
1372 // and a fetch that did not would be decoration.
1373 var stop = pollStop(address, name);
1374 if (stop) {
1375 if (!auto) { state.err = pausedWords(stop); state.note = ''; render(); }
1376 return;
1377 }
1378
1379 state.busy = true; state.err = '';
1380 // When this folder was last TRIED, which is what the schedule counts from.
1381 // See `dueAt`: a failure must cost an interval, not nothing.
1382 f.lastTry = Date.now();
1383 // An automatic poll says nothing on the way in. A line that appeared every
1384 // five minutes to announce a sync nobody asked for would train the reader
1385 // to ignore the one place this panel has to say anything.
1386 state.note = auto ? state.note
1387 : t(older ? 'mail.note.fetching_older' : 'mail.note.syncing',
1388 { address: address, folder: labelFor(a, name) });
1389 render();
1390 try {
1391 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
1392 throw new Error(t('mail.err.unlock_first'));
1393 }
1394 var password = await DaimondIdentity.unwrap(a.pass);
1395
1396 var body = {
1397 address: a.address,
1398 host: a.host,
1399 port: a.port || 993,
1400 // 993 is TLS from the first byte; 143 starts in the clear and
1401 // must upgrade before the password is sent. Without this the
1402 // gateway assumed TLS on both, so port 143 could never work.
1403 security: a.security || (a.port === 143 ? 'starttls' : 'tls'),
1404 user: a.user || a.address,
1405 password: password,
1406 // The SERVER's spelling, not the flattened directory name: this
1407 // is the string it will SELECT.
1408 mailbox: name,
1409 };
1410 if (older) body.before_uid = f.firstUid;
1411 else body.since_uid = f.lastUid || 0;
1412
1413 // Read BEFORE the fetch, because the rebuild below moves both of them
1414 // and the arrival test at the bottom is a question about the folder as
1415 // it stood a moment ago.
1416 var mark = f.lastUid || 0; // the high-water mark this fetch starts from
1417 var known = !!f.lastSync; // has this folder ever been fetched?
1418 var rebuilt = false; // did the generation change under us?
1419
1420 var j = await post('/api/mail/sync', body);
1421
1422 // The mailbox generation changed, so every UID held locally names a
1423 // different message now — or no message. Start again. It is the
1424 // FOLDER's generation: two folders on one server have two of them.
1425 if (f.uidValidity && j.uid_validity && j.uid_validity !== f.uidValidity) {
1426 f.lastUid = 0;
1427 f.firstUid = 0;
1428 // The re-fetch below asks for the folder from uid 0, so everything in
1429 // it comes back. That is a rebuild, not a delivery, and the arrival
1430 // test at the bottom of this function has to be told.
1431 rebuilt = true;
1432 // KNOWN AND NOT FIXED HERE: the old generation's files stay on disk.
1433 // A Maildir name carries the generation it was fetched under
1434 // (`<uid>.<uidValidity>.daimond:2,`), so the re-fetch below writes
1435 // every message again under a new name and nothing removes the old
1436 // copies -- the panel then shows each message twice, and the folder
1437 // row's count climbs with every change of generation. Measured, in a
1438 // session that left thirteen copies of three messages on disk.
1439 //
1440 // Not fixed in this pass because the fix DELETES A USER'S MAIL, and
1441 // `verify_mailfolders` cannot currently tell one run's files from
1442 // another's (see the generation check in that file) -- so there is no
1443 // way to prove the deletion right before shipping it. It needs a test
1444 // that can see, and that needs the instrument fixed first.
1445 f.uidValidity = j.uid_validity;
1446 save();
1447 state.note = t('mail.note.rebuilt');
1448 render();
1449 j = await post('/api/mail/sync', Object.assign({}, body, { since_uid: 0, before_uid: 0 }));
1450 }
1451 f.uidValidity = j.uid_validity || f.uidValidity;
1452
1453 var msgs = j.messages || [];
1454 for (var i = 0; i < msgs.length; i++) {
1455 var m = msgs[i];
1456 var bytes = Uint8Array.from(atob(m.raw), function (c) { return c.charCodeAt(0); });
1457 var path = mailboxDir(a.address, name) + '/cur/'
1458 + maildirName(m.uid, f.uidValidity, m.flags);
1459 await deps.writeBytes(path, bytes);
1460 if (m.uid > (f.lastUid || 0)) f.lastUid = m.uid;
1461 // The oldest UID held is the floor a later "fetch older" reaches back from.
1462 if (!f.firstUid || m.uid < f.firstUid) f.firstUid = m.uid;
1463 }
1464 // A trigger watches for mail ARRIVING, and this is the only place that
1465 // knows any has. Announced rather than called directly: mail must not
1466 // have to know what a triggered action is, and a second listener --
1467 // a badge, a sound, a notification -- costs nothing to add later.
1468 //
1469 // ARRIVING IS NARROWER THAN "MESSAGES CAME BACK", and the difference is
1470 // money: what hears this fires a triggered action, which is a Diamond
1471 // spending without being asked. Three occasions return messages and are
1472 // not arrivals:
1473 //
1474 // * a "fetch older" backfill, which reaches BELOW what is held. Every
1475 // message it brings is one the user has had for months, and pressing
1476 // the button was itself the asking;
1477 // * a `uidValidity` rebuild, which has just re-fetched the folder from
1478 // uid 0. The whole mailbox comes back, so announcing it is a bill the
1479 // size of the mailbox;
1480 // * the first fetch of a folder nobody has fetched before. That is a
1481 // baseline, not a delivery: a trigger armed before the account was
1482 // added would otherwise fire on everything already in it. It costs
1483 // one missed firing, once per folder, and bounds the worst case.
1484 //
1485 // What is left is what came in ABOVE the mark this fetch started from.
1486 // The uids travel with it so a listener can say WHICH messages it acted
1487 // on, rather than only how many.
1488 //
1489 // The mark and the `older` test OVERLAP deliberately: a backfill cannot
1490 // return anything above the mark while the server honours `before_uid`,
1491 // so a well-behaved one is refused twice. The rebuild is the case where
1492 // the mark is no defence at all -- a generation change RENUMBERS, and the
1493 // new uids are commonly far above the old ones -- which is why it has to
1494 // say so itself. See dev/verify_mailtrigger.mjs, which breaks each fence
1495 // in turn.
1496 var fresh = (older || rebuilt || !known)
1497 ? []
1498 : msgs.filter(function (m) { return m.uid > mark; });
1499 if (fresh.length) {
1500 try {
1501 window.dispatchEvent(new CustomEvent('daimond:mail-arrived', {
1502 detail: {
1503 mailbox: a.address,
1504 folder: name,
1505 count: fresh.length,
1506 uids: fresh.map(function (m) { return m.uid; }),
1507 },
1508 }));
1509 } catch (e) { /* an old browser: the sync still happened */ }
1510 }
1511 // What the cap left behind, so the panel can offer to go back for it.
1512 f.heldBack = j.held_back || 0;
1513 f.limit = j.limit || f.limit || 0;
1514 f.lastSync = Date.now();
1515 a.lastSync = f.lastSync; // the account's row shows its latest sync
1516 save();
1517
1518 await rebuildIndex(a, name);
1519 await loadDigest(a.address, name);
1520 save(); // the count `loadDigest` just took, kept across a reload
1521 var parts = [];
1522 if (!msgs.length) {
1523 // An automatic poll that found nothing leaves the panel as it was.
1524 // The folder row already carries the count and its as-at, which is
1525 // where "I looked and there was nothing" belongs.
1526 if (auto) { state.note = state.note || ''; return; }
1527 parts.push(t(older ? 'mail.note.no_older' : 'mail.note.up_to_date'));
1528 } else {
1529 parts.push(tn(older ? 'mail.note.older' : 'mail.note.new', msgs.length));
1530 if (j.charged_minor) parts.push(fmtMinor(j.charged_minor));
1531 if (f.heldBack) parts.push(tn('mail.note.still_older', f.heldBack));
1532 }
1533 state.note = parts.join(' · ');
1534 if (deps.refreshFiles) deps.refreshFiles();
1535 } catch (e) {
1536 state.err = friendly(e);
1537 state.note = '';
1538 } finally {
1539 state.busy = false;
1540 render();
1541 }
1542 }
1543
1544 /// Walk the whole mailbox down, a batch at a time, until nothing is left on the server.
1545 ///
1546 /// This is the one action that can pull ten years of mail across the wire, so it says what it
1547 /// is about to do before it does it, reports progress while it runs, and stops the moment it
1548 /// is asked to. Every batch is an ordinary sync, so a run that is stopped — or that fails
1549 /// halfway — leaves the mailbox exactly as consistent as it would have been anyway, and can be
1550 /// resumed later.
1551 async function fetchAll(address) {
1552 var a = acct(address);
1553 if (!a || state.busy) return;
1554 var name = a.folder || 'INBOX';
1555 var f = fld(a, name);
1556 if (!f.heldBack) return;
1557
1558 var total = f.heldBack;
1559 var ok = await deps.confirm(
1560 t('mail.all.title', { n: fmtCount(total) }),
1561 t('mail.all.body', { batch: f.limit || 25 }),
1562 { ok: t('mail.all.ok') });
1563 if (!ok) return;
1564
1565 state.draining = true;
1566 var got = 0;
1567 while (state.draining) {
1568 var before = f.firstUid;
1569 await syncAccount(address, true, name); // one batch older
1570 a = acct(address);
1571 if (!a) break;
1572 f = fld(a, name);
1573 // No progress means the server has nothing further below what we hold: stop, rather
1574 // than ask again forever.
1575 if (!f.firstUid || f.firstUid === before) break;
1576 got = total - (f.heldBack || 0);
1577 if (!f.heldBack) break;
1578 if (state.draining) {
1579 state.note = t('mail.all.progress',
1580 { got: fmtCount(got), total: fmtCount(total) });
1581 render();
1582 }
1583 }
1584 var stopped = !state.draining;
1585 state.draining = false;
1586 a = acct(address);
1587 f = a ? fld(a, name) : null;
1588 var count = tn('mail.all.count', got, { n: fmtCount(got) });
1589 state.note = (f && f.heldBack)
1590 ? t(stopped ? 'mail.all.stopped_left' : 'mail.all.done_left',
1591 { count: count, left: fmtCount(f.heldBack) })
1592 : t(stopped ? 'mail.all.stopped' : 'mail.all.done', { count: count });
1593 render();
1594 }
1595
1596 /// A digest of the mailbox, written where the agents look. Without it, an
1597 /// agent asked "what is in my inbox" has to open every message to find out.
1598 async function rebuildIndex(a, folder) {
1599 var name = folder || a.folder || 'INBOX';
1600 var f = fld(a, name);
1601 var msgs = await readMailbox(a.address, name);
1602 // English, and deliberately so: this file is written for the agents'
1603 // file tools to read, and a digest whose column headings move with the
1604 // interface language would be a moving target for every prompt.
1605 var lines = [
1606 '# ' + a.address + ' — ' + name,
1607 '',
1608 'Synced ' + new Date(f.lastSync || Date.now()).toISOString() + '. '
1609 + msgs.length + ' message' + (msgs.length === 1 ? '' : 's') + '.',
1610 'The full message is the file named in the last column.',
1611 '',
1612 '| UID | Date | From | Subject | File |',
1613 '|----:|------|------|---------|------|',
1614 ];
1615 msgs.slice().reverse().forEach(function (m) {
1616 var cell = function (s) { return String(s || '').replace(/\|/g, '\\|').replace(/\n/g, ' '); };
1617 lines.push('| ' + m.uid + ' | ' + cell(m.date) + ' | ' + cell(m.from)
1618 + ' | ' + cell(m.subject) + ' | `' + cell(m.file) + '` |');
1619 });
1620 await deps.runTool('file_write', {
1621 path: mailboxDir(a.address, name) + '/index.md',
1622 content: lines.join('\n') + '\n',
1623 });
1624 }
1625
1626 /// Read the mailbox back off disk. The files are the truth; nothing about a
1627 /// message is cached anywhere else, so a mailbox survives a wiped
1628 /// localStorage and is legible to anything that can read a folder.
1629 async function readMailbox(address, folder) {
1630 var dir = mailboxDir(address, folder) + '/cur';
1631 var listing;
1632 try {
1633 listing = await deps.runTool('file_list', { path: dir });
1634 } catch (e) {
1635 return []; // the workspace is not up yet
1636 }
1637 // Refused, failed and empty are three different answers, and only one of them
1638 // means the mailbox has nothing in it.
1639 if (!listing || listing.outcome !== 'done') return [];
1640 var out = [];
1641 var names = listing.text.split('\n').map(function (l) {
1642 var m = l.match(/^\s*(?:[-*]\s*)?(\S.*?)(?:\s+\(\d+.*\))?\s*$/);
1643 return m ? m[1].trim() : '';
1644 }).filter(function (n) { return n && n.indexOf(':2,') > 0; });
1645
1646 for (var i = 0; i < names.length; i++) {
1647 var name = names[i].replace(/\/$/, '');
1648 var raw = await readText(dir + '/' + name);
1649 if (raw.outcome !== 'done') continue;
1650 var hs = parseHeaders(raw.text);
1651 out.push({
1652 uid: parseInt(name.split('.')[0], 10) || 0,
1653 file: dir + '/' + name,
1654 from: decodeWords(header(hs, 'from')),
1655 subject: decodeWords(header(hs, 'subject')) || t('mail.no_subject'),
1656 date: header(hs, 'date'),
1657 seen: /:2,[^,]*S/.test(name),
1658 });
1659 }
1660 out.sort(function (x, y) { return x.uid - y.uid; });
1661 return out;
1662 }
1663
1664 /// Read one folder's digest, and adopt it as what the panel SHOWS only when
1665 /// that folder is the one on screen.
1666 ///
1667 /// `state.msgs` is a property of the SELECTION; a count is a property of the
1668 /// folder. Conflating them made the list flicker on every manual refresh:
1669 /// `refreshAll` walks every folder of every mailbox in turn, each sync ends
1670 /// here, and an unconditional assignment let Sent, then Spam, then Trash each
1671 /// replace the INBOX the user was reading — appearing, emptying and
1672 /// reappearing as the walk went by, and leaving whichever folder happened to
1673 /// sync last on screen. A Gmail account, with its labels, does this a dozen
1674 /// times per refresh.
1675 async function loadDigest(address, folder) {
1676 var a = acct(address);
1677 // The same defaulting as `mailboxDir`, so the folder read is the folder
1678 // counted and the folder compared.
1679 var name = folder || (a && a.folder) || 'INBOX';
1680 var msgs = await readMailbox(address, name);
1681 if (address === state.sel && a && name === (a.folder || 'INBOX')) {
1682 state.msgs = msgs;
1683 }
1684 // What the folder holds, recorded where a row can read it without listing
1685 // the directory again — the panel draws a dozen rows and reads none of
1686 // them off disk. These are the messages the server handed over, so the
1687 // number's as-at is the folder's last sync and nothing fresher: nothing
1688 // new lands in a Maildir without a sync putting it there. Recorded for
1689 // every folder, selected or not, because that is what a row shows.
1690 if (!a) return;
1691 var f = fld(a, name);
1692 if (f) f.count = msgs.length;
1693 }
1694
1695 // ── The tunnel ──────────────────────────────────────────────────
1696 //
1697 // BUILT AND NOT YET CARRYING MAIL. Nothing above this line calls into it: the
1698 // three fetch paths still use the bridge, for the reason in the file header. What
1699 // is here is the whole client half of the blind tunnel, exercised by
1700 // `dev/verify_mailtunnel.mjs` against a real provider and a real bad certificate,
1701 // waiting on two protocol exports. Finishing it is moving three call sites.
1702 //
1703 // TLS in the page. The wasm side is `src/wasm/mailtls.rs` — a rustls client
1704 // compiled to wasm32 with the Mozilla root store bundled — and it owns no socket.
1705 // This side owns the socket and no key. Ciphertext goes out through
1706 // `mail_tunnel_take` and comes in through `mail_tunnel_feed`; the plaintext of the
1707 // conversation never leaves the page at all.
1708 //
1709 // WHAT THE GATEWAY CAN STILL SEE, once this is the transport, and what nobody may
1710 // write "we see nothing" about: which host, when, how many bytes each way, and for
1711 // how long. Traffic analysis survives a blind pipe.
1712 //
1713 // Nothing here parses a TLS record, and nothing here parses IMAP either: see the
1714 // seam below for why the protocol is not written in JavaScript.
1715
1716 /// The gateway route that upgrades to the pipe. Same origin: Steel front-proxies
1717 /// `/api/*` to the gateway on loopback, so the session cookie rides along as an
1718 /// ordinary same-origin cookie and this file sends no credential of its own. The
1719 /// query string is carried through the hop verbatim, which is why `host`, `port`
1720 /// and `security` travel there rather than in a first frame.
1721 var TUNNEL_PATH = '/api/mail/tunnel';
1722
1723 // ONE TUNNEL PER CONVERSATION, OPENED AND CLOSED. Never held between polls.
1724 //
1725 // The gateway closes an idle tunnel after 300 seconds and any tunnel after 1800,
1726 // and its own keepalive ping deliberately does not reset the idle clock — a
1727 // keepalive that did would mean nothing is ever idle. Every refresh interval this
1728 // panel offers except the fastest is longer than that window, and the fastest
1729 // would race it, so a held socket would be closed under us every time. Each of
1730 // `tunnelSync`, `tunnelFolders` and `tunnelSend` therefore opens one, converses,
1731 // and closes it in a `finally`.
1732 //
1733 // Nothing is lost by that: this file has been request/response throughout since it
1734 // was written, with no `IDLE`, no held socket and no push of any kind. The window
1735 // forbids a capability mail here has never had.
1736
1737 /// Longest a handshake may take before the tunnel is given up on, in ms.
1738 ///
1739 /// It is a backstop and not the usual way a bad connection ends: a refused
1740 /// certificate lands in `failed` within a round trip, and every gateway refusal
1741 /// arrives as a close code. This catches the case where the far end accepted the
1742 /// socket and then said nothing.
1743 var TUNNEL_OPEN_MS = 20000;
1744
1745 /// Most that goes into one frame, in bytes. Half the gateway's 128 KiB ceiling,
1746 /// so a record that straddles a split still cannot reach it.
1747 var FRAME_MAX = 64 * 1024;
1748
1749 /// The wasm namespace, once.
1750 async function engine() {
1751 if (!pkgP) pkgP = import(PKG);
1752 return pkgP;
1753 }
1754
1755 /// The socket's URL. Host, port and security, and nothing else — a URL is the one
1756 /// place a secret is hardest to get back out of, because it is in the request
1757 /// line, and the gateway logs request lines.
1758 function tunnelUrl(host, port, security) {
1759 var scheme = (location.protocol === 'https:') ? 'wss:' : 'ws:';
1760 return scheme + '//' + location.host + TUNNEL_PATH
1761 + '?host=' + encodeURIComponent(host)
1762 + '&port=' + encodeURIComponent(String(port))
1763 + '&security=' + encodeURIComponent(security);
1764 }
1765
1766 /// What a close code means to the person waiting on their mail.
1767 ///
1768 /// The codes arrive on `ws.onclose` and the wasm cannot see them, so this mapping
1769 /// is this file's and is the only place it exists.
1770 ///
1771 /// THE PAIR IS THE KEY, NEVER THE CODE ALONE. `4403` and `4429` are each
1772 /// overloaded and the gateway's own reason word is the only discriminator
1773 /// (`gateway/src/handlers/mail_tunnel.rs`, and the vocabulary table in
1774 /// `dev/SOCIAL_OFFICE_CONTRACT.md`). The halves name different repairs: `port` is
1775 /// a number to correct and `host` is a mailbox to bind, `unresolved` is a name
1776 /// that does not resolve and `credits` is a top-up while `concurrent` is a tab to
1777 /// close. A single sentence per code would be a sentence nobody can act on — and
1778 /// `host` against `unresolved` is the pair that once let a test pass with the
1779 /// check it was named after switched off, because both refusals said `host`.
1780 ///
1781 /// NOTHING HERE IS SAID AFTER A SUCCESSFUL FETCH. This is reached only from a
1782 /// throw, which is to say only when the socket closed while a verb was still
1783 /// waiting. A tunnel is opened per conversation and closed, so `1000 done` is the
1784 /// ordinary end of every sync — and a warning printed after each one would teach
1785 /// the reader to ignore the warnings that matter.
1786 function closeWords(code, reason, host, port) {
1787 var r = String(reason || '');
1788 switch (code) {
1789 case 4401: return t('mail.tunnel.close.auth');
1790 case 4402: return t('mail.tunnel.close.pro');
1791 case 4403: if (r === 'port') return t('mail.tunnel.close.port', { port: port || 0 });
1792 if (r === 'unresolved') return t('mail.tunnel.close.unresolved', { host: host || '' });
1793 return t('mail.tunnel.close.host', { host: host || '' });
1794 case 4429: return r === 'concurrent'
1795 ? t('mail.tunnel.close.concurrent')
1796 : t('mail.tunnel.close.credits');
1797 case 1009: return t('mail.tunnel.close.toobig');
1798 case 1013: return t('mail.tunnel.close.unreachable', { host: host || '' });
1799 // Not the gateway's: 1006 is what a browser reports when the socket never
1800 // opened at all — no gateway, or something in the way of the upgrade.
1801 case 1006: return t('mail.tunnel.close.no_socket');
1802 }
1803 // Three endings share 1000, so the reason word is all there is to tell them
1804 // apart. None of the three is a fault in the mail; each is a fetch to repeat.
1805 if (r === 'idle') return t('mail.tunnel.close.idle');
1806 if (r === 'expired') return t('mail.tunnel.close.expired');
1807 if (r === 'done') return t('mail.tunnel.close.done');
1808 return t('mail.tunnel.close.other', { code: code });
1809 }
1810
1811 /// What a refused certificate says to a person.
1812 ///
1813 /// `fault` is rustls's own discriminant, verbatim — `InvalidCertificate(UnknownIssuer)`
1814 /// and the like. Every sentence carries it, because the three named below do not
1815 /// cover every refusal and a fault the user cannot see is a fault nobody can
1816 /// report.
1817 function certWords(host, fault) {
1818 var f = String(fault || '');
1819 if (/NotValidForName/.test(f)) return t('mail.tunnel.err.cert_name', { host: host, fault: f });
1820 if (/Expired/.test(f)) return t('mail.tunnel.err.cert_expired', { host: host, fault: f });
1821 if (/UnknownIssuer/.test(f)) return t('mail.tunnel.err.cert_issuer', { host: host, fault: f });
1822 return t('mail.tunnel.err.cert', { host: host, fault: f });
1823 }
1824
1825 /// Open one tunnel to one mail server.
1826 ///
1827 /// The returned object owns the socket and the wasm handle together, because
1828 /// neither is any use without the other. It resolves as soon as the socket is up:
1829 /// `ready` is what waits for the encrypted channel, since a STARTTLS tunnel is
1830 /// deliberately in the clear for the line or two before its promotion.
1831 ///
1832 /// EVERY CALLER MUST `close()`, in a `finally`. A tunnel holds a socket at the
1833 /// gateway and a socket at the provider, an account may hold only four at once,
1834 /// and the gateway charges for the bytes either way.
1835 async function openTunnel(spec) {
1836 var wasm = await engine();
1837 var host = String((spec && spec.host) || '').trim();
1838 var port = parseInt((spec && spec.port), 10) || 0;
1839 // 993 and 465 are TLS from the first byte; 143 and 587 begin in the clear and
1840 // must be promoted before the password is spoken. What the account says wins
1841 // over what the port implies, exactly as it did over the old bridge.
1842 var sec = ((spec && spec.security) === 'starttls') ? 'starttls' : 'tls';
1843 if (!host || !port) throw new Error(t('mail.tunnel.err.no_server'));
1844
1845 var h;
1846 try { h = wasm.mail_tunnel_open(host, port, sec); }
1847 catch (e) { throw new Error(t('mail.tunnel.err.no_client', { reason: friendly(e) })); }
1848
1849 var ws = new WebSocket(tunnelUrl(host, port, sec));
1850 ws.binaryType = 'arraybuffer';
1851
1852 var gone = null; // { code, reason } once the socket has closed
1853 var waiters = []; // one-shot, woken by anything that can move the state
1854
1855 function wake() {
1856 var w = waiters;
1857 waiters = [];
1858 w.forEach(function (f) { f(); });
1859 }
1860
1861 /// Wait for the next thing that could change the answer, or `ms`.
1862 function step(ms) {
1863 return new Promise(function (res) {
1864 var done = false;
1865 var fire = function () { if (!done) { done = true; clearTimeout(tm); res(); } };
1866 var tm = setTimeout(fire, Math.max(5, ms));
1867 waiters.push(fire);
1868 });
1869 }
1870
1871 /// Ciphertext out. Called after everything that can queue a record: the
1872 /// socket opening (which releases the ClientHello), a frame arriving, a
1873 /// plaintext write, and a STARTTLS promotion.
1874 function pump() {
1875 if (ws.readyState !== 1) return;
1876 var out;
1877 try { out = wasm.mail_tunnel_take(h); }
1878 catch (e) { return; } // the handle is closed; `state` will say so
1879 if (!out || !out.length) return;
1880 // SPLIT, because the gateway closes 1009 on an assembled message over
1881 // 128 KiB and a take can return more than that: several TLS records queue
1882 // behind one flush whenever a fetch pipelines. A frame limit met by
1883 // construction beats a close code the user has to read.
1884 for (var i = 0; i < out.length; i += FRAME_MAX) {
1885 ws.send(out.subarray(i, Math.min(i + FRAME_MAX, out.length)));
1886 }
1887 }
1888
1889 /// The tunnel's own state, and `failed` is what it answers first.
1890 ///
1891 /// `mail_tunnel_state` puts `failed` ahead of everything else because rustls
1892 /// abandons a handshake mid-flight on a refused certificate — it does not
1893 /// come back to say so. A caller that tested `open` first would wait for
1894 /// ever, and that defect was found and fixed once already in the wasm. This
1895 /// end must not reintroduce it: nothing here tests for `open` before it has
1896 /// tested for `failed`.
1897 function state() {
1898 try { return wasm.mail_tunnel_state(h); }
1899 catch (e) { return 'closed'; }
1900 }
1901
1902 function fault() {
1903 try { return wasm.mail_tunnel_fault(h) || ''; }
1904 catch (e) { return ''; }
1905 }
1906
1907 ws.onopen = function () { pump(); wake(); };
1908 ws.onmessage = function (ev) {
1909 try {
1910 wasm.mail_tunnel_feed(h, new Uint8Array(ev.data));
1911 pump();
1912 } catch (e) {
1913 // A feed that threw is a broken stream, not a protocol failure, and
1914 // `state` cannot report it. Recorded so `ready` has something to say.
1915 gone = gone || { code: 1002, reason: 'feed' };
1916 }
1917 wake();
1918 };
1919 // An error is always followed by a close, per the WebSocket specification, so
1920 // there is nothing for this arm to do but keep the browser from logging an
1921 // unhandled event. `gone` is set by `onclose`, with the code.
1922 ws.onerror = function () { };
1923 ws.onclose = function (ev) {
1924 gone = { code: ev.code, reason: ev.reason || '' };
1925 wake();
1926 };
1927
1928 var tun = {
1929 handle: h,
1930 host: host,
1931 port: port,
1932 security: sec,
1933 state: state,
1934 fault: fault,
1935 /// Did the socket carry anything? The gateway meters bytes, so this is
1936 /// what decides whether the balance in the header has moved.
1937 moved: false,
1938
1939 /// Wait until the encrypted channel is up, or say why it never will be.
1940 ///
1941 /// `failed` first, then a socket the gateway closed, then the state the
1942 /// caller asked for. In that order, always: a certificate refusal and a
1943 /// close code can both be true at once — rustls sends an alert, the
1944 /// gateway forwards it, the provider drops the connection — and the
1945 /// certificate is the more useful of the two things to be told.
1946 ready: async function (want) {
1947 want = want || 'open';
1948 var t0 = Date.now();
1949 for (;;) {
1950 if (state() === 'failed') throw new Error(certWords(host, fault()));
1951 if (gone) throw new Error(closeWords(gone.code, gone.reason, host, port));
1952 if (state() === want) return;
1953 if (Date.now() - t0 >= TUNNEL_OPEN_MS) {
1954 throw new Error(t('mail.tunnel.err.slow',
1955 { host: host, secs: Math.round(TUNNEL_OPEN_MS / 1000) }));
1956 }
1957 await step(TUNNEL_OPEN_MS - (Date.now() - t0));
1958 }
1959 },
1960
1961 /// Plaintext into the session. Encrypted before it is bytes on the socket,
1962 /// unless the tunnel is still in its pre-STARTTLS clear phase — which is
1963 /// what that phase is for, and why no password may be written in it.
1964 write: function (bytes) {
1965 wasm.mail_tunnel_write(h, bytes);
1966 tun.moved = true;
1967 pump();
1968 },
1969
1970 /// Plaintext the peer has sent. Empty until something arrives.
1971 read: function () { return wasm.mail_tunnel_read(h); },
1972
1973 /// Move whatever the session has queued out to the socket.
1974 ///
1975 /// The pump runs by itself at every point that can queue a record, so this
1976 /// is for the seam below: a protocol step that queued a command through the
1977 /// wasm has queued it where nothing in this file saw it happen.
1978 flush: pump,
1979
1980 /// Wait for the next frame, close or deadline. What makes the seam's loop a
1981 /// loop rather than a spin.
1982 wait: step,
1983
1984 /// Promote a STARTTLS tunnel, once the server has agreed.
1985 ///
1986 /// The wasm REFUSES this while unread cleartext is still buffered, which
1987 /// is CVE-2011-0411: a pipelined response and an injected one are
1988 /// indistinguishable, so the pre-TLS buffer must be discarded rather than
1989 /// carried across the handshake. The refusal is surfaced and not retried
1990 /// past — a second attempt would either hit the same guard or, if
1991 /// something had drained the buffer in between, be exactly the attack.
1992 secure: function () {
1993 try {
1994 wasm.mail_tunnel_secure(h);
1995 } catch (e) {
1996 var m = friendly(e);
1997 throw new Error(/unread cleartext/.test(m)
1998 ? t('mail.tunnel.err.starttls_early', { host: host })
1999 : t('mail.tunnel.err.starttls', { host: host, reason: m }));
2000 }
2001 pump();
2002 },
2003
2004 /// The negotiated version, for the panel and for a test that must know a
2005 /// handshake really happened rather than that nothing failed.
2006 version: function () {
2007 try { return wasm.mail_tunnel_version(h) || ''; }
2008 catch (e) { return ''; }
2009 },
2010
2011 /// How the socket ended, or null while it is up.
2012 closed: function () { return gone; },
2013
2014 close: function () {
2015 try { ws.close(); } catch (e) { /* already gone */ }
2016 try { wasm.mail_tunnel_close(h); } catch (e) { /* already closed */ }
2017 // The gateway meters as it goes and has no way to answer in band, so
2018 // the header's figure is refreshed from the ledger instead. Only when
2019 // something actually crossed: a tunnel that carried nothing is free,
2020 // as a sync that found nothing was.
2021 if (tun.moved && window.DaimondGateway && DaimondGateway.refreshBalance) {
2022 DaimondGateway.refreshBalance();
2023 }
2024 },
2025 };
2026
2027 // The socket, not the handshake. A caller that wants the encrypted channel
2028 // asks for it: a STARTTLS tunnel is legitimately in the clear at this point.
2029 var t0 = Date.now();
2030 while (ws.readyState === 0 && !gone && Date.now() - t0 < TUNNEL_OPEN_MS) {
2031 await step(TUNNEL_OPEN_MS - (Date.now() - t0));
2032 }
2033 if (gone) {
2034 try { wasm.mail_tunnel_close(h); } catch (e) { /* nothing to release */ }
2035 throw new Error(closeWords(gone.code, gone.reason, host, port));
2036 }
2037 if (ws.readyState !== 1) {
2038 tun.close();
2039 throw new Error(t('mail.tunnel.err.slow',
2040 { host: host, secs: Math.round(TUNNEL_OPEN_MS / 1000) }));
2041 }
2042 return tun;
2043 }
2044
2045 /// Open a tunnel and take it all the way to an encrypted channel.
2046 ///
2047 /// The STARTTLS sequence is here rather than in each caller because getting it
2048 /// wrong is a password on the wire in the clear: the greeting is read, `STARTTLS`
2049 /// is spoken in the clear, the server's agreement is read, and only then is the
2050 /// tunnel promoted. Reading the agreement is not politeness —
2051 /// `mail_tunnel_secure` refuses to promote over an unread buffer, so a caller
2052 /// that skipped it would meet the CVE guard instead of a working connection.
2053 async function secureTunnel(spec) {
2054 var tun = await openTunnel(spec);
2055 try {
2056 if (tun.security === 'tls') {
2057 await tun.ready('open');
2058 return tun;
2059 }
2060 // The clear phase, which exists only in order to ask for the encrypted one.
2061 await tun.ready('clear');
2062 await mailImap(tun, 'starttls', {});
2063 tun.secure();
2064 await tun.ready('open');
2065 return tun;
2066 } catch (e) {
2067 tun.close();
2068 throw e;
2069 }
2070 }
2071
2072 // ── The seam ────────────────────────────────────────────────────
2073 //
2074 // Two exports are the last gap in the chain and nothing has written them yet:
2075 //
2076 // mail_imap(handle, verb, args) -> a JSON string
2077 // mail_smtp_send(handle, args) -> a JSON string
2078 //
2079 // They cannot exist until `fe2o3_net`'s IMAP and SMTP clients are split sans-io:
2080 // `imap::client` is welded to `tokio::net::TcpStream`, so `fe2o3_net` cannot be a
2081 // wasm dependency, and Daimond pins it for non-wasm targets only. That split is in
2082 // flight and is the serial part of the release.
2083 //
2084 // THERE IS NO JAVASCRIPT IMAP HERE AND THERE IS NOT GOING TO BE ONE. Two
2085 // implementations of a wire protocol that must agree byte for byte is the seam this
2086 // app has been bitten by repeatedly, and a second one written to fill a fortnight's
2087 // gap would outlive the gap. The two functions below are the whole of what this
2088 // file asks of the protocol, and both fail loudly today: a transport that quietly
2089 // answered "no messages" would read as an empty mailbox, which is the one wrong
2090 // answer a mail client can give that nobody investigates.
2091 //
2092 // THE SHAPE, and the one part of it that is not obvious. Neither export can block:
2093 // the handle's I/O is driven from here, so a verb that needs another round trip
2094 // cannot wait for one. Each call therefore answers either
2095 //
2096 // { "state": "pending" } it queued bytes and wants more
2097 // { "state": "done", "result": { … } } the verb finished
2098 //
2099 // and the loop below pumps the socket and calls again. A verb that answered only
2100 // when it was finished would deadlock, every time, with the ClientHello or the
2101 // command sitting in the wasm's out-queue and nothing to carry it.
2102 //
2103 // mail_imap verbs, and what `result` must hold:
2104 //
2105 // 'starttls' { } -> { }
2106 // the greeting read, STARTTLS sent, the agreement read, and the buffer
2107 // DRAINED — `mail_tunnel_secure` refuses to promote over unread cleartext.
2108 // 'login' { user, password } -> { }
2109 // 'list' { } -> { folders: [{ name, role,
2110 // selectable, delimiter }] }
2111 // 'fetch' { mailbox, since_uid, before_uid }
2112 // -> { uid_validity, messages: [{ uid, flags, raw }],
2113 // held_back, limit }
2114 //
2115 // `raw` is base64, as the bridge's reply was, so `syncOne` below is unchanged.
2116 // `charged_minor` is deliberately absent: the gateway meters bytes on a pipe it
2117 // cannot see into and has no way to answer in band, so the balance is re-read from
2118 // the ledger when a tunnel closes.
2119 //
2120 // `mail_smtp_send` takes `{ user, password, rcpt: [...], raw }` and runs the whole
2121 // submission — EHLO, STARTTLS and the promotion, AUTH, MAIL/RCPT/DATA. It has to
2122 // own all of it because it is the only SMTP name the contract fixes, so there is no
2123 // verb to sequence from here; and the credential and the envelope have to be
2124 // arguments because AUTH is not optional at any provider and a Bcc means RCPT TO
2125 // cannot be read off the headers. Both of those are gaps in the fixed signature
2126 // `mail_smtp_send(handle, rfc5322)` rather than choices, and they are reported
2127 // rather than worked around.
2128
2129 /// Longest one protocol verb may take, in ms. A whole-mailbox fetch is many verbs
2130 /// and is not bounded by this; one round of question and answer is.
2131 var PROTO_MS = 45000;
2132
2133 /// One protocol verb, driven to completion over a tunnel this file owns.
2134 async function drive(tun, call, what) {
2135 var t0 = Date.now();
2136 for (;;) {
2137 var out = call();
2138 var r = (typeof out === 'string') ? JSON.parse(out) : out;
2139 // Called even on `done`: the last step of a verb queues a command as often
2140 // as not, and a reply nobody sent is a reply nobody gets.
2141 tun.flush();
2142 if (!r || r.state !== 'pending') return (r && r.result !== undefined) ? r.result : r;
2143 // `failed` first, always. See `state` in `openTunnel`.
2144 if (tun.state() === 'failed') throw new Error(certWords(tun.host, tun.fault()));
2145 var c = tun.closed();
2146 if (c) throw new Error(closeWords(c.code, c.reason, tun.host, tun.port));
2147 if (Date.now() - t0 >= PROTO_MS) {
2148 throw new Error(t('mail.tunnel.err.no_reply',
2149 { host: tun.host, what: what, secs: Math.round(PROTO_MS / 1000) }));
2150 }
2151 await tun.wait(PROTO_MS - (Date.now() - t0));
2152 }
2153 }
2154
2155 async function mailImap(tun, verb, args) {
2156 var wasm = await engine();
2157 if (typeof wasm.mail_imap !== 'function') {
2158 throw new Error(t('mail.err.protocol_pending'));
2159 }
2160 var body = JSON.stringify(args || {});
2161 return drive(tun, function () { return wasm.mail_imap(tun.handle, verb, body); }, verb);
2162 }
2163
2164 async function mailSmtpSend(tun, args) {
2165 var wasm = await engine();
2166 if (typeof wasm.mail_smtp_send !== 'function') {
2167 throw new Error(t('mail.err.protocol_pending'));
2168 }
2169 var body = JSON.stringify(args || {});
2170 return drive(tun, function () { return wasm.mail_smtp_send(tun.handle, body); }, 'send');
2171 }
2172
2173 /// The one place a plaintext mail password exists at all.
2174 ///
2175 /// Unwrapped HERE and nowhere earlier, which is why `secret` is a function and not
2176 /// a string: a fetch that fails on the handshake, on a close code or at the seam
2177 /// above never decrypts the password at all. What it does produce lives for the
2178 /// length of one call and goes into the TLS session, so no request body, no URL
2179 /// and no log line can hold it. That is the property this whole route exists for.
2180 async function login(tun, user, secret) {
2181 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
2182 throw new Error(t('mail.err.unlock_first'));
2183 }
2184 var password = await secret();
2185 return mailImap(tun, 'login', { user: user, password: password });
2186 }
2187
2188 /// Mailboxes bound at the gateway this session, by address, and what they were
2189 /// bound with.
2190 ///
2191 /// Not persisted and not in the account record: it is a fact about the gateway's
2192 /// store, and the only honest way to learn it after a reload is to state it again.
2193 var bound = {};
2194 /// A bind in flight, by address, so two fetches make one request.
2195 var binding = {};
2196
2197 /// Bind a mailbox, which is what makes its servers reachable at all.
2198 ///
2199 /// The gateway allowlists the far end of every tunnel against the hosts this
2200 /// account has already bound — without that, a blind pipe is an open proxy wearing
2201 /// a Daimond badge. Binding used to be a side effect of a sync that authenticated,
2202 /// and the tunnel cannot be that door: nothing it observes distinguishes a
2203 /// successful login from a rejected one. So it is an explicit act on an ordinary
2204 /// route, and this is the caller.
2205 ///
2206 /// Idempotent, and skipped when this session already bound the same pair, so an
2207 /// ordinary poll costs no request.
2208 async function ensureBound(address) {
2209 var a = acct(address);
2210 if (!a) throw new Error(t('mail.err.send_from_added'));
2211 var smtp = smtpFor(a);
2212 var want = a.host + '|' + smtp.host;
2213 if (bound[a.address] === want) return;
2214 // `addAccount` starts a sync and a folder list in the same breath and both pass
2215 // through here, so without the memo one mailbox binds twice in parallel.
2216 if (!binding[a.address]) {
2217 binding[a.address] = post('/api/mail/accounts', {
2218 address: a.address,
2219 action: 'bind',
2220 host: a.host || '',
2221 smtp_host: smtp.host || '',
2222 }).then(function (j) {
2223 bound[a.address] = want;
2224 delete binding[a.address];
2225 return j;
2226 }, function (e) {
2227 delete binding[a.address];
2228 throw e;
2229 });
2230 }
2231 return binding[a.address];
2232 }
2233
2234 /// Fetch a folder over the tunnel, answering in the shape the panel already reads.
2235 ///
2236 /// Deliberately the same shape `post('/api/mail/sync', body)` answered —
2237 /// `{ uid_validity, messages: [{ uid, flags, raw }], held_back, limit }` — so
2238 /// nothing downstream of this call knows the transport moved.
2239 async function tunnelSync(body, secret) {
2240 // Before the socket: the gateway allowlists the far end against the hosts this
2241 // account has bound, so an unbound mailbox is a 4403 the user cannot act on
2242 // unless something binds it first. Idempotent, and free.
2243 await ensureBound(body.address);
2244 var tun = await secureTunnel(body);
2245 try {
2246 await login(tun, body.user, secret);
2247 return await mailImap(tun, 'fetch', {
2248 mailbox: body.mailbox,
2249 since_uid: body.since_uid,
2250 before_uid: body.before_uid,
2251 });
2252 } finally {
2253 tun.close();
2254 }
2255 }
2256
2257 /// Ask the server what folders it has, in the shape `/api/mail/folders` answered.
2258 async function tunnelFolders(body, secret) {
2259 // Before the socket: the gateway allowlists the far end against the hosts this
2260 // account has bound, so an unbound mailbox is a 4403 the user cannot act on
2261 // unless something binds it first. Idempotent, and free.
2262 await ensureBound(body.address);
2263 var tun = await secureTunnel(body);
2264 try {
2265 await login(tun, body.user, secret);
2266 return await mailImap(tun, 'list', {});
2267 } finally {
2268 tun.close();
2269 }
2270 }
2271
2272 /// Put a message on the wire, in the shape `/api/mail/send` answered.
2273 ///
2274 /// Submission does not go through `login`, because `mail_smtp_send` owns the whole
2275 /// conversation — see the seam above — so the credential is one of its arguments.
2276 /// The unwrap still happens at the last possible moment and nowhere else.
2277 async function tunnelSend(body, secret) {
2278 await ensureBound(body.address);
2279 var tun = await openTunnel(body);
2280 try {
2281 await tun.ready(tun.security === 'tls' ? 'open' : 'clear');
2282 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
2283 throw new Error(t('mail.err.unlock_first'));
2284 }
2285 return await mailSmtpSend(tun, {
2286 user: body.user,
2287 password: await secret(),
2288 rcpt: body.rcpt,
2289 raw: body.raw,
2290 });
2291 } finally {
2292 tun.close();
2293 }
2294 }
2295
2296 // ── The gateway ─────────────────────────────────────────────────
2297
2298 // Every call below goes through `DaimondGateway.gwFetch`, which meets a 401 by
2299 // renewing the session once and asking once more -- single-flight, so mail and
2300 // sync refused in the same moment share one renewal.
2301 //
2302 // The gateway's session lives an hour and only an unlock ever minted one, so
2303 // an hour into a sitting every mail call came back 401: a sync showed the
2304 // gateway's own "No valid session." where the new-message count belongs, the
2305 // entitlement read fell back to "unknown" and the panel offered the Pro pitch
2306 // to an account that holds Pro, and freeing a seat on a removed mailbox was
2307 // discarded without a word.
2308 //
2309 // Safe to repeat, INCLUDING `/api/mail/send`, and this is why: every
2310 // session-authed handler in the gateway checks the session BEFORE it parses
2311 // the body and before it opens a connection to anybody's mail server
2312 // (`common::authed_account` is the first statement of `send_impl`,
2313 // `sync_impl`, `folders_impl` and `accounts_impl`), so a 401 is proof that
2314 // nothing happened -- no message left, no seat moved.
2315 //
2316 // This file used to carry its own copy of that rule, one of five identical
2317 // copies across the app. There is one now, in gateway.js, beside the renewal
2318 // it drives.
2319
2320 async function post(path, body) {
2321 if (!window.DaimondGateway) throw new Error(t('mail.err.service_unavailable'));
2322 var st = DaimondGateway.state();
2323 if (!st.authed) {
2324 var ok = await DaimondGateway.bootstrap();
2325 if (!ok) throw new Error(t('mail.err.service_unreachable'));
2326 }
2327 var r = await DaimondGateway.gwFetch(path, {
2328 method: 'POST',
2329 headers: { 'content-type': 'application/json' },
2330 credentials: 'same-origin',
2331 body: JSON.stringify(body || {}),
2332 });
2333 // A 401 that survived the renewal is this device signed out, and it is
2334 // said in those terms. The gateway's "No valid session." was appearing
2335 // verbatim on the mail panel where a sync result belongs.
2336 if (r.status === 401) throw new Error(t('mail.err.service_unreachable'));
2337 var j = null;
2338 try { j = await r.json(); } catch (e) { j = null; }
2339 if (!r.ok || !j || j.ok === false) {
2340 throw new Error((j && j.error) || ('HTTP ' + r.status));
2341 }
2342 // Syncing and sending cost credits, and the reply says what is left. One place owns
2343 // that number; this hands it over rather than letting the header go stale.
2344 if (window.DaimondGateway && DaimondGateway.noteBalance) DaimondGateway.noteBalance(j);
2345 return j;
2346 }
2347
2348 /// Ask the gateway what this account may do. Called when the panel opens, so
2349 /// the panel never advertises a mailbox the account cannot have.
2350 async function refreshEntitlement() {
2351 try {
2352 var st = DaimondGateway.state();
2353 if (!st.authed) await DaimondGateway.bootstrap();
2354 var r = await DaimondGateway.gwFetch('/api/mail/accounts', { credentials: 'same-origin' });
2355 var j = await r.json();
2356 if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
2357 state.unlocked = !!j.unlocked;
2358 state.cap = j.max_accounts || state.cap;
2359
2360 // Email is part of Pro now, not a separate purchase, so there is no
2361 // à la carte price to fetch: the pitch points at Pro instead.
2362 } catch (e) {
2363 state.unlocked = null; // unknown, not "locked"
2364 }
2365 render();
2366 }
2367
2368 // ── Folders ─────────────────────────────────────────────────────
2369 // A mailbox is not an inbox. The gateway asks the server what it has
2370 // (`LIST`) and hands back each folder's own spelling — which may be
2371 // localised (`[Gmail]/Gesendet`), nested, or a container holding no mail at
2372 // all. What is NOT localised is the RFC 6154 role, so a folder the server
2373 // declares `\Sent` is called Sent here whatever the server calls it.
2374 //
2375 // Nothing about the list is stored. A folder renamed on the server should
2376 // stop being offered the moment the page is reloaded, and the files already
2377 // pulled out of it stay where they are either way.
2378
2379 /// The order roles are offered in — the order a mail client has put them in
2380 /// for thirty years, rather than the order the server happened to answer.
2381 var ROLE_ORDER = ['drafts', 'sent', 'archive', 'flagged', 'junk', 'trash', 'all'];
2382
2383 /// What a folder is called on screen: the role's name where the server
2384 /// declares one, and the server's own spelling where it does not.
2385 function labelFor(a, name) {
2386 if (name === 'INBOX') return t('mail.folder.inbox');
2387 var e = folderEntry(a, name);
2388 if (e && e.role && ROLE_ORDER.indexOf(e.role) >= 0) return t('mail.folder.' + e.role);
2389 return name;
2390 }
2391
2392 function folderEntry(a, name) {
2393 var c = a && state.folders[a.address];
2394 if (!c || !c.list) return null;
2395 for (var i = 0; i < c.list.length; i++) if (c.list[i].name === name) return c.list[i];
2396 return null;
2397 }
2398
2399 /// Put the server's answer in the order and shape the panel draws.
2400 function shapeFolders(raw) {
2401 var out = (raw || []).map(function (f) {
2402 return {
2403 name: String(f.name || ''),
2404 role: f.role || '',
2405 selectable: f.selectable !== false,
2406 delimiter: f.delimiter || '',
2407 };
2408 }).filter(function (f) { return f.name; });
2409 // A server that does not name its inbox in LIST still has one.
2410 if (!out.some(function (f) { return f.name === 'INBOX'; })) {
2411 out.unshift({ name: 'INBOX', role: '', selectable: true, delimiter: '' });
2412 }
2413 // The inbox first, then the roles every mail client has put in that
2414 // order for thirty years, then the folders the user made — and All Mail
2415 // below all of them. It is a copy of everything already listed above it,
2416 // so it is the one entry that is never what somebody meant to open.
2417 var rank = function (f) {
2418 if (f.name === 'INBOX') return -1;
2419 if (f.role === 'all') return ROLE_ORDER.length + 1;
2420 var i = ROLE_ORDER.indexOf(f.role);
2421 return i < 0 ? ROLE_ORDER.length : i;
2422 };
2423 out.sort(function (x, y) {
2424 var d = rank(x) - rank(y);
2425 if (d) return d;
2426 return x.name.localeCompare(y.name);
2427 });
2428 return out;
2429 }
2430
2431 /// Ask the server what folders it has. Free — the gateway charges nothing
2432 /// for a LIST — so it runs when the panel opens and when the account
2433 /// changes, and again whenever the user asks.
2434 async function loadFolders(address, force) {
2435 var a = acct(address);
2436 if (!a) return;
2437 var cur = state.folders[address];
2438 if (cur && cur.busy) return;
2439 if (cur && cur.list && !force) return;
2440 // The password is encrypted under the passphrase. A locked device is not
2441 // an error here; it simply means the inbox is all that can be offered.
2442 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return;
2443 state.folders[address] = { busy: true, err: '', list: (cur && cur.list) || null };
2444 render();
2445 try {
2446 var password = await DaimondIdentity.unwrap(a.pass);
2447 var j = await post('/api/mail/folders', {
2448 address: a.address,
2449 host: a.host,
2450 port: a.port || 993,
2451 security: a.security || (a.port === 143 ? 'starttls' : 'tls'),
2452 user: a.user || a.address,
2453 password: password,
2454 });
2455 state.folders[address] = { busy: false, err: '', list: shapeFolders(j.folders) };
2456 // A record per selectable folder. It is what the folder rows read their
2457 // count out of, and it is what daimond.js builds the pause tree from
2458 // (daimond.js:6819) — so without it, a folder the gear dialog offers a
2459 // control for would not be walked when the mailbox itself is paused.
2460 // A record is watermarks at zero; it fetches nothing and says nothing
2461 // beyond "this folder exists".
2462 state.folders[address].list.forEach(function (e) { if (e.selectable) fld(a, e.name); });
2463 save();
2464 // A folder that is no longer there cannot go on being the selected
2465 // one, or every sync would ask for a mailbox the server has not got.
2466 var list = state.folders[address].list;
2467 if (a.folder && !list.some(function (f) { return f.name === a.folder && f.selectable; })) {
2468 a.folder = 'INBOX';
2469 save();
2470 await loadDigest(address, 'INBOX');
2471 }
2472 } catch (e) {
2473 state.folders[address] = {
2474 busy: false,
2475 err: t('mail.folders_err', { reason: friendly(e) }),
2476 list: (cur && cur.list) || null,
2477 };
2478 }
2479 render();
2480 }
2481
2482 /// Move to another folder of the same account. The messages already on disk
2483 /// are read straight back; nothing is fetched until a sync is asked for.
2484 async function selectFolder(name) {
2485 var a = acct(state.sel);
2486 if (!a || a.folder === name) return;
2487 a.folder = name;
2488 fld(a, name);
2489 save();
2490 state.note = '';
2491 state.err = '';
2492 await loadDigest(a.address, name);
2493 render();
2494 // A folder opened for the first time holds nothing, and an empty list
2495 // looks like an empty folder rather than one never fetched. Go and get
2496 // its first batch, which is what the user meant by opening it.
2497 if (!state.msgs.length && !fld(a, name).lastSync) syncAccount(a.address, false, name);
2498 }
2499
2500 /// The folders this app has anything to say about: the ones it already holds
2501 /// mail from, the ones with a schedule, and the one on screen. The inbox is
2502 /// always among them, because every mailbox has one.
2503 ///
2504 /// NOT every folder the server lists. `a.folders` carries a record for each of
2505 /// those, so that the pause tree has a leaf for each — but a record is not the
2506 /// same as a folder anybody has asked for, and a refresh that pulled a first
2507 /// batch out of forty Gmail labels would be a bill rather than a refresh.
2508 function trackedFolders(a) {
2509 var seen = {}, out = [];
2510 var add = function (n) { if (n && !seen[n]) { seen[n] = 1; out.push(n); } };
2511 add('INBOX');
2512 add(a.folder || 'INBOX');
2513 Object.keys(a.folders || {}).forEach(function (n) {
2514 if (ms(a.folders[n] && a.folders[n].lastSync)) add(n);
2515 });
2516 Object.keys(refreshMap(a)).forEach(add);
2517 return out;
2518 }
2519
2520 /// Every folder of a mailbox that could be refreshed: the server's own list
2521 /// where it has answered, and what this device tracks where it has not.
2522 function allFolders(a) {
2523 var cache = a && state.folders[a.address];
2524 var list = (cache && cache.list) || null;
2525 if (!list) return trackedFolders(a);
2526 var out = list.filter(function (f) { return f.selectable; })
2527 .map(function (f) { return f.name; });
2528 return out.length ? out : trackedFolders(a);
2529 }
2530
2531 /// How many folders the manual refresh would touch, across how many
2532 /// mailboxes. The button's tooltip carries it, so the size of the thing is
2533 /// known BEFORE it is pressed rather than reported afterwards: every folder
2534 /// costs a call whether or not anything has arrived in it.
2535 function refreshScale() {
2536 var n = 0;
2537 state.accounts.forEach(function (a) { n += allFolders(a).length; });
2538 return { folders: n, boxes: state.accounts.length };
2539 }
2540
2541 /// The manual refresh, doing what its name has always claimed: every folder
2542 /// of every mailbox.
2543 ///
2544 /// It used to re-list the folders of the selected mailbox and nothing else,
2545 /// which is neither what the tooltip said nor what anybody reading "refresh"
2546 /// expects. Listing is free and is done first, so the walk that follows is of
2547 /// the folders the server has NOW.
2548 ///
2549 /// Held folders are skipped and counted, not silently dropped: a refresh that
2550 /// quietly did less than it said is the thing this function exists to end.
2551 async function refreshAll() {
2552 if (state.busy || state.draining) return;
2553 var boxes = state.accounts.map(function (a) { return a.address; });
2554 if (!boxes.length) return;
2555 var done = 0, held = 0;
2556 for (var i = 0; i < boxes.length; i++) {
2557 await loadFolders(boxes[i], true);
2558 }
2559 for (var j = 0; j < boxes.length; j++) {
2560 var a = acct(boxes[j]);
2561 if (!a) continue;
2562 var names = allFolders(a);
2563 for (var k = 0; k < names.length; k++) {
2564 if (pollStop(a.address, names[k])) { held++; continue; }
2565 await syncAccount(a.address, false, names[k], true);
2566 done++;
2567 }
2568 }
2569 state.note = held
2570 ? tf('mail.refreshed_held', '{done} folders refreshed in {boxes} mailboxes; '
2571 + '{held} held by a pause.', { done: done, boxes: boxes.length, held: held })
2572 : tf('mail.refreshed', '{done} folders refreshed in {boxes} mailboxes.',
2573 { done: done, boxes: boxes.length });
2574 render();
2575 }
2576
2577 /// Email rides Pro now, so the button opens the Pro surface in Credits
2578 /// rather than a checkout of its own. The purchase, the return handling and
2579 /// the "you own it" confirmation all live in one place.
2580 function unlock() {
2581 if (window.DaimondAdmin && DaimondAdmin.credits) DaimondAdmin.credits(t('mail.pro_pitch'));
2582 }
2583
2584 function friendly(e) {
2585 var m = (e && e.message) ? e.message : String(e);
2586 return m.replace(/\[[0-9;]*m/g, '');
2587 }
2588 function fmtMinor(n) {
2589 return window.DaimondGateway ? DaimondGateway.fmtMoney(n, 'usd') : ('$' + (n / 100).toFixed(2));
2590 }
2591 var HOUR = 3600000;
2592
2593 /// What a folder row says about how much is in it, and when that was true.
2594 ///
2595 /// The number is the messages the server handed over as at the folder's last
2596 /// sync, which is not the same thing as what is in the folder now — so the
2597 /// row never shows a bare figure. A folder never fetched shows no number at
2598 /// all: zero would say "I looked and it is empty", and that is a lie somebody
2599 /// will act on. A figure gone stale carries its age beside it in the row,
2600 /// because a `title` is a thing nobody can hover on a phone.
2601 function countPhrase(a, name) {
2602 var f = a && a.folders && a.folders[name];
2603 var last = (f && ms(f.lastSync)) || 0;
2604 if (!last) {
2605 return {
2606 text: '—', when: ago(0), stale: true,
2607 title: tf('mail.count.never',
2608 'Not fetched yet, so there is no count.'),
2609 };
2610 }
2611 var n = (f && f.count) | 0;
2612 // Stale at twice its own period, or after an hour where it has none: a
2613 // folder polled every five minutes whose figure is twenty minutes old has
2614 // missed a poll, and an unscheduled one is only ever as fresh as the last
2615 // time somebody pressed refresh.
2616 var stale = (Date.now() - last) > Math.max(refreshOf(a, name) * 2000, HOUR);
2617 var title = tf('mail.count.asat', '{n} messages, as at {when}.',
2618 { n: fmtCount(n), when: ago(last) });
2619 if (f && f.heldBack) {
2620 title += ' ' + tf('mail.count.more',
2621 '{n} more were waiting on the server then.', { n: fmtCount(f.heldBack) });
2622 }
2623 return { text: fmtCount(n), when: ago(last), stale: stale, title: title };
2624 }
2625
2626 function ago(ts) {
2627 if (!ts) return t('mail.ago.never');
2628 var s = Math.floor((Date.now() - ts) / 1000);
2629 if (s < 60) return t('mail.ago.just_now');
2630 if (s < 3600) return t('mail.ago.mins', { n: Math.floor(s / 60) });
2631 if (s < 86400) return t('mail.ago.hours', { n: Math.floor(s / 3600) });
2632 return t('mail.ago.days', { n: Math.floor(s / 86400) });
2633 }
2634
2635 // ── The panel ───────────────────────────────────────────────────
2636
2637 function render() {
2638 if (!els.state) return;
2639
2640 // The unlock, or the reason there is nothing to show.
2641 els.state.innerHTML = '';
2642 if (state.unlocked === false) {
2643 els.state.appendChild(html(
2644 '<div class="mail-pitch">'
2645 + '<p>' + t('mail.pitch.head') + '</p>'
2646 + '<p class="mail-fine">' + t('mail.pitch.fine', { cap: state.cap }) + '</p>'
2647 + '<p class="mail-fine">' + t('mail.pitch.privacy') + '</p>'
2648 + '<button class="mail-unlock"' + (state.busy ? ' disabled' : '') + '>'
2649 + esc(t('pro.subscribe')) + '</button>'
2650 + '</div>'));
2651 var ub = els.state.querySelector('.mail-unlock');
2652 if (ub) ub.addEventListener('click', unlock);
2653 } else if (state.unlocked === null) {
2654 els.state.appendChild(html('<div class="mail-fine">'
2655 + esc(t('mail.pitch.unknown')) + '</div>'));
2656 }
2657 if (state.err) els.state.appendChild(html('<div class="mail-err">' + esc(state.err) + '</div>'));
2658 else if (state.note) els.state.appendChild(html('<div class="mail-note">' + esc(state.note) + '</div>'));
2659
2660 // The mailboxes.
2661 els.accounts.innerHTML = '';
2662 if (state.unlocked !== false) {
2663 els.accounts.appendChild(globalRow());
2664 state.accounts.forEach(function (a) {
2665 var row = document.createElement('div');
2666 row.className = 'mail-acct' + (a.address === state.sel ? ' on' : '');
2667 // The mailbox's own control leads the row, as it leads every row on
2668 // the rail. It governs the BRANCH: pressing it pauses the mailbox's
2669 // own polling and every folder under it at once, and it shows amber
2670 // when only some of them are held.
2671 row.appendChild(pptw(boxNode(a.address), a.address));
2672 row.appendChild(html('<span class="mail-addr">' + esc(a.address) + '</span>'));
2673 row.appendChild(html('<span class="mail-when">' + esc(ago(a.lastSync)) + '</span>'));
2674 var gear = document.createElement('button');
2675 gear.className = 'mail-gear';
2676 gear.title = tf('mail.settings', 'Mailbox settings');
2677 gear.setAttribute('aria-label',
2678 tf('mail.settings_named', 'Settings for {address}', { address: a.address }));
2679 // A cog, drawn as the app draws its icons: a stroked path in a
2680 // 24-unit box, so it sits on the same grid as the railhead's.
2681 // One drawing of the cog for the whole app. This file used to hold its
2682 // own copy of the same path, which is how an icon set drifts.
2683 if (window.DaimondUI && DaimondUI.cogIcon) gear.appendChild(DaimondUI.cogIcon());
2684 gear.addEventListener('click', function (ev) {
2685 ev.stopPropagation();
2686 openSettings(a.address);
2687 });
2688 row.appendChild(gear);
2689 // The closer cross is gone, and Remove is at the foot of what the gear
2690 // opens. It was `opacity: 0` until hover -- no control at all on a phone
2691 // -- and it put the one irreversible act on the row's most reachable
2692 // pixel, beside the act of SELECTING the mailbox. Exactly the reasoning
2693 // phase C applied to a tile, and exactly what notes2 asks for here.
2694 //
2695 // The one exception is a container with no dialog of its own to put it
2696 // in: `openSettings` says so, and the cross stays for that case alone.
2697 if (!(deps && typeof deps.bodyDialog === 'function')) {
2698 var del = document.createElement('button');
2699 del.className = 'mail-del';
2700 del.title = t('mail.remove_mailbox');
2701 del.setAttribute('aria-label', t('mail.remove_mailbox_named', { address: a.address }));
2702 del.textContent = '×';
2703 del.addEventListener('click', function (ev) {
2704 ev.stopPropagation();
2705 removeAccount(a.address);
2706 });
2707 row.appendChild(del);
2708 }
2709 rowAsButton(row, function () {
2710 state.sel = a.address; save();
2711 Promise.all([loadDigest(a.address), refreshDrafts()]).then(render);
2712 loadFolders(a.address);
2713 }, a.address);
2714 // Which mailbox is being shown, said rather than only coloured.
2715 if (a.address === state.sel) row.setAttribute('aria-current', 'true');
2716 els.accounts.appendChild(row);
2717 });
2718 if (!state.accounts.length && state.unlocked) {
2719 els.accounts.appendChild(html('<div class="mail-fine">'
2720 + t('mail.no_mailbox') + '</div>'));
2721 }
2722 }
2723
2724 renderFolders();
2725
2726 // The drafts. Unsent mail sits above the inbox because it is the only thing in the
2727 // panel that is waiting on the user — and because a draft an agent wrote for them
2728 // to check would otherwise be written into a folder nobody looks in.
2729 els.list.innerHTML = '';
2730 if (state.sel && state.drafts.length) {
2731 var box = html('<div class="mail-drafts"><div class="mail-drafts-head">'
2732 + esc(t('mail.drafts_head', { n: state.drafts.length })) + '</div></div>');
2733 state.drafts.forEach(function (d) {
2734 var row = document.createElement('div');
2735 row.className = 'mail-draft';
2736 row.innerHTML = '<div class="mail-subj">' + esc(d.subject) + '</div>'
2737 + '<div class="mail-from">' + esc(d.to || t('mail.no_recipient')) + '</div>';
2738 rowAsButton(row, function () { openDraft(d.path); });
2739 box.appendChild(row);
2740 });
2741 els.list.appendChild(box);
2742 }
2743
2744 // The messages.
2745 if (state.sel && state.msgs.length) {
2746 state.msgs.slice().reverse().forEach(function (m) {
2747 var row = document.createElement('div');
2748 row.className = 'mail-msg' + (m.seen ? '' : ' unread');
2749 row.innerHTML = '<div class="mail-from">' + esc(m.from || t('mail.unknown_sender')) + '</div>'
2750 + '<div class="mail-subj">' + esc(m.subject) + '</div>'
2751 + '<div class="mail-date">' + esc((m.date || '').replace(/\s*\(.*\)$/, '')) + '</div>';
2752 rowAsButton(row, function () { openMessage(m); });
2753 els.list.appendChild(row);
2754 });
2755
2756 // A sync stops at the cap, and a list that just stops looks like a mailbox that ends.
2757 // Say what is still up there, and offer to go and get it.
2758 var sel = acct(state.sel);
2759 var sf = sel ? fld(sel) : null;
2760 if (sf && sf.heldBack > 0) {
2761 var n = Math.min(sf.limit || 0, sf.heldBack) || sf.heldBack;
2762 var more = html(
2763 '<div class="mail-more">'
2764 + '<div class="mail-fine">'
2765 + esc(tn('mail.more.note', sf.heldBack,
2766 { n: fmtCount(sf.heldBack), batch: sf.limit || n }))
2767 + '</div>'
2768 + '<div class="mail-more-btns">'
2769 + '<button class="mail-older"' + (state.busy ? ' disabled' : '') + '>'
2770 + esc(t('mail.more.next', { n: n })) + '</button>'
2771 + (state.draining
2772 ? '<button class="mail-stop">' + esc(t('mail.more.stop')) + '</button>'
2773 : '<button class="mail-all"' + (state.busy ? ' disabled' : '') + '>'
2774 + esc(t('mail.more.all')) + '</button>')
2775 + '</div>'
2776 + '</div>');
2777 var ob = more.querySelector('.mail-older');
2778 if (ob) ob.addEventListener('click', function () { syncAccount(state.sel, true); });
2779 var ab = more.querySelector('.mail-all');
2780 if (ab) ab.addEventListener('click', function () { fetchAll(state.sel); });
2781 var sb = more.querySelector('.mail-stop');
2782 if (sb) sb.addEventListener('click', function () { state.draining = false; });
2783 els.list.appendChild(more);
2784 }
2785 } else if (state.sel && state.unlocked !== false) {
2786 els.list.appendChild(html('<div class="mail-fine">' + t('mail.nothing_yet') + '</div>'));
2787 }
2788
2789 // One re-arming point for the schedule. Every change that could move a due
2790 // time — a sync finishing, a frequency changing, a mailbox arriving in a
2791 // parcel, the device unlocking — already ends here, so none of them has to
2792 // remember the timer.
2793 arm();
2794 }
2795
2796 /// The row above the mailbox list: one control for all of mail, and the one
2797 /// manual refresh.
2798 ///
2799 /// The pause control governs `root/mail`, the branch every mailbox hangs
2800 /// from — the honest "global" for this panel. It is deliberately NOT `root`:
2801 /// the rail already carries that one, and a second control for the same node
2802 /// in a second place is two answers to one question.
2803 ///
2804 /// Sentence case and a rule under it, not a section heading. The rail learnt
2805 /// that the hard way: dressed as a heading, a row led by a light reads as a
2806 /// section that has lost its alignment (see `.pptw-head`, app.css:207).
2807 function globalRow() {
2808 var row = document.createElement('div');
2809 row.className = 'mail-globals';
2810 row.appendChild(pptw(mailNode(), tf('pause.mail', 'Mail')));
2811 row.appendChild(html('<span class="mail-globals-label">'
2812 + esc(tf('mail.all_mailboxes', 'All mailboxes')) + '</span>'));
2813 var b = document.createElement('button');
2814 b.className = 'mail-refresh';
2815 // The size of it, before it is pressed. Every folder costs a call whether
2816 // or not anything has arrived in it, and a person with forty Gmail labels
2817 // should be able to see that coming.
2818 var sc = refreshScale();
2819 b.title = tf('mail.refresh_all',
2820 'Refresh all {folders} folders in {boxes} mailboxes',
2821 { folders: sc.folders, boxes: sc.boxes });
2822 b.setAttribute('aria-label', b.title);
2823 b.textContent = '⟳';
2824 b.disabled = !!(state.busy || state.draining) || !state.accounts.length;
2825 b.addEventListener('click', function () { refreshAll(); });
2826 row.appendChild(b);
2827 return row;
2828 }
2829
2830 /// The folder picker: which of the account's mailboxes the list below is
2831 /// showing. It is drawn only when there is an account to have folders, and
2832 /// stays a single row — the inbox — until the server has answered.
2833 function renderFolders() {
2834 if (!els.folders) return;
2835 els.folders.innerHTML = '';
2836 var a = acct(state.sel);
2837 if (!a || state.unlocked === false) return;
2838
2839 var cache = state.folders[a.address] || {};
2840 var list = cache.list || [{ name: 'INBOX', role: '', selectable: true }];
2841
2842 // The refresh that used to sit here now leads the panel, beside the pause
2843 // control that supplements it: it acts on every mailbox, so a head scoped
2844 // to one mailbox was the wrong place to press it from.
2845 els.folders.appendChild(html('<div class="mail-folders-head">'
2846 + '<span>' + esc(t('mail.folders')) + '</span></div>'));
2847
2848 var box = document.createElement('div');
2849 box.className = 'mail-folder-list';
2850 list.forEach(function (f) {
2851 var row = document.createElement('div');
2852 // `mail-acct` carries the row's shape and its selected state already:
2853 // a folder is the same kind of choice as a mailbox, one level down.
2854 row.className = 'mail-acct mail-folder' + (f.name === (a.folder || 'INBOX') ? ' on' : '');
2855 row.setAttribute('data-folder', f.name);
2856 if (f.role) row.setAttribute('data-role', f.role);
2857 var depth = f.delimiter ? f.name.split(f.delimiter).length - 1 : 0;
2858 if (depth > 0) row.style.setProperty('--folder-depth', depth);
2859 row.innerHTML = '<span class="mail-addr">' + esc(labelFor(a, f.name)) + '</span>';
2860 // How much is in it, and when that was true. A container holds no mail,
2861 // so it gets no number rather than a nought.
2862 var c = null;
2863 if (f.selectable) {
2864 c = countPhrase(a, f.name);
2865 // The age comes FIRST and the number last, so the numbers make a
2866 // column down the right edge. Put the age after and every count
2867 // with an age beside it steps left, which is exactly the reading
2868 // the column exists to give: which folder holds the most.
2869 //
2870 // It is shown only where the figure can no longer be trusted on its
2871 // own. On a fresh one it is noise, and the title carries it anyway.
2872 if (c.stale) {
2873 row.appendChild(html('<span class="mail-when">' + esc(c.when) + '</span>'));
2874 }
2875 var cnt = document.createElement('span');
2876 cnt.className = 'mail-count' + (c.stale ? ' stale' : '');
2877 cnt.textContent = c.text;
2878 cnt.title = c.title;
2879 row.appendChild(cnt);
2880 }
2881 if (!f.selectable) {
2882 // A container, not a mailbox: `[Gmail]` holds folders, not mail. It is
2883 // not made operable and stays out of the tab order, which is the whole
2884 // of what `aria-disabled` is claiming here.
2885 row.setAttribute('aria-disabled', 'true');
2886 } else {
2887 // The count is read out with the name: a screen reader cannot hover
2888 // the title, and "as at" is the half of the number that matters.
2889 rowAsButton(row, function () { selectFolder(f.name); },
2890 labelFor(a, f.name) + ' — ' + c.title);
2891 if (f.name === (a.folder || 'INBOX')) row.setAttribute('aria-current', 'true');
2892 }
2893 box.appendChild(row);
2894 });
2895 els.folders.appendChild(box);
2896
2897 if (cache.busy) {
2898 els.folders.appendChild(html('<div class="mail-fine">'
2899 + esc(t('mail.folders_loading')) + '</div>'));
2900 } else if (cache.err) {
2901 els.folders.appendChild(html('<div class="mail-fine">' + esc(cache.err) + '</div>'));
2902 }
2903 }
2904
2905 function html(s) {
2906 var d = document.createElement('div');
2907 d.innerHTML = s;
2908 return d.firstElementChild || d;
2909 }
2910
2911 // ── A mailbox's settings ────────────────────────────────────────
2912
2913 /// How often, in words. The frequencies are a fixed list and each gets its
2914 /// own key: "every {n} minutes" is a sentence a translator cannot decline
2915 /// without knowing the number, and there are only eight of them.
2916 var EVERY_EN = {
2917 0: 'Manual only',
2918 300: 'Every 5 minutes',
2919 900: 'Every 15 minutes',
2920 1800: 'Every 30 minutes',
2921 3600: 'Every hour',
2922 14400: 'Every 4 hours',
2923 43200: 'Every 12 hours',
2924 86400: 'Once a day',
2925 };
2926 function everyLabel(secs) {
2927 return EVERY_EN[secs] ? tf('mail.every.' + secs, EVERY_EN[secs])
2928 : tf('mail.every.secs', 'Every {n} seconds', { n: secs });
2929 }
2930
2931 /// The frequency picker for one folder.
2932 function everySelect(a, name) {
2933 var sel = document.createElement('select');
2934 sel.className = 'mail-every';
2935 var cur = refreshOf(a, name);
2936 var opts = EVERY.slice();
2937 // A frequency set from outside the list — a test, or a parcel from a
2938 // build that offered a different list — stays offered rather than being
2939 // silently rounded to whatever is nearest.
2940 if (opts.indexOf(cur) < 0) opts.push(cur);
2941 opts.sort(function (x, y) { return x - y; });
2942 opts.forEach(function (v) {
2943 var o = document.createElement('option');
2944 o.value = String(v);
2945 o.textContent = everyLabel(v);
2946 if (v === cur) o.selected = true;
2947 sel.appendChild(o);
2948 });
2949 sel.setAttribute('aria-label',
2950 tf('mail.every_for', 'How often {folder} refreshes itself',
2951 { folder: labelFor(a, name) }));
2952 sel.addEventListener('change', function () {
2953 setRefresh(a.address, name, parseInt(sel.value, 10) || 0);
2954 });
2955 return sel;
2956 }
2957
2958 /// The body of a mailbox's settings dialog: one tile per folder, each with
2959 /// its own pause control and how often it refreshes itself.
2960 ///
2961 /// Returns the element and nothing else. The dialog that carries it is phase
2962 /// C's tile dialog, and so is the Delete that belongs at its foot in place of
2963 /// the closer cross on the mailbox row — neither is built here.
2964 function settingsBody(address) {
2965 var a = acct(address);
2966 var box = document.createElement('div');
2967 box.className = 'mail-cfg';
2968 if (!a) return box;
2969
2970 box.appendChild(html('<p class="mail-fine">'
2971 + esc(tf('mail.cfg.head', 'How often each folder goes and looks, and which of them may. Every refresh '
2972 + 'costs credits, so nothing polls until you say so.')) + '</p>'));
2973
2974 // The mailbox's own leaf, first, because it governs everything below it.
2975 // It carries no frequency of its own: what the user schedules is folders,
2976 // and this is the switch that holds all of them at once.
2977 var self = document.createElement('div');
2978 self.className = 'mail-tile mail-tile-self';
2979 self.appendChild(pptw(selfNode(address), tf('pause.mail_polling', 'Mailbox polling')));
2980 self.appendChild(html('<span class="mail-tile-name">'
2981 + esc(tf('pause.mail_polling', 'Mailbox polling')) + '</span>'));
2982 self.appendChild(html('<span class="mail-fine">'
2983 + esc(tf('mail.cfg.self', 'Holds every folder below.')) + '</span>'));
2984 box.appendChild(self);
2985
2986 // The server's list where it has answered, and what this device tracks
2987 // where it has not: a locked or offline device should still show the
2988 // folders it holds mail for rather than the inbox alone.
2989 var cache = state.folders[address] || {};
2990 var names = (cache.list || []).filter(function (f) { return f.selectable; })
2991 .map(function (f) { return f.name; });
2992 if (!names.length) names = trackedFolders(a);
2993
2994 names.forEach(function (n) {
2995 var tile = document.createElement('div');
2996 tile.className = 'mail-tile';
2997 tile.setAttribute('data-folder', n);
2998 tile.appendChild(pptw(folderNode(address, n), labelFor(a, n)));
2999 tile.appendChild(html('<span class="mail-tile-name">'
3000 + esc(labelFor(a, n)) + '</span>'));
3001 // The same reading as the folder row, in the same order: the age where
3002 // the figure can no longer be trusted, then the figure. This is the one
3003 // screen where somebody is deciding how often a folder should look, so
3004 // a `title` nobody can hover on a phone is not enough on its own.
3005 var c = countPhrase(a, n);
3006 if (c.stale) tile.appendChild(html('<span class="mail-when">' + esc(c.when) + '</span>'));
3007 var cnt = html('<span class="mail-count' + (c.stale ? ' stale' : '') + '">'
3008 + esc(c.text) + '</span>');
3009 cnt.title = c.title;
3010 tile.appendChild(cnt);
3011 tile.appendChild(everySelect(a, n));
3012 box.appendChild(tile);
3013 });
3014 return box;
3015 }
3016
3017 /// Open a mailbox's settings, with Remove at the foot.
3018 ///
3019 /// `deps.bodyDialog` is the container's own dialog -- phase C's, the one every
3020 /// tile uses -- and it owns the focus trap, the Escape handling and the
3021 /// destructive button at the foot. Notes2 asks for the mailbox's closer cross to
3022 /// become "a delete button at bottom", which is word for word what it asks for a
3023 /// tile, so it had better be the same dialog: two copies drift the first time
3024 /// either is touched.
3025 ///
3026 /// The stand-in below survives for a container that does not offer one. It has no
3027 /// Remove, and that is the honest version rather than a second implementation of
3028 /// the destructive path -- the row's own control is still there in that case.
3029 function openSettings(address) {
3030 var body = settingsBody(address);
3031 var title = tf('mail.cfg.title', 'Settings for {address}', { address: address });
3032 if (deps && typeof deps.bodyDialog === 'function') {
3033 // No Done. Everything in this dialog has already taken effect by the
3034 // time you would press it, so the way out is the cross in the corner
3035 // and the foot is left to the one act that decides something.
3036 return deps.bodyDialog(title, body, {
3037 deleteLabel: t('mail.remove_mailbox'),
3038 onDelete: function () { return removeAccount(address); },
3039 });
3040 }
3041 var back = html('<div class="modal dlg"></div>');
3042 var card = html('<div class="modal-card dlg-card"></div>');
3043 var h = document.createElement('h2');
3044 h.id = 'mail-cfg-title';
3045 h.textContent = title;
3046 // Named and declared, which the app's own dialogs are not yet
3047 // (dev/a11y_report.md §5). A stand-in is no reason to repeat a defect.
3048 card.setAttribute('role', 'dialog');
3049 card.setAttribute('aria-modal', 'true');
3050 card.setAttribute('aria-labelledby', h.id);
3051 card.appendChild(h);
3052 card.appendChild(body);
3053 var row = html('<div class="dlg-actions"></div>');
3054 var ok = document.createElement('button');
3055 ok.type = 'button';
3056 ok.className = 'dlg-ok';
3057 ok.textContent = tf('dlg.done', 'Done');
3058 row.appendChild(ok);
3059 card.appendChild(row);
3060 back.appendChild(card);
3061 document.body.appendChild(back);
3062
3063 var prev = document.activeElement;
3064 function close() {
3065 document.removeEventListener('keydown', onKey, true);
3066 back.remove();
3067 if (prev && prev.focus) { try { prev.focus(); } catch (e) { /* gone */ } }
3068 }
3069 function onKey(e) {
3070 if (e.key === 'Escape') { e.preventDefault(); close(); return; }
3071 if (e.key !== 'Tab') return;
3072 // Keep Tab inside the card. Without it the focus ring walks off into the
3073 // panel behind and a keyboard user cannot get back to Done.
3074 var f = card.querySelectorAll('button, select, [tabindex]:not([tabindex="-1"])');
3075 if (!f.length) return;
3076 var first = f[0], last = f[f.length - 1];
3077 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
3078 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
3079 }
3080 document.addEventListener('keydown', onKey, true);
3081 back.addEventListener('mousedown', function (e) { if (e.target === back) close(); });
3082 ok.addEventListener('click', close);
3083 ok.focus();
3084 return Promise.resolve(true);
3085 }
3086
3087 /// Make a row behave as the button it already is.
3088 ///
3089 /// Every choice in this panel -- a mailbox, a folder, a draft, a message -- was a
3090 /// `<div>` with a click handler, so the whole of Email could be reached only with a
3091 /// pointer: not picking a mailbox, not changing folder, not opening anything. This is
3092 /// the same treatment the Diamond rows in the rail already carry, and it is deliberately
3093 /// the same code, because a second way of doing it is a second thing to keep right.
3094 ///
3095 /// `label` is optional. A `role="button"` takes its spoken name from its own contents,
3096 /// which for a draft or a message is exactly the right name -- sender, subject, date, in
3097 /// the order they are read. It is passed only where the contents would mislead: the
3098 /// mailbox row ends in a `×` closer whose text would otherwise be read out as part of
3099 /// the mailbox's name.
3100 ///
3101 /// @param row The element to make operable.
3102 /// @param onPress What a click or an Enter/Space does.
3103 /// @param label An explicit accessible name, where the contents will not serve.
3104 function rowAsButton(row, onPress, label) {
3105 row.setAttribute('role', 'button');
3106 row.setAttribute('tabindex', '0');
3107 if (label) row.setAttribute('aria-label', label);
3108 row.addEventListener('click', onPress);
3109 row.addEventListener('keydown', function (e) {
3110 if (e.key !== 'Enter' && e.key !== ' ') return;
3111 // Not when the press belongs to something inside the row -- the closer answers
3112 // for itself, and Space on a nested button must not also open the row.
3113 if (e.target !== row) return;
3114 e.preventDefault();
3115 onPress();
3116 });
3117 }
3118
3119 /// Show a message where there is room to read it. The body is inserted as
3120 /// text, never as markup — a mail body is the least trustworthy string in
3121 /// the application, and this is the one place it meets the DOM.
3122 /// Walk a MIME tree and collect what a reader needs: the plain part, the HTML part, and every
3123 /// attachment. `readableText` answers "what does this message say" in one string, which is the
3124 /// right answer for an index and the wrong one for a person reading their mail — it throws
3125 /// away the markup, the pictures and the files.
3126 ///
3127 /// Returns `{ plain, html, attachments: [{ name, type, size, bytes }] }`.
3128 function parseMime(raw, depth) {
3129 var out = { plain: '', html: '', attachments: [] };
3130 if ((depth || 0) > 8) return out; // a malformed message must not recurse forever
3131
3132 var hs = parseHeaders(raw);
3133 var ctype = header(hs, 'content-type') || 'text/plain';
3134 var body = bodyOf(raw);
3135 var mb = ctype.match(/boundary="?([^";]+)"?/i);
3136
3137 if (/^multipart\//i.test(ctype.trim()) && mb) {
3138 var parts = body.split('--' + mb[1]);
3139 parts.forEach(function (p) {
3140 p = p.replace(/^\r?\n/, '');
3141 if (!p.trim() || /^--/.test(p)) return; // the closing delimiter, not a part
3142 var sub = parseMime(p, (depth || 0) + 1);
3143 if (!out.plain && sub.plain) out.plain = sub.plain;
3144 if (!out.html && sub.html) out.html = sub.html;
3145 out.attachments = out.attachments.concat(sub.attachments);
3146 });
3147 return out;
3148 }
3149
3150 // A leaf part.
3151 var enc = (header(hs, 'content-transfer-encoding') || '').toLowerCase();
3152 var disp = header(hs, 'content-disposition') || '';
3153 var name = decodeWords(
3154 (disp.match(/filename="?([^";]+)"?/i) || ctype.match(/name="?([^";]+)"?/i) || [])[1] || '');
3155
3156 var decoded = body;
3157 if (enc === 'base64') decoded = decodeB64(body);
3158 else if (enc === 'quoted-printable') decoded = decodeQP(body);
3159
3160 // An attachment is anything the sender marked as one, or any leaf that is not text and
3161 // carries a filename. Inline images (a signature logo) are attachments too as far as we
3162 // are concerned: we do not render remote or embedded pictures.
3163 var isText = /^text\/(plain|html)/i.test(ctype.trim());
3164 if (/attachment/i.test(disp) || (!isText && name)) {
3165 var bytes = new Uint8Array(decoded.length);
3166 for (var i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i) & 0xff;
3167 out.attachments.push({
3168 name: name || 'attachment',
3169 type: (ctype.split(';')[0] || '').trim(),
3170 size: bytes.length,
3171 bytes: bytes,
3172 });
3173 return out;
3174 }
3175
3176 var cs = (ctype.match(/charset="?([^";]+)"?/i) || [])[1];
3177 var txt = asUtf8(decoded, cs);
3178 if (/text\/html/i.test(ctype)) out.html = txt;
3179 else if (/text\/plain/i.test(ctype)) out.plain = txt;
3180 else if (!/^multipart\//i.test(ctype.trim()) && !name) out.plain = txt;
3181 return out;
3182 }
3183
3184 /// Split "Jason Hoogland <jason@example.com>" into the two things a reader wants shown
3185 /// differently: a name to read, and an address to check.
3186 function splitAddr(s) {
3187 s = decodeWords(s || '').trim();
3188 var m = s.match(/^\s*(.*?)\s*<([^>]+)>\s*$/);
3189 if (m) {
3190 var nm = m[1].replace(/^["']|["']$/g, '').trim();
3191 return { name: nm, addr: m[2].trim() };
3192 }
3193 return { name: '', addr: s };
3194 }
3195
3196 async function openMessage(m) {
3197 var raw = await readText(m.file);
3198 if (raw.outcome !== 'done') {
3199 state.err = t('mail.err.msg_unreadable');
3200 render();
3201 return;
3202 }
3203 var hs = parseHeaders(raw.text);
3204 var mime = parseMime(raw.text, 0);
3205 var view = {
3206 subject: decodeWords(header(hs, 'subject')) || t('mail.no_subject'),
3207 from: splitAddr(header(hs, 'from')),
3208 to: decodeWords(header(hs, 'to')),
3209 cc: decodeWords(header(hs, 'cc')),
3210 replyTo: decodeWords(header(hs, 'reply-to')),
3211 date: header(hs, 'date'),
3212 html: mime.html,
3213 text: mime.plain || (mime.html ? '' : readableText(raw)),
3214 attachments: mime.attachments,
3215 mailbox: state.sel,
3216 // The folder it was read from, so an attachment saved out of it
3217 // lands beside the message rather than in the inbox.
3218 folder: (acct(state.sel) || {}).folder || 'INBOX',
3219 file: m.file,
3220 // What a reply to this message must point back at, so it threads rather than
3221 // arriving as an unrelated message with a similar subject.
3222 messageId: header(hs, 'message-id'),
3223 references: header(hs, 'references'),
3224 // Saving an attachment is the panel's job, but the workspace is the mail module's:
3225 // it knows where this mailbox lives on disk.
3226 save: async function (att) {
3227 var dir = mailboxDir(state.sel, view.folder) + '/attachments';
3228 var safe = String(att.name || 'attachment').replace(/[^A-Za-z0-9._-]/g, '_');
3229 var path = dir + '/' + safe;
3230 await deps.writeBytes(path, att.bytes);
3231 if (deps.refreshFiles) deps.refreshFiles();
3232 return path;
3233 },
3234 };
3235 // The verbs live on the message, where the reader is when they decide to answer it.
3236 view.reply = function () { replyTo(view, false); };
3237 view.replyAll = function () { replyTo(view, true); };
3238 view.forward = function () { forward(view); };
3239 view.canReplyAll = others(view, view.mailbox).length > 0;
3240 deps.showMessage(view);
3241 }
3242
3243 // ── Composing ───────────────────────────────────────────────────
3244
3245 /// Hand a draft to the compose panel, with the three things it can do to it.
3246 ///
3247 /// The panel edits fields and hands them back; the draft's threading — its own
3248 /// `Message-ID`, and what it is a reply to — is not on screen and not editable, so it
3249 /// is carried here rather than through the DOM.
3250 function openCompose(d) {
3251 if (!deps.showCompose) return;
3252 if (!state.accounts.length) {
3253 state.err = t('mail.err.add_mailbox_first');
3254 render();
3255 return;
3256 }
3257 d.from = d.from || state.sel || state.accounts[0].address;
3258 deps.showCompose({
3259 draft: d,
3260 from: state.accounts.map(function (a) { return a.address; }),
3261 send: async function (fields) { return sendDraft(Object.assign({}, d, fields)); },
3262 save: async function (fields) {
3263 var path = await saveDraft(Object.assign(d, fields));
3264 await refreshDrafts();
3265 return path;
3266 },
3267 discard: async function () {
3268 await discardDraft(d);
3269 await refreshDrafts();
3270 },
3271 sent: function (note) {
3272 state.note = note;
3273 state.err = '';
3274 refreshDrafts().then(render);
3275 },
3276 });
3277 }
3278
3279 /// The quoted body of a message being answered, in the shape every mail client has
3280 /// used for thirty years: a line saying who said it, then their words behind `>`.
3281 function quote(v) {
3282 var who = (v.from && (v.from.name || v.from.addr)) || t('mail.quote.they');
3283 var when = v.date ? new Date(v.date) : null;
3284 var dated = when && !isNaN(when.getTime());
3285 // A quote with no readable date used to put the phrase "an earlier date"
3286 // into a slot the sentence was built around a DATE for, which every
3287 // translator then had to work around. An undated quote gets its own
3288 // sentence instead.
3289 var head = dated
3290 ? t('mail.quote.head', { date: longDate(when), who: who })
3291 : t('mail.quote.head_undated', { who: who });
3292 var text = v.text || (v.html ? stripHtml(v.html) : '');
3293 var body = String(text).split('\n').map(function (l) { return '> ' + l; }).join('\n');
3294 return '\n\n' + head + '\n' + body + '\n';
3295 }
3296
3297 /// Everyone on the message except me: a reply-all that copies the sender back to
3298 /// themselves is a nuisance, and one that copies *me* is noise in my own inbox.
3299 function others(v, mine) {
3300 var seen = {};
3301 return addrList([v.to, v.cc].filter(Boolean).join(', '))
3302 .filter(function (x) {
3303 var a = splitAddr(x).addr.toLowerCase();
3304 if (!a || a === String(mine || '').toLowerCase() || seen[a]) return false;
3305 seen[a] = 1;
3306 return true;
3307 });
3308 }
3309
3310 function replyTo(v, all) {
3311 var mine = v.mailbox || state.sel;
3312 var to = v.replyTo || (v.from && (v.from.name ? v.from.name + ' <' + v.from.addr + '>' : v.from.addr)) || '';
3313 var subj = /^re:/i.test(v.subject || '') ? v.subject : 'Re: ' + (v.subject || '');
3314 openCompose({
3315 from: mine,
3316 to: to,
3317 cc: all ? others(v, mine).join(', ') : '',
3318 subject: subj,
3319 body: quote(v),
3320 inReplyTo: v.messageId || '',
3321 // A thread is the chain of every message before this one, so the reply carries
3322 // the parent's references and adds the parent itself.
3323 references: [v.references, v.messageId].filter(Boolean).join(' ').trim(),
3324 attachments: [],
3325 });
3326 }
3327
3328 function forward(v) {
3329 var subj = /^fwd?:/i.test(v.subject || '') ? v.subject : 'Fwd: ' + (v.subject || '');
3330 // The separator is what the reader sees; the four field names below it
3331 // are the message's own headers, and stay spelled as headers are.
3332 var head = '\n\n' + t('mail.fwd.sep') + '\n'
3333 + 'From: ' + ((v.from && (v.from.name ? v.from.name + ' <' + v.from.addr + '>' : v.from.addr)) || '') + '\n'
3334 + (v.date ? 'Date: ' + v.date + '\n' : '')
3335 + 'Subject: ' + (v.subject || '') + '\n'
3336 + (v.to ? 'To: ' + v.to + '\n' : '') + '\n';
3337 openCompose({
3338 from: v.mailbox || state.sel,
3339 to: '',
3340 cc: '',
3341 subject: subj,
3342 body: head + (v.text || (v.html ? stripHtml(v.html) : '')),
3343 // A forward that dropped the attachments would forward the wrong message.
3344 attachments: (v.attachments || []).slice(),
3345 });
3346 }
3347
3348 async function refreshDrafts() {
3349 state.drafts = state.sel ? await listDrafts(state.sel) : [];
3350 }
3351
3352 async function openDraft(path) {
3353 try {
3354 openCompose(await readDraft(state.sel, path));
3355 } catch (e) {
3356 state.err = friendly(e);
3357 render();
3358 }
3359 }
3360
3361 // ── Adding a mailbox ────────────────────────────────────────────
3362
3363 async function addAccount() {
3364 if (state.unlocked === false) { unlock(); return; }
3365 if (state.accounts.length >= state.cap) {
3366 state.err = tn('mail.err.cap', state.cap);
3367 render();
3368 return;
3369 }
3370 var v = await deps.mailDialog(PRESETS, UNREACHABLE);
3371 if (!v) return;
3372 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
3373 state.err = t('mail.err.unlock_first');
3374 render();
3375 return;
3376 }
3377 var wrapped = await DaimondIdentity.wrap(v.password);
3378 state.accounts.push({
3379 address: v.address,
3380 host: v.host,
3381 port: v.port,
3382 // Reading and posting are different servers, and the account holds both, so a
3383 // message can be sent from the mailbox it was read in without asking again.
3384 smtpHost: v.smtpHost,
3385 smtpPort: v.smtpPort,
3386 user: v.user || v.address,
3387 pass: wrapped,
3388 // The inbox is where a new mailbox starts; the rest of its folders
3389 // arrive when the server is asked what it has.
3390 folder: 'INBOX',
3391 folders: { INBOX: blankFolder('INBOX') },
3392 lastSync: 0,
3393 // When this configuration was last stated. The cross-device merge decides
3394 // on it, and on nothing else: a device that merely SYNCED a mailbox has
3395 // not thereby won an argument about which server it lives on. It must
3396 // also beat any tombstone this address already carries -- removing a
3397 // mailbox and adding it straight back is one action to the user and two
3398 // to the store, and a re-add stamped in the same millisecond as its own
3399 // deletion would lose to it and vanish again on the next pull.
3400 touched: Math.max(Date.now(), ms(tombs()[v.address]) + 1),
3401 });
3402 state.sel = v.address;
3403 save();
3404 render();
3405 syncAccount(v.address);
3406 loadFolders(v.address, true);
3407 }
3408
3409 async function removeAccount(address) {
3410 var ok = await deps.confirm(t('mail.remove.title', { address: address }),
3411 t('mail.remove.body'),
3412 { ok: t('mail.remove.ok'), danger: true });
3413 if (!ok) return;
3414 // Before the list is written, so the very next push carries the deletion:
3415 // without a tombstone the other device still holds this mailbox and simply
3416 // hands it back — with its password — on the following pull.
3417 tombstone(address);
3418 state.accounts = state.accounts.filter(function (a) { return a.address !== address; });
3419 delete state.folders[address];
3420 // The seat is released below, so what this session bound is no longer true.
3421 delete bound[address];
3422 // Every pause flag under the mailbox goes with it. A stale leaf id is
3423 // harmless to `isPaused`, but it would hold `root/mail` amber for ever and
3424 // travel in the parcel for the life of the account.
3425 try { if (window.DaimondPause) DaimondPause.forget(boxNode(address)); }
3426 catch (e) { /* module not up */ }
3427 if (state.sel === address) {
3428 state.sel = (state.accounts[0] && state.accounts[0].address) || null;
3429 state.msgs = [];
3430 }
3431 save();
3432 // Free the seat at the gateway, which is the only place the cap is real.
3433 // Through DaimondGateway.gwFetch: an hour into a sitting this was a 401 into a swallowed
3434 // catch, so the mailbox left the panel and the seat stayed taken -- and
3435 // the next add met a cap the user could see no reason for.
3436 try {
3437 await DaimondGateway.gwFetch('/api/mail/accounts', {
3438 method: 'POST',
3439 headers: { 'content-type': 'application/json' },
3440 credentials: 'same-origin',
3441 body: JSON.stringify({ address: address }),
3442 });
3443 } catch (e) { /* the local list is what the user sees; the seat is retried on the next add */ }
3444 render();
3445 }
3446
3447 // ── Wiring ──────────────────────────────────────────────────────
3448
3449 function init(d) {
3450 deps = d;
3451 var panel = document.getElementById('panel-mail');
3452 if (!panel) return;
3453 els.state = document.getElementById('mail-state');
3454 els.accounts = document.getElementById('mail-accounts');
3455 els.folders = document.getElementById('mail-folders');
3456 els.list = document.getElementById('mail-list');
3457 var add = panel.querySelector('[data-act="mail-add"]');
3458 var sync = panel.querySelector('[data-act="mail-sync"]');
3459 var neu = panel.querySelector('[data-act="mail-new"]');
3460 if (add) add.addEventListener('click', addAccount);
3461 if (sync) sync.addEventListener('click', function () {
3462 if (state.sel) syncAccount(state.sel);
3463 });
3464 if (neu) neu.addEventListener('click', function () {
3465 openCompose({ to: '', cc: '', subject: '', body: '', attachments: [] });
3466 });
3467 load();
3468 render();
3469 // The digest is NOT read here: init runs during boot, before the wasm
3470 // module that backs the file tools exists, and reading it threw a
3471 // TypeError into the console. It is read in onOpen(), which runs once
3472 // the app is up.
3473 }
3474
3475 /// Called when the panel is opened, and after a returning Stripe checkout.
3476 function onOpen() {
3477 refreshEntitlement();
3478 if (state.sel) {
3479 var a = acct(state.sel);
3480 Promise.all([loadDigest(state.sel, a && a.folder), refreshDrafts()]).then(render);
3481 // Free, so it can be asked every time the panel is opened; cached,
3482 // so it is asked of the server once per account per page.
3483 loadFolders(state.sel);
3484 }
3485 }
3486
3487 /// Logging out clears the user's content from the DOM. Mail is theirs.
3488 function clear() {
3489 // Before the accounts go: a timer armed against a mailbox that has just
3490 // left the page would poll a mailbox nobody is signed in to.
3491 if (timer) { clearTimeout(timer); timer = null; }
3492 state.accounts = [];
3493 state.msgs = [];
3494 state.drafts = [];
3495 state.folders = {};
3496 // What was bound at the gateway is a fact about somebody who has just signed
3497 // out. The next session states it again rather than assuming it.
3498 bound = {};
3499 state.sel = null;
3500 state.unlocked = null;
3501 state.note = '';
3502 state.err = '';
3503 render();
3504 }
3505
3506 // The panel stays mounted once it is open, so a language change has to
3507 // redraw it where it stands rather than waiting for it to be built again.
3508 if (window.DaimondI18n) {
3509 DaimondI18n.onChange(function () { if (els.state) render(); });
3510 }
3511
3512 // ── The model-facing mail tools ─────────────────────────────────
3513 //
3514 // The five functions `src/wasm/mail.rs` reaches, behind the registry's
3515 // `mail_list`, `mail_search`, `mail_read` and `mail_draft`. They read what the
3516 // human panel reads -- the mailbox already synced to disk -- and file a draft in
3517 // the SAME drafts folder the user's Send button reads.
3518 //
3519 // NOTHING HERE SENDS. None of these reaches `/api/mail/send`; that stays
3520 // `sendDraft`'s alone, and `sendDraft` runs only when a person presses Send. A
3521 // draft written here is a file for the user to review, exactly as one they typed
3522 // themselves. The English is deliberate, as with the digest: these strings are
3523 // read by a model relaying them to the user, not drawn on the panel.
3524
3525 function parseReq(json) {
3526 try { var v = JSON.parse(json || '{}'); return (v && typeof v === 'object') ? v : {}; }
3527 catch (e) { return {}; }
3528 }
3529
3530 function b64ToBytes(s) {
3531 try {
3532 var bin = atob(String(s || '').replace(/\s+/g, ''));
3533 var out = new Uint8Array(bin.length);
3534 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) & 0xff;
3535 return out;
3536 } catch (e) { return new Uint8Array(0); }
3537 }
3538
3539 /// Which mailbox a draft is from when the model named none: the selected one,
3540 /// or the first configured, or '' when there is none.
3541 function senderAddr(req) {
3542 var a = req.address ? acct(req.address) : (acct(state.sel) || state.accounts[0]);
3543 return a ? a.address : '';
3544 }
3545
3546 async function toolSender(json) {
3547 return senderAddr(parseReq(json));
3548 }
3549
3550 function summariseRow(m) {
3551 return 'uid ' + m.uid + (m.seen ? '' : ' [unread]')
3552 + ' ' + (m.date || '') + ' from ' + (m.from || '?')
3553 + ' — ' + (m.subject || '(no subject)');
3554 }
3555
3556 async function toolList(json) {
3557 var req = parseReq(json);
3558 if (!state.accounts.length) {
3559 return 'No mailboxes are configured. The user adds one in the Mail panel; '
3560 + 'until then there is nothing to read.';
3561 }
3562 var addr = req.address || state.sel || (state.accounts[0] && state.accounts[0].address);
3563 var lines = ['Mailboxes:'];
3564 state.accounts.forEach(function (a) {
3565 lines.push(' ' + a.address + (a.address === addr ? ' (selected)' : ''));
3566 var folders = a.folders || { INBOX: {} };
3567 Object.keys(folders).sort().forEach(function (n) {
3568 var f = folders[n] || {};
3569 lines.push(' ' + n + ' — ' + (f.count | 0) + ' message(s) synced');
3570 });
3571 });
3572 var folder = req.folder || folderOf(addr);
3573 var limit = (req.limit > 0) ? Math.min(req.limit | 0, 100) : 20;
3574 var msgs = await readMailbox(addr, folder);
3575 lines.push('');
3576 lines.push('Recent in ' + addr + ' / ' + folder + ', newest first:');
3577 if (!msgs.length) {
3578 lines.push(' (nothing synced yet — the user syncs mail from the Mail panel)');
3579 } else {
3580 msgs.slice().reverse().slice(0, limit).forEach(function (m) {
3581 lines.push(' ' + summariseRow(m));
3582 });
3583 }
3584 lines.push('');
3585 lines.push('Read one in full with mail_read (its address, folder and uid). '
3586 + 'Write or reply with mail_draft.');
3587 return lines.join('\n');
3588 }
3589
3590 async function toolSearch(json) {
3591 var req = parseReq(json);
3592 var q = String(req.query || '').trim().toLowerCase();
3593 if (!q) return 'mail_search needs a non-empty "query".';
3594 var addr = req.address || state.sel || (state.accounts[0] && state.accounts[0].address);
3595 if (!addr) return 'No mailbox is configured to search.';
3596 var folder = req.folder || folderOf(addr);
3597 var limit = (req.limit > 0) ? Math.min(req.limit | 0, 100) : 20;
3598 var msgs = await readMailbox(addr, folder);
3599 var hits = msgs.filter(function (m) {
3600 return (String(m.from || '') + ' ' + String(m.subject || '')).toLowerCase().indexOf(q) >= 0;
3601 });
3602 if (!hits.length) {
3603 return 'No message in ' + addr + ' / ' + folder + ' matched "' + req.query
3604 + '" in its sender or subject. ' + msgs.length + ' message(s) are synced there. '
3605 + 'This searches the sender and subject of synced mail, not the body.';
3606 }
3607 var lines = ['Matches for "' + req.query + '" in ' + addr + ' / ' + folder
3608 + ' (sender and subject of synced mail):'];
3609 hits.slice().reverse().slice(0, limit).forEach(function (m) {
3610 lines.push(' ' + summariseRow(m));
3611 });
3612 lines.push('');
3613 lines.push('Read one in full with mail_read.');
3614 return lines.join('\n');
3615 }
3616
3617 /// Hand one message's raw bytes to the caller, base64 in a JSON envelope, or an
3618 /// error sentence in the same envelope. The bytes are read but never parsed here:
3619 /// `oxedyne_fe2o3_mail::message` decodes them, so there is one decoder, not two.
3620 async function toolReadRaw(json) {
3621 var req = parseReq(json);
3622 var path = req.path;
3623 if (!path) {
3624 var addr = req.address || state.sel || (state.accounts[0] && state.accounts[0].address);
3625 var folder = req.folder || folderOf(addr);
3626 var uid = parseInt(req.uid, 10);
3627 if (!addr || !uid) {
3628 return JSON.stringify({ error: 'mail_read needs a mailbox address, a folder and a '
3629 + 'uid — or a path. Use mail_list to see them.' });
3630 }
3631 var msgs = await readMailbox(addr, folder);
3632 var hit = msgs.find(function (m) { return m.uid === uid; });
3633 if (!hit) {
3634 return JSON.stringify({ error: 'No message with uid ' + uid + ' in ' + addr + ' / '
3635 + folder + '. Use mail_list to see what is there.' });
3636 }
3637 path = hit.file;
3638 }
3639 var raw = await readText(path);
3640 if (raw.outcome !== 'done') {
3641 return JSON.stringify({ error: 'The message at ' + path + ' could not be read.' });
3642 }
3643 return JSON.stringify({ raw_b64: b64(utf8(raw.text)) });
3644 }
3645
3646 /// File an already-built draft. The bytes are `oxedyne_fe2o3_mail`'s; this only
3647 /// writes them where `sendDraft` and the compose panel read a draft, and never
3648 /// sends. Always answers with a sentence, so a refusal reaches the model as words.
3649 async function putDraftRaw(json) {
3650 var req = parseReq(json);
3651 var addr = req.address || state.sel;
3652 if (!addr) return 'A draft needs a mailbox to be from, and none is configured.';
3653 if (!acct(addr)) return 'There is no configured mailbox ' + addr
3654 + '. Use mail_list to see the addresses.';
3655 var bytes = b64ToBytes(req.raw_b64);
3656 if (!bytes.length) return 'The draft had no bytes to write, so nothing was saved.';
3657 try {
3658 var id = 'draft-' + Date.now() + '-' + rand(3);
3659 var path = draftsDir(addr) + '/' + id + '.eml';
3660 await deps.writeBytes(path, bytes);
3661 if (deps.refreshFiles) deps.refreshFiles();
3662 try { await refreshDrafts(); } catch (e) { /* the file is written regardless */ }
3663 render();
3664 return 'Draft saved to ' + path + ' for ' + addr + '. It is in the Mail panel drafts '
3665 + 'now, where the user reviews it and presses Send. Nothing has been sent.';
3666 } catch (e) {
3667 return 'The draft could not be saved: ' + ((e && e.message) || e);
3668 }
3669 }
3670
3671 window.DaimondMail = {
3672 init: init,
3673 // The model-facing edge, reached from `src/wasm/mail.rs`. None of these
3674 // sends; `mail_draft` files a draft for the user, and the send stays
3675 // `sendDraft`'s, run only when a person presses Send.
3676 toolList: toolList,
3677 toolSearch: toolSearch,
3678 toolReadRaw: toolReadRaw,
3679 putDraftRaw: putDraftRaw,
3680 toolSender: toolSender,
3681 /// Whether any account is configured. The Message and Compose panels are
3682 /// held off the chip row until one is, since neither means anything
3683 /// without somewhere for mail to come from.
3684 hasAccounts: function () { return state.accounts.length > 0; },
3685 /// The addresses configured, for a trigger that watches one of them. Names
3686 /// only: nothing outside this module has any business with a password.
3687 accounts: function () {
3688 return state.accounts.map(function (a) { return a.address; });
3689 },
3690 // Surviving a passphrase change. The caller drives the two halves; the
3691 // passwords never cross this boundary in either direction.
3692 unsealForRekey: unsealForRekey,
3693 resealAfterRekey: resealAfterRekey,
3694 forgetRekey: forgetRekey,
3695 onOpen: onOpen,
3696 clear: clear,
3697 // Cross-device sync (driven by sync.js through DaimondCore): what to put in
3698 // the parcel, and what to do with what comes back.
3699 exportSync: exportSync,
3700 applySync: applySync,
3701 sync: function () { if (state.sel) syncAccount(state.sel); },
3702 /// Open a draft the daimon wrote, for the user to check and send. Exposed
3703 /// because the Pending panel is where an outgoing message is approved, and
3704 /// approving one means opening it -- notes2 is explicit that the user
3705 /// approves all outgoing mail, so nothing here sends on their behalf.
3706 openDraft: openDraft,
3707 reload: function () { load(); render(); },
3708 /// The folder list held for an account, and the way to ask for it
3709 /// again. Exposed so a test can see what the server offered without
3710 /// reading it back out of the DOM.
3711 folders: function (address) {
3712 var c = state.folders[address || state.sel];
3713 return (c && c.list) ? c.list.slice() : [];
3714 },
3715 loadFolders: function (address, force) { return loadFolders(address || state.sel, force); },
3716 folder: function () { var a = acct(state.sel); return (a && a.folder) || 'INBOX'; },
3717 selectFolder: selectFolder,
3718 /// How often a folder refreshes itself, in seconds; 0 for manual only.
3719 /// Seconds rather than the dialog's eight choices, so a verifier can ask
3720 /// for an interval short enough to watch without waiting five minutes.
3721 refreshOf: function (address, name) { return refreshOf(acct(address || state.sel), name); },
3722 setRefresh: setRefresh,
3723 /// Every folder of every mailbox, which is what the panel's one refresh
3724 /// button does.
3725 refreshAll: refreshAll,
3726 /// What the folder rows say they hold, and when that was true. Exposed so
3727 /// a test can read the number without parsing it back out of the DOM.
3728 counts: function (address) {
3729 var a = acct(address || state.sel);
3730 if (!a) return {};
3731 var out = {};
3732 Object.keys(a.folders || {}).sort().forEach(function (n) {
3733 var f = a.folders[n] || {};
3734 out[n] = {
3735 count: f.count | 0,
3736 lastSync: ms(f.lastSync),
3737 every: refreshOf(a, n),
3738 // The row's own words, so a test asserts what the user reads
3739 // rather than a number the row might be dressing differently.
3740 says: countPhrase(a, n),
3741 };
3742 });
3743 return out;
3744 },
3745 /// The gear dialog's body, for the container that will carry it and for a
3746 /// test that wants to read the tiles without opening a modal.
3747 settingsBody: settingsBody,
3748 openSettings: openSettings,
3749 /// Where a folder's messages sit in the workspace.
3750 folderDir: function (address, name) { return mailboxDir(address || state.sel, name); },
3751 compose: function () {
3752 openCompose({ to: '', cc: '', subject: '', body: '', attachments: [] });
3753 },
3754 // Exposed for the tests, which have no business driving the DOM to find out whether
3755 // a message they built is the message that would go on the wire.
3756 build: buildMessage,
3757 /// Open one tunnel, for a test that has to watch a handshake finish, a
3758 /// certificate be refused, or a close code become a sentence — none of which a
3759 /// mailbox is needed for, and none of which can be seen from outside the page.
3760 ///
3761 /// It is the SAME function the sync path calls. A test hook that opened a
3762 /// second, simpler tunnel would be proving things about the hook.
3763 tunnel: openTunnel,
3764 /// The bundle's own account of how much it checks: `mailtls/verify-always` in
3765 /// anything shipped. A page cannot be allowed to drive a module that verifies
3766 /// less than the page believes, and this is how a release notices.
3767 flavour: async function () {
3768 var wasm = await engine();
3769 return wasm.mail_tunnel_flavour();
3770 },
3771 };
3772})();