Просмотр исходного кода

Enhance Mail windows, caching, and sync

Refactors the Mail WebApp to support dedicated compose and message windows, shared cross-window helpers, and BroadcastChannel-based state updates so drafts, sends, and message changes stay in sync across open views. It also adds list/message caching with background refresh and prefetching for faster navigation, improves menu UX with nested submenus, and updates rendering/theme behavior plus flat visual styling.

The AGI runtime is bumped to 3.8 and the email library is fully documented in both `mod/agi/README.md` and Terminal API docs. Desktop float windows also gain a focus-cover mechanism (including container apps) so background windows can reliably be brought to front on first click.
Toby Chui 6 часов назад
Родитель
Сommit
d24b041697

+ 7 - 1
CLAUDE.md

@@ -42,7 +42,12 @@ ffmpeg helpers, SQLite, WebSockets, an LLM/`aimodel` chat library, and more).
   permission), `git` (version control over folders in the user's file system —
   permission), `git` (version control over folders in the user's file system —
   clone/status/stage/commit/branch/diff/fetch/pull/push via go-git with no `git`
   clone/status/stage/commit/branch/diff/fetch/pull/push via go-git with no `git`
   binary on the host, plus encrypted per-user HTTPS credentials,
   binary on the host, plus encrypted per-user HTTPS credentials,
-  [`src/mod/git/`](src/mod/git/); the GitApp WebApp is its front end), and
+  [`src/mod/git/`](src/mod/git/); the GitApp WebApp is its front end), `email`
+  (IMAP / SMTP mail client — accounts with password or Google / Microsoft OAuth
+  sign-in, sanitised reading, drafts, undo / scheduled sending through a
+  server-side outbox, contacts, labels and snooze; mail data and the encrypted
+  secrets live in their own `system/mail/mail.db`,
+  [`src/mod/email/`](src/mod/email/); the Mail WebApp is its front end), and
   `ffmpeg` (only when ffmpeg is on the host), plus
   `ffmpeg` (only when ffmpeg is on the host), plus
   `websocket` and `scheduler` which are injected only in an HTTP request
   `websocket` and `scheduler` which are injected only in an HTTP request
   context.
   context.
@@ -572,6 +577,7 @@ short comment explaining why. Use it sparingly — it is reviewed.
 - [`src/mod/prouter/`](src/mod/prouter/) — permission/auth router (rule 4).
 - [`src/mod/prouter/`](src/mod/prouter/) — permission/auth router (rule 4).
 - [`src/mod/agi/`](src/mod/agi/) — the AGI JavaScript gateway runtime (see "What AGI is"); API reference in [`src/mod/agi/README.md`](src/mod/agi/README.md).
 - [`src/mod/agi/`](src/mod/agi/) — the AGI JavaScript gateway runtime (see "What AGI is"); API reference in [`src/mod/agi/README.md`](src/mod/agi/README.md).
 - [`src/mod/modules/`](src/mod/modules/) — module registry and the `ModuleInfo` struct shared by WebApps and SubServices (see "What a WebApp is").
 - [`src/mod/modules/`](src/mod/modules/) — module registry and the `ModuleInfo` struct shared by WebApps and SubServices (see "What a WebApp is").
+- [`src/mod/email/`](src/mod/email/) — IMAP / SMTP backend of the `email` AGI library and the Mail WebApp ([`src/web/Mail/`](src/web/Mail/)).
 - [`src/mod/subservice/`](src/mod/subservice/) — reverse-proxied binary subservices (see "What a SubService is"); wired up in [`src/subservice.go`](src/subservice.go).
 - [`src/mod/subservice/`](src/mod/subservice/) — reverse-proxied binary subservices (see "What a SubService is"); wired up in [`src/subservice.go`](src/subservice.go).
 - [`src/mod/appproxy/`](src/mod/appproxy/) — Container Apps reverse proxy (see "What Container Apps are").
 - [`src/mod/appproxy/`](src/mod/appproxy/) — Container Apps reverse proxy (see "What Container Apps are").
 - [`src/web/`](src/web/) — front-end assets and WebApps (one folder per module; see "What a WebApp is").
 - [`src/web/`](src/web/) — front-end assets and WebApps (one folder per module; see "What a WebApp is").

+ 259 - 1
src/mod/agi/README.md

@@ -15,7 +15,7 @@ This document is updated to match the current AGI implementation in `mod/agi/agi
 
 
 ## AGI Version
 ## AGI Version
 
 
-- Runtime version: `3.7` (`AgiVersion` in `agi.go`)
+- Runtime version: `3.8` (`AgiVersion` in `agi.go`)
 
 
 ## Quick Start
 ## Quick Start
 
 
@@ -278,6 +278,7 @@ Registered library IDs:
 - `office` (ArozOS Office suite: .pptx / .xlsx / .docx converters + native zip container pack/unpack)
 - `office` (ArozOS Office suite: .pptx / .xlsx / .docx converters + native zip container pack/unpack)
 - `notification` (raise notifications to users via the core notification system, with priority - requires the host to wire in a notification sender)
 - `notification` (raise notifications to users via the core notification system, with priority - requires the host to wire in a notification sender)
 - `git` (version control for folders in the user's file system: clone / status / stage / commit / branch / diff / fetch / pull / push, with encrypted per-user HTTPS credentials — requires the host to wire in a git manager)
 - `git` (version control for folders in the user's file system: clone / status / stage / commit / branch / diff / fetch / pull / push, with encrypted per-user HTTPS credentials — requires the host to wire in a git manager)
+- `email` (IMAP / SMTP mail client: accounts with password or OAuth sign-in, folders, search, reading sanitised mail, drafts, sending with undo / scheduling, address book, labels, snooze — requires the host to wire in an email manager)
 - `ffmpeg` (only when ffmpeg exists on host)
 - `ffmpeg` (only when ffmpeg exists on host)
 
 
 Special case:
 Special case:
@@ -2544,6 +2545,263 @@ The host a remote URL maps to, i.e. the key credentials are stored under. Both
 
 
 ---
 ---
 
 
+## email API
+
+Load:
+
+```javascript
+requirelib("email");
+```
+
+A complete IMAP / SMTP mail client, backed by `mod/email`: multiple accounts
+per ArozOS user (Gmail, Outlook / Hotmail / Microsoft 365, Yahoo, iCloud and any
+IMAP server), folders, paging and server-side search, reading with sanitised
+HTML, flags, moving, drafts, sending with undo and scheduling, an address book,
+labels, snoozing and OAuth sign-in. It is the backend of the Mail WebApp and is
+available only when the host wired an email manager into the AGI gateway.
+
+**Return convention**
+
+Every function returns an object. On success it is `{success: true, data: …}`;
+on failure `{success: false, error: "…"}` plus, where it applies:
+
+- `authFailed: true` — the stored password or sign-in was rejected; ask the
+  user to sign in again (`updateAccount` with a new password, or a new OAuth
+  sign-in).
+- `hint` — what the user should do, e.g. "Gmail only accepts an app password".
+- `code` — `notfound` (account, folder or message is gone), `blocked`
+  (administrator policy), `toolarge`, `oauthdisabled`.
+
+**Privacy and security**
+
+- Accounts belong to the calling ArozOS user; other users can never list or
+  use them. Mail data is kept in its own database (`system/mail/mail.db`), out
+  of reach of the generic DB functions.
+- Passwords, OAuth refresh tokens and OAuth client secrets are sealed with
+  AES-256-GCM and are never returned to a script.
+- Non-admin users cannot connect to loopback / LAN addresses or use
+  unencrypted connections unless an administrator allows it (`setAdminConfig`).
+- `get` returns sanitised HTML (no scripts, frames, forms or event handlers;
+  remote images blocked unless allowed). Show it in a sandboxed iframe anyway.
+- Paths (`destDir`, `.eml` files, attachments to send) are ArozOS virtual paths
+  and are permission and quota checked. Saved files never overwrite an
+  existing file: `report.pdf` becomes `report (1).pdf`.
+
+**Identifiers**
+
+A message is addressed by `accountId`, `folder` (full IMAP mailbox name, e.g.
+`"INBOX"` or `"[Gmail]/Sent Mail"`) and `uid`. Attachments are addressed by
+their part id (`"2"`, `"1.3"`, …) as listed in `message.attachments`.
+
+### Accounts and sign-in
+
+#### `email.providers()` → `{success, data: [preset]}`
+
+Known providers: server settings, whether OAuth applies and how to create an
+app password.
+
+#### `email.discover(address)` → `{success, data}`
+
+Suggests server settings for an address (provider presets, MX records,
+Thunderbird ISPDB, the domain's autoconfig, SRV records, then probing).
+
+#### `email.listAccounts()` / `email.getAccount(id)`
+
+Accounts without secrets: `{id, email, displayName, provider, color, imap,
+smtp, auth, signature, replyTo, saveSent, hasSecret, authError}`.
+
+#### `email.testAccount(input)` → `{success, data: {imapOk, smtpOk}}`
+
+Logs in to both servers without storing anything (password accounts only).
+
+#### `email.addAccount(input)` → `{success, data: {account, test}}`
+
+Verifies IMAP (and SMTP unless `skipSmtpCheck`) and stores the account. Input:
+`{email, displayName, provider, imap: {host, port, security, username},
+smtp: {…}, auth: "password" | "oauth2", password, smtpPassword, oauthState,
+signature, replyTo, saveSent: "auto" | "always" | "never"}`. Server settings
+left empty are filled from the provider preset. `security` is `ssl`,
+`starttls` or `none`.
+
+```javascript
+var result = email.addAccount({
+    email: "me@example.com", displayName: "Me", provider: "custom",
+    imap: { host: "mail.example.com", port: 993, security: "ssl" },
+    smtp: { host: "mail.example.com", port: 465, security: "ssl" },
+    auth: "password", password: "app-password"
+});
+if (!result.success && result.authFailed) { /* show result.hint */ }
+```
+
+#### `email.updateAccount(id, input)` / `email.removeAccount(id)` / `email.reorderAccounts(ids)`
+
+An empty password keeps the stored one. Changed connection settings are
+verified before they are saved. Removing an account also forgets its local
+labels, snoozes and queued mail.
+
+#### `email.oauthProviders()` → `{success, data: [{id, name, enabled, flow}]}`
+
+#### `email.oauthStart(provider, address, redirectURI)` → `{success, data}`
+
+Starts a Google or Microsoft sign-in. The result carries `state`, `flow` and
+either `authUrl` (redirect / loopback flows: open it in a window) or
+`userCode` + `verificationUri` (device flow). `redirectURI` is the address of
+`Mail/oauth.html` as the browser sees it.
+
+#### `email.oauthComplete(state, codeOrURL)` / `email.oauthStatus(state)` / `email.oauthCancel(state)`
+
+Finish a redirect / loopback sign-in with the code (or the whole URL the
+provider redirected to), poll a sign-in (`status`: `pending`, `done`, `error`),
+or abandon it. A finished `state` is passed to `addAccount` /
+`updateAccount` as `oauthState`; tokens never reach the script.
+
+### Folders
+
+#### `email.folders(accountId, refresh)` → `{success, data: [folder]}`
+
+`{name, display, parent, delimiter, depth, role, selectable, total, unread}`.
+`role` is `inbox`, `sent`, `drafts`, `trash`, `junk`, `archive`, `all`,
+`flagged`, `important` or empty. Lists are cached for a few minutes unless
+`refresh` is true.
+
+#### `email.createFolder(accountId, parent, name)` / `email.renameFolder(accountId, folder, newName)` / `email.deleteFolder(accountId, folder)`
+
+#### `email.emptyFolder(accountId, folder)` / `email.markAllRead(accountId, folder)`
+
+`emptyFolder` only works on the Trash and Junk folders.
+
+### Messages
+
+#### `email.list(accountId, query)` → `{success, data: {total, page, pageSize, messages}}`
+
+`query`: `{folder, page, pageSize, sort, filter, search, searchIn, previews}`.
+`sort` is `date`, `date_asc`, `from`, `subject` or `size`; `filter` is `all`,
+`unread`, `flagged`, `attachments` or `unanswered`; `searchIn` is `all`,
+`from`, `to`, `subject` or `body`. Each message summary carries `uid`,
+`subject`, `from`, `to`, `date`, `seen`, `flagged`, `hasAttachments`,
+`preview`, `labels`, ….
+
+```javascript
+var page = email.list(accountId, { folder: "INBOX", page: 0, pageSize: 50, previews: true });
+```
+
+#### `email.unified(view, query, accountIds)` → `{success, data}`
+
+One list across accounts (all, or the given ids): `inbox`, `flagged`,
+`unread`, or `role:<role>` (e.g. `role:sent`). A failing account is reported
+in `data.errors` instead of failing the view.
+
+#### `email.get(accountId, folder, uid, options)` → `{success, data: message}`
+
+The message with `html` (sanitised), `text`, `attachments`, `replyTo`,
+`references`, `listUnsubscribe`, `auth` (SPF / DKIM / DMARC verdicts) and
+more. Options: `markSeen`, `allowRemote`.
+
+#### `email.rawSource(accountId, folder, uid)` → `{success, data: {source, truncated, size}}`
+
+#### `email.setFlag(accountId, folder, uids, flag, value)`
+
+`flag` is `seen`, `flagged`, `answered`, `forwarded` or `draft`.
+
+#### `email.move(accountId, folder, uids, destination)` / `email.copy(…)`
+
+#### `email.moveToRole(accountId, folder, uids, role)`
+
+Moves to the account's `archive`, `junk`, `inbox` or `trash` folder, creating
+it when missing. On Gmail, archive moves to All Mail.
+
+#### `email.remove(accountId, folder, uids, permanent)`
+
+Moves to Trash, or deletes for good inside Trash / Junk or when `permanent`.
+
+#### `email.locate(accountId, messageId, hint)` → `{success, data: {folder, uid}}`
+
+Finds a message by its Message-ID after it moved.
+
+#### `email.checkInboxes()` / `email.newSince(accountId, uidNext, limit)`
+
+Cheap STATUS poll of every inbox (`unread`, `total`, `uidNext`), and the new
+unread messages with a UID at or above `uidNext`.
+
+### Files
+
+#### `email.saveMessage(accountId, folder, uid, destDir)` → `{success, data: {path}}`
+
+Saves the message as an `.eml` file.
+
+#### `email.saveAttachment(accountId, folder, uid, partId, destDir)` / `email.saveAllAttachments(accountId, folder, uid, partIds, destDir)`
+
+#### `email.openEml(vpath, allowRemote)` / `email.saveEmlAttachment(vpath, partId, destDir)` / `email.importEml(vpath, accountId, folder)`
+
+Read an `.eml` file like a mailbox message, extract its attachments, or append
+it to a mailbox folder.
+
+#### `email.tempFolder(purpose)` → `{success, data: vpath}`
+
+A private scratch folder under `tmp:/Mail` (`uploads` or `downloads`), pruned
+after a day.
+
+### Composing
+
+#### `email.send(message)` → `{success, data: {queued, outboxId, sendAt, messageId, warning}}`
+
+`message`: `{accountId, fromName, to, cc, bcc, replyTo, subject, html, text,
+plainOnly, priority: "high" | "normal" | "low", readReceipt, inReplyTo,
+references, replyMode: "reply" | "forward", originalFolder, originalUid,
+draftFolder, draftUid, files: [{path, name}], forwarded: [{accountId, folder,
+uid, partId}], undoSeconds, sendAt}`. Images pasted as `data:` URIs become
+inline parts. With `undoSeconds` or a future `sendAt` the message waits in the
+server-side outbox and is delivered even if the browser closes. After delivery
+a copy is filed in Sent (unless the provider does that itself), the original is
+flagged answered / forwarded and the draft is removed. A `forwarded` entry with
+an empty `partId` attaches the whole original message.
+
+```javascript
+email.send({
+    accountId: accountId,
+    to: ["Alice <alice@example.com>"],
+    subject: "Report",
+    html: "<p>Attached.</p>",
+    files: [{ path: "user:/Desktop/report.pdf" }]
+});
+```
+
+#### `email.saveDraft(message)` → `{success, data: {folder, uid, attachments}}`
+
+Stores the message in Drafts, replacing `draftUid`.
+
+#### `email.deleteDraft(accountId, folder, uid)` / `email.outbox()` / `email.outboxCancel(id, toDrafts)` / `email.outboxSendNow(id)`
+
+### Address book, labels and snoozing
+
+#### `email.contacts()` / `email.searchContacts(query, limit)` / `email.saveContact(contact)` / `email.deleteContact(address)` / `email.importContacts(contacts)`
+
+Recipients of sent mail are collected automatically (a user preference).
+
+#### `email.labels()` / `email.saveLabels(labels)` / `email.setLabels(message, labelIds)` / `email.labelMessages(labelId)`
+
+Local, coloured labels (`{id, name, color}`) that work the same on every
+provider. `message` is a message summary as returned by `list`.
+
+#### `email.snooze(message, until)` / `email.unsnooze(message)` / `email.snoozed()`
+
+Hides a message until `until` (unix ms); it then reappears unread.
+
+### Preferences and administration
+
+#### `email.settings()` / `email.saveSettings(settings)` / `email.trustSender(sender)`
+
+The user's Mail preferences, and the list of senders (addresses or domains)
+whose remote images always load.
+
+#### `email.isAdmin()` / `email.adminConfig()` / `email.setAdminConfig(config)`
+
+Administrators configure the Google and Microsoft OAuth applications, whether
+users may reach LAN servers or use unencrypted connections, the attachment
+size limit and the number of accounts per user. Client secrets are write-only.
+
+---
+
 ## Cluster Library
 ## Cluster Library
 
 
 `requirelib("cluster")` exposes the ArozOS cluster to scripts. Ordinary file
 `requirelib("cluster")` exposes the ArozOS cluster to scripts. Ordinary file

+ 1 - 1
src/mod/agi/agi.go

@@ -42,7 +42,7 @@ import (
 */
 */
 
 
 var (
 var (
-	AgiVersion string = "3.7" //Defination of the agi runtime version. Update this when new function is added
+	AgiVersion string = "3.8" //Defination of the agi runtime version. Update this when new function is added
 
 
 	//AGI Internal Error Standard
 	//AGI Internal Error Standard
 	errExitcall = errors.New("errExit")
 	errExitcall = errors.New("errExit")

+ 186 - 0
src/web/Mail/compose.html

@@ -0,0 +1,186 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>New message</title>
+    <link rel="icon" href="img/icon.svg">
+    <link rel="stylesheet" href="../script/semantic/components/icon.min.css">
+    <link rel="stylesheet" href="style/mail.css">
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+</head>
+
+<body class="window-page">
+    <div class="compose-host" id="composeHost">
+        <div class="reader-loading"><div class="spinner large"></div></div>
+    </div>
+
+    <script src="script/util.js"></script>
+    <script src="script/api.js"></script>
+    <script src="script/ui.js"></script>
+    <script src="script/shared.js"></script>
+    <script src="script/compose.js"></script>
+    <script>
+        /*
+            Mail — composer window
+
+            Every new message, reply, forward and reopened draft gets a float
+            window of its own. The message to start with arrives through a
+            storage hand-off (see Mail.shared.openComposeWindow), because a
+            quoted message is often too large for a URL.
+
+            Closing the window saves the message to Drafts first. A browser tab
+            that is closed without that chance sends what it has as a beacon.
+        */
+        (function () {
+            "use strict";
+            var util = Mail.util;
+            var ui = Mail.ui;
+            var api = Mail.api;
+            var shared = Mail.shared;
+            var el = util.el;
+            var icon = util.icon;
+            var host = document.getElementById("composeHost");
+            var accounts = [];
+            var settings = {};
+            var closing = false;
+            var finished = false;
+            var applyTheme = shared.followTheme(function () { return settings.theme; });
+
+            function setTitle(title) {
+                if (typeof ao_module_setWindowTitle === "function") {
+                    ao_module_setWindowTitle(title || "New message");
+                } else {
+                    document.title = title || "New message";
+                }
+            }
+
+            //closeWindow closes the window once the composer is done with it
+            function closeWindow() {
+                finished = true;
+                ao_module_closeHandler();
+            }
+
+            //The title bar's close button lands here: save the draft, then close
+            window.ao_module_close = function () {
+                var composer = Mail.compose.current();
+                if (!composer || finished) {
+                    closeWindow();
+                    return;
+                }
+                if (closing) {
+                    return;
+                }
+                closing = true;
+                composer.close().then(function (ok) {
+                    closing = false;
+                    if (ok) {
+                        closeWindow();
+                    }
+                }, function () {
+                    closing = false;
+                });
+            };
+
+            //Last resort when the window goes away without asking (a browser
+            //tab, a reload): hand the unsaved message to the server
+            window.addEventListener("pagehide", function () {
+                var composer = Mail.compose.current();
+                var draft = composer && !finished ? composer.unsavedDraft() : null;
+                if (!draft || !navigator.sendBeacon) {
+                    return;
+                }
+                var root = (typeof ao_root === "string" && ao_root) ? ao_root : "../";
+                navigator.sendBeacon(root + "system/ajgi/interface?script=Mail/backend/compose.agi", new URLSearchParams({
+                    opr: "draft",
+                    data: JSON.stringify({ message: draft })
+                }));
+            });
+
+            function showProblem(title, text, withMailButton) {
+                util.clear(host);
+                host.appendChild(el("div", { class: "reader-empty" }, [
+                    icon("exclamation triangle"),
+                    el("div", { class: "title", text: title }),
+                    el("div", { text: text }),
+                    withMailButton ? el("button", {
+                        class: "btn primary", on: {
+                            click: function () {
+                                ao_module_newfw({ url: "Mail/index.html", width: 1280, height: 800, appicon: "Mail/img/icon.svg", title: "Mail" });
+                                closeWindow();
+                            }
+                        }
+                    }, [icon("envelope outline"), "Open Mail"]) : null
+                ]));
+            }
+
+            //startOptions reads what to write from the launch hash
+            function startOptions() {
+                var hash = (window.location.hash || "").replace(/^#/, "");
+                var options = null;
+                if (hash.indexOf("handoff=") === 0) {
+                    options = shared.takeHandOff(hash.slice(8));
+                } else if (/^mailto:/i.test(decodeURIComponent(hash))) {
+                    options = Mail.compose.mailtoOptions(decodeURIComponent(hash), "");
+                } else if (hash.indexOf("compose=") === 0) {
+                    try {
+                        options = JSON.parse(decodeURIComponent(hash.slice(8)));
+                    } catch (e) { /* malformed */ }
+                }
+                //A reload must not start the same message twice
+                try {
+                    history.replaceState(null, "", window.location.pathname + window.location.search);
+                } catch (e) { /* ignore */ }
+                return options || {};
+            }
+
+            var options = startOptions();
+            if (!api.available) {
+                showProblem("Not connected", "Open Mail from the ArozOS desktop.", false);
+                return;
+            }
+
+            Promise.all([
+                api.accounts("list", {}),
+                api.settings("get", {}).catch(function () { return {}; })
+            ]).then(function (results) {
+                accounts = results[0] || [];
+                settings = results[1] || {};
+                applyTheme();
+                if (accounts.length === 0) {
+                    showProblem("No mail account", "Add a mail account in Mail before writing a message.", true);
+                    return;
+                }
+                util.clear(host);
+                Mail.compose.init({
+                    host: host,
+                    context: {
+                        windowMode: true,
+                        accounts: function () { return accounts; },
+                        settings: function () { return settings; },
+                        defaultAccountId: function () {
+                            if (settings.defaultAccount && accounts.some(function (item) { return item.id === settings.defaultAccount; })) {
+                                return settings.defaultAccount;
+                            }
+                            return accounts[0].id;
+                        },
+                        onTitle: setTitle,
+                        closeWindow: closeWindow,
+                        onAuthFailed: function (accountId) {
+                            var item = accounts.filter(function (account) { return account.id === accountId; })[0];
+                            ui.toast("Sign in to " + (item ? item.email : "this account") + " again from the Mail window, then send the message.", { error: true, duration: 9000 });
+                        }
+                    }
+                });
+                setTitle(options.subject || "New message");
+                Mail.compose.openHere(options);
+            }).catch(function (error) {
+                showProblem("Cannot write a message", error.message, false);
+            });
+        })();
+    </script>
+</body>
+
+</html>

+ 2 - 1
src/web/Mail/index.html

@@ -40,7 +40,7 @@
                 <button class="iconbtn" id="replyButton" title="Reply (R)"><i class="reply icon"></i></button>
                 <button class="iconbtn" id="replyButton" title="Reply (R)"><i class="reply icon"></i></button>
                 <button class="iconbtn" id="moreButton" title="More actions"><i class="ellipsis horizontal icon"></i></button>
                 <button class="iconbtn" id="moreButton" title="More actions"><i class="ellipsis horizontal icon"></i></button>
                 <span class="sep wide-only"></span>
                 <span class="sep wide-only"></span>
-                <button class="iconbtn wide-only" id="settingsButton" title="Settings"><i class="cog icon"></i></button>
+                <button class="iconbtn" id="settingsButton" title="Settings"><i class="cog icon"></i></button>
                 <button class="avatar-btn" id="avatarButton" title="Account"></button>
                 <button class="avatar-btn" id="avatarButton" title="Account"></button>
             </div>
             </div>
         </header>
         </header>
@@ -72,6 +72,7 @@
     <script src="script/api.js"></script>
     <script src="script/api.js"></script>
     <script src="script/ui.js"></script>
     <script src="script/ui.js"></script>
     <script src="script/render.js"></script>
     <script src="script/render.js"></script>
+    <script src="script/shared.js"></script>
     <script src="script/compose.js"></script>
     <script src="script/compose.js"></script>
     <script src="script/accounts.js"></script>
     <script src="script/accounts.js"></script>
     <script src="script/settings.js"></script>
     <script src="script/settings.js"></script>

+ 502 - 0
src/web/Mail/message.html

@@ -0,0 +1,502 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Message</title>
+    <link rel="icon" href="img/icon.svg">
+    <link rel="stylesheet" href="../script/semantic/components/icon.min.css">
+    <link rel="stylesheet" href="style/mail.css">
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+</head>
+
+<body class="window-page">
+    <div class="viewer-app message-window" id="messageWindow">
+        <div class="viewer-bar">
+            <button class="btn small" id="replyButton" title="Reply (R)" disabled><i class="reply icon"></i><span class="text">Reply</span></button>
+            <button class="btn small" id="replyAllButton" title="Reply all (A)" disabled><i class="reply all icon"></i><span class="text">Reply all</span></button>
+            <button class="btn small" id="forwardButton" title="Forward (F)" disabled><i class="share icon"></i><span class="text">Forward</span></button>
+            <div class="grow"></div>
+            <button class="iconbtn" id="starButton" title="Star (S)" disabled><i class="star outline icon"></i></button>
+            <button class="iconbtn" id="unreadButton" title="Mark as unread (U)" disabled><i class="envelope outline icon"></i></button>
+            <button class="iconbtn" id="archiveButton" title="Archive (E)" disabled><i class="archive icon"></i></button>
+            <button class="iconbtn" id="deleteButton" title="Delete (Del)" disabled><i class="trash alternate outline icon"></i></button>
+            <button class="iconbtn" id="moreButton" title="More actions" disabled><i class="ellipsis horizontal icon"></i></button>
+        </div>
+        <div class="reader-scroll scroll" id="content">
+            <div class="reader-loading"><div class="spinner large"></div></div>
+        </div>
+    </div>
+
+    <script src="script/util.js"></script>
+    <script src="script/api.js"></script>
+    <script src="script/ui.js"></script>
+    <script src="script/render.js"></script>
+    <script src="script/shared.js"></script>
+    <script src="script/compose.js"></script>
+    <script src="script/settings.js"></script>
+    <script>
+        /*
+            Mail — message window
+
+            One mailbox message in a float window of its own (double-click a
+            message in the list). It offers the reading pane's actions and
+            tells the main window what changed through Mail.shared.emit, so
+            its list and counters follow.
+        */
+        (function () {
+            "use strict";
+            var util = Mail.util;
+            var ui = Mail.ui;
+            var api = Mail.api;
+            var render = Mail.render;
+            var shared = Mail.shared;
+            var compose = Mail.compose;
+            var el = util.el;
+            var icon = util.icon;
+            var content = document.getElementById("content");
+            var target = null;
+            var message = null;
+            var accounts = [];
+            var settings = {};
+            var labels = [];
+            var folders = [];
+            var frame = null;
+            var markTimer = null;
+            var busy = false;
+            var applyTheme = shared.followTheme(function () { return settings.theme; });
+
+            var buttons = {};
+            ["replyButton", "replyAllButton", "forwardButton", "starButton", "unreadButton", "archiveButton", "deleteButton", "moreButton"].forEach(function (id) {
+                buttons[id] = document.getElementById(id);
+            });
+
+            compose.init({
+                host: document.body,
+                context: {
+                    accounts: function () { return accounts; },
+                    settings: function () { return settings; },
+                    defaultAccountId: function () { return message ? message.accountId : (accounts.length > 0 ? accounts[0].id : ""); }
+                }
+            });
+
+            function setTitle(title) {
+                if (typeof ao_module_setWindowTitle === "function") {
+                    ao_module_setWindowTitle(title);
+                } else {
+                    document.title = title;
+                }
+            }
+
+            function readTarget() {
+                try {
+                    var parsed = JSON.parse(decodeURIComponent((window.location.hash || "").replace(/^#/, "")));
+                    if (parsed && parsed.accountId && parsed.folder && parsed.uid) {
+                        return parsed;
+                    }
+                } catch (e) { /* malformed */ }
+                return null;
+            }
+
+            function role() {
+                if (!message) {
+                    return "";
+                }
+                if (String(message.folder).toUpperCase() === "INBOX") {
+                    return "inbox";
+                }
+                var folder = folders.filter(function (item) { return item.name === message.folder; })[0];
+                return folder ? folder.role : "";
+            }
+
+            function destroyFrame() {
+                if (frame && frame.destroy) {
+                    frame.destroy();
+                }
+                frame = null;
+                clearTimeout(markTimer);
+            }
+
+            function showState(iconName, title, text) {
+                destroyFrame();
+                util.clear(content);
+                content.appendChild(el("div", { class: "reader-empty" }, [icon(iconName), el("div", { class: "title", text: title }), text ? el("div", { text: text }) : null]));
+                Object.keys(buttons).forEach(function (id) { buttons[id].disabled = true; });
+            }
+
+            //finish closes the window after the message left this folder
+            function finish(text) {
+                if (shared.isDesktop()) {
+                    ao_module_closeHandler();
+                    return;
+                }
+                showState("check", text, "You can close this tab.");
+            }
+
+            function updateButtons() {
+                var ready = message !== null && !busy;
+                Object.keys(buttons).forEach(function (id) { buttons[id].disabled = !ready; });
+                if (!message) {
+                    return;
+                }
+                buttons.starButton.classList.toggle("on", message.flagged === true);
+                buttons.starButton.title = message.flagged ? "Unstar (S)" : "Star (S)";
+                buttons.starButton.firstChild.className = (message.flagged ? "star" : "star outline") + " icon";
+                buttons.unreadButton.title = message.seen ? "Mark as unread (U)" : "Mark as read (Shift+I)";
+                buttons.unreadButton.firstChild.className = (message.seen ? "envelope outline" : "envelope open outline") + " icon";
+                var currentRole = role();
+                buttons.archiveButton.classList.toggle("hidden", currentRole === "archive" || currentRole === "all");
+                buttons.deleteButton.title = currentRole === "trash" ? "Delete permanently" : "Delete (Del)";
+            }
+
+            function emitChange(change, extra) {
+                var data = { change: change, accountId: message.accountId, folder: message.folder, uid: message.uid };
+                Object.keys(extra || {}).forEach(function (key) { data[key] = extra[key]; });
+                shared.emit("changed", data);
+            }
+
+            /* ---------- Loading ---------- */
+
+            function fetch(allowRemote) {
+                var request = function (folder, uid) {
+                    return api.message("get", { accountId: target.accountId, folder: folder, uid: uid, options: { allowRemote: allowRemote === true } });
+                };
+                return request(target.folder, target.uid).catch(function (error) {
+                    //Labelled and snoozed mail may have moved since it was listed
+                    if (error.code === "notfound" && target.messageId) {
+                        return api.message("locate", { accountId: target.accountId, messageId: target.messageId, hint: target.folder }).then(function (located) {
+                            target.folder = located.folder;
+                            target.uid = located.uid;
+                            return request(located.folder, located.uid);
+                        });
+                    }
+                    throw error;
+                });
+            }
+
+            function load(allowRemote) {
+                fetch(allowRemote).then(function (loaded) {
+                    show(loaded);
+                }).catch(function (error) {
+                    showState(error.authFailed ? "key" : "exclamation triangle",
+                        error.code === "notfound" ? "This message is no longer here" : "Could not open the message", error.message);
+                });
+            }
+
+            function labelPills() {
+                var pills = el("div", { class: "msg-labels" });
+                (message.labels || []).forEach(function (id) {
+                    var label = labels.filter(function (item) { return item.id === id; })[0];
+                    if (label) {
+                        pills.appendChild(el("span", { class: "label-pill", style: { "--pill": label.color }, text: label.name }));
+                    }
+                });
+                return pills;
+            }
+
+            function show(loaded) {
+                message = loaded;
+                destroyFrame();
+                util.clear(content);
+                setTitle(message.subject || "(no subject)");
+
+                var subject = el("div", { class: "reader-subject" }, [el("h1", { text: message.subject || "(no subject)" })]);
+                subject.appendChild(labelPills());
+                content.appendChild(subject);
+
+                var acts = el("div", { class: "acts" });
+                if (message.listUnsubscribe) {
+                    acts.appendChild(el("button", {
+                        class: "btn small ghost", title: "Unsubscribe from this mailing list", text: "Unsubscribe",
+                        on: { click: function () { shared.unsubscribe(message, function (href) { compose.mailto(href, message.accountId); }); } }
+                    }));
+                }
+                content.appendChild(render.header(message, { actions: acts, onAddress: addressMenu }));
+
+                var notices = render.notices(message, {
+                    loadRemote: function () { load(true); },
+                    trustSender: function (sender) {
+                        api.settings("trust", { sender: sender }).then(function () {
+                            ui.toast("Images from " + sender + " will always load");
+                            load(true);
+                        }).catch(function (error) { ui.errorToast(error); });
+                    }
+                });
+                if (notices) {
+                    content.appendChild(notices);
+                }
+
+                var body = el("div", { class: "reader-body" });
+                content.appendChild(body);
+                frame = render.body(body, message, {
+                    onMailto: function (href) { compose.mailto(href, message.accountId); }
+                });
+
+                var attachments = render.attachments(message.attachments, shared.attachmentHandlers(message));
+                if (attachments) {
+                    content.appendChild(attachments);
+                }
+                updateButtons();
+
+                if (!message.seen) {
+                    if (settings.markReadDelay === 0) {
+                        setFlag("seen", true);
+                    } else if (settings.markReadDelay > 0) {
+                        markTimer = setTimeout(function () { setFlag("seen", true); }, settings.markReadDelay * 1000);
+                    }
+                }
+            }
+
+            /* ---------- Actions ---------- */
+
+            function setFlag(flag, value) {
+                if (!message || message[flag] === value) {
+                    return;
+                }
+                if (flag === "seen") {
+                    clearTimeout(markTimer);
+                }
+                var previous = message[flag];
+                message[flag] = value;
+                updateButtons();
+                api.message("flag", { accountId: message.accountId, folder: message.folder, uids: [message.uid], flag: flag, value: value }).then(function () {
+                    emitChange(flag, { value: value });
+                }).catch(function (error) {
+                    message[flag] = previous;
+                    updateButtons();
+                    ui.errorToast(error);
+                });
+            }
+
+            //leave runs an action that takes the message out of this folder
+            function leave(operation, payload, doneText) {
+                if (!message || busy) {
+                    return;
+                }
+                var data = { accountId: message.accountId, folder: message.folder, uids: [message.uid] };
+                Object.keys(payload || {}).forEach(function (key) { data[key] = payload[key]; });
+                busy = true;
+                updateButtons();
+                api.message(operation, data).then(function () {
+                    emitChange("removed");
+                    finish(doneText);
+                }).catch(function (error) {
+                    busy = false;
+                    updateButtons();
+                    ui.errorToast(error);
+                });
+            }
+
+            function remove() {
+                var permanent = role() === "trash";
+                var proceed = (settings.confirmDelete || permanent) ?
+                    ui.confirm(permanent ? "Delete permanently?" : "Delete this message?",
+                        permanent ? "The message will be deleted and cannot be recovered." : "The message will be moved to Trash.",
+                        { okLabel: "Delete", danger: true }) : Promise.resolve(true);
+                proceed.then(function (ok) {
+                    if (ok) {
+                        leave("remove", { permanent: permanent }, permanent ? "Message deleted" : "Moved to Trash");
+                    }
+                });
+            }
+
+            function snooze(until) {
+                if (!message || busy) {
+                    return;
+                }
+                busy = true;
+                updateButtons();
+                api.message("snooze", { message: shared.summaryOf(message), until: until }).then(function () {
+                    emitChange("removed");
+                    finish("Snoozed until " + util.formatFullDate(until));
+                }).catch(function (error) {
+                    busy = false;
+                    updateButtons();
+                    ui.errorToast(error);
+                });
+            }
+
+            function toggleLabel(labelId, value) {
+                var next = (message.labels || []).filter(function (id) { return id !== labelId; });
+                if (value) {
+                    next.push(labelId);
+                }
+                message.labels = next;
+                var pills = content.querySelector(".reader-subject .msg-labels");
+                if (pills) {
+                    pills.replaceWith(labelPills());
+                }
+                api.message("setLabels", { message: shared.summaryOf(message), labels: next }).then(function () {
+                    emitChange("labels", { labels: next });
+                }).catch(function (error) { ui.errorToast(error); });
+            }
+
+            function chooseFolder() {
+                var body = el("div");
+                var search = el("input", { class: "input", type: "search", placeholder: "Find a folder", autofocus: true });
+                var list = el("div", { class: "folder-choices" });
+                body.appendChild(search);
+                body.appendChild(list);
+                var dialog = ui.modal({ title: "Move to", body: body, buttons: [{ label: "Cancel" }] });
+                var renderFolders = function () {
+                    util.clear(list);
+                    var query = search.value.trim().toLowerCase();
+                    folders.filter(function (folder) {
+                        return folder.selectable && folder.name !== message.folder && (!query || folder.name.toLowerCase().indexOf(query) >= 0);
+                    }).forEach(function (folder) {
+                        var choice = el("button", { class: "nav-item", type: "button" }, [icon(folder.role === "inbox" ? "inbox" : "folder outline"), el("span", { class: "label", text: folder.display || folder.name })]);
+                        choice.addEventListener("click", function () {
+                            dialog.close();
+                            leave("move", { destination: folder.name }, "Moved to " + (folder.display || folder.name));
+                        });
+                        list.appendChild(choice);
+                    });
+                };
+                search.addEventListener("input", renderFolders);
+                renderFolders();
+            }
+
+            function labelItems() {
+                var items = [];
+                labels.forEach(function (label) {
+                    var on = (message.labels || []).indexOf(label.id) >= 0;
+                    items.push({ label: label.name, dot: label.color, checked: on, onClick: function () { toggleLabel(label.id, !on); } });
+                });
+                if (labels.length === 0) {
+                    items.push({ label: "No labels yet", disabled: true });
+                }
+                return items;
+            }
+
+            function snoozeItems() {
+                var items = [];
+                ui.presetTimes().forEach(function (preset) {
+                    items.push({ label: preset.label, hint: preset.hint, icon: "clock outline", onClick: function () { snooze(preset.value); } });
+                });
+                items.push({
+                    label: "Pick date and time…", icon: "calendar alternate outline", onClick: function () {
+                        ui.pickDateTime("Snooze until", null, "Snooze").then(function (value) {
+                            if (value) {
+                                snooze(value);
+                            }
+                        });
+                    }
+                });
+                return items;
+            }
+
+            function moreMenu(anchor) {
+                var currentRole = role();
+                var items = [
+                    { label: "Forward as attachment", icon: "paperclip", onClick: function () { compose.forward(message, true); } },
+                    "-",
+                    { label: "Labels", icon: "tags", submenu: labelItems },
+                    { label: "Snooze", icon: "clock outline", submenu: snoozeItems }
+                ];
+                if (currentRole === "junk") {
+                    items.push({ label: "Not junk", icon: "check circle outline", onClick: function () { leave("moveToRole", { role: "inbox" }, "Moved to Inbox"); } });
+                } else {
+                    items.push({ label: "Mark as junk", icon: "ban", onClick: function () { leave("moveToRole", { role: "junk" }, "Moved to Junk"); } });
+                }
+                if (currentRole === "trash" || currentRole === "archive") {
+                    items.push({ label: "Move to Inbox", icon: "inbox", onClick: function () { leave("moveToRole", { role: "inbox" }, "Moved to Inbox"); } });
+                }
+                items.push({ label: "Move to…", icon: "folder open outline", disabled: folders.length === 0, onClick: chooseFolder });
+                items.push("-");
+                items.push({ label: "Save to ArozOS (.eml)…", icon: "save outline", onClick: function () { shared.saveAsEML(message); } });
+                items.push({ label: "Download (.eml)", icon: "download", onClick: function () { shared.downloadEML(message); } });
+                items.push({ label: "Print", icon: "print", onClick: function () { shared.printMessage(message); } });
+                items.push({ label: "View source", icon: "code", onClick: function () { shared.viewSource(message); } });
+                var sender = (message.from || [])[0];
+                if (sender && sender.email) {
+                    items.push({ label: "Add sender to address book", icon: "address book outline", onClick: function () { Mail.settings.contactDialog({ name: sender.name, email: sender.email }, function () { ui.toast("Contact saved"); }); } });
+                }
+                ui.menu(anchor, items, { alignRight: true });
+            }
+
+            function addressMenu(address) {
+                var anchor = { x: window.innerWidth / 2, y: 140 };
+                if (window.event && window.event.clientX !== undefined) {
+                    anchor = { x: window.event.clientX, y: window.event.clientY };
+                }
+                ui.menu(anchor, [
+                    { title: util.addressFull(address) },
+                    { label: "Write to " + (address.name || address.email), icon: "edit outline", onClick: function () { compose.open({ accountId: message.accountId, to: [util.formatAddressForInput(address)], focus: "body" }); } },
+                    { label: "Add to address book", icon: "address book outline", onClick: function () { Mail.settings.contactDialog({ name: address.name, email: address.email }, function () { ui.toast("Contact saved"); }); } },
+                    {
+                        label: "Copy address", icon: "copy outline", onClick: function () {
+                            if (navigator.clipboard) {
+                                navigator.clipboard.writeText(address.email).then(function () { ui.toast("Copied"); });
+                            }
+                        }
+                    }
+                ]);
+            }
+
+            buttons.replyButton.addEventListener("click", function () { compose.reply(message, false); });
+            buttons.replyAllButton.addEventListener("click", function () { compose.reply(message, true); });
+            buttons.forwardButton.addEventListener("click", function () { compose.forward(message, false); });
+            buttons.starButton.addEventListener("click", function () { setFlag("flagged", !message.flagged); });
+            buttons.unreadButton.addEventListener("click", function () { setFlag("seen", !message.seen); });
+            buttons.archiveButton.addEventListener("click", function () { leave("moveToRole", { role: "archive" }, "Archived"); });
+            buttons.deleteButton.addEventListener("click", remove);
+            buttons.moreButton.addEventListener("click", function () { moreMenu(buttons.moreButton); });
+
+            document.addEventListener("keydown", function (event) {
+                var typing = event.target && (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.tagName === "SELECT" || event.target.isContentEditable);
+                if (!message || busy || typing || ui.hasOpenModal() || ui.isMenuOpen() || compose.isOpen() || event.ctrlKey || event.metaKey || event.altKey) {
+                    return;
+                }
+                var handled = true;
+                switch (event.key) {
+                    case "r": compose.reply(message, false); break;
+                    case "a": compose.reply(message, true); break;
+                    case "f": compose.forward(message, false); break;
+                    case "s": setFlag("flagged", !message.flagged); break;
+                    case "u": setFlag("seen", false); break;
+                    case "I": if (event.shiftKey) { setFlag("seen", true); } break;
+                    case "e": if (!buttons.archiveButton.classList.contains("hidden")) { leave("moveToRole", { role: "archive" }, "Archived"); } break;
+                    case "#": case "Delete": remove(); break;
+                    default: handled = false;
+                }
+                if (handled) {
+                    event.preventDefault();
+                }
+            });
+
+            /* ---------- Start ---------- */
+
+            target = readTarget();
+            if (!target) {
+                showState("exclamation triangle", "No message to show", "Open messages from the Mail window.");
+                return;
+            }
+            if (target.subject) {
+                setTitle(target.subject);
+            }
+            if (!api.available) {
+                showState("plug", "Not connected", "Open Mail from the ArozOS desktop.");
+                return;
+            }
+            Promise.all([
+                api.settings("get", {}).catch(function () { return {}; }),
+                api.accounts("list", {}).catch(function () { return []; }),
+                api.settings("labels", {}).catch(function () { return []; })
+            ]).then(function (results) {
+                settings = results[0] || {};
+                accounts = results[1] || [];
+                labels = results[2] || [];
+                applyTheme();
+                api.mailbox("folders", { accountId: target.accountId }).then(function (list) {
+                    folders = list || [];
+                    updateButtons();
+                }).catch(function () { });
+                load(false);
+            });
+        })();
+    </script>
+</body>
+
+</html>

+ 1 - 1
src/web/Mail/oauth.html

@@ -13,7 +13,7 @@
     <script src="../script/ao_module.js"></script>
     <script src="../script/ao_module.js"></script>
     <style>
     <style>
         body { display: flex; align-items: center; justify-content: center; height: 100%; overflow: auto; }
         body { display: flex; align-items: center; justify-content: center; height: 100%; overflow: auto; }
-        .card { max-width: 420px; margin: 20px; padding: 32px 28px; text-align: center; background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); }
+        .card { max-width: 420px; margin: 20px; padding: 32px 28px; text-align: center; background: var(--surface); border-radius: 16px; border: 1px solid var(--border-strong); }
         .card img { width: 56px; height: 56px; }
         .card img { width: 56px; height: 56px; }
         .card h1 { font-size: 19px; margin: 16px 0 8px; }
         .card h1 { font-size: 19px; margin: 16px 0 8px; }
         .card p { color: var(--text-2); line-height: 1.55; margin: 0 0 16px; }
         .card p { color: var(--text-2); line-height: 1.55; margin: 0 0 16px; }

Разница между файлами не показана из-за своего большого размера
+ 471 - 253
src/web/Mail/script/app.js


+ 251 - 60
src/web/Mail/script/compose.js

@@ -436,7 +436,18 @@ Mail.compose = (function () {
 
 
     /* ---------- Composer ---------- */
     /* ---------- Composer ---------- */
 
 
+    //open starts a message. Inside the ArozOS desktop every message gets its
+    //own float window (compose.html); elsewhere it opens over the page.
     function open(options) {
     function open(options) {
+        options = options || {};
+        if (!context.windowMode && Mail.shared && Mail.shared.isDesktop()) {
+            return Promise.resolve(Mail.shared.openComposeWindow(options));
+        }
+        return openHere(options);
+    }
+
+    //openHere always opens the composer inside the current page
+    function openHere(options) {
         options = options || {};
         options = options || {};
         if (current) {
         if (current) {
             return current.confirmReplace().then(function (ok) {
             return current.confirmReplace().then(function (ok) {
@@ -472,6 +483,7 @@ Mail.compose = (function () {
             sending: false,
             sending: false,
             closed: false,
             closed: false,
             tempFolder: null,
             tempFolder: null,
+            inFlight: null,
             uploadCounter: 0,
             uploadCounter: 0,
             lastSaved: null
             lastSaved: null
         };
         };
@@ -490,6 +502,12 @@ Mail.compose = (function () {
         var closeButton = el("button", { class: "iconbtn small", title: "Save draft and close" }, icon("close"));
         var closeButton = el("button", { class: "iconbtn small", title: "Save draft and close" }, icon("close"));
         var head = el("div", { class: "composer-head" }, [title, statusText, minimizeButton, maximizeButton, closeButton]);
         var head = el("div", { class: "composer-head" }, [title, statusText, minimizeButton, maximizeButton, closeButton]);
         root.appendChild(head);
         root.appendChild(head);
+        //In its own window the float window's title bar replaces the header
+        var windowMode = context.windowMode === true;
+        if (windowMode) {
+            root.classList.add("window-mode");
+            head.classList.add("hidden");
+        }
 
 
         var body = el("div", { class: "composer-body" });
         var body = el("div", { class: "composer-body" });
         root.appendChild(body);
         root.appendChild(body);
@@ -555,6 +573,9 @@ Mail.compose = (function () {
             el("div", { class: "grow" }), discardButton, fileInput, imageInput
             el("div", { class: "grow" }), discardButton, fileInput, imageInput
         ]);
         ]);
         root.appendChild(foot);
         root.appendChild(foot);
+        if (windowMode) {
+            foot.insertBefore(statusText, discardButton);
+        }
         host.appendChild(root);
         host.appendChild(root);
         self.root = root;
         self.root = root;
 
 
@@ -640,6 +661,9 @@ Mail.compose = (function () {
         [toField, ccField, bccField].forEach(function (field) { field.onChange(markDirty); });
         [toField, ccField, bccField].forEach(function (field) { field.onChange(markDirty); });
         subjectInput.addEventListener("input", function () {
         subjectInput.addEventListener("input", function () {
             title.textContent = subjectInput.value.trim() || "New message";
             title.textContent = subjectInput.value.trim() || "New message";
+            if (context.onTitle) {
+                context.onTitle(title.textContent);
+            }
             markDirty();
             markDirty();
         });
         });
         fromSelect.addEventListener("change", function () {
         fromSelect.addEventListener("change", function () {
@@ -1147,7 +1171,7 @@ Mail.compose = (function () {
             state.saving = true;
             state.saving = true;
             state.dirty = false;
             state.dirty = false;
             setStatus("Saving…");
             setStatus("Saving…");
-            return api.compose("draft", { message: request }).then(function (result) {
+            state.savePromise = api.compose("draft", { message: request }).then(function (result) {
                 state.saving = false;
                 state.saving = false;
                 state.draft = { folder: result.folder, uid: result.uid };
                 state.draft = { folder: result.folder, uid: result.uid };
                 //The attachments now live inside the new draft
                 //The attachments now live inside the new draft
@@ -1164,6 +1188,7 @@ Mail.compose = (function () {
                 if (context.onDraftChanged) {
                 if (context.onDraftChanged) {
                     context.onDraftChanged(state.accountId);
                     context.onDraftChanged(state.accountId);
                 }
                 }
+                announce("draft-saved", {});
                 return true;
                 return true;
             }).catch(function (error) {
             }).catch(function (error) {
                 state.saving = false;
                 state.saving = false;
@@ -1174,6 +1199,26 @@ Mail.compose = (function () {
                 }
                 }
                 return false;
                 return false;
             });
             });
+            return state.savePromise;
+        }
+
+        //waitForSave settles an autosave that is still on its way, so a send
+        //or discard acts on the newest draft instead of leaving a copy behind
+        function waitForSave() {
+            if (!state.saving || !state.savePromise) {
+                return Promise.resolve();
+            }
+            return state.savePromise.then(function () { }, function () { });
+        }
+
+        //announce tells the other Mail windows (main window, lists) what happened
+        function announce(type, data) {
+            if (!Mail.shared) {
+                return;
+            }
+            data = data || {};
+            data.accountId = state.accountId;
+            Mail.shared.emit(type, data);
         }
         }
 
 
         var autosave = setInterval(function () {
         var autosave = setInterval(function () {
@@ -1225,61 +1270,180 @@ Mail.compose = (function () {
                 if (!ok) {
                 if (!ok) {
                     return;
                     return;
                 }
                 }
-                var undoSeconds = context.settings().undoSendSeconds || 0;
-                request.undoSeconds = sendAt ? 0 : undoSeconds;
-                request.sendAt = sendAt || 0;
                 state.sending = true;
                 state.sending = true;
                 clearInterval(autosave);
                 clearInterval(autosave);
-                var snapshot = self.snapshot(request);
-                hide();
-                var progress = ui.toast(sendAt ? "Scheduling…" : "Sending…", { duration: 0 });
+                //An autosave still in flight would otherwise create a draft
+                //the send does not know about, left behind once delivered
+                return waitForSave().then(function () {
+                    var undoSeconds = context.settings().undoSendSeconds || 0;
+                    request = buildRequest();
+                    request.undoSeconds = sendAt ? 0 : undoSeconds;
+                    request.sendAt = sendAt || 0;
+                    deliver(request, sendAt, undoSeconds);
+                });
+            });
+        }
 
 
-                api.compose("send", { message: request }).then(function (result) {
-                    progress.close();
-                    destroy();
-                    if (context.onSent) {
-                        context.onSent(result, request);
-                    }
-                    if (result.queued) {
-                        var label = sendAt ? "Scheduled for " + util.formatFullDate(result.sendAt) : "Message sent";
-                        ui.toast(label, {
-                            duration: sendAt ? 8000 : Math.max(3000, (undoSeconds - 1) * 1000),
-                            action: {
-                                label: "Undo", fn: function () {
-                                    api.compose("outboxCancel", { id: result.outboxId, toDrafts: false }).then(function () {
-                                        ui.toast(sendAt ? "Scheduled message cancelled" : "Sending cancelled");
-                                        open(snapshot);
-                                        if (context.onSent) {
-                                            context.onSent(null, null);
-                                        }
-                                    }).catch(function (error) {
-                                        ui.errorToast(error, "Too late to undo");
-                                    });
-                                }
+        function restartAutosave() {
+            clearInterval(autosave);
+            autosave = setInterval(function () {
+                if (state.dirty && !state.closed) {
+                    saveDraft(false);
+                }
+            }, AUTOSAVE_MS);
+        }
+
+        //deliver hands the message to the server and reports the outcome
+        function deliver(request, sendAt, undoSeconds) {
+            var snapshot = self.snapshot(request);
+            if (windowMode) {
+                deliverInWindow(request, sendAt, undoSeconds);
+                return;
+            }
+            hide();
+            var progress = ui.toast(sendAt ? "Scheduling…" : "Sending…", { duration: 0 });
+
+            track(api.compose("send", { message: request }).then(function (result) {
+                progress.close();
+                destroy();
+                announce("sent", { queued: result.queued === true, sendAt: result.sendAt || 0, outboxId: result.outboxId || "" });
+                if (context.onSent) {
+                    context.onSent(result, request);
+                }
+                if (result.queued) {
+                    var label = sendAt ? "Scheduled for " + util.formatFullDate(result.sendAt) : "Message sent";
+                    ui.toast(label, {
+                        duration: sendAt ? 8000 : Math.max(3000, (undoSeconds - 1) * 1000),
+                        action: {
+                            label: "Undo", fn: function () {
+                                api.compose("outboxCancel", { id: result.outboxId, toDrafts: false }).then(function () {
+                                    ui.toast(sendAt ? "Scheduled message cancelled" : "Sending cancelled");
+                                    openHere(snapshot);
+                                    announce("send-cancelled", {});
+                                    if (context.onSent) {
+                                        context.onSent(null, null);
+                                    }
+                                }).catch(function (error) {
+                                    ui.errorToast(error, "Too late to undo");
+                                });
                             }
                             }
-                        });
-                    } else if (result.warning) {
-                        ui.toast("Message sent. " + result.warning, { duration: 9000 });
-                    } else {
-                        ui.toast("Message sent");
-                    }
-                }).catch(function (error) {
-                    progress.close();
-                    state.sending = false;
-                    show();
-                    autosave = setInterval(function () {
-                        if (state.dirty && !state.closed) {
-                            saveDraft(false);
                         }
                         }
-                    }, AUTOSAVE_MS);
-                    ui.errorToast(error, "Not sent");
-                    if (error.authFailed && context.onAuthFailed) {
-                        context.onAuthFailed(state.accountId, error);
-                    }
-                });
+                    });
+                } else if (result.warning) {
+                    ui.toast("Message sent. " + result.warning, { duration: 9000 });
+                } else {
+                    ui.toast("Message sent");
+                }
+            }).catch(function (error) {
+                progress.close();
+                sendFailed(error);
+            }));
+        }
+
+        //track remembers the send request on its way, handlers included.
+        //Closing the window waits for it: closing earlier would abort the
+        //request and leave it unknown whether the message went out.
+        function track(chain) {
+            state.inFlight = chain;
+            chain.then(function () {
+                if (state.inFlight === chain) {
+                    state.inFlight = null;
+                }
             });
             });
         }
         }
 
 
+        function sendFailed(error) {
+            state.sending = false;
+            show();
+            restartAutosave();
+            ui.errorToast(error, "Not sent");
+            if (error.authFailed && context.onAuthFailed) {
+                context.onAuthFailed(state.accountId, error);
+            }
+        }
+
+        //deliverInWindow sends from a composer window. The window stays open
+        //through the undo period, showing a countdown, and then closes itself.
+        //Closing it early is fine: the server outbox delivers regardless.
+        function deliverInWindow(request, sendAt, undoSeconds) {
+            var overlay = el("div", { class: "sent-overlay" });
+            var spinner = el("div", { class: "spinner large" });
+            var heading = el("div", { class: "heading", text: sendAt ? "Scheduling…" : "Sending…" });
+            var detail = el("div", { class: "detail" });
+            var actions = el("div", { class: "actions" });
+            overlay.appendChild(el("div", { class: "card" }, [spinner, heading, detail, actions]));
+            root.appendChild(overlay);
+            var timer = null;
+
+            var finish = function () {
+                clearInterval(timer);
+                state.closed = true;
+                if (context.closeWindow) {
+                    context.closeWindow();
+                }
+            };
+
+            track(api.compose("send", { message: request }).then(function (result) {
+                announce("sent", { queued: result.queued === true, sendAt: result.sendAt || 0, outboxId: result.outboxId || "" });
+                if (context.onSent) {
+                    context.onSent(result, request);
+                }
+                spinner.remove();
+                overlay.querySelector(".card").insertBefore(icon(result.queued && sendAt ? "clock outline" : "paper plane outline", "big-icon"), heading);
+                util.clear(actions);
+                if (!result.queued) {
+                    heading.textContent = "Message sent";
+                    detail.textContent = result.warning || "";
+                    setTimeout(finish, result.warning ? 4000 : 900);
+                    return;
+                }
+
+                var undo = el("button", { class: "btn", text: sendAt ? "Cancel and edit" : "Undo" });
+                var close = el("button", { class: "btn primary", text: "Close" });
+                actions.appendChild(undo);
+                actions.appendChild(close);
+                close.addEventListener("click", finish);
+                undo.addEventListener("click", function () {
+                    clearInterval(timer);
+                    undo.disabled = true;
+                    api.compose("outboxCancel", { id: result.outboxId, toDrafts: false }).then(function () {
+                        overlay.remove();
+                        state.sending = false;
+                        restartAutosave();
+                        announce("send-cancelled", {});
+                        ui.toast(sendAt ? "Scheduled message cancelled" : "Sending cancelled");
+                    }).catch(function (error) {
+                        undo.disabled = false;
+                        ui.errorToast(error, "Too late to undo");
+                    });
+                });
+
+                if (sendAt) {
+                    heading.textContent = "Scheduled";
+                    detail.textContent = "It will be sent " + util.formatFullDate(result.sendAt) + ".";
+                    return;
+                }
+                var remaining = Math.max(1, Math.round((result.sendAt - Date.now()) / 1000));
+                var tick = function () {
+                    heading.textContent = "Sending in " + remaining + "s";
+                    detail.textContent = "You can still undo.";
+                    if (remaining <= 0) {
+                        heading.textContent = "Message sent";
+                        detail.textContent = "";
+                        undo.disabled = true;
+                        clearInterval(timer);
+                        setTimeout(finish, 600);
+                    }
+                    remaining--;
+                };
+                tick();
+                timer = setInterval(tick, 1000);
+            }).catch(function (error) {
+                overlay.remove();
+                sendFailed(error);
+            }));
+        }
+
         //snapshot captures everything needed to reopen this message after an undo
         //snapshot captures everything needed to reopen this message after an undo
         self.snapshot = function (request) {
         self.snapshot = function (request) {
             return {
             return {
@@ -1365,11 +1529,18 @@ Mail.compose = (function () {
                 state.attachments.length > 0 || editorPlainText().replace(/\s+/g, "").length > 0;
                 state.attachments.length > 0 || editorPlainText().replace(/\s+/g, "").length > 0;
         }
         }
 
 
-        //close saves a draft when there is something worth keeping
+        //close saves a draft when there is something worth keeping. The
+        //composer window calls it from ao_module_close before it closes.
         self.close = function () {
         self.close = function () {
-            if (state.sending) {
+            if (state.inFlight) {
+                return state.inFlight.then(function () { return self.close(); });
+            }
+            if (state.sending || state.closed) {
                 return Promise.resolve(true);
                 return Promise.resolve(true);
             }
             }
+            if (state.saving) {
+                return waitForSave().then(function () { return self.close(); });
+            }
             if (state.dirty && hasContent()) {
             if (state.dirty && hasContent()) {
                 return saveDraft(true).then(function (saved) {
                 return saveDraft(true).then(function (saved) {
                     if (saved) {
                     if (saved) {
@@ -1392,16 +1563,26 @@ Mail.compose = (function () {
 
 
         discardButton.addEventListener("click", function () {
         discardButton.addEventListener("click", function () {
             var discard = function () {
             var discard = function () {
-                var draft = state.draft;
-                destroy();
-                if (draft && draft.uid) {
-                    api.compose("deleteDraft", { accountId: state.accountId, folder: draft.folder, uid: draft.uid }).then(function () {
-                        if (context.onDraftChanged) {
-                            context.onDraftChanged(state.accountId);
-                        }
-                    }).catch(function () { /* draft may already be gone */ });
-                }
-                ui.toast("Message discarded");
+                state.sending = true; //No autosave may start from here on
+                clearInterval(autosave);
+                waitForSave().then(function () {
+                    var draft = state.draft;
+                    destroy();
+                    var removal = Promise.resolve();
+                    if (draft && draft.uid) {
+                        removal = api.compose("deleteDraft", { accountId: state.accountId, folder: draft.folder, uid: draft.uid }).then(function () {
+                            if (context.onDraftChanged) {
+                                context.onDraftChanged(state.accountId);
+                            }
+                            announce("draft-deleted", {});
+                        }).catch(function () { /* draft may already be gone */ });
+                    }
+                    ui.toast("Message discarded");
+                    //A window must outlive the request, or closing it aborts it
+                    if (windowMode && context.closeWindow) {
+                        removal.then(function () { context.closeWindow(); });
+                    }
+                });
             };
             };
             if (hasContent()) {
             if (hasContent()) {
                 ui.confirm("Discard this message?", "The message and its saved draft will be deleted.", { okLabel: "Discard", danger: true }).then(function (ok) {
                 ui.confirm("Discard this message?", "The message and its saved draft will be deleted.", { okLabel: "Discard", danger: true }).then(function (ok) {
@@ -1446,11 +1627,21 @@ Mail.compose = (function () {
             root.classList.remove("minimized");
             root.classList.remove("minimized");
             focusInitial();
             focusInitial();
         };
         };
+
+        //unsavedDraft is what a browser tab tries to save when it is closed
+        //without the composer's own close path (see compose.html)
+        self.unsavedDraft = function () {
+            if (state.sending || state.closed || !state.dirty || !hasContent() || uploading()) {
+                return null;
+            }
+            return buildRequest();
+        };
     }
     }
 
 
     return {
     return {
         init: init,
         init: init,
         open: open,
         open: open,
+        openHere: openHere,
         isOpen: isOpen,
         isOpen: isOpen,
         current: function () { return current; },
         current: function () { return current; },
         newMessage: function (accountId) { return open({ accountId: accountId, focus: "to" }); },
         newMessage: function (accountId) { return open({ accountId: accountId, focus: "to" }); },

+ 52 - 12
src/web/Mail/script/render.js

@@ -59,17 +59,57 @@ Mail.render = (function () {
         container.appendChild(preview);
         container.appendChild(preview);
 
 
         var observer = null;
         var observer = null;
+        var lastHeight = -1;
+        var ready = false;
+
+        //contentHeight measures the content itself. The document and body
+        //cannot be used: they are at least as tall as the frame (mail without
+        //a doctype renders in quirks mode, where the body fills the viewport),
+        //so measuring them feeds the frame's own height back and it creeps.
+        var contentHeight = function (doc) {
+            var body = doc.body;
+            var view = doc.defaultView;
+            var range = doc.createRange();
+            range.selectNodeContents(body);
+            var rect = range.getBoundingClientRect();
+            if (!rect || (rect.height === 0 && rect.bottom === 0)) {
+                return 60;
+            }
+            var style = view.getComputedStyle(body);
+            var bottom = rect.bottom + view.scrollY + (parseFloat(style.paddingBottom) || 0) +
+                (parseFloat(style.marginBottom) || 0) + (parseFloat(style.borderBottomWidth) || 0);
+            //Room for the horizontal scroll bar of wide newsletters
+            if (doc.documentElement.scrollWidth > doc.documentElement.clientWidth + 1) {
+                bottom += 18;
+            }
+            return Math.max(60, Math.ceil(bottom));
+        };
+
         var resize = function () {
         var resize = function () {
             try {
             try {
                 var doc = frame.contentDocument;
                 var doc = frame.contentDocument;
-                if (!doc || !doc.documentElement) {
+                if (!doc || !doc.body) {
                     return;
                     return;
                 }
                 }
-                var height = Math.max(doc.documentElement.scrollHeight, doc.body ? doc.body.scrollHeight : 0);
-                frame.style.height = Math.max(60, height + 2) + "px";
+                var height = contentHeight(doc);
+                if (Math.abs(height - lastHeight) > 1) {
+                    lastHeight = height;
+                    frame.style.height = height + "px";
+                }
+                if (!ready) {
+                    ready = true;
+                    frame.style.visibility = "visible";
+                    if (options.onReady) {
+                        options.onReady();
+                    }
+                }
             } catch (e) { /* frame navigated away */ }
             } catch (e) { /* frame navigated away */ }
         };
         };
 
 
+        //Hidden and flat until measured, so nothing below it jumps around
+        frame.style.height = "0px";
+        frame.style.visibility = "hidden";
+
         frame.addEventListener("load", function () {
         frame.addEventListener("load", function () {
             var doc;
             var doc;
             try {
             try {
@@ -89,14 +129,10 @@ Mail.render = (function () {
                 observer = new ResizeObserver(resize);
                 observer = new ResizeObserver(resize);
                 observer.observe(doc.body);
                 observer.observe(doc.body);
             }
             }
-            //Fonts and late layout settle within a few seconds
-            var passes = 0;
-            var settle = setInterval(function () {
-                resize();
-                if (++passes > 10 || !frame.isConnected) {
-                    clearInterval(settle);
-                }
-            }, 400);
+            //Web fonts may still change the layout once
+            if (doc.fonts && doc.fonts.ready) {
+                doc.fonts.ready.then(resize).catch(function () { });
+            }
 
 
             doc.addEventListener("click", function (event) {
             doc.addEventListener("click", function (event) {
                 var link = event.target.closest ? event.target.closest("a[href]") : null;
                 var link = event.target.closest ? event.target.closest("a[href]") : null;
@@ -154,7 +190,8 @@ Mail.render = (function () {
         if (message.html) {
         if (message.html) {
             return htmlFrame(container, message.html, {
             return htmlFrame(container, message.html, {
                 allowRemote: message.remoteAllowed,
                 allowRemote: message.remoteAllowed,
-                onMailto: options.onMailto
+                onMailto: options.onMailto,
+                onReady: options.onReady
             });
             });
         }
         }
         if (message.text) {
         if (message.text) {
@@ -162,6 +199,9 @@ Mail.render = (function () {
         } else {
         } else {
             container.appendChild(el("div", { class: "muted", text: message.encrypted ? "This message is encrypted." : "This message has no text." }));
             container.appendChild(el("div", { class: "muted", text: message.encrypted ? "This message is encrypted." : "This message has no text." }));
         }
         }
+        if (options.onReady) {
+            options.onReady();
+        }
         return null;
         return null;
     }
     }
 
 

+ 385 - 0
src/web/Mail/script/shared.js

@@ -0,0 +1,385 @@
+/*
+    Mail — actions shared by every Mail window
+
+    The main window, the message window (message.html), the composer window
+    (compose.html) and the .eml viewer all save attachments, print, show
+    sources and talk to each other. That code lives here.
+
+    Windows coordinate through a BroadcastChannel: a composer window announces
+    saved drafts and sent mail, a message window announces moved or flagged
+    mail, and the main window refreshes its lists and counters.
+*/
+
+var Mail = window.Mail || {};
+window.Mail = Mail;
+
+Mail.shared = (function () {
+    "use strict";
+    var util = Mail.util;
+    var ui = Mail.ui;
+    var api = Mail.api;
+    var el = util.el;
+
+    /* ---------- Windows ---------- */
+
+    //isDesktop reports whether Mail runs inside the ArozOS virtual desktop,
+    //where new windows are float windows rather than browser tabs
+    function isDesktop() {
+        return typeof ao_module_virtualDesktop !== "undefined" && ao_module_virtualDesktop === true;
+    }
+
+    function rootPath() {
+        return (typeof ao_root === "string" && ao_root) ? ao_root : "../";
+    }
+
+    //handOff stores a payload too large for a URL and returns its key
+    function handOff(payload) {
+        var key = "handoff-" + util.randomId();
+        localStorage.setItem("aroz-mail:" + key, JSON.stringify(payload));
+        return key;
+    }
+
+    //takeHandOff reads (and removes) a payload stored by handOff
+    function takeHandOff(key) {
+        try {
+            var storageKey = "aroz-mail:" + key;
+            var raw = localStorage.getItem(storageKey);
+            localStorage.removeItem(storageKey);
+            return raw ? JSON.parse(raw) : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function newWindow(page, hash, title, width, height) {
+        //The desktop opens windows 100px from the top and only keeps them
+        //inside the screen, so on a small screen the bottom (and the Send
+        //button) would end up under the taskbar
+        try {
+            if (isDesktop() && parent.innerHeight) {
+                width = Math.max(380, Math.min(width, parent.innerWidth - 40));
+                height = Math.max(360, Math.min(height, parent.innerHeight - 150));
+            }
+        } catch (e) { /* no access to the desktop */ }
+        ao_module_newfw({
+            url: "Mail/" + page + "#" + hash,
+            width: width,
+            height: height,
+            appicon: "Mail/img/icon.svg",
+            title: title
+        });
+    }
+
+    //openMessageWindow shows one message in its own window
+    function openMessageWindow(summary) {
+        var target = {
+            accountId: summary.accountId, folder: summary.folder, uid: summary.uid,
+            messageId: summary.messageId || "", subject: summary.subject || ""
+        };
+        newWindow("message.html", encodeURIComponent(JSON.stringify(target)), summary.subject || "Message", 900, 720);
+    }
+
+    //openComposeWindow opens the composer in its own window
+    function openComposeWindow(options) {
+        var key;
+        try {
+            key = handOff(options || {});
+        } catch (e) {
+            ui.toast("The message is too large to open in a new window", { error: true });
+            return false;
+        }
+        newWindow("compose.html", "handoff=" + key, (options && options.subject) || "New message", 820, 700);
+        return true;
+    }
+
+    /* ---------- Event bus ---------- */
+
+    var channel = null;
+    var listeners = [];
+
+    function ensureChannel() {
+        if (channel || typeof BroadcastChannel === "undefined") {
+            return;
+        }
+        channel = new BroadcastChannel("arozos-mail-events");
+        channel.onmessage = function (event) {
+            listeners.forEach(function (listener) {
+                try {
+                    listener(event.data || {});
+                } catch (e) { /* a broken listener must not stop the others */ }
+            });
+        };
+    }
+
+    //emit tells the other Mail windows that something changed
+    function emit(type, data) {
+        ensureChannel();
+        if (!channel) {
+            return;
+        }
+        var message = { type: type };
+        Object.keys(data || {}).forEach(function (key) { message[key] = data[key]; });
+        channel.postMessage(message);
+    }
+
+    function on(listener) {
+        ensureChannel();
+        listeners.push(listener);
+    }
+
+    /* ---------- Files ---------- */
+
+    function tempDownloads() {
+        return api.files("tempFolder", { purpose: "downloads" });
+    }
+
+    function attachmentRequest(message, attachment, dest) {
+        return api.files("saveAttachment", { accountId: message.accountId, folder: message.folder, uid: message.uid, partId: attachment.id, dest: dest });
+    }
+
+    function pickFolder(callback) {
+        if (typeof ao_module_openFileSelector !== "function") {
+            ui.toast("The ArozOS folder picker is not available", { error: true });
+            return;
+        }
+        ao_module_openFileSelector(function (files) {
+            if (files && files.length > 0) {
+                callback(files[0].filepath);
+            }
+        }, "user:/Desktop", "folder", false, { path_memory_key: "mail-save" });
+    }
+
+    function savedToast(path) {
+        var dir = path.substring(0, path.lastIndexOf("/"));
+        var name = path.substring(path.lastIndexOf("/") + 1);
+        ui.toast("Saved to " + path, {
+            action: typeof ao_module_openPath === "function" ? { label: "Show", fn: function () { ao_module_openPath(dir, name); } } : null
+        });
+    }
+
+    function downloadPath(path) {
+        var link = el("a", { href: rootPath() + "media/?file=" + encodeURIComponent(path) + "&download=true", download: path.split("/").pop() });
+        document.body.appendChild(link);
+        link.click();
+        setTimeout(function () { link.remove(); }, 1000);
+    }
+
+    //openWithDefaultApp opens a file with the WebApp registered for its type
+    function openWithDefaultApp(path) {
+        var filename = path.split("/").pop();
+        var ext = "." + filename.split(".").pop().toLowerCase();
+        $.ajax({
+            url: rootPath() + "system/modules/getDefault",
+            method: "GET",
+            data: { opr: "launch", ext: ext, mode: "launch" },
+            success: function (data) {
+                if (!data || data.error !== undefined) {
+                    ao_module_newfw({
+                        url: "SystemAO/file_system/defaultOpener.html#" + encodeURIComponent(JSON.stringify({ filepath: path, filename: filename })),
+                        width: 380, height: 560, appicon: "SystemAO/file_system/img/opener.png", title: "Open with"
+                    });
+                    return;
+                }
+                var url = data.StartDir;
+                var size = [undefined, undefined];
+                if (data.SupportFW && data.LaunchFWDir) {
+                    url = data.LaunchFWDir;
+                    size = data.InitFWSize || size;
+                }
+                if (data.SupportEmb && data.LaunchEmb) {
+                    url = data.LaunchEmb;
+                    size = data.InitEmbSize || size;
+                }
+                ao_module_newfw({
+                    url: url + "#" + encodeURIComponent(JSON.stringify([{ filepath: path, filename: filename }])),
+                    width: size[0], height: size[1], appicon: data.IconPath || "Mail/img/icon.svg", title: data.Name
+                });
+            },
+            error: function () {
+                downloadPath(path);
+            }
+        });
+    }
+
+    function openAttachment(message, attachment) {
+        var progress = ui.toast("Opening " + attachment.filename + "…", { duration: 0 });
+        tempDownloads().then(function (folder) {
+            return attachmentRequest(message, attachment, folder);
+        }).then(function (saved) {
+            progress.close();
+            openWithDefaultApp(saved.path);
+        }).catch(function (error) {
+            progress.close();
+            ui.errorToast(error, "Could not open the attachment");
+        });
+    }
+
+    function saveAttachment(message, attachment) {
+        pickFolder(function (dir) {
+            attachmentRequest(message, attachment, dir).then(function (saved) {
+                savedToast(saved.path);
+            }).catch(function (error) { ui.errorToast(error, "Could not save"); });
+        });
+    }
+
+    function saveAllAttachments(message, list) {
+        pickFolder(function (dir) {
+            var progress = ui.toast("Saving " + util.plural(list.length, "attachment") + "…", { duration: 0 });
+            api.files("saveAll", {
+                accountId: message.accountId, folder: message.folder, uid: message.uid,
+                partIds: list.map(function (item) { return item.id; }), dest: dir
+            }).then(function (result) {
+                progress.close();
+                ui.toast(util.plural(result.paths.length, "file") + " saved to " + dir, {
+                    action: typeof ao_module_openPath === "function" ? { label: "Show", fn: function () { ao_module_openPath(dir); } } : null
+                });
+            }).catch(function (error) {
+                progress.close();
+                ui.errorToast(error, "Could not save");
+            });
+        });
+    }
+
+    function downloadAttachment(message, attachment) {
+        tempDownloads().then(function (folder) {
+            return attachmentRequest(message, attachment, folder);
+        }).then(function (saved) {
+            downloadPath(saved.path);
+        }).catch(function (error) { ui.errorToast(error, "Download failed"); });
+    }
+
+    //attachmentHandlers wires the attachment cards of a mailbox message
+    function attachmentHandlers(message) {
+        return {
+            open: function (attachment) { openAttachment(message, attachment); },
+            save: function (attachment) { saveAttachment(message, attachment); },
+            download: function (attachment) { downloadAttachment(message, attachment); },
+            saveAll: function (list) { saveAllAttachments(message, list); }
+        };
+    }
+
+    function saveAsEML(message) {
+        pickFolder(function (dir) {
+            api.files("saveMessage", { accountId: message.accountId, folder: message.folder, uid: message.uid, dest: dir }).then(function (saved) {
+                savedToast(saved.path);
+            }).catch(function (error) { ui.errorToast(error, "Could not save"); });
+        });
+    }
+
+    function downloadEML(message) {
+        tempDownloads().then(function (folder) {
+            return api.files("saveMessage", { accountId: message.accountId, folder: message.folder, uid: message.uid, dest: folder });
+        }).then(function (saved) {
+            downloadPath(saved.path);
+        }).catch(function (error) { ui.errorToast(error, "Download failed"); });
+    }
+
+    function viewSource(message) {
+        var pre = el("pre", { style: { whiteSpace: "pre-wrap", wordBreak: "break-all", fontFamily: "var(--mono)", fontSize: "12px", margin: "0", maxHeight: "65vh", overflow: "auto" }, text: "Loading…" });
+        ui.modal({ title: "Message source", subtitle: message.subject || "", xwide: true, body: pre, buttons: [{ label: "Close", primary: true }] });
+        api.message("raw", { accountId: message.accountId, folder: message.folder, uid: message.uid }).then(function (result) {
+            pre.textContent = result.source + (result.truncated ? "\n\n[… source truncated, " + util.formatSize(result.size) + " in total]" : "");
+        }).catch(function (error) {
+            pre.textContent = error.message;
+        });
+    }
+
+    function printMessage(message) {
+        var popup = window.open("", "_blank", "width=860,height=900");
+        if (!popup) {
+            ui.toast("Allow pop-ups to print", { error: true });
+            return;
+        }
+        var from = (message.from && message.from[0]) || {};
+        var header = '<div style="font-family:sans-serif;border-bottom:1px solid #ccc;padding-bottom:10px;margin-bottom:14px">' +
+            "<h2 style=\"margin:0 0 8px\">" + util.escapeHTML(message.subject || "(no subject)") + "</h2>" +
+            "<div><b>From:</b> " + util.escapeHTML(util.addressFull(from)) + "</div>" +
+            "<div><b>To:</b> " + util.escapeHTML((message.to || []).map(util.addressFull).join(", ")) + "</div>" +
+            (message.cc && message.cc.length ? "<div><b>Cc:</b> " + util.escapeHTML(message.cc.map(util.addressFull).join(", ")) + "</div>" : "") +
+            "<div><b>Date:</b> " + util.escapeHTML(util.formatFullDate(message.date)) + "</div></div>";
+        var content = message.html ? new DOMParser().parseFromString(message.html, "text/html").body.innerHTML :
+            '<pre style="white-space:pre-wrap;font-family:sans-serif">' + util.escapeHTML(message.text || "") + "</pre>";
+        var csp = "default-src 'none'; img-src data:" + (message.remoteAllowed ? " https: http:" : "") + "; style-src 'unsafe-inline'";
+        popup.document.open();
+        popup.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="' + csp + '"><title>' +
+            util.escapeHTML(message.subject || "Message") + "</title></head><body>" + header + content + "</body></html>");
+        popup.document.close();
+        popup.focus();
+        setTimeout(function () { popup.print(); }, 400);
+    }
+
+    function unsubscribe(message, onMailto) {
+        var target = message.listUnsubscribe;
+        if (/^mailto:/i.test(target)) {
+            onMailto(target);
+            return;
+        }
+        ui.confirm("Unsubscribe?", "This opens the sender's unsubscribe page: " + target, { okLabel: "Open page" }).then(function (ok) {
+            if (ok) {
+                window.open(target, "_blank", "noopener,noreferrer");
+            }
+        });
+    }
+
+    //summaryOf strips the heavy fields of a full message so it can be stored
+    //with a label or snooze
+    function summaryOf(message) {
+        var summary = JSON.parse(JSON.stringify(message));
+        ["html", "text", "attachments", "auth", "references", "replyTo", "bcc"].forEach(function (key) { delete summary[key]; });
+        return summary;
+    }
+
+    /* ---------- Theme ---------- */
+
+    //followTheme applies the user's theme choice, following ArozOS when it is
+    //"system". It calls back whenever the effective theme changes.
+    function followTheme(getChoice) {
+        var system = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
+        var apply = function () {
+            var choice = getChoice ? getChoice() : "system";
+            var theme = (!choice || choice === "system") ? system : choice;
+            document.documentElement.setAttribute("data-theme", theme === "dark" ? "dark" : "light");
+        };
+        apply();
+        if (typeof ao_module_getSystemThemeColor === "function") {
+            try {
+                ao_module_getSystemThemeColor(function (theme) {
+                    if (theme === "darkTheme" || theme === "whiteTheme") {
+                        system = theme === "darkTheme" ? "dark" : "light";
+                        apply();
+                    }
+                });
+            } catch (e) { /* standalone */ }
+        }
+        if (typeof ao_module_onThemeChanged === "function") {
+            ao_module_onThemeChanged(function (theme) {
+                system = theme === "dark" ? "dark" : "light";
+                apply();
+            });
+        }
+        return apply;
+    }
+
+    return {
+        isDesktop: isDesktop,
+        handOff: handOff,
+        takeHandOff: takeHandOff,
+        openMessageWindow: openMessageWindow,
+        openComposeWindow: openComposeWindow,
+        emit: emit,
+        on: on,
+        tempDownloads: tempDownloads,
+        pickFolder: pickFolder,
+        savedToast: savedToast,
+        downloadPath: downloadPath,
+        openWithDefaultApp: openWithDefaultApp,
+        attachmentHandlers: attachmentHandlers,
+        saveAsEML: saveAsEML,
+        downloadEML: downloadEML,
+        viewSource: viewSource,
+        printMessage: printMessage,
+        unsubscribe: unsubscribe,
+        summaryOf: summaryOf,
+        followTheme: followTheme
+    };
+})();

+ 169 - 47
src/web/Mail/script/ui.js

@@ -73,30 +73,62 @@ Mail.ui = (function () {
 
 
     /* ---------- Menus ---------- */
     /* ---------- Menus ---------- */
 
 
-    var openMenu = null;
+    //menuStack holds the open menu and its open submenus, outermost first
+    var menuStack = [];
+    var menuSession = null;
 
 
     function closeMenu() {
     function closeMenu() {
-        if (openMenu) {
-            var current = openMenu;
-            openMenu = null;
-            current.node.remove();
-            document.removeEventListener("mousedown", current.outside, true);
-            document.removeEventListener("keydown", current.keys, true);
+        if (menuStack.length === 0) {
+            return;
+        }
+        var stack = menuStack;
+        var session = menuSession;
+        menuStack = [];
+        menuSession = null;
+        stack.forEach(function (level) { level.node.remove(); });
+        if (session) {
+            clearTimeout(session.hoverTimer);
+            document.removeEventListener("mousedown", session.outside, true);
+            document.removeEventListener("keydown", session.keys, true);
             window.removeEventListener("blur", closeMenu);
             window.removeEventListener("blur", closeMenu);
             window.removeEventListener("resize", closeMenu);
             window.removeEventListener("resize", closeMenu);
-            if (current.onClose) {
-                current.onClose();
+            if (session.onClose) {
+                session.onClose();
             }
             }
         }
         }
     }
     }
 
 
-    //menu shows items next to an anchor element or at {x, y}. Items:
-    //{label, icon, hint, onClick, danger, checked, disabled, dot}, "-" or {title}
-    function menu(anchor, items, options) {
-        closeMenu();
-        options = options || {};
+    //closeLevelsAbove closes the submenus deeper than level
+    function closeLevelsAbove(level) {
+        while (menuStack.length > level + 1) {
+            var closing = menuStack.pop();
+            closing.node.remove();
+            if (closing.parentRow) {
+                closing.parentRow.classList.remove("open");
+            }
+        }
+    }
+
+    function setFocus(level, index) {
+        var entry = menuStack[level];
+        if (!entry) {
+            return;
+        }
+        if (entry.focus >= 0 && entry.rows[entry.focus]) {
+            entry.rows[entry.focus].classList.remove("focus");
+        }
+        entry.focus = index;
+        if (index >= 0 && entry.rows[index]) {
+            entry.rows[index].classList.add("focus");
+            entry.rows[index].scrollIntoView({ block: "nearest" });
+        }
+    }
+
+    //buildMenu renders one menu level. Items: {label, icon, hint, onClick,
+    //danger, checked, disabled, dot, submenu}, "-" or {title}
+    function buildMenu(items, level) {
         var node = el("div", { class: "menu", role: "menu" });
         var node = el("div", { class: "menu", role: "menu" });
-        var actionable = [];
+        var rows = [];
         items.forEach(function (item) {
         items.forEach(function (item) {
             if (!item) {
             if (!item) {
                 return;
                 return;
@@ -110,9 +142,12 @@ Mail.ui = (function () {
                 return;
                 return;
             }
             }
             var row = el("div", {
             var row = el("div", {
-                class: "menu-item" + (item.danger ? " danger" : "") + (item.checked ? " checked" : "") + (item.disabled ? " disabled" : ""),
+                class: "menu-item" + (item.danger ? " danger" : "") + (item.checked ? " checked" : "") + (item.disabled ? " disabled" : "") + (item.submenu ? " has-sub" : ""),
                 role: "menuitem", tabindex: "-1"
                 role: "menuitem", tabindex: "-1"
             });
             });
+            if (item.submenu) {
+                row.setAttribute("aria-haspopup", "true");
+            }
             if (item.dot) {
             if (item.dot) {
                 row.appendChild(el("span", { class: "dot", style: { background: item.dot } }));
                 row.appendChild(el("span", { class: "dot", style: { background: item.dot } }));
             } else if (item.icon) {
             } else if (item.icon) {
@@ -122,11 +157,19 @@ Mail.ui = (function () {
             if (item.hint) {
             if (item.hint) {
                 row.appendChild(el("span", { class: "hint", text: item.hint }));
                 row.appendChild(el("span", { class: "hint", text: item.hint }));
             }
             }
+            if (item.submenu) {
+                row.appendChild(icon("caret right", "sub-caret"));
+            }
+            var index = rows.length;
             row.addEventListener("click", function (event) {
             row.addEventListener("click", function (event) {
                 event.stopPropagation();
                 event.stopPropagation();
                 if (item.disabled) {
                 if (item.disabled) {
                     return;
                     return;
                 }
                 }
+                if (item.submenu) {
+                    openSubmenu(level, row, item, false);
+                    return;
+                }
                 if (!item.keepOpen) {
                 if (!item.keepOpen) {
                     closeMenu();
                     closeMenu();
                 }
                 }
@@ -134,21 +177,35 @@ Mail.ui = (function () {
                     item.onClick(event);
                     item.onClick(event);
                 }
                 }
             });
             });
-            actionable.push(row);
+            row.addEventListener("mouseenter", function () {
+                if (!menuSession) {
+                    return;
+                }
+                setFocus(level, index);
+                clearTimeout(menuSession.hoverTimer);
+                if (item.submenu && !item.disabled) {
+                    //A short delay keeps a diagonal mouse path from flickering
+                    //through the neighbouring items' submenus
+                    menuSession.hoverTimer = setTimeout(function () {
+                        if (menuStack[level + 1] && menuStack[level + 1].parentRow === row) {
+                            return;
+                        }
+                        openSubmenu(level, row, item, false);
+                    }, 160);
+                } else {
+                    menuSession.hoverTimer = setTimeout(function () { closeLevelsAbove(level); }, 160);
+                }
+            });
+            rows.push(row);
             node.appendChild(row);
             node.appendChild(row);
         });
         });
-        document.body.appendChild(node);
+        return { node: node, rows: rows };
+    }
 
 
-        //Position: below the anchor, flipped to stay on screen
-        var rect;
-        if (anchor && anchor.getBoundingClientRect) {
-            rect = anchor.getBoundingClientRect();
-        } else {
-            rect = { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y, width: 0, height: 0 };
-        }
+    function placeNear(node, rect, alignRight) {
         var width = node.offsetWidth;
         var width = node.offsetWidth;
         var height = node.offsetHeight;
         var height = node.offsetHeight;
-        var left = options.alignRight ? rect.right - width : rect.left;
+        var left = alignRight ? rect.right - width : rect.left;
         var top = rect.bottom + 4;
         var top = rect.bottom + 4;
         if (left + width > window.innerWidth - 8) {
         if (left + width > window.innerWidth - 8) {
             left = window.innerWidth - width - 8;
             left = window.innerWidth - width - 8;
@@ -161,44 +218,109 @@ Mail.ui = (function () {
         }
         }
         node.style.left = left + "px";
         node.style.left = left + "px";
         node.style.top = top + "px";
         node.style.top = top + "px";
+    }
+
+    //placeBeside puts a submenu to the right of its parent menu with its top
+    //edge level with the item that opened it, flipping left near the edge
+    function placeBeside(node, rowRect, parentRect) {
+        var width = node.offsetWidth;
+        var height = node.offsetHeight;
+        var left = parentRect.right + 2;
+        if (left + width > window.innerWidth - 8) {
+            left = Math.max(8, parentRect.left - width - 2);
+        }
+        var top = rowRect.top;
+        if (top + height > window.innerHeight - 8) {
+            top = Math.max(8, window.innerHeight - height - 8);
+        }
+        node.style.left = left + "px";
+        node.style.top = top + "px";
+    }
+
+    function openSubmenu(level, row, item, focusFirst) {
+        closeLevelsAbove(level);
+        var items = typeof item.submenu === "function" ? item.submenu() : item.submenu;
+        var built = buildMenu(items || [], level + 1);
+        built.node.classList.add("submenu");
+        document.body.appendChild(built.node);
+        placeBeside(built.node, row.getBoundingClientRect(), menuStack[level].node.getBoundingClientRect());
+        menuStack.push({ node: built.node, rows: built.rows, parentRow: row, focus: -1 });
+        row.classList.add("open");
+        if (focusFirst) {
+            setFocus(level + 1, 0);
+        }
+    }
+
+    //menu shows items next to an anchor element or at {x, y}. An item with a
+    //submenu (array or function returning one) opens it beside the menu.
+    function menu(anchor, items, options) {
+        closeMenu();
+        options = options || {};
+        var built = buildMenu(items, 0);
+        document.body.appendChild(built.node);
+
+        var rect;
+        if (anchor && anchor.getBoundingClientRect) {
+            rect = anchor.getBoundingClientRect();
+        } else {
+            rect = { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y, width: 0, height: 0 };
+        }
+        placeNear(built.node, rect, options.alignRight);
+        menuStack = [{ node: built.node, rows: built.rows, parentRow: null, focus: -1 }];
 
 
-        var focusIndex = -1;
         var keys = function (event) {
         var keys = function (event) {
+            var level = menuStack.length - 1;
+            var entry = menuStack[level];
+            if (!entry) {
+                return;
+            }
+            var handled = true;
             if (event.key === "Escape") {
             if (event.key === "Escape") {
-                event.preventDefault();
-                event.stopPropagation();
-                closeMenu();
+                if (level > 0) {
+                    closeLevelsAbove(level - 1);
+                } else {
+                    closeMenu();
+                }
             } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
             } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
-                event.preventDefault();
-                event.stopPropagation();
-                if (actionable.length === 0) {
-                    return;
+                if (entry.rows.length > 0) {
+                    var next = (entry.focus + (event.key === "ArrowDown" ? 1 : -1) + entry.rows.length) % entry.rows.length;
+                    setFocus(level, next);
                 }
                 }
-                if (focusIndex >= 0) {
-                    actionable[focusIndex].classList.remove("focus");
+            } else if (event.key === "ArrowRight" && entry.focus >= 0 && entry.rows[entry.focus].classList.contains("has-sub")) {
+                entry.rows[entry.focus].click();
+                setFocus(level + 1, 0);
+            } else if (event.key === "ArrowLeft" && level > 0) {
+                closeLevelsAbove(level - 1);
+            } else if (event.key === "Enter" && entry.focus >= 0) {
+                var row = entry.rows[entry.focus];
+                row.click();
+                if (row.classList.contains("has-sub")) {
+                    setFocus(level + 1, 0);
                 }
                 }
-                focusIndex = (focusIndex + (event.key === "ArrowDown" ? 1 : -1) + actionable.length) % actionable.length;
-                actionable[focusIndex].classList.add("focus");
-                actionable[focusIndex].scrollIntoView({ block: "nearest" });
-            } else if (event.key === "Enter" && focusIndex >= 0) {
+            } else {
+                handled = false;
+            }
+            if (handled) {
                 event.preventDefault();
                 event.preventDefault();
                 event.stopPropagation();
                 event.stopPropagation();
-                actionable[focusIndex].click();
             }
             }
         };
         };
         var outside = function (event) {
         var outside = function (event) {
-            if (!node.contains(event.target)) {
+            var inside = menuStack.some(function (entry) { return entry.node.contains(event.target); });
+            if (!inside) {
                 closeMenu();
                 closeMenu();
             }
             }
         };
         };
+        menuSession = { outside: outside, keys: keys, onClose: options.onClose, hoverTimer: null };
         setTimeout(function () {
         setTimeout(function () {
-            document.addEventListener("mousedown", outside, true);
+            if (menuSession && menuSession.outside === outside) {
+                document.addEventListener("mousedown", outside, true);
+            }
         }, 0);
         }, 0);
         document.addEventListener("keydown", keys, true);
         document.addEventListener("keydown", keys, true);
         window.addEventListener("blur", closeMenu);
         window.addEventListener("blur", closeMenu);
         window.addEventListener("resize", closeMenu);
         window.addEventListener("resize", closeMenu);
-        openMenu = { node: node, outside: outside, keys: keys, onClose: options.onClose };
-        return { close: closeMenu, node: node };
+        return { close: closeMenu, node: built.node };
     }
     }
 
 
     /* ---------- Modals ---------- */
     /* ---------- Modals ---------- */
@@ -281,7 +403,7 @@ Mail.ui = (function () {
             if (modalStack[modalStack.length - 1] !== api) {
             if (modalStack[modalStack.length - 1] !== api) {
                 return;
                 return;
             }
             }
-            if (event.key === "Escape" && dismissable && !openMenu) {
+            if (event.key === "Escape" && dismissable && menuStack.length === 0) {
                 event.preventDefault();
                 event.preventDefault();
                 event.stopPropagation();
                 event.stopPropagation();
                 close();
                 close();
@@ -454,6 +576,6 @@ Mail.ui = (function () {
         toast: toast, errorToast: errorToast, menu: menu, closeMenu: closeMenu,
         toast: toast, errorToast: errorToast, menu: menu, closeMenu: closeMenu,
         modal: modal, hasOpenModal: hasOpenModal, confirm: confirmDialog, choice: choice, prompt: prompt,
         modal: modal, hasOpenModal: hasOpenModal, confirm: confirmDialog, choice: choice, prompt: prompt,
         pickDateTime: pickDateTime, presetTimes: presetTimes,
         pickDateTime: pickDateTime, presetTimes: presetTimes,
-        isMenuOpen: function () { return openMenu !== null; }
+        isMenuOpen: function () { return menuStack.length > 0; }
     };
     };
 })();
 })();

+ 94 - 32
src/web/Mail/style/mail.css

@@ -4,6 +4,10 @@
     Three panes (folders, message list, reading pane) under a top bar, in
     Three panes (folders, message list, reading pane) under a top bar, in
     the light airy style of the ArozOS desktop, with a full dark theme.
     the light airy style of the ArozOS desktop, with a full dark theme.
     Colours are CSS variables switched by [data-theme] on <html>.
     Colours are CSS variables switched by [data-theme] on <html>.
+
+    The look is flat: no drop shadows or gradients. Floating surfaces
+    (menus, dialogs, the composer) are set apart by a 1px border, and focus
+    is shown by the border colour.
 */
 */
 
 
 :root {
 :root {
@@ -34,9 +38,9 @@
     --success: #12a150;
     --success: #12a150;
     --success-soft: #e6f6ec;
     --success-soft: #e6f6ec;
     --unread: #2f7cf6;
     --unread: #2f7cf6;
-    --shadow-sm: 0 1px 2px rgba(20, 30, 50, 0.06);
-    --shadow: 0 6px 24px rgba(20, 30, 50, 0.10), 0 1px 3px rgba(20, 30, 50, 0.06);
-    --shadow-lg: 0 18px 50px rgba(15, 25, 45, 0.22), 0 2px 8px rgba(15, 25, 45, 0.10);
+    --shadow-sm: none;
+    --shadow: none;
+    --shadow-lg: none;
     --radius: 10px;
     --radius: 10px;
     --radius-sm: 7px;
     --radius-sm: 7px;
     --sidebar-w: 260px;
     --sidebar-w: 260px;
@@ -70,9 +74,9 @@
     --success: #3ccf7a;
     --success: #3ccf7a;
     --success-soft: #16301f;
     --success-soft: #16301f;
     --unread: #5b9bff;
     --unread: #5b9bff;
-    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
-    --shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
-    --shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.6);
+    --shadow-sm: none;
+    --shadow: none;
+    --shadow-lg: none;
     --paper: #ffffff;
     --paper: #ffffff;
     color-scheme: dark;
     color-scheme: dark;
 }
 }
@@ -113,7 +117,7 @@ i.icon { margin: 0; line-height: 1; }
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
     background: var(--surface); color: var(--text);
     background: var(--surface); color: var(--text);
     cursor: pointer; white-space: nowrap; user-select: none;
     cursor: pointer; white-space: nowrap; user-select: none;
-    transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
+    transition: background 0.12s, border-color 0.12s;
 }
 }
 .btn:hover { background: var(--hover); }
 .btn:hover { background: var(--hover); }
 .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
 .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -149,10 +153,10 @@ i.icon { margin: 0; line-height: 1; }
     width: 100%; height: 38px; padding: 0 12px;
     width: 100%; height: 38px; padding: 0 12px;
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
     background: var(--surface); color: var(--text); outline: none;
     background: var(--surface); color: var(--text); outline: none;
-    transition: border-color 0.12s, box-shadow 0.12s;
+    transition: border-color 0.12s;
 }
 }
 .textarea { height: auto; min-height: 90px; padding: 10px 12px; resize: vertical; line-height: 1.5; }
 .textarea { height: auto; min-height: 90px; padding: 10px 12px; resize: vertical; line-height: 1.5; }
-.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
+.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
 .input.invalid { border-color: var(--danger); }
 .input.invalid { border-color: var(--danger); }
 .row { display: flex; gap: 12px; align-items: flex-start; }
 .row { display: flex; gap: 12px; align-items: flex-start; }
 .row > .field { flex: 1; min-width: 0; }
 .row > .field { flex: 1; min-width: 0; }
@@ -204,14 +208,14 @@ i.icon { margin: 0; line-height: 1; }
 .brand .menu-toggle { display: none; margin-left: -10px; }
 .brand .menu-toggle { display: none; margin-left: -10px; }
 
 
 .searchbar {
 .searchbar {
-    flex: 1 1 auto; max-width: 520px; height: 40px; margin-left: 4px;
+    flex: 1 1 auto; min-width: 0; max-width: 520px; height: 40px; margin-left: 4px;
     display: flex; align-items: center; gap: 8px; padding: 0 6px 0 14px;
     display: flex; align-items: center; gap: 8px; padding: 0 6px 0 14px;
     border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2);
     border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2);
-    transition: border-color 0.12s, background 0.12s, box-shadow 0.12s;
+    transition: border-color 0.12s, background 0.12s;
 }
 }
-.searchbar:focus-within { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft); }
+.searchbar:focus-within { border-color: var(--accent); background: var(--surface); }
 .searchbar > i.icon { color: var(--text-3); }
 .searchbar > i.icon { color: var(--text-3); }
-.searchbar input { flex: 1; min-width: 0; height: 100%; border: none; outline: none; background: transparent; }
+.searchbar input { flex: 1; width: 0; min-width: 0; height: 100%; border: none; outline: none; background: transparent; }
 .searchbar input::-webkit-search-cancel-button { -webkit-appearance: none; display: none; }
 .searchbar input::-webkit-search-cancel-button { -webkit-appearance: none; display: none; }
 .searchbar select {
 .searchbar select {
     border: none; background: transparent; color: var(--text-3); font-size: 12.5px; outline: none; cursor: pointer;
     border: none; background: transparent; color: var(--text-3); font-size: 12.5px; outline: none; cursor: pointer;
@@ -244,13 +248,13 @@ i.icon { margin: 0; line-height: 1; }
     display: inline-flex; align-items: center; gap: 12px;
     display: inline-flex; align-items: center; gap: 12px;
     height: 46px; padding: 0 22px; min-width: 168px;
     height: 46px; padding: 0 22px; min-width: 168px;
     border: none; border-radius: 10px; cursor: pointer;
     border: none; border-radius: 10px; cursor: pointer;
-    background: linear-gradient(180deg, #4b93ff 0%, #2f7cf6 100%);
+    background: var(--accent);
     color: #fff; font-size: 15px; font-weight: 500;
     color: #fff; font-size: 15px; font-weight: 500;
-    box-shadow: 0 6px 16px rgba(47, 124, 246, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.2);
-    transition: transform 0.08s, box-shadow 0.12s, filter 0.12s;
+    transition: background 0.12s;
 }
 }
-.compose-btn:hover { filter: brightness(1.05); box-shadow: 0 8px 20px rgba(47, 124, 246, 0.38); }
-.compose-btn:active { transform: translateY(1px); }
+.compose-btn:hover { background: var(--accent-2); }
+.compose-btn:active { background: var(--accent-text); }
+.compose-btn:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
 .compose-btn i.icon { font-size: 16px; }
 .compose-btn i.icon { font-size: 16px; }
 
 
 .sidebar .nav { flex: 1 1 auto; padding: 4px 12px 16px; }
 .sidebar .nav { flex: 1 1 auto; padding: 4px 12px 16px; }
@@ -293,7 +297,15 @@ i.icon { margin: 0; line-height: 1; }
     width: var(--list-w); flex: none; min-width: 300px;
     width: var(--list-w); flex: none; min-width: 300px;
     display: flex; flex-direction: column; min-height: 0;
     display: flex; flex-direction: column; min-height: 0;
     border-right: 1px solid var(--border); background: var(--surface);
     border-right: 1px solid var(--border); background: var(--surface);
+    position: relative; overflow: hidden;
+}
+/* A cached list being refreshed in the background */
+.listpane.refreshing::after {
+    content: ""; position: absolute; left: 0; top: 51px; z-index: 3;
+    width: 25%; height: 2px; background: var(--accent);
+    animation: refreshbar 1.1s ease-in-out infinite;
 }
 }
+@keyframes refreshbar { from { transform: translateX(-100%); } to { transform: translateX(400%); } }
 .list-header {
 .list-header {
     height: 52px; flex: none; display: flex; align-items: center; gap: 6px;
     height: 52px; flex: none; display: flex; align-items: center; gap: 6px;
     padding: 0 10px 0 16px; border-bottom: 1px solid var(--border);
     padding: 0 10px 0 16px; border-bottom: 1px solid var(--border);
@@ -332,11 +344,13 @@ i.icon { margin: 0; line-height: 1; }
     border-bottom: 1px solid var(--border); cursor: pointer;
     border-bottom: 1px solid var(--border); cursor: pointer;
     background: var(--surface);
     background: var(--surface);
     transition: background 0.08s;
     transition: background 0.08s;
+    user-select: none;
 }
 }
 .msg-row:hover { background: var(--hover); }
 .msg-row:hover { background: var(--hover); }
 .msg-row.selected { background: var(--selected); }
 .msg-row.selected { background: var(--selected); }
 .msg-row.checked { background: var(--accent-soft); }
 .msg-row.checked { background: var(--accent-soft); }
-.msg-row.current { background: var(--selected); box-shadow: inset 3px 0 0 var(--accent); }
+.msg-row.current { background: var(--selected); }
+.msg-row.current::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
 .msg-row .unread-dot {
 .msg-row .unread-dot {
     position: absolute; left: 11px; top: 30px; width: 8px; height: 8px; border-radius: 50%;
     position: absolute; left: 11px; top: 30px; width: 8px; height: 8px; border-radius: 50%;
     background: var(--unread);
     background: var(--unread);
@@ -388,8 +402,8 @@ i.icon { margin: 0; line-height: 1; }
 .list-status .title { color: var(--text-2); font-size: 15px; font-weight: 500; }
 .list-status .title { color: var(--text-2); font-size: 15px; font-weight: 500; }
 
 
 .skeleton-row { display: flex; gap: 12px; padding: 16px 16px 16px 26px; border-bottom: 1px solid var(--border); }
 .skeleton-row { display: flex; gap: 12px; padding: 16px 16px 16px 26px; border-bottom: 1px solid var(--border); }
-.skeleton { background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 50%, var(--surface-3) 75%); background-size: 200% 100%; animation: shimmer 1.4s infinite; border-radius: 6px; }
-@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
+.skeleton { background: var(--surface-3); animation: pulse 1.4s ease-in-out infinite; border-radius: 6px; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
 
 
 /* Splitter between list and reading pane */
 /* Splitter between list and reading pane */
 .splitter { width: 5px; margin-left: -3px; margin-right: -2px; cursor: col-resize; flex: none; z-index: 2; }
 .splitter { width: 5px; margin-left: -3px; margin-right: -2px; cursor: col-resize; flex: none; z-index: 2; }
@@ -440,7 +454,7 @@ i.icon { margin: 0; line-height: 1; }
     width: 100%; border: none; display: block; min-height: 80px;
     width: 100%; border: none; display: block; min-height: 80px;
     background: var(--paper); border-radius: 8px;
     background: var(--paper); border-radius: 8px;
 }
 }
-:root[data-theme="dark"] .reader-body iframe { box-shadow: 0 0 0 1px var(--border); }
+:root[data-theme="dark"] .reader-body iframe { outline: 1px solid var(--border); }
 .plain-body {
 .plain-body {
     white-space: pre-wrap; word-break: break-word; line-height: 1.62; font-size: 14.5px;
     white-space: pre-wrap; word-break: break-word; line-height: 1.62; font-size: 14.5px;
 }
 }
@@ -488,9 +502,10 @@ i.icon { margin: 0; line-height: 1; }
 .menu {
 .menu {
     position: fixed; z-index: 1000; min-width: 210px; max-width: 320px; max-height: 70vh;
     position: fixed; z-index: 1000; min-width: 210px; max-width: 320px; max-height: 70vh;
     padding: 6px; border-radius: 10px; background: var(--surface);
     padding: 6px; border-radius: 10px; background: var(--surface);
-    border: 1px solid var(--border); box-shadow: var(--shadow);
+    border: 1px solid var(--border-strong);
     overflow-y: auto; animation: pop 0.12s ease-out;
     overflow-y: auto; animation: pop 0.12s ease-out;
 }
 }
+.menu.submenu { z-index: 1001; animation: none; }
 @keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } }
 @keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } }
 .menu-item {
 .menu-item {
     display: flex; align-items: center; gap: 10px; min-height: 34px; padding: 6px 10px;
     display: flex; align-items: center; gap: 10px; min-height: 34px; padding: 6px 10px;
@@ -503,6 +518,8 @@ i.icon { margin: 0; line-height: 1; }
 .menu-item.danger, .menu-item.danger i.icon { color: var(--danger); }
 .menu-item.danger, .menu-item.danger i.icon { color: var(--danger); }
 .menu-item.checked::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-left: auto; }
 .menu-item.checked::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-left: auto; }
 .menu-item.disabled { opacity: 0.45; pointer-events: none; }
 .menu-item.disabled { opacity: 0.45; pointer-events: none; }
+.menu-item.open { background: var(--hover); }
+.menu-item .sub-caret { margin-left: auto; width: auto; font-size: 12px; color: var(--text-3); }
 .menu-sep { height: 1px; background: var(--border); margin: 5px 4px; }
 .menu-sep { height: 1px; background: var(--border); margin: 5px 4px; }
 .menu-title { padding: 6px 10px 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-3); font-weight: 600; }
 .menu-title { padding: 6px 10px 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-3); font-weight: 600; }
 .menu .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
 .menu .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
@@ -513,7 +530,7 @@ i.icon { margin: 0; line-height: 1; }
 .toast {
 .toast {
     pointer-events: auto; display: flex; align-items: center; gap: 12px;
     pointer-events: auto; display: flex; align-items: center; gap: 12px;
     min-width: 260px; max-width: min(560px, 92vw); padding: 10px 12px 10px 16px;
     min-width: 260px; max-width: min(560px, 92vw); padding: 10px 12px 10px 16px;
-    border-radius: 10px; background: #1f2633; color: #f3f5f8; box-shadow: var(--shadow-lg);
+    border-radius: 10px; background: #1f2633; color: #f3f5f8;
     animation: toastin 0.18s ease-out; font-size: 13.5px;
     animation: toastin 0.18s ease-out; font-size: 13.5px;
 }
 }
 .toast.error { background: #8f1d23; }
 .toast.error { background: #8f1d23; }
@@ -534,7 +551,7 @@ i.icon { margin: 0; line-height: 1; }
 .modal {
 .modal {
     width: 100%; max-width: 520px; max-height: calc(100vh - 40px);
     width: 100%; max-width: 520px; max-height: calc(100vh - 40px);
     display: flex; flex-direction: column;
     display: flex; flex-direction: column;
-    background: var(--surface); border-radius: 14px; box-shadow: var(--shadow-lg);
+    background: var(--surface); border-radius: 14px; border: 1px solid var(--border-strong);
     animation: pop 0.16s ease-out; overflow: hidden;
     animation: pop 0.16s ease-out; overflow: hidden;
 }
 }
 .modal.wide { max-width: 760px; }
 .modal.wide { max-width: 760px; }
@@ -568,9 +585,9 @@ i.icon { margin: 0; line-height: 1; }
 .provider-tile {
 .provider-tile {
     display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
     display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
     height: 104px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
     height: 104px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
-    cursor: pointer; transition: border-color 0.12s, box-shadow 0.12s, transform 0.08s; text-align: center; padding: 8px;
+    cursor: pointer; transition: border-color 0.12s, background 0.12s; text-align: center; padding: 8px;
 }
 }
-.provider-tile:hover { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
+.provider-tile:hover { border-color: var(--accent); background: var(--accent-soft); }
 .provider-tile i.icon { font-size: 30px; height: auto; }
 .provider-tile i.icon { font-size: 30px; height: auto; }
 .provider-tile .pname { font-size: 13px; font-weight: 500; line-height: 1.3; }
 .provider-tile .pname { font-size: 13px; font-weight: 500; line-height: 1.3; }
 .provider-chosen { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 10px; background: var(--surface-2); margin-bottom: 16px; }
 .provider-chosen { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 10px; background: var(--surface-2); margin-bottom: 16px; }
@@ -602,7 +619,7 @@ i.icon { margin: 0; line-height: 1; }
     min-height: 110px; max-height: 260px; overflow-y: auto; padding: 10px 12px;
     min-height: 110px; max-height: 260px; overflow-y: auto; padding: 10px 12px;
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); outline: none; line-height: 1.5;
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); outline: none; line-height: 1.5;
 }
 }
-.signature-editor:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
+.signature-editor:focus { border-color: var(--accent); }
 
 
 /* ---------- Composer ---------- */
 /* ---------- Composer ---------- */
 
 
@@ -610,8 +627,8 @@ i.icon { margin: 0; line-height: 1; }
     position: absolute; z-index: 900; right: 22px; bottom: 0; top: 18px;
     position: absolute; z-index: 900; right: 22px; bottom: 0; top: 18px;
     width: min(760px, calc(100% - 44px));
     width: min(760px, calc(100% - 44px));
     display: flex; flex-direction: column;
     display: flex; flex-direction: column;
-    background: var(--surface); border: 1px solid var(--border);
-    border-radius: 14px 14px 0 0; box-shadow: var(--shadow-lg);
+    background: var(--surface); border: 1px solid var(--border-strong); border-bottom: none;
+    border-radius: 14px 14px 0 0;
     animation: slideup 0.18s ease-out;
     animation: slideup 0.18s ease-out;
 }
 }
 @keyframes slideup { from { transform: translateY(30px); opacity: 0; } to { transform: none; opacity: 1; } }
 @keyframes slideup { from { transform: translateY(30px); opacity: 0; } to { transform: none; opacity: 1; } }
@@ -649,7 +666,7 @@ i.icon { margin: 0; line-height: 1; }
 .addr-chip button:hover { background: var(--hover); color: var(--text); }
 .addr-chip button:hover { background: var(--hover); color: var(--text); }
 .suggestions {
 .suggestions {
     position: absolute; top: 100%; left: 0; z-index: 30; width: min(420px, 100%); margin-top: 2px;
     position: absolute; top: 100%; left: 0; z-index: 30; width: min(420px, 100%); margin-top: 2px;
-    background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); padding: 4px;
+    background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; padding: 4px;
 }
 }
 .suggestion { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
 .suggestion { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
 .suggestion.focus, .suggestion:hover { background: var(--hover); }
 .suggestion.focus, .suggestion:hover { background: var(--hover); }
@@ -700,6 +717,37 @@ i.icon { margin: 0; line-height: 1; }
 .composer-foot .opts { display: flex; align-items: center; gap: 6px; color: var(--text-3); font-size: 12.5px; margin-left: 8px; }
 .composer-foot .opts { display: flex; align-items: center; gap: 6px; color: var(--text-3); font-size: 12.5px; margin-left: 8px; }
 .composer-foot .opt-badge { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 12px; background: var(--surface-3); color: var(--text-2); font-size: 12px; }
 .composer-foot .opt-badge { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 12px; background: var(--surface-3); color: var(--text-2); font-size: 12px; }
 
 
+/* ---------- Composer and message windows ---------- */
+
+.window-page { background: var(--surface); }
+.compose-host { position: relative; height: 100%; display: flex; flex-direction: column; }
+.composer.window-mode {
+    position: relative; top: auto; right: auto; bottom: auto; left: auto;
+    flex: 1 1 auto; width: auto; min-height: 0;
+    border: none; border-radius: 0; animation: none;
+}
+.composer-foot .status { color: var(--text-3); font-size: 12px; margin-right: 6px; white-space: nowrap; }
+
+/* Shown over a composer window while its message is sent */
+.sent-overlay {
+    position: absolute; inset: 0; z-index: 50;
+    display: flex; align-items: center; justify-content: center; padding: 24px;
+    background: var(--surface);
+}
+.sent-overlay .card { display: flex; flex-direction: column; align-items: center; gap: 12px; max-width: 380px; text-align: center; }
+.sent-overlay .big-icon { font-size: 40px; height: auto; color: var(--accent); }
+.sent-overlay .heading { font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; }
+.sent-overlay .detail { color: var(--text-2); line-height: 1.5; }
+.sent-overlay .detail:empty { display: none; }
+.sent-overlay .actions { display: flex; gap: 8px; margin-top: 6px; }
+.sent-overlay .actions:empty { display: none; }
+
+.message-window .viewer-bar { gap: 4px; }
+.message-window .viewer-bar .btn { margin-right: 2px; }
+.message-window .iconbtn.on { color: #f5a524; }
+.folder-choices { margin-top: 10px; max-height: 50vh; overflow-y: auto; }
+.folder-choices .nav-item { width: 100%; border: none; background: transparent; text-align: left; font: inherit; }
+
 /* ---------- Viewer (.eml) ---------- */
 /* ---------- Viewer (.eml) ---------- */
 
 
 .viewer-app { display: flex; flex-direction: column; height: 100%; background: var(--surface); }
 .viewer-app { display: flex; flex-direction: column; height: 100%; background: var(--surface); }
@@ -719,7 +767,7 @@ i.icon { margin: 0; line-height: 1; }
     .brand .menu-toggle { display: inline-flex; }
     .brand .menu-toggle { display: inline-flex; }
     .sidebar {
     .sidebar {
         position: absolute; z-index: 950; top: 0; bottom: 0; left: 0; width: 280px;
         position: absolute; z-index: 950; top: 0; bottom: 0; left: 0; width: 280px;
-        transform: translateX(-100%); transition: transform 0.18s ease-out; box-shadow: var(--shadow-lg);
+        transform: translateX(-100%); transition: transform 0.18s ease-out;
     }
     }
     .mail-app.sidebar-open .sidebar { transform: none; }
     .mail-app.sidebar-open .sidebar { transform: none; }
     .mail-app.sidebar-open .workspace::before { content: ""; position: absolute; inset: 0; z-index: 940; background: rgba(15, 22, 36, 0.3); }
     .mail-app.sidebar-open .workspace::before { content: ""; position: absolute; inset: 0; z-index: 940; background: rgba(15, 22, 36, 0.3); }
@@ -727,9 +775,23 @@ i.icon { margin: 0; line-height: 1; }
     .toolbar .sep { display: none; }
     .toolbar .sep { display: none; }
 }
 }
 
 
+@media (max-width: 560px) {
+    .message-window .viewer-bar .btn .text { display: none; }
+}
+
+@media (max-width: 480px) {
+    .brand span { display: none; }
+    .topbar { gap: 8px; }
+}
+
 @media (max-width: 720px) {
 @media (max-width: 720px) {
     .searchbar select { display: none; }
     .searchbar select { display: none; }
     .toolbar .wide-only { display: none; }
     .toolbar .wide-only { display: none; }
+    /* Reply and mark-as-read live in the reading pane and the row menu */
+    .toolbar #markButton, .toolbar #replyButton { display: none; }
+    .reader-head { flex-wrap: wrap; }
+    .reader-head .who { flex-basis: calc(100% - 60px); }
+    .reader-head .meta { width: 100%; padding-left: 60px; flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: space-between; row-gap: 4px; }
     .listpane { width: 100%; border-right: none; }
     .listpane { width: 100%; border-right: none; }
     .readpane { position: absolute; inset: 0; z-index: 800; transform: translateX(100%); transition: transform 0.18s ease-out; }
     .readpane { position: absolute; inset: 0; z-index: 800; transform: translateX(100%); transition: transform 0.18s ease-out; }
     .mail-app.reading .readpane { transform: none; }
     .mail-app.reading .readpane { transform: none; }

+ 9 - 32
src/web/Mail/viewer.html

@@ -28,6 +28,7 @@
     <script src="script/api.js"></script>
     <script src="script/api.js"></script>
     <script src="script/ui.js"></script>
     <script src="script/ui.js"></script>
     <script src="script/render.js"></script>
     <script src="script/render.js"></script>
+    <script src="script/shared.js"></script>
     <script src="script/compose.js"></script>
     <script src="script/compose.js"></script>
     <script>
     <script>
         /*
         /*
@@ -35,7 +36,7 @@
 
 
             Opened by ArozOS for .eml files. Shows the message with the same
             Opened by ArozOS for .eml files. Shows the message with the same
             sanitiser and sandbox as the mailbox view; replying and forwarding
             sanitiser and sandbox as the mailbox view; replying and forwarding
-            hand the message over to the main Mail window.
+            open a composer window.
         */
         */
         (function () {
         (function () {
             "use strict";
             "use strict";
@@ -51,24 +52,7 @@
             var accounts = [];
             var accounts = [];
             var settings = {};
             var settings = {};
 
 
-            function applyTheme(theme) {
-                document.documentElement.setAttribute("data-theme", theme === "dark" ? "dark" : "light");
-            }
-            if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
-                applyTheme("dark");
-            }
-            if (typeof ao_module_getSystemThemeColor === "function") {
-                try {
-                    ao_module_getSystemThemeColor(function (theme) {
-                        if (theme === "darkTheme" || theme === "whiteTheme") {
-                            applyTheme(theme === "darkTheme" ? "dark" : "light");
-                        }
-                    });
-                } catch (e) { /* standalone */ }
-            }
-            if (typeof ao_module_onThemeChanged === "function") {
-                ao_module_onThemeChanged(applyTheme);
-            }
+            var applyTheme = Mail.shared.followTheme(function () { return settings.theme; });
 
 
             Mail.compose.init({
             Mail.compose.init({
                 host: document.body,
                 host: document.body,
@@ -84,23 +68,13 @@
                 content.appendChild(el("div", { class: "reader-empty" }, [icon("exclamation triangle"), el("div", { class: "title", text: "Cannot open this message" }), el("div", { text: text })]));
                 content.appendChild(el("div", { class: "reader-empty" }, [icon("exclamation triangle"), el("div", { class: "title", text: "Cannot open this message" }), el("div", { text: text })]));
             }
             }
 
 
-            //handOver opens the main Mail window with a prepared composer
+            //handOver opens a composer window with a prepared message
             function handOver(options) {
             function handOver(options) {
                 if (accounts.length === 0) {
                 if (accounts.length === 0) {
                     ui.toast("Add a mail account in Mail first", { error: true });
                     ui.toast("Add a mail account in Mail first", { error: true });
                     return;
                     return;
                 }
                 }
-                var key = "handoff-" + util.randomId();
-                try {
-                    localStorage.setItem("aroz-mail:" + key, JSON.stringify(options));
-                } catch (e) {
-                    ui.toast("The message is too large to hand over", { error: true });
-                    return;
-                }
-                ao_module_newfw({
-                    url: "Mail/index.html#handoff=" + key,
-                    width: 1280, height: 800, appicon: "Mail/img/icon.svg", title: "Mail"
-                });
+                Mail.shared.openComposeWindow(options);
             }
             }
 
 
             function withAttachmentsAsFiles(options) {
             function withAttachmentsAsFiles(options) {
@@ -232,7 +206,10 @@
             file = files[0];
             file = files[0];
             document.getElementById("fileName").textContent = file.filename;
             document.getElementById("fileName").textContent = file.filename;
             api.accounts("list", {}).then(function (list) { accounts = list || []; }).catch(function () { });
             api.accounts("list", {}).then(function (list) { accounts = list || []; }).catch(function () { });
-            api.settings("get", {}).then(function (loaded) { settings = loaded || {}; }).catch(function () { });
+            api.settings("get", {}).then(function (loaded) {
+                settings = loaded || {};
+                applyTheme();
+            }).catch(function () { });
             load(false);
             load(false);
         })();
         })();
     </script>
     </script>

+ 449 - 0
src/web/Terminal/docs/api.json

@@ -1488,6 +1488,455 @@
         }
         }
       ]
       ]
     },
     },
+    {
+      "id": "email",
+      "name": "email",
+      "desc": "IMAP / SMTP mail client backing the Mail WebApp: accounts (password, app password or Google / Microsoft OAuth), folders, paging and search, sanitised reading, flags and moving, drafts, sending with undo and scheduling, address book, labels and snoozing. Every call returns {success, data} or {success: false, error} with authFailed, hint and code where they apply. Accounts and secrets are private to the calling user; secrets are encrypted and never returned. Available when the host wired the email manager in.",
+      "load": "requirelib(\"email\");",
+      "functions": [
+        {
+          "name": "email.providers",
+          "sig": "email.providers()",
+          "desc": "Known mail providers (Gmail, Outlook, Yahoo, iCloud, ...): server settings, OAuth support and app password instructions.",
+          "ret": "object",
+          "example": "requirelib(\"email\");\nvar presets = email.providers().data;"
+        },
+        {
+          "name": "email.discover",
+          "sig": "email.discover(address)",
+          "desc": "Suggest server settings for an address from provider presets, MX records, the Thunderbird ISPDB, autoconfig, SRV records or probing.",
+          "ret": "object",
+          "example": "var guess = email.discover(\"me@example.com\");\nif (guess.success) { console.log(guess.data.imap.host); }"
+        },
+        {
+          "name": "email.listAccounts",
+          "sig": "email.listAccounts()",
+          "desc": "The calling user's mail accounts, without secrets.",
+          "ret": "object",
+          "example": "var accounts = email.listAccounts().data;"
+        },
+        {
+          "name": "email.getAccount",
+          "sig": "email.getAccount(id)",
+          "desc": "One account, without secrets.",
+          "ret": "object",
+          "example": "var account = email.getAccount(id).data;"
+        },
+        {
+          "name": "email.testAccount",
+          "sig": "email.testAccount(input)",
+          "desc": "Log in to the IMAP and SMTP servers of an account form without storing it (password accounts).",
+          "ret": "object",
+          "example": "var test = email.testAccount({ email: \"me@example.com\", password: \"app-password\" });"
+        },
+        {
+          "name": "email.addAccount",
+          "sig": "email.addAccount(input)",
+          "desc": "Verify and store an account. input: {email, displayName, provider, imap: {host, port, security, username}, smtp, auth: \"password\" | \"oauth2\", password, smtpPassword, oauthState, signature, replyTo, saveSent}. Empty server settings come from the provider preset. On a rejected login the reply carries authFailed and a hint.",
+          "ret": "object",
+          "example": "var result = email.addAccount({\n    email: \"me@example.com\", provider: \"custom\",\n    imap: { host: \"mail.example.com\", port: 993, security: \"ssl\" },\n    smtp: { host: \"mail.example.com\", port: 465, security: \"ssl\" },\n    auth: \"password\", password: \"app-password\"\n});\nif (!result.success && result.authFailed) { console.log(result.hint); }"
+        },
+        {
+          "name": "email.updateAccount",
+          "sig": "email.updateAccount(id, input)",
+          "desc": "Change an account. An empty password keeps the stored one; changed connection settings are verified first.",
+          "ret": "object",
+          "example": "email.updateAccount(id, { email: \"me@example.com\", signature: \"<b>Me</b>\" });"
+        },
+        {
+          "name": "email.removeAccount",
+          "sig": "email.removeAccount(id)",
+          "desc": "Remove an account and its local labels, snoozes and queued mail. Messages stay on the server.",
+          "ret": "object",
+          "example": "email.removeAccount(id);"
+        },
+        {
+          "name": "email.reorderAccounts",
+          "sig": "email.reorderAccounts(ids)",
+          "desc": "Store the sidebar order of accounts.",
+          "ret": "object",
+          "example": "email.reorderAccounts([idA, idB]);"
+        },
+        {
+          "name": "email.oauthProviders",
+          "sig": "email.oauthProviders()",
+          "desc": "Google and Microsoft sign-in availability: [{id, name, enabled, flow}].",
+          "ret": "object",
+          "example": "var providers = email.oauthProviders().data;"
+        },
+        {
+          "name": "email.oauthStart",
+          "sig": "email.oauthStart(provider, address, redirectURI)",
+          "desc": "Start a Google or Microsoft sign-in. Returns state and flow, plus authUrl (redirect / loopback) or userCode and verificationUri (device flow).",
+          "ret": "object",
+          "example": "var start = email.oauthStart(\"microsoft\", \"me@outlook.com\", \"\").data;\nconsole.log(start.userCode, start.verificationUri);"
+        },
+        {
+          "name": "email.oauthComplete",
+          "sig": "email.oauthComplete(state, codeOrURL)",
+          "desc": "Finish a redirect or loopback sign-in with the authorization code or the full redirect URL.",
+          "ret": "object",
+          "example": "email.oauthComplete(state, \"http://127.0.0.1:53682/?state=...&code=...\");"
+        },
+        {
+          "name": "email.oauthStatus",
+          "sig": "email.oauthStatus(state)",
+          "desc": "Progress of a sign-in: status is pending, done or error. A done state is passed to addAccount as oauthState.",
+          "ret": "object",
+          "example": "var status = email.oauthStatus(state).data.status;"
+        },
+        {
+          "name": "email.oauthCancel",
+          "sig": "email.oauthCancel(state)",
+          "desc": "Abandon a pending sign-in.",
+          "ret": "object",
+          "example": "email.oauthCancel(state);"
+        },
+        {
+          "name": "email.folders",
+          "sig": "email.folders(accountId, refresh)",
+          "desc": "Mailboxes with role (inbox, sent, drafts, trash, junk, archive, all, flagged, important), total and unread counts. Cached for a few minutes unless refresh is true.",
+          "ret": "object",
+          "example": "var folders = email.folders(accountId, true).data;"
+        },
+        {
+          "name": "email.createFolder",
+          "sig": "email.createFolder(accountId, parent, name)",
+          "desc": "Create a folder, optionally inside parent.",
+          "ret": "object",
+          "example": "email.createFolder(accountId, \"\", \"Projects\");"
+        },
+        {
+          "name": "email.renameFolder",
+          "sig": "email.renameFolder(accountId, folder, newName)",
+          "desc": "Rename a user folder.",
+          "ret": "object",
+          "example": "email.renameFolder(accountId, \"Projects\", \"Clients\");"
+        },
+        {
+          "name": "email.deleteFolder",
+          "sig": "email.deleteFolder(accountId, folder)",
+          "desc": "Delete a user folder and its messages.",
+          "ret": "object",
+          "example": "email.deleteFolder(accountId, \"Old\");"
+        },
+        {
+          "name": "email.emptyFolder",
+          "sig": "email.emptyFolder(accountId, folder)",
+          "desc": "Permanently delete every message of the Trash or Junk folder.",
+          "ret": "object",
+          "example": "email.emptyFolder(accountId, \"Trash\");"
+        },
+        {
+          "name": "email.markAllRead",
+          "sig": "email.markAllRead(accountId, folder)",
+          "desc": "Mark every unread message of a folder as read.",
+          "ret": "object",
+          "example": "email.markAllRead(accountId, \"INBOX\");"
+        },
+        {
+          "name": "email.list",
+          "sig": "email.list(accountId, query)",
+          "desc": "A page of a folder. query: {folder, page, pageSize, sort: date | date_asc | from | subject | size, filter: all | unread | flagged | attachments | unanswered, search, searchIn: all | from | to | subject | body, previews}.",
+          "ret": "object",
+          "example": "var page = email.list(accountId, { folder: \"INBOX\", page: 0, pageSize: 50, previews: true }).data;\nconsole.log(page.total, page.messages.length);"
+        },
+        {
+          "name": "email.unified",
+          "sig": "email.unified(view, query, accountIds)",
+          "desc": "One list across accounts: inbox, flagged, unread or role:<role> (e.g. role:sent). Failing accounts are reported in data.errors.",
+          "ret": "object",
+          "example": "var inbox = email.unified(\"inbox\", { pageSize: 50 }, []).data;"
+        },
+        {
+          "name": "email.get",
+          "sig": "email.get(accountId, folder, uid, options)",
+          "desc": "A full message with sanitised html, text, attachments, auth verdicts and more. options: {markSeen, allowRemote}.",
+          "ret": "object",
+          "example": "var message = email.get(accountId, \"INBOX\", uid, { markSeen: true }).data;"
+        },
+        {
+          "name": "email.rawSource",
+          "sig": "email.rawSource(accountId, folder, uid)",
+          "desc": "The RFC 822 source as text (truncated at 2 MB).",
+          "ret": "object",
+          "example": "var source = email.rawSource(accountId, \"INBOX\", uid).data.source;"
+        },
+        {
+          "name": "email.setFlag",
+          "sig": "email.setFlag(accountId, folder, uids, flag, value)",
+          "desc": "Set or clear seen, flagged, answered, forwarded or draft on messages.",
+          "ret": "object",
+          "example": "email.setFlag(accountId, \"INBOX\", [uid], \"flagged\", true);"
+        },
+        {
+          "name": "email.move",
+          "sig": "email.move(accountId, folder, uids, destination)",
+          "desc": "Move messages to another folder of the same account.",
+          "ret": "object",
+          "example": "email.move(accountId, \"INBOX\", [uid], \"Projects\");"
+        },
+        {
+          "name": "email.copy",
+          "sig": "email.copy(accountId, folder, uids, destination)",
+          "desc": "Copy messages to another folder of the same account.",
+          "ret": "object",
+          "example": "email.copy(accountId, \"INBOX\", [uid], \"Projects\");"
+        },
+        {
+          "name": "email.moveToRole",
+          "sig": "email.moveToRole(accountId, folder, uids, role)",
+          "desc": "Move to the archive, junk, inbox or trash folder, creating it when missing. Gmail archives to All Mail.",
+          "ret": "object",
+          "example": "email.moveToRole(accountId, \"INBOX\", [uid], \"archive\");"
+        },
+        {
+          "name": "email.remove",
+          "sig": "email.remove(accountId, folder, uids, permanent)",
+          "desc": "Move to Trash, or delete for good inside Trash / Junk or when permanent is true.",
+          "ret": "object",
+          "example": "email.remove(accountId, \"INBOX\", [uid], false);"
+        },
+        {
+          "name": "email.locate",
+          "sig": "email.locate(accountId, messageId, hint)",
+          "desc": "Find a message by Message-ID after it moved: {folder, uid}.",
+          "ret": "object",
+          "example": "var where = email.locate(accountId, messageId, \"INBOX\").data;"
+        },
+        {
+          "name": "email.checkInboxes",
+          "sig": "email.checkInboxes()",
+          "desc": "STATUS of every inbox: unread, total and uidNext per account.",
+          "ret": "object",
+          "example": "var statuses = email.checkInboxes().data;"
+        },
+        {
+          "name": "email.newSince",
+          "sig": "email.newSince(accountId, uidNext, limit)",
+          "desc": "Unread inbox messages with a UID at or above uidNext, newest first.",
+          "ret": "object",
+          "example": "var fresh = email.newSince(accountId, lastUidNext, 5).data;"
+        },
+        {
+          "name": "email.saveMessage",
+          "sig": "email.saveMessage(accountId, folder, uid, destDir)",
+          "desc": "Save a message as an .eml file into an ArozOS folder. Existing files are never overwritten.",
+          "ret": "object",
+          "example": "var saved = email.saveMessage(accountId, \"INBOX\", uid, \"user:/Desktop\").data.path;"
+        },
+        {
+          "name": "email.saveAttachment",
+          "sig": "email.saveAttachment(accountId, folder, uid, partId, destDir)",
+          "desc": "Save one attachment into an ArozOS folder.",
+          "ret": "object",
+          "example": "email.saveAttachment(accountId, \"INBOX\", uid, \"2\", \"user:/Documents\");"
+        },
+        {
+          "name": "email.saveAllAttachments",
+          "sig": "email.saveAllAttachments(accountId, folder, uid, partIds, destDir)",
+          "desc": "Save several attachments into an ArozOS folder.",
+          "ret": "object",
+          "example": "email.saveAllAttachments(accountId, \"INBOX\", uid, [\"2\", \"3\"], \"user:/Documents\");"
+        },
+        {
+          "name": "email.openEml",
+          "sig": "email.openEml(vpath, allowRemote)",
+          "desc": "Read an .eml file like a mailbox message.",
+          "ret": "object",
+          "example": "var message = email.openEml(\"user:/Desktop/mail.eml\", false).data.message;"
+        },
+        {
+          "name": "email.saveEmlAttachment",
+          "sig": "email.saveEmlAttachment(vpath, partId, destDir)",
+          "desc": "Extract an attachment of an .eml file.",
+          "ret": "object",
+          "example": "email.saveEmlAttachment(\"user:/Desktop/mail.eml\", \"2\", \"user:/Desktop\");"
+        },
+        {
+          "name": "email.importEml",
+          "sig": "email.importEml(vpath, accountId, folder)",
+          "desc": "Append an .eml file to a mailbox folder.",
+          "ret": "object",
+          "example": "email.importEml(\"user:/Desktop/mail.eml\", accountId, \"INBOX\");"
+        },
+        {
+          "name": "email.tempFolder",
+          "sig": "email.tempFolder(purpose)",
+          "desc": "A private scratch folder under tmp:/Mail (uploads or downloads), pruned after a day.",
+          "ret": "object",
+          "example": "var folder = email.tempFolder(\"downloads\").data;"
+        },
+        {
+          "name": "email.send",
+          "sig": "email.send(message)",
+          "desc": "Send a message. message: {accountId, to, cc, bcc, subject, html, text, plainOnly, priority, readReceipt, inReplyTo, references, replyMode, originalFolder, originalUid, draftFolder, draftUid, files: [{path, name}], forwarded: [{accountId, folder, uid, partId}], undoSeconds, sendAt}. With undoSeconds or sendAt it waits in the server-side outbox.",
+          "ret": "object",
+          "example": "email.send({\n    accountId: accountId,\n    to: [\"Alice <alice@example.com>\"],\n    subject: \"Report\",\n    html: \"<p>Attached.</p>\",\n    files: [{ path: \"user:/Desktop/report.pdf\" }]\n});"
+        },
+        {
+          "name": "email.saveDraft",
+          "sig": "email.saveDraft(message)",
+          "desc": "Store the message in Drafts, replacing draftUid. Returns {folder, uid, attachments}.",
+          "ret": "object",
+          "example": "var draft = email.saveDraft({ accountId: accountId, subject: \"Later\" }).data;"
+        },
+        {
+          "name": "email.deleteDraft",
+          "sig": "email.deleteDraft(accountId, folder, uid)",
+          "desc": "Delete a draft permanently.",
+          "ret": "object",
+          "example": "email.deleteDraft(accountId, draft.folder, draft.uid);"
+        },
+        {
+          "name": "email.outbox",
+          "sig": "email.outbox()",
+          "desc": "Queued (undo period or scheduled) and failed messages.",
+          "ret": "object",
+          "example": "var queued = email.outbox().data;"
+        },
+        {
+          "name": "email.outboxCancel",
+          "sig": "email.outboxCancel(id, toDrafts)",
+          "desc": "Stop a queued message, optionally saving it to Drafts.",
+          "ret": "object",
+          "example": "email.outboxCancel(outboxId, true);"
+        },
+        {
+          "name": "email.outboxSendNow",
+          "sig": "email.outboxSendNow(id)",
+          "desc": "Deliver a queued or failed message at the next tick.",
+          "ret": "object",
+          "example": "email.outboxSendNow(outboxId);"
+        },
+        {
+          "name": "email.contacts",
+          "sig": "email.contacts()",
+          "desc": "The user's address book.",
+          "ret": "object",
+          "example": "var contacts = email.contacts().data;"
+        },
+        {
+          "name": "email.searchContacts",
+          "sig": "email.searchContacts(query, limit)",
+          "desc": "Address completion candidates, most used first.",
+          "ret": "object",
+          "example": "var matches = email.searchContacts(\"ali\", 8).data;"
+        },
+        {
+          "name": "email.saveContact",
+          "sig": "email.saveContact(contact)",
+          "desc": "Create or edit an entry: {email, name, company, phone, notes}.",
+          "ret": "object",
+          "example": "email.saveContact({ email: \"alice@example.com\", name: \"Alice\" });"
+        },
+        {
+          "name": "email.deleteContact",
+          "sig": "email.deleteContact(address)",
+          "desc": "Remove an entry.",
+          "ret": "object",
+          "example": "email.deleteContact(\"alice@example.com\");"
+        },
+        {
+          "name": "email.importContacts",
+          "sig": "email.importContacts(contacts)",
+          "desc": "Add many entries at once.",
+          "ret": "object",
+          "example": "email.importContacts([{ email: \"bob@example.com\", name: \"Bob\" }]);"
+        },
+        {
+          "name": "email.labels",
+          "sig": "email.labels()",
+          "desc": "The user's coloured labels: [{id, name, color}].",
+          "ret": "object",
+          "example": "var labels = email.labels().data;"
+        },
+        {
+          "name": "email.saveLabels",
+          "sig": "email.saveLabels(labels)",
+          "desc": "Replace the label definitions; removed labels are taken off every message.",
+          "ret": "object",
+          "example": "email.saveLabels([{ id: \"\", name: \"Clients\", color: \"#3b82f6\" }]);"
+        },
+        {
+          "name": "email.setLabels",
+          "sig": "email.setLabels(message, labelIds)",
+          "desc": "Assign labels to a message summary (as returned by list).",
+          "ret": "object",
+          "example": "email.setLabels(message, [\"work\"]);"
+        },
+        {
+          "name": "email.labelMessages",
+          "sig": "email.labelMessages(labelId)",
+          "desc": "Messages carrying a label, newest first.",
+          "ret": "object",
+          "example": "var tagged = email.labelMessages(\"work\").data;"
+        },
+        {
+          "name": "email.snooze",
+          "sig": "email.snooze(message, until)",
+          "desc": "Hide a message until a time (unix ms); it then reappears unread.",
+          "ret": "object",
+          "example": "email.snooze(message, Date.now() + 24 * 3600 * 1000);"
+        },
+        {
+          "name": "email.unsnooze",
+          "sig": "email.unsnooze(message)",
+          "desc": "Bring a snoozed message back now.",
+          "ret": "object",
+          "example": "email.unsnooze(message);"
+        },
+        {
+          "name": "email.snoozed",
+          "sig": "email.snoozed()",
+          "desc": "Snoozed messages, soonest first.",
+          "ret": "object",
+          "example": "var later = email.snoozed().data;"
+        },
+        {
+          "name": "email.settings",
+          "sig": "email.settings()",
+          "desc": "The user's Mail preferences.",
+          "ret": "object",
+          "example": "var settings = email.settings().data;"
+        },
+        {
+          "name": "email.saveSettings",
+          "sig": "email.saveSettings(settings)",
+          "desc": "Replace the user's Mail preferences (invalid values fall back to defaults).",
+          "ret": "object",
+          "example": "email.saveSettings({ density: \"compact\", undoSendSeconds: 10 });"
+        },
+        {
+          "name": "email.trustSender",
+          "sig": "email.trustSender(sender)",
+          "desc": "Always load remote content from an address or domain.",
+          "ret": "object",
+          "example": "email.trustSender(\"news@example.com\");"
+        },
+        {
+          "name": "email.isAdmin",
+          "sig": "email.isAdmin()",
+          "desc": "Whether the calling user administers Mail.",
+          "ret": "object",
+          "example": "var admin = email.isAdmin().data;"
+        },
+        {
+          "name": "email.adminConfig",
+          "sig": "email.adminConfig()",
+          "desc": "Administrator settings: OAuth clients (secrets hidden), network policy and limits. Admin only.",
+          "ret": "object",
+          "example": "var config = email.adminConfig().data;"
+        },
+        {
+          "name": "email.setAdminConfig",
+          "sig": "email.setAdminConfig(config)",
+          "desc": "Change the administrator settings. Client secrets are only replaced when googleSecretSet / microsoftSecretSet is true. Admin only.",
+          "ret": "object",
+          "example": "email.setAdminConfig({ allowPrivateHosts: false, maxAttachmentMB: 25,\n    microsoft: { enabled: true, clientId: \"...\", flow: \"device\", tenant: \"common\" } });"
+        }
+      ]
+    },
     {
     {
       "id": "cluster",
       "id": "cluster",
       "name": "Cluster",
       "name": "Cluster",

+ 51 - 2
src/web/desktop.html

@@ -1058,6 +1058,19 @@
             border: 0px;
             border: 0px;
         }
         }
 
 
+        /* Click catcher over windows whose page does not load ao_module.js
+           (e.g. container apps), so a click on a background window can still
+           bring it to front. Shown only while the window is not focused. */
+        .floatWindow .iframewrapper .focuscover{
+            position: absolute;
+            top: 0px;
+            left: 0px;
+            width: 100%;
+            height: 100%;
+            display: none;
+            background: transparent;
+        }
+
         .alternativeAccount.item{
         .alternativeAccount.item{
             padding-top: 0.6em !important;
             padding-top: 0.6em !important;
             padding-bottom: 0.6em !important;
             padding-bottom: 0.6em !important;
@@ -2435,6 +2448,17 @@
                 dockWindowToRight($(this).parent().parent().parent());
                 dockWindowToRight($(this).parent().parent().parent());
             });
             });
 
 
+            //Focus cover: bring a background window forward. The cover hides once
+            //the window is focused so the next click reaches the page inside
+            $(".focuscover").off("mousedown touchstart").on("mousedown touchstart", function(evt) {
+                evt.preventDefault();
+                evt.stopImmediatePropagation();
+                var fw = $(this).closest(".floatWindow");
+                MoveFloatWindowToTop(fw);
+                updateFloatWindowFocusCovers();
+                hideAllContextMenus();
+            });
+
             //Float Window events for resizing
             //Float Window events for resizing
             $(".iframewrapper").off("mousedown").on("mousedown", function(evt) {
             $(".iframewrapper").off("mousedown").on("mousedown", function(evt) {
                 evt.preventDefault();
                 evt.preventDefault();
@@ -3280,6 +3304,7 @@
            
            
             
             
             focusedWindow = $(object);
             focusedWindow = $(object);
+            updateFloatWindowFocusCovers();
             
             
             //Check if this div is hidden. If yes, fade it in.
             //Check if this div is hidden. If yes, fade it in.
             if ($(object).is(":hidden")) {
             if ($(object).is(":hidden")) {
@@ -3292,6 +3317,27 @@
 
 
         }
         }
 
 
+        //Check if a floatWindow URL points to a container app (reverse proxied under /app/<slug>/)
+        function isContainerAppURL(url){
+            try {
+                var target = new URL(url, window.location.href);
+                return target.origin == window.location.origin && /^\/app\/[^\/]+/.test(target.pathname);
+            } catch (ex) {
+                return false;
+            }
+        }
+
+        //Show the focus cover on every covered floatWindow except the focused one.
+        //Pages without ao_module.js cannot report clicks back to the desktop, so the
+        //cover catches the first click on a background window and brings it forward
+        function updateFloatWindowFocusCovers(){
+            var focusedID = $(focusedWindow).attr("windowId");
+            $(".floatWindow.focuscovered").each(function(){
+                var isFocused = (focusedID !== undefined && $(this).attr("windowId") == focusedID);
+                $(this).find(".focuscover").css("display", isFocused ? "none" : "block");
+            });
+        }
+
         function newFloatWindow(config, callback=undefined) {
         function newFloatWindow(config, callback=undefined) {
             //Check if the number of floatWindow already reaching its maxmium
             //Check if the number of floatWindow already reaching its maxmium
             if ($(".floatWindow").length > maxWindowCount){
             if ($(".floatWindow").length > maxWindowCount){
@@ -3310,6 +3356,7 @@
             var parentWindowID = defaultOrKey(config, "", "parent");
             var parentWindowID = defaultOrKey(config, "", "parent");
             var callbackFunctionName = defaultOrKey(config, "", "callback");
             var callbackFunctionName = defaultOrKey(config, "", "callback");
             var bgcolor = defaultOrKey(config, "", "background-color");
             var bgcolor = defaultOrKey(config, "", "background-color");
+            var needFocusCover = defaultOrKey(config, false, "focuscover") || isContainerAppURL(url);
 
 
             //Encode function name
             //Encode function name
             callbackFunctionName = encodeURIComponent(callbackFunctionName);
             callbackFunctionName = encodeURIComponent(callbackFunctionName);
@@ -3363,7 +3410,7 @@
                 dockButtons = "";
                 dockButtons = "";
             }
             }
             //Append the floatWindow into the body
             //Append the floatWindow into the body
-            $("body").append(`<div class="floatWindow" windowId="${uuid}" parent="${parentWindowID}" callback="${callbackFunctionName}" style="z-index:0; width:${width}px; height:${height}px; left:${left}px; top:${top}px;${backgroundStyle}">
+            $("body").append(`<div class="floatWindow${needFocusCover?" focuscovered":""}" windowId="${uuid}" parent="${parentWindowID}" callback="${callbackFunctionName}" style="z-index:0; width:${width}px; height:${height}px; left:${left}px; top:${top}px;${backgroundStyle}">
                 <div class="controls fwdragger themeColorSolid" style="${overWriteThemeColor}">
                 <div class="controls fwdragger themeColorSolid" style="${overWriteThemeColor}">
                     <img class="moduleicon" src="${moduleIcon}"></img>
                     <img class="moduleicon" src="${moduleIcon}"></img>
                     <div class="title">${title}</div>
                     <div class="title">${title}</div>
@@ -3383,6 +3430,7 @@
                 <div class="iframewrapper" style="${overWriteIframeWrapperBorderStyle}">
                 <div class="iframewrapper" style="${overWriteIframeWrapperBorderStyle}">
                     <iframe src="${url}" allowfullscreen="true"></iframe>
                     <iframe src="${url}" allowfullscreen="true"></iframe>
                     <div class="iframecover"></div>
                     <div class="iframecover"></div>
+                    ${needFocusCover?'<div class="focuscover"></div>':''}
                 </div>
                 </div>
             </div>`);
             </div>`);
             var newWindowObject = getFloatWindowByID(uuid);
             var newWindowObject = getFloatWindowByID(uuid);
@@ -5805,7 +5853,8 @@
                     url: app.URL,
                     url: app.URL,
                     appicon: fd.ShortcutImage || app.Icon,
                     appicon: fd.ShortcutImage || app.Icon,
                     title: app.Name,
                     title: app.Name,
-                    parent: "app:" + app.Slug
+                    parent: "app:" + app.Slug,
+                    focuscover: true
                 };
                 };
                 if (app.Width > 0){
                 if (app.Width > 0){
                     config.width = app.Width;
                     config.width = app.Width;

Некоторые файлы не были показаны из-за большого количества измененных файлов