M4: Calendar plugin (builtin) + mail HTML/attachment fixes

Calendar plugin (webmail_plugin_calendar, non-removable core plugin):
- Local calendars/events CRUD with RLS isolation; recurrence expanded at
  query time (DST-correct), EXDATE + detached-override merge.
- Calendar app bundle: month/week/day/3-day views, mini-month, event form
  (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient
  autocomplete, Jitsi video call), event popover with RSVP badges, resizable/
  collapsible left panel + theme toggle, overlap layout.
- iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML +
  text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token)
  records accepted/declined/tentative per attendee.
- "This and following" edit + scoped delete (this / this-and-following), with
  a delete confirmation dialog for recurring events.

Core:
- Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations
  section; date order follows time format (en-GB vs en-US) across Mail.
- Plugin SDK: mail (send from a user's account), vault (envelope encrypt),
  multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed
  PluginPage.

Mail HTML rendering:
- Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like,
  strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so
  already-indexed mail re-sanitizes on view.
- Attachments: a Content-Disposition: attachment part is a real attachment
  even when it also carries a Content-ID (fixes hidden invite.ics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:33:34 +02:00
co-authored by Claude Opus 4.8
parent d46ad13b7b
commit 8c634f7967
56 changed files with 3308 additions and 37 deletions
+7 -5
View File
@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import { useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate } from "react-router-dom";
import { AppSwitcher } from "../components/AppSwitcher";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { DraftsList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
@@ -14,6 +15,7 @@ import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { usePluginPages } from "../plugins/manifest";
@@ -137,6 +139,10 @@ export function MailLayout() {
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const timeFormat = settingsQuery.data?.time_format;
useEffect(() => {
if (timeFormat) setTimeFormat(timeFormat);
}, [timeFormat]);
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
@@ -430,11 +436,7 @@ export function MailLayout() {
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`}
>
{!collapsed && (
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
{t("common.appName")}
</span>
)}
{!collapsed && <AppSwitcher />}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
<button
onClick={() => setCollapsed((v) => !v)}