Quellcode durchsuchen

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 vor 14 Stunden
Ursprung
Commit
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 —
   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,
-  [`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
   `websocket` and `scheduler` which are injected only in an HTTP request
   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/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/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/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").

+ 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
 
-- Runtime version: `3.7` (`AgiVersion` in `agi.go`)
+- Runtime version: `3.8` (`AgiVersion` in `agi.go`)
 
 ## Quick Start
 
@@ -278,6 +278,7 @@ Registered library IDs:
 - `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)
 - `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)
 
 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
 
 `requirelib("cluster")` exposes the ArozOS cluster to scripts. Ordinary file

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

@@ -42,7 +42,7 @@ import (
 */
 
 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
 	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="moreButton" title="More actions"><i class="ellipsis horizontal icon"></i></button>
                 <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>
             </div>
         </header>
@@ -72,6 +72,7 @@
     <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/accounts.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>
     <style>
         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 h1 { font-size: 19px; margin: 16px 0 8px; }
         .card p { color: var(--text-2); line-height: 1.55; margin: 0 0 16px; }

Datei-Diff unterdrückt, da er zu groß ist
+ 471 - 253
src/web/Mail/script/app.js


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

@@ -436,7 +436,18 @@ Mail.compose = (function () {
 
     /* ---------- 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) {
+        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 || {};
         if (current) {
             return current.confirmReplace().then(function (ok) {
@@ -472,6 +483,7 @@ Mail.compose = (function () {
             sending: false,
             closed: false,
             tempFolder: null,
+            inFlight: null,
             uploadCounter: 0,
             lastSaved: null
         };
@@ -490,6 +502,12 @@ Mail.compose = (function () {
         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]);
         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" });
         root.appendChild(body);
@@ -555,6 +573,9 @@ Mail.compose = (function () {
             el("div", { class: "grow" }), discardButton, fileInput, imageInput
         ]);
         root.appendChild(foot);
+        if (windowMode) {
+            foot.insertBefore(statusText, discardButton);
+        }
         host.appendChild(root);
         self.root = root;
 
@@ -640,6 +661,9 @@ Mail.compose = (function () {
         [toField, ccField, bccField].forEach(function (field) { field.onChange(markDirty); });
         subjectInput.addEventListener("input", function () {
             title.textContent = subjectInput.value.trim() || "New message";
+            if (context.onTitle) {
+                context.onTitle(title.textContent);
+            }
             markDirty();
         });
         fromSelect.addEventListener("change", function () {
@@ -1147,7 +1171,7 @@ Mail.compose = (function () {
             state.saving = true;
             state.dirty = false;
             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.draft = { folder: result.folder, uid: result.uid };
                 //The attachments now live inside the new draft
@@ -1164,6 +1188,7 @@ Mail.compose = (function () {
                 if (context.onDraftChanged) {
                     context.onDraftChanged(state.accountId);
                 }
+                announce("draft-saved", {});
                 return true;
             }).catch(function (error) {
                 state.saving = false;
@@ -1174,6 +1199,26 @@ Mail.compose = (function () {
                 }
                 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 () {
@@ -1225,61 +1270,180 @@ Mail.compose = (function () {
                 if (!ok) {
                     return;
                 }
-                var undoSeconds = context.settings().undoSendSeconds || 0;
-                request.undoSeconds = sendAt ? 0 : undoSeconds;
-                request.sendAt = sendAt || 0;
                 state.sending = true;
                 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
         self.snapshot = function (request) {
             return {
@@ -1365,11 +1529,18 @@ Mail.compose = (function () {
                 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 () {
-            if (state.sending) {
+            if (state.inFlight) {
+                return state.inFlight.then(function () { return self.close(); });
+            }
+            if (state.sending || state.closed) {
                 return Promise.resolve(true);
             }
+            if (state.saving) {
+                return waitForSave().then(function () { return self.close(); });
+            }
             if (state.dirty && hasContent()) {
                 return saveDraft(true).then(function (saved) {
                     if (saved) {
@@ -1392,16 +1563,26 @@ Mail.compose = (function () {
 
         discardButton.addEventListener("click", 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()) {
                 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");
             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 {
         init: init,
         open: open,
+        openHere: openHere,
         isOpen: isOpen,
         current: function () { return current; },
         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);
 
         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 () {
             try {
                 var doc = frame.contentDocument;
-                if (!doc || !doc.documentElement) {
+                if (!doc || !doc.body) {
                     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 */ }
         };
 
+        //Hidden and flat until measured, so nothing below it jumps around
+        frame.style.height = "0px";
+        frame.style.visibility = "hidden";
+
         frame.addEventListener("load", function () {
             var doc;
             try {
@@ -89,14 +129,10 @@ Mail.render = (function () {
                 observer = new ResizeObserver(resize);
                 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) {
                 var link = event.target.closest ? event.target.closest("a[href]") : null;
@@ -154,7 +190,8 @@ Mail.render = (function () {
         if (message.html) {
             return htmlFrame(container, message.html, {
                 allowRemote: message.remoteAllowed,
-                onMailto: options.onMailto
+                onMailto: options.onMailto,
+                onReady: options.onReady
             });
         }
         if (message.text) {
@@ -162,6 +199,9 @@ Mail.render = (function () {
         } else {
             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;
     }
 

+ 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 ---------- */
 
-    var openMenu = null;
+    //menuStack holds the open menu and its open submenus, outermost first
+    var menuStack = [];
+    var menuSession = null;
 
     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("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 actionable = [];
+        var rows = [];
         items.forEach(function (item) {
             if (!item) {
                 return;
@@ -110,9 +142,12 @@ Mail.ui = (function () {
                 return;
             }
             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"
             });
+            if (item.submenu) {
+                row.setAttribute("aria-haspopup", "true");
+            }
             if (item.dot) {
                 row.appendChild(el("span", { class: "dot", style: { background: item.dot } }));
             } else if (item.icon) {
@@ -122,11 +157,19 @@ Mail.ui = (function () {
             if (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) {
                 event.stopPropagation();
                 if (item.disabled) {
                     return;
                 }
+                if (item.submenu) {
+                    openSubmenu(level, row, item, false);
+                    return;
+                }
                 if (!item.keepOpen) {
                     closeMenu();
                 }
@@ -134,21 +177,35 @@ Mail.ui = (function () {
                     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);
         });
-        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 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;
         if (left + width > window.innerWidth - 8) {
             left = window.innerWidth - width - 8;
@@ -161,44 +218,109 @@ Mail.ui = (function () {
         }
         node.style.left = left + "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 level = menuStack.length - 1;
+            var entry = menuStack[level];
+            if (!entry) {
+                return;
+            }
+            var handled = true;
             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") {
-                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.stopPropagation();
-                actionable[focusIndex].click();
             }
         };
         var outside = function (event) {
-            if (!node.contains(event.target)) {
+            var inside = menuStack.some(function (entry) { return entry.node.contains(event.target); });
+            if (!inside) {
                 closeMenu();
             }
         };
+        menuSession = { outside: outside, keys: keys, onClose: options.onClose, hoverTimer: null };
         setTimeout(function () {
-            document.addEventListener("mousedown", outside, true);
+            if (menuSession && menuSession.outside === outside) {
+                document.addEventListener("mousedown", outside, true);
+            }
         }, 0);
         document.addEventListener("keydown", keys, true);
         window.addEventListener("blur", 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 ---------- */
@@ -281,7 +403,7 @@ Mail.ui = (function () {
             if (modalStack[modalStack.length - 1] !== api) {
                 return;
             }
-            if (event.key === "Escape" && dismissable && !openMenu) {
+            if (event.key === "Escape" && dismissable && menuStack.length === 0) {
                 event.preventDefault();
                 event.stopPropagation();
                 close();
@@ -454,6 +576,6 @@ Mail.ui = (function () {
         toast: toast, errorToast: errorToast, menu: menu, closeMenu: closeMenu,
         modal: modal, hasOpenModal: hasOpenModal, confirm: confirmDialog, choice: choice, prompt: prompt,
         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
     the light airy style of the ArozOS desktop, with a full dark theme.
     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 {
@@ -34,9 +38,9 @@
     --success: #12a150;
     --success-soft: #e6f6ec;
     --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-sm: 7px;
     --sidebar-w: 260px;
@@ -70,9 +74,9 @@
     --success: #3ccf7a;
     --success-soft: #16301f;
     --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;
     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);
     background: var(--surface); color: var(--text);
     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: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;
     border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
     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; }
-.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); }
 .row { display: flex; gap: 12px; align-items: flex-start; }
 .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; }
 
 .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;
     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 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 select {
     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;
     height: 46px; padding: 0 22px; min-width: 168px;
     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;
-    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; }
 
 .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;
     display: flex; flex-direction: column; min-height: 0;
     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 {
     height: 52px; flex: none; display: flex; align-items: center; gap: 6px;
     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;
     background: var(--surface);
     transition: background 0.08s;
+    user-select: none;
 }
 .msg-row:hover { background: var(--hover); }
 .msg-row.selected { background: var(--selected); }
 .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 {
     position: absolute; left: 11px; top: 30px; width: 8px; height: 8px; border-radius: 50%;
     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; }
 
 .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 { 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;
     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 {
     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 {
     position: fixed; z-index: 1000; min-width: 210px; max-width: 320px; max-height: 70vh;
     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;
 }
+.menu.submenu { z-index: 1001; animation: none; }
 @keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } }
 .menu-item {
     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.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.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-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; }
@@ -513,7 +530,7 @@ i.icon { margin: 0; line-height: 1; }
 .toast {
     pointer-events: auto; display: flex; align-items: center; gap: 12px;
     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;
 }
 .toast.error { background: #8f1d23; }
@@ -534,7 +551,7 @@ i.icon { margin: 0; line-height: 1; }
 .modal {
     width: 100%; max-width: 520px; max-height: calc(100vh - 40px);
     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;
 }
 .modal.wide { max-width: 760px; }
@@ -568,9 +585,9 @@ i.icon { margin: 0; line-height: 1; }
 .provider-tile {
     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);
-    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 .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; }
@@ -602,7 +619,7 @@ i.icon { margin: 0; line-height: 1; }
     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;
 }
-.signature-editor:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
+.signature-editor:focus { border-color: var(--accent); }
 
 /* ---------- Composer ---------- */
 
@@ -610,8 +627,8 @@ i.icon { margin: 0; line-height: 1; }
     position: absolute; z-index: 900; right: 22px; bottom: 0; top: 18px;
     width: min(760px, calc(100% - 44px));
     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;
 }
 @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); }
 .suggestions {
     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.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 .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-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; }
     .sidebar {
         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 .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; }
 }
 
+@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) {
     .searchbar select { 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; }
     .readpane { position: absolute; inset: 0; z-index: 800; transform: translateX(100%); transition: transform 0.18s ease-out; }
     .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/ui.js"></script>
     <script src="script/render.js"></script>
+    <script src="script/shared.js"></script>
     <script src="script/compose.js"></script>
     <script>
         /*
@@ -35,7 +36,7 @@
 
             Opened by ArozOS for .eml files. Shows the message with the same
             sanitiser and sandbox as the mailbox view; replying and forwarding
-            hand the message over to the main Mail window.
+            open a composer window.
         */
         (function () {
             "use strict";
@@ -51,24 +52,7 @@
             var accounts = [];
             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({
                 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 })]));
             }
 
-            //handOver opens the main Mail window with a prepared composer
+            //handOver opens a composer window with a prepared message
             function handOver(options) {
                 if (accounts.length === 0) {
                     ui.toast("Add a mail account in Mail first", { error: true });
                     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) {
@@ -232,7 +206,10 @@
             file = files[0];
             document.getElementById("fileName").textContent = file.filename;
             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);
         })();
     </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",
       "name": "Cluster",

+ 51 - 2
src/web/desktop.html

@@ -1058,6 +1058,19 @@
             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{
             padding-top: 0.6em !important;
             padding-bottom: 0.6em !important;
@@ -2435,6 +2448,17 @@
                 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
             $(".iframewrapper").off("mousedown").on("mousedown", function(evt) {
                 evt.preventDefault();
@@ -3280,6 +3304,7 @@
            
             
             focusedWindow = $(object);
+            updateFloatWindowFocusCovers();
             
             //Check if this div is hidden. If yes, fade it in.
             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) {
             //Check if the number of floatWindow already reaching its maxmium
             if ($(".floatWindow").length > maxWindowCount){
@@ -3310,6 +3356,7 @@
             var parentWindowID = defaultOrKey(config, "", "parent");
             var callbackFunctionName = defaultOrKey(config, "", "callback");
             var bgcolor = defaultOrKey(config, "", "background-color");
+            var needFocusCover = defaultOrKey(config, false, "focuscover") || isContainerAppURL(url);
 
             //Encode function name
             callbackFunctionName = encodeURIComponent(callbackFunctionName);
@@ -3363,7 +3410,7 @@
                 dockButtons = "";
             }
             //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}">
                     <img class="moduleicon" src="${moduleIcon}"></img>
                     <div class="title">${title}</div>
@@ -3383,6 +3430,7 @@
                 <div class="iframewrapper" style="${overWriteIframeWrapperBorderStyle}">
                     <iframe src="${url}" allowfullscreen="true"></iframe>
                     <div class="iframecover"></div>
+                    ${needFocusCover?'<div class="focuscover"></div>':''}
                 </div>
             </div>`);
             var newWindowObject = getFloatWindowByID(uuid);
@@ -5805,7 +5853,8 @@
                     url: app.URL,
                     appicon: fd.ShortcutImage || app.Icon,
                     title: app.Name,
-                    parent: "app:" + app.Slug
+                    parent: "app:" + app.Slug,
+                    focuscover: true
                 };
                 if (app.Width > 0){
                     config.width = app.Width;

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.