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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 | })(); |