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
+36 -1
View File
@@ -17,7 +17,7 @@ import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
SANITIZER_VERSION = 2
SANITIZER_VERSION = 3
_ALLOWED_TAGS: set[str] = {
# structure
@@ -76,6 +76,7 @@ _ALLOWED_TAGS: set[str] = {
"span",
"strike",
"strong",
"style",
"sub",
"summary",
"sup",
@@ -97,6 +98,7 @@ _ALLOWED_TAGS: set[str] = {
# Attributes allowed on any tag (presentational email formatting).
_GENERIC_ATTRS: set[str] = {
"style",
"class",
"align",
"valign",
"dir",
@@ -119,6 +121,7 @@ _TAG_SPECIFIC: dict[str, set[str]] = {
"th": {"colspan", "rowspan", "scope"},
"table": {"cellpadding", "cellspacing", "border"},
"font": {"face", "color", "size"},
"style": {"type", "media"},
}
# nh3 has no "generic attributes" option, so expand the generic set onto every tag.
@@ -132,6 +135,12 @@ _STYLE_DANGEROUS = re.compile(
r"(expression\s*\(|javascript:|vbscript:|@import|-moz-binding|behavior\s*:)", re.IGNORECASE
)
_STYLE_URL = re.compile(r"url\s*\([^)]*\)", re.IGNORECASE)
# Script-like CSS that voids a whole <style> block (unlike @import, which we can strip).
_STYLE_BLOCK_DANGEROUS = re.compile(
r"(expression\s*\(|javascript:|vbscript:|-moz-binding|behavior\s*:)", re.IGNORECASE
)
_STYLE_IMPORT = re.compile(r"@import[^;]*;?", re.IGNORECASE)
_STYLE_ELEMENT = re.compile(r"(<style\b[^>]*>)(.*?)(</style>)", re.IGNORECASE | re.DOTALL)
@dataclass(frozen=True)
@@ -150,6 +159,28 @@ def _sanitize_style(value: str, allow_remote: bool) -> str | None:
return value
def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, bool]) -> str:
"""Sanitize CSS inside ``<style>`` blocks (nh3 keeps the tag but not its text).
Many mailers (e.g. Fastmail's calendar invites) put all layout in a ``<style>``
block of class rules — dropping it loses tables/buttons entirely. We keep the
block but neutralize script-like CSS and, unless remote images are approved,
strip ``@import``/``url()`` so it can't fetch remote/tracking resources.
"""
def repl(match: re.Match[str]) -> str:
css = match.group(2)
if _STYLE_BLOCK_DANGEROUS.search(css):
return "" # nuke the block entirely
css = _STYLE_IMPORT.sub("", css)
if not allow_remote and _STYLE_URL.search(css):
state["has_remote"] = True
css = _STYLE_URL.sub("none", css)
return f"{match.group(1)}{css}{match.group(3)}"
return _STYLE_ELEMENT.sub(repl, html)
def sanitize_html(
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False
) -> SanitizeResult:
@@ -186,8 +217,12 @@ def sanitize_html(
url_schemes=_URL_SCHEMES,
link_rel="noopener noreferrer nofollow",
strip_comments=True,
# nh3 strips <script>/<style> *content* by default; we allow <style> (above)
# and sanitize its CSS ourselves, so only script content is stripped here.
clean_content_tags={"script"},
attribute_filter=attribute_filter,
)
cleaned = _sanitize_style_elements(cleaned, allow_remote_images, state)
return SanitizeResult(
html=cleaned,
has_remote_images=state["has_remote"],