Productivity features, UI translations, local email translation

- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 20:00:31 +02:00
co-authored by Claude Opus 5.5
parent 463f96ad57
commit 8a1b9979e9
83 changed files with 8677 additions and 293 deletions
@@ -15,15 +15,22 @@ export default function mount(el, ctx, props) {
const messageId = props && props.messageId;
if (!messageId) return;
let disposed = false;
let generation = 0;
(async () => {
// The host mounts this with the message (hidden in a closed menu), not when
// the menu opens, so the item is ready by then. It loads again after a
// lesson, so a reopened menu shows the new state without a wait.
const load = async () => {
const mine = ++generation;
let data = null;
try {
data = await ctx.api.pluginFetch(`/context/${messageId}`);
} catch {
return; // no antispam service, no menu item
}
if (disposed || !data) return;
if (disposed || mine !== generation) return;
el.innerHTML = "";
if (!data) return;
// The folder decides which action is on offer — FIRST. A message sitting in
// the spam folder offers "not spam" no matter what it was taught, because
@@ -52,10 +59,15 @@ export default function mount(el, ctx, props) {
/* the menu is closing regardless; nothing useful to show here */
}
});
})();
};
const onLearned = () => void load();
window.addEventListener("webmail:antispam-learned", onLearned);
void load();
return () => {
disposed = true;
window.removeEventListener("webmail:antispam-learned", onLearned);
el.innerHTML = "";
};
}