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>
@@ -31,3 +31,9 @@ frontend/dist/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Editor backups
|
||||
*~
|
||||
|
||||
# Local session helper (Claude Code resume command)
|
||||
/claude-session.txt
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
@@ -87,3 +88,51 @@ async def guarded_get(url: str) -> str | None:
|
||||
raise SsrfError("response exceeds 256 KB cap")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode("utf-8", "replace")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuardedResponse:
|
||||
status: int
|
||||
headers: dict[str, str]
|
||||
text: str
|
||||
|
||||
|
||||
async def guarded_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
content: bytes | str | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
timeout: float = 15.0, # noqa: ASYNC109 - passed straight to httpx's own timeout
|
||||
max_bytes: int = 25 * 1024 * 1024,
|
||||
) -> GuardedResponse:
|
||||
"""Arbitrary-method HTTPS request under the same SSRF policy as :func:`guarded_get`.
|
||||
|
||||
Used by plugins that speak WebDAV/CalDAV/CardDAV (PROPFIND/REPORT/PUT/DELETE) or
|
||||
fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body
|
||||
capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns
|
||||
status + headers + body (all statuses; the caller decides what's an error)."""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme != "https":
|
||||
raise SsrfError("only https is permitted")
|
||||
if not parts.hostname:
|
||||
raise SsrfError("missing host")
|
||||
await assert_host_is_public(parts.hostname)
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client,
|
||||
client.stream(method, url, headers=headers, content=content) as resp,
|
||||
):
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise SsrfError("response exceeds size cap")
|
||||
chunks.append(chunk)
|
||||
return GuardedResponse(
|
||||
status=resp.status_code,
|
||||
headers=dict(resp.headers),
|
||||
text=b"".join(chunks).decode("utf-8", "replace"),
|
||||
)
|
||||
|
||||
@@ -18,17 +18,22 @@ router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_ALLOWED_ORDER = {"asc", "desc"}
|
||||
_ALLOWED_TIME_FORMAT = {"12", "24"}
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
thread_order: str = "asc"
|
||||
tz: str = "UTC"
|
||||
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
|
||||
jitsi_server: str = "" # blank => https://meet.jit.si/
|
||||
allowed_image_senders: list[str] = []
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
thread_order: str | None = None
|
||||
tz: str | None = None
|
||||
time_format: str | None = None
|
||||
jitsi_server: str | None = None
|
||||
|
||||
|
||||
class QuotaOut(BaseModel):
|
||||
@@ -56,6 +61,8 @@ def _settings_out(user: User) -> SettingsOut:
|
||||
return SettingsOut(
|
||||
thread_order=str(s.get("thread_order", "asc")),
|
||||
tz=str(s.get("tz", "UTC")),
|
||||
time_format=str(s.get("time_format", "24")),
|
||||
jitsi_server=str(s.get("jitsi_server", "")),
|
||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||
)
|
||||
|
||||
@@ -81,6 +88,10 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
|
||||
updates["thread_order"] = body.thread_order
|
||||
if body.tz is not None:
|
||||
updates["tz"] = body.tz
|
||||
if body.time_format is not None and body.time_format in _ALLOWED_TIME_FORMAT:
|
||||
updates["time_format"] = body.time_format
|
||||
if body.jitsi_server is not None:
|
||||
updates["jitsi_server"] = body.jitsi_server.strip()
|
||||
user.settings = updates
|
||||
await db.commit()
|
||||
return _settings_out(user)
|
||||
|
||||
@@ -98,16 +98,23 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
|
||||
continue
|
||||
cid = part.get("Content-ID")
|
||||
filename = part.get_filename()
|
||||
if not (filename or cid or part.get_content_disposition() == "attachment"):
|
||||
disposition = part.get_content_disposition()
|
||||
if not (filename or cid or disposition == "attachment"):
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if not isinstance(payload, bytes):
|
||||
continue
|
||||
# A part is an INLINE resource (cid: image rendered in-body, hidden from the
|
||||
# attachment list) only when it carries a Content-ID AND is not explicitly an
|
||||
# attachment. A part marked `Content-Disposition: attachment` is a real
|
||||
# attachment even when it also has a Content-ID (e.g. Fastmail's invite.ics),
|
||||
# so we drop the cid for it and it shows as a downloadable chip.
|
||||
inline = cid is not None and disposition != "attachment"
|
||||
out.append(
|
||||
ParsedAttachment(
|
||||
filename=filename or "attachment",
|
||||
mime_type=part.get_content_type(),
|
||||
content_id=cid.strip("<>") if cid else None,
|
||||
content_id=cid.strip("<>") if (inline and cid is not None) else None,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""SSRF-guarded, multi-method HTTP client exposed to plugins (SPEC §10.6).
|
||||
|
||||
``sdk.http_get`` covers simple GETs; heavy plugins that speak CalDAV/CardDAV need
|
||||
other verbs (PROPFIND/REPORT/PUT/DELETE) with request bodies and basic auth. This
|
||||
facade routes all of them through the same SSRF policy (HTTPS-only, public host,
|
||||
no off-domain redirects, size cap) and returns status + headers + body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.accounts.ssrf import GuardedResponse, guarded_request
|
||||
|
||||
|
||||
class PluginHttp:
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
content: bytes | str | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
) -> GuardedResponse:
|
||||
return await guarded_request(
|
||||
method, url, headers=headers, content=content, auth=auth
|
||||
)
|
||||
|
||||
async def get(self, url: str, **kw: object) -> GuardedResponse:
|
||||
return await self.request("GET", url, **kw) # type: ignore[arg-type]
|
||||
|
||||
async def put(self, url: str, **kw: object) -> GuardedResponse:
|
||||
return await self.request("PUT", url, **kw) # type: ignore[arg-type]
|
||||
|
||||
async def delete(self, url: str, **kw: object) -> GuardedResponse:
|
||||
return await self.request("DELETE", url, **kw) # type: ignore[arg-type]
|
||||
|
||||
async def propfind(self, url: str, **kw: object) -> GuardedResponse:
|
||||
return await self.request("PROPFIND", url, **kw) # type: ignore[arg-type]
|
||||
|
||||
async def report(self, url: str, **kw: object) -> GuardedResponse:
|
||||
return await self.request("REPORT", url, **kw) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Send an email *as the user*, from one of their own accounts (SPEC §11 SDK).
|
||||
|
||||
Heavy plugins (calendar iMIP invites, later contacts) need to send a message
|
||||
through the organizer's real mailbox — reusing the same SMTP path as the compose
|
||||
outbox, but with a plugin-built body (e.g. a ``text/calendar; method=REQUEST``
|
||||
part). This helper looks up the user's account, assembles the MIME, and sends via
|
||||
the account's provider. It is exposed as ``sdk(name).mail`` so plugins never touch
|
||||
``app.providers`` / ``app.sync`` directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import format_datetime, make_msgid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import session_scope
|
||||
from app.models import Account
|
||||
from app.sync.provider_factory import build_provider
|
||||
|
||||
|
||||
class PluginMailError(Exception):
|
||||
"""No usable account, or the send failed."""
|
||||
|
||||
|
||||
class PluginMail:
|
||||
"""Send outbound mail from one of a user's own accounts."""
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
to: list[str],
|
||||
subject: str,
|
||||
html: str,
|
||||
text: str,
|
||||
from_email: str | None = None,
|
||||
ics: str | None = None,
|
||||
ics_method: str = "REQUEST",
|
||||
ics_filename: str = "invite.ics",
|
||||
) -> None:
|
||||
"""Send an email to ``to`` from the user's account.
|
||||
|
||||
``from_email`` selects which of the user's accounts to send from (falls
|
||||
back to their first account). When ``ics`` is given it is added both as a
|
||||
``text/calendar; method=<ics_method>`` alternative (so the recipient's
|
||||
client shows its native RSVP UI) and as a downloadable attachment.
|
||||
"""
|
||||
recipients = [r for r in (to or []) if r]
|
||||
if not recipients:
|
||||
return
|
||||
async with session_scope() as session:
|
||||
account = None
|
||||
if from_email:
|
||||
account = await session.scalar(
|
||||
select(Account).where(
|
||||
Account.user_id == user_id,
|
||||
Account.email == from_email,
|
||||
)
|
||||
)
|
||||
if account is None:
|
||||
account = await session.scalar(
|
||||
select(Account).where(Account.user_id == user_id).order_by(Account.created_at)
|
||||
)
|
||||
if account is None:
|
||||
raise PluginMailError("no account to send from")
|
||||
raw = _build_message(
|
||||
account, recipients, subject, html, text, ics, ics_method, ics_filename
|
||||
)
|
||||
envelope_from = account.email
|
||||
provider = build_provider(account)
|
||||
await provider.send(raw, envelope_from, recipients)
|
||||
|
||||
|
||||
def _build_message(
|
||||
account: Account,
|
||||
to: list[str],
|
||||
subject: str,
|
||||
html: str,
|
||||
text: str,
|
||||
ics: str | None,
|
||||
ics_method: str,
|
||||
ics_filename: str,
|
||||
) -> bytes:
|
||||
root = MIMEMultipart("mixed")
|
||||
from_name = account.display_name or ""
|
||||
root["From"] = f"{from_name} <{account.email}>" if from_name else account.email
|
||||
root["To"] = ", ".join(to)
|
||||
root["Subject"] = subject
|
||||
root["Date"] = format_datetime(datetime.now(UTC))
|
||||
root["Message-ID"] = make_msgid(domain=account.email.rsplit("@", 1)[-1])
|
||||
|
||||
alt = MIMEMultipart("alternative")
|
||||
alt.attach(MIMEText(text, "plain", "utf-8"))
|
||||
alt.attach(MIMEText(html, "html", "utf-8"))
|
||||
if ics is not None:
|
||||
cal = MIMEText(ics, "calendar", "utf-8")
|
||||
cal.set_param("method", ics_method)
|
||||
cal.set_param("component", "VEVENT")
|
||||
alt.attach(cal)
|
||||
root.attach(alt)
|
||||
|
||||
if ics is not None:
|
||||
att = MIMEApplication(ics.encode("utf-8"), _subtype="ics")
|
||||
att.add_header("Content-Disposition", "attachment", filename=ics_filename)
|
||||
root.attach(att)
|
||||
|
||||
return root.as_bytes()
|
||||
@@ -29,7 +29,9 @@ from app.models import User
|
||||
from app.outbox import service as outbox_service
|
||||
from app.plugins.actions import PluginActions, PluginMessages
|
||||
from app.plugins.db import plugin_session
|
||||
from app.plugins.http import PluginHttp
|
||||
from app.plugins.kv import PluginKvStore
|
||||
from app.plugins.mail import PluginMail
|
||||
from app.plugins.types import (
|
||||
Hook,
|
||||
JobFunc,
|
||||
@@ -40,6 +42,7 @@ from app.plugins.types import (
|
||||
Slot,
|
||||
UiManifest,
|
||||
)
|
||||
from app.plugins.vault import PluginVault
|
||||
from app.queue import enqueue_job
|
||||
|
||||
__all__ = [
|
||||
@@ -122,6 +125,11 @@ class PluginSdk:
|
||||
return outbox_service
|
||||
|
||||
# --- messages -----------------------------------------------------------
|
||||
@property
|
||||
def mail(self) -> PluginMail:
|
||||
"""Send an email from one of the user's own accounts (iMIP invites, …)."""
|
||||
return PluginMail()
|
||||
|
||||
@property
|
||||
def messages(self) -> PluginMessages:
|
||||
"""Read a user's message envelope (subject/sender/flags) for matching."""
|
||||
@@ -137,6 +145,19 @@ class PluginSdk:
|
||||
"""GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize)."""
|
||||
return await guarded_get(url)
|
||||
|
||||
@property
|
||||
def http(self) -> PluginHttp:
|
||||
"""Multi-method SSRF-guarded HTTP (GET/PUT/DELETE/PROPFIND/REPORT) for
|
||||
CalDAV/CardDAV/ICS. Returns status + headers + body."""
|
||||
return PluginHttp()
|
||||
|
||||
# --- secret vault -------------------------------------------------------
|
||||
@property
|
||||
def vault(self) -> PluginVault:
|
||||
"""Envelope encrypt/decrypt for a plugin's own ``secret_ct`` columns
|
||||
(CalDAV/CardDAV credentials, tokens) — §10.2 at-rest protection."""
|
||||
return PluginVault()
|
||||
|
||||
|
||||
def sdk(name: str) -> PluginSdk:
|
||||
"""Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Secret encryption exposed to plugins (SPEC §10.2 vault).
|
||||
|
||||
Heavy plugins (calendar/contacts) store third-party credentials — CalDAV/CardDAV
|
||||
passwords, ICS tokens — in their own ``secret_ct bytea`` columns. They encrypt via
|
||||
this facade so those secrets are protected at rest with the same envelope scheme as
|
||||
core account credentials, without importing ``app.security`` directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.security.crypto import decrypt_secret, encrypt_secret
|
||||
|
||||
|
||||
class PluginVault:
|
||||
def encrypt(self, plaintext: bytes) -> bytes:
|
||||
"""Envelope-encrypt ``plaintext`` for storage in a ``secret_ct`` column."""
|
||||
return encrypt_secret(plaintext)
|
||||
|
||||
def decrypt(self, ciphertext: bytes) -> bytes:
|
||||
"""Reverse :meth:`encrypt`. Raises on a wrong/rotated master key."""
|
||||
return decrypt_secret(ciphertext)
|
||||
@@ -126,6 +126,32 @@ def test_inline_image_nested_in_related_is_extracted() -> None:
|
||||
assert parsed.has_attachments is False
|
||||
|
||||
|
||||
def test_attachment_with_content_id_is_a_real_attachment() -> None:
|
||||
# iMIP invites (e.g. Fastmail) send invite.ics as Content-Disposition: attachment
|
||||
# yet also give it a Content-ID. It must show as a downloadable chip, not be
|
||||
# hidden as an inline resource (regression: "no attachments" shown).
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
root = MIMEMultipart("mixed")
|
||||
root["Subject"] = "invite"
|
||||
root["From"] = "org@example.com"
|
||||
root["To"] = "me@example.com"
|
||||
root["Message-ID"] = "<inv@x.com>"
|
||||
root.attach(MIMEText("body", "plain"))
|
||||
ics = MIMEText("BEGIN:VCALENDAR\nEND:VCALENDAR", "calendar")
|
||||
ics.add_header("Content-Disposition", "attachment", filename="invite.ics")
|
||||
ics.add_header("Content-ID", "<cal123@x>")
|
||||
root.attach(ics)
|
||||
|
||||
parsed = parse_message(root.as_bytes())
|
||||
assert len(parsed.attachments) == 1
|
||||
att = parsed.attachments[0]
|
||||
assert att.filename == "invite.ics"
|
||||
assert att.content_id is None # not treated as inline despite the Content-ID
|
||||
assert parsed.has_attachments is True
|
||||
|
||||
|
||||
def test_missing_message_id_returns_none() -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "no id"
|
||||
|
||||
@@ -54,3 +54,35 @@ def test_javascript_uri_is_stripped() -> None:
|
||||
def test_sanitizer_version_reported() -> None:
|
||||
result = sanitize_html("<p>hi</p>", MID)
|
||||
assert result.sanitizer_v >= 1
|
||||
|
||||
|
||||
def test_style_block_and_classes_are_kept() -> None:
|
||||
# Mailers (e.g. Fastmail invites) put layout in a <style> block + classes.
|
||||
html = '<style>.banner{color:#333}</style><div class="banner">hi</div>'
|
||||
result = sanitize_html(html, MID)
|
||||
assert "<style" in result.html.lower()
|
||||
assert ".banner" in result.html
|
||||
assert 'class="banner"' in result.html
|
||||
|
||||
|
||||
def test_style_block_script_css_is_dropped() -> None:
|
||||
html = '<style>.x{width:expression(alert(1))}</style><p class="x">hi</p>'
|
||||
result = sanitize_html(html, MID)
|
||||
assert "expression(" not in result.html.lower()
|
||||
# the block is nuked but the element/class survive
|
||||
assert 'class="x"' in result.html
|
||||
|
||||
|
||||
def test_style_block_import_and_url_stripped_when_remote_blocked() -> None:
|
||||
html = "<style>@import url('http://evil/x.css'); .b{background:url(http://evil/p.gif)}</style>"
|
||||
result = sanitize_html(html, MID)
|
||||
assert "@import" not in result.html.lower()
|
||||
assert "evil" not in result.html.lower()
|
||||
assert result.has_remote_images
|
||||
|
||||
|
||||
def test_script_content_still_stripped_with_style_allowed() -> None:
|
||||
html = "<script>alert(1)</script><style>.a{color:red}</style>"
|
||||
result = sanitize_html(html, MID)
|
||||
assert "alert(1)" not in result.html
|
||||
assert ".a{color:red}" in result.html
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Calendar plugin A1 API: calendars/events CRUD + range expansion (SPEC §A).
|
||||
|
||||
Exercises the real HTTP surface: default-calendar bootstrap, event create/list with
|
||||
recurrence expansion, edit, delete, read-only rejection, and per-user isolation.
|
||||
Requires the calendar plugin installed + migrated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import User
|
||||
|
||||
CAL = "/api/plugins/calendar"
|
||||
|
||||
|
||||
class _Ctx:
|
||||
user_id: uuid.UUID
|
||||
sm: async_sessionmaker # type: ignore[type-arg]
|
||||
client: httpx.AsyncClient
|
||||
client_b: httpx.AsyncClient
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[_Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
c = _Ctx()
|
||||
c.sm = sm
|
||||
ids: list[uuid.UUID] = []
|
||||
async with sm() as s:
|
||||
for _ in range(2):
|
||||
handle = f"cal{uuid.uuid4().hex[:8]}@test.local"
|
||||
u = User(handle=handle, password_hash=hash_password("pw"))
|
||||
s.add(u)
|
||||
await s.flush()
|
||||
ids.append(u.id)
|
||||
await s.commit()
|
||||
c.user_id = ids[0]
|
||||
transport = ASGITransport(app=create_app())
|
||||
|
||||
async def client(uid: uuid.UUID) -> httpx.AsyncClient:
|
||||
token = await create_session(uid, "pytest")
|
||||
return httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": "csrf"},
|
||||
headers={"X-CSRF": "csrf"},
|
||||
)
|
||||
|
||||
c.client = await client(ids[0])
|
||||
c.client_b = await client(ids[1])
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await c.client.aclose()
|
||||
await c.client_b.aclose()
|
||||
async with sm() as s: # cascade drops plg_calendar_* rows (user_id FK-less but scoped)
|
||||
await s.execute(
|
||||
text("DELETE FROM plg_calendar_calendars WHERE user_id = ANY(:ids)"),
|
||||
{"ids": [str(i) for i in ids]},
|
||||
)
|
||||
await s.execute(delete(User).where(User.id.in_(ids)))
|
||||
await s.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _events(
|
||||
client: httpx.AsyncClient,
|
||||
start: str = "2026-06-01T00:00:00Z",
|
||||
end: str = "2026-06-02T00:00:00Z",
|
||||
) -> list[dict[str, object]]:
|
||||
r = await client.get(f"{CAL}/events", params={"start": start, "end": end})
|
||||
return r.json()["events"]
|
||||
|
||||
|
||||
async def test_default_calendar_and_event_crud(ctx: _Ctx) -> None:
|
||||
# First calendars call bootstraps a default "Personal" calendar.
|
||||
cals = (await ctx.client.get(f"{CAL}/calendars")).json()
|
||||
assert [c["name"] for c in cals] == ["Personal"]
|
||||
cal_id = cals[0]["id"]
|
||||
|
||||
# Create a timed event and read it back in a range.
|
||||
r = await ctx.client.post(
|
||||
f"{CAL}/events",
|
||||
json={
|
||||
"calendar_id": cal_id,
|
||||
"summary": "Standup",
|
||||
"dtstart": "2026-06-01T09:00:00Z",
|
||||
"dtend": "2026-06-01T09:30:00Z",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
event_id = r.json()["id"]
|
||||
|
||||
listing = await _events(ctx.client)
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["summary"] == "Standup"
|
||||
assert listing[0]["color"] == "#3b82f6"
|
||||
|
||||
# Edit, then delete.
|
||||
patched = await ctx.client.patch(f"{CAL}/events/{event_id}", json={"summary": "Daily standup"})
|
||||
assert patched.status_code == 200
|
||||
again = await _events(ctx.client)
|
||||
assert again[0]["summary"] == "Daily standup"
|
||||
|
||||
assert (await ctx.client.delete(f"{CAL}/events/{event_id}")).status_code == 200
|
||||
empty = await _events(ctx.client)
|
||||
assert empty == []
|
||||
|
||||
|
||||
async def test_recurring_event_expands_in_range(ctx: _Ctx) -> None:
|
||||
cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
|
||||
await ctx.client.post(
|
||||
f"{CAL}/events",
|
||||
json={
|
||||
"calendar_id": cal_id,
|
||||
"summary": "Weekly sync",
|
||||
"dtstart": "2026-06-01T14:00:00Z", # Monday
|
||||
"dtend": "2026-06-01T15:00:00Z",
|
||||
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||||
},
|
||||
)
|
||||
# A 3-week window contains 3 Monday occurrences of one recurring event.
|
||||
listing = await _events(ctx.client, end="2026-06-22T00:00:00Z")
|
||||
assert len(listing) == 3
|
||||
assert all(i["recurring"] for i in listing)
|
||||
assert [i["start"][:10] for i in listing] == ["2026-06-01", "2026-06-08", "2026-06-15"]
|
||||
|
||||
|
||||
async def test_read_only_calendar_rejects_writes(ctx: _Ctx) -> None:
|
||||
cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
|
||||
# Simulate an ICS subscription: flip the calendar read-only directly.
|
||||
async with ctx.sm() as s:
|
||||
await s.execute(
|
||||
text("UPDATE plg_calendar_calendars SET read_only = true WHERE id = :id"),
|
||||
{"id": cal_id},
|
||||
)
|
||||
await s.commit()
|
||||
r = await ctx.client.post(
|
||||
f"{CAL}/events",
|
||||
json={"calendar_id": cal_id, "summary": "nope", "dtstart": "2026-06-01T09:00:00Z"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
body = r.json()
|
||||
detail = body.get("error", {}).get("message") or body.get("detail")
|
||||
assert detail == "read_only_calendar"
|
||||
|
||||
|
||||
async def test_events_are_per_user_isolated(ctx: _Ctx) -> None:
|
||||
cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
|
||||
await ctx.client.post(
|
||||
f"{CAL}/events",
|
||||
json={"calendar_id": cal_id, "summary": "Private", "dtstart": "2026-06-05T10:00:00Z"},
|
||||
)
|
||||
# User B has their own default calendar and sees none of A's events.
|
||||
b_events = await _events(ctx.client_b, end="2026-06-30T00:00:00Z")
|
||||
assert b_events == []
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Calendar recurrence expansion across a DST boundary (SPEC §A.3, acceptance A.7.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from webmail_plugin_calendar.recurrence import expand_occurrences # type: ignore[import-not-found]
|
||||
|
||||
WARSAW = ZoneInfo("Europe/Warsaw")
|
||||
|
||||
|
||||
def _local(occ: list[tuple[datetime, datetime]]) -> dict[str, datetime]:
|
||||
# Map "YYYY-MM-DD" (Warsaw date) -> the UTC start of that occurrence.
|
||||
return {s.astimezone(WARSAW).date().isoformat(): s for s, _ in occ}
|
||||
|
||||
|
||||
def test_weekly_recurrence_keeps_local_time_across_dst() -> None:
|
||||
# Monday 2026-03-23 10:00 Europe/Warsaw == 09:00 UTC (UTC+1, before DST).
|
||||
# DST starts 2026-03-29, so from 03-30 the offset is UTC+2.
|
||||
dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC)
|
||||
occ = expand_occurrences(
|
||||
dtstart=dtstart,
|
||||
duration=timedelta(hours=1),
|
||||
rrule="FREQ=WEEKLY;BYDAY=MO",
|
||||
exdates=[],
|
||||
range_start=datetime(2026, 3, 1, tzinfo=UTC),
|
||||
range_end=datetime(2026, 4, 30, tzinfo=UTC),
|
||||
tz="Europe/Warsaw",
|
||||
)
|
||||
by_date = _local(occ)
|
||||
|
||||
# Every occurrence is at 10:00 local wall-clock, on both sides of the DST change.
|
||||
for start, _ in occ:
|
||||
assert start.astimezone(WARSAW).strftime("%H:%M") == "10:00"
|
||||
|
||||
# ...but the UTC offset moves across the boundary.
|
||||
assert by_date["2026-03-23"].hour == 9 # UTC+1
|
||||
assert by_date["2026-03-30"].hour == 8 # UTC+2 (DST)
|
||||
assert by_date["2026-04-06"].hour == 8
|
||||
|
||||
|
||||
def test_exdates_remove_instances() -> None:
|
||||
dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC)
|
||||
occ = expand_occurrences(
|
||||
dtstart=dtstart,
|
||||
duration=timedelta(hours=1),
|
||||
rrule="FREQ=WEEKLY;BYDAY=MO",
|
||||
# Exclude the 2026-03-30 occurrence (10:00 Warsaw == 08:00 UTC under DST).
|
||||
exdates=[datetime(2026, 3, 30, 8, 0, tzinfo=UTC)],
|
||||
range_start=datetime(2026, 3, 1, tzinfo=UTC),
|
||||
range_end=datetime(2026, 4, 30, tzinfo=UTC),
|
||||
tz="Europe/Warsaw",
|
||||
)
|
||||
dates = set(_local(occ))
|
||||
assert "2026-03-23" in dates
|
||||
assert "2026-03-30" not in dates # excluded
|
||||
assert "2026-04-06" in dates
|
||||
|
||||
|
||||
def test_single_event_no_rrule() -> None:
|
||||
dtstart = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
||||
occ = expand_occurrences(
|
||||
dtstart=dtstart,
|
||||
duration=timedelta(minutes=30),
|
||||
rrule=None,
|
||||
exdates=[],
|
||||
range_start=datetime(2026, 6, 1, tzinfo=UTC),
|
||||
range_end=datetime(2026, 6, 2, tzinfo=UTC),
|
||||
tz="UTC",
|
||||
)
|
||||
assert occ == [(dtstart, dtstart + timedelta(minutes=30))]
|
||||
|
||||
# Outside the window -> nothing.
|
||||
empty = expand_occurrences(
|
||||
dtstart=dtstart,
|
||||
duration=timedelta(minutes=30),
|
||||
rrule=None,
|
||||
exdates=[],
|
||||
range_start=datetime(2026, 7, 1, tzinfo=UTC),
|
||||
range_end=datetime(2026, 7, 2, tzinfo=UTC),
|
||||
tz="UTC",
|
||||
)
|
||||
assert empty == []
|
||||
@@ -0,0 +1,33 @@
|
||||
"""SDK security surface for heavy plugins: vault + multi-method guarded HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.accounts.ssrf import SsrfError, guarded_request
|
||||
from app.plugins.sdk import sdk
|
||||
|
||||
|
||||
def test_vault_round_trips_secrets() -> None:
|
||||
vault = sdk("calendar").vault
|
||||
secret = b"caldav-app-password-123"
|
||||
blob = vault.encrypt(secret)
|
||||
assert blob != secret # encrypted at rest
|
||||
assert vault.decrypt(blob) == secret
|
||||
|
||||
|
||||
async def test_guarded_request_rejects_non_https() -> None:
|
||||
with pytest.raises(SsrfError, match="https"):
|
||||
await guarded_request("PROPFIND", "http://dav.example/cal/")
|
||||
|
||||
|
||||
async def test_guarded_request_rejects_private_host() -> None:
|
||||
# Resolves to a loopback/private address -> blocked before any connection.
|
||||
with pytest.raises(SsrfError, match="non-public|resolve"):
|
||||
await guarded_request("GET", "https://localhost/")
|
||||
|
||||
|
||||
def test_http_facade_exposes_dav_verbs() -> None:
|
||||
http = sdk("calendar").http
|
||||
for verb in ("request", "get", "put", "delete", "propfind", "report"):
|
||||
assert callable(getattr(http, verb))
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { usePluginPages } from "../plugins/manifest";
|
||||
|
||||
// Product switcher shown in the sidebar header: Webmail + any plugin "app.page"
|
||||
// (Calendar, later Contacts). Plugin-agnostic — the app list comes from the
|
||||
// manifest, so a new page-plugin appears here with no core change.
|
||||
export function AppSwitcher() {
|
||||
const navigate = useNavigate();
|
||||
const pages = usePluginPages();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const apps = [
|
||||
{ key: "mail", label: "Mail", to: "/mail" },
|
||||
...pages.map((p) => ({ key: `${p.plugin}${p.route}`, label: p.nav.label, to: `/p${p.route}` })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover"
|
||||
>
|
||||
Mail
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-app-border bg-app-elevated py-1 shadow-lg">
|
||||
{apps.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate(a.to);
|
||||
}}
|
||||
className={`flex w-full items-center px-3 py-1.5 text-left text-sm hover:bg-app-hover ${
|
||||
a.key === "mail" ? "font-medium text-app-fg" : "text-app-muted"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useT } from "../../i18n";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
if (d.toDateString() === now.toDateString()) return formatClock(d);
|
||||
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function DraftRow({
|
||||
|
||||
@@ -2,17 +2,17 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (d.toDateString() === now.toDateString()) return formatClock(d);
|
||||
if (d.getFullYear() === now.getFullYear())
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { printMessages } from "../../lib/print";
|
||||
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
||||
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||
@@ -39,13 +40,17 @@ function recipientSummary(addrs: { name: string; email: string }[]): string {
|
||||
|
||||
function attributionFor(msg: MessageEnvelope): string {
|
||||
const d = new Date(msg.date);
|
||||
const date = d.toLocaleDateString("en-US", {
|
||||
const date = d.toLocaleDateString(getDateLocale(), {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
const time = d.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
|
||||
return `On ${date}, at ${time}, ${who} wrote:`;
|
||||
}
|
||||
@@ -110,13 +115,14 @@ function fmtRelative(iso: string): string {
|
||||
}
|
||||
|
||||
function fmtFull(iso: string): string {
|
||||
return new Date(iso).toLocaleString("en-US", {
|
||||
return new Date(iso).toLocaleString(getDateLocale(), {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,13 @@ export const en = {
|
||||
"settings.revoke": "Revoke",
|
||||
"settings.threadOrder": "Conversation order",
|
||||
"settings.timezone": "Time zone",
|
||||
"settings.timeFormat": "Time format",
|
||||
"settings.timeFormat24": "24-hour (14:30)",
|
||||
"settings.timeFormat12": "12-hour (2:30 PM)",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||
"settings.addAccount": "Add account",
|
||||
"settings.syncState": "State: {state}",
|
||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||
|
||||
@@ -166,7 +166,9 @@ export const api = {
|
||||
|
||||
// --- settings ---
|
||||
settings: () => apiFetch<UserSettings>("/settings"),
|
||||
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
||||
patchSettings: (
|
||||
patch: Partial<Pick<UserSettings, "thread_order" | "tz" | "time_format" | "jitsi_server">>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// the browser's print dialog. Reuses the same sanitized HTML the reader shows.
|
||||
|
||||
import { api } from "./api";
|
||||
import { getDateLocale, getHour12 } from "./timeFormat";
|
||||
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -24,13 +25,14 @@ function fmtList(addrs: Address[]): string {
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
return d.toLocaleString(getDateLocale(), {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Shared 12h/24h time preference (SPEC §9.1). The value lives in user settings
|
||||
// (`time_format`), synced here so the module-level date formatters used across
|
||||
// Mail can read it synchronously during render. 24-hour is the default.
|
||||
|
||||
let hour12 = false;
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined" && localStorage.getItem("time_format") === "12") {
|
||||
hour12 = true;
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable (SSR/tests) — keep the 24h default. */
|
||||
}
|
||||
|
||||
/** Called when the settings query resolves/changes to sync the preference. */
|
||||
export function setTimeFormat(fmt: "12" | "24"): void {
|
||||
hour12 = fmt === "12";
|
||||
try {
|
||||
localStorage.setItem("time_format", fmt);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** `hour12` option for Intl date/time formatting, reflecting the preference. */
|
||||
export function getHour12(): boolean {
|
||||
return hour12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale for date formatting, tied to the time preference: 24-hour → en-GB
|
||||
* (day/month/year, European), 12-hour → en-US (month/day/year, American).
|
||||
*/
|
||||
export function getDateLocale(): string {
|
||||
return hour12 ? "en-US" : "en-GB";
|
||||
}
|
||||
|
||||
/** Format just the clock portion (e.g. "14:30" or "2:30 PM") of a Date. */
|
||||
export function formatClock(d: Date): string {
|
||||
return d.toLocaleTimeString([], { hour: hour12 ? "numeric" : "2-digit", minute: "2-digit", hour12 });
|
||||
}
|
||||
@@ -151,6 +151,8 @@ export interface SessionInfo {
|
||||
export interface UserSettings {
|
||||
thread_order: "asc" | "desc";
|
||||
tz: string;
|
||||
time_format: "12" | "24";
|
||||
jitsi_server: string;
|
||||
allowed_image_senders: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// header keeps a way back to Mail. (Sidebar persistence is a later refinement.)
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { loadBundle } from "./bundleLoader";
|
||||
@@ -40,7 +40,7 @@ function PageMount({
|
||||
};
|
||||
}, [bundle, ctx, props, pluginName]);
|
||||
|
||||
return <div ref={ref} data-plugin-page={pluginName} className="min-h-0 flex-1 overflow-auto" />;
|
||||
return <div ref={ref} data-plugin-page={pluginName} className="h-full overflow-auto" />;
|
||||
}
|
||||
|
||||
export function PluginPage() {
|
||||
@@ -58,17 +58,10 @@ export function PluginPage() {
|
||||
}
|
||||
if (!match) return <Navigate to="/mail" replace />;
|
||||
|
||||
// Full-bleed: app pages (like Calendar) own their entire chrome, including the
|
||||
// product switcher for navigating back to Mail.
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-app-bg">
|
||||
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
|
||||
<Link
|
||||
to="/mail"
|
||||
className="rounded px-2 py-1 text-sm text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
← {t("nav.mail")}
|
||||
</Link>
|
||||
<span className="text-sm font-semibold text-app-fg">{match.nav.label}</span>
|
||||
</header>
|
||||
<div className="h-screen bg-app-bg">
|
||||
<PageMount pluginName={match.plugin} bundle={match.bundle} subpath={subpath} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3).
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { useT } from "../i18n";
|
||||
@@ -11,6 +12,7 @@ import type { PluginContext } from "./types";
|
||||
/** A memoised context bound to one plugin, usable from slots and pages. */
|
||||
export function usePluginContext(pluginName: string): PluginContext {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
return useMemo<PluginContext>(
|
||||
() => ({
|
||||
pluginName,
|
||||
@@ -27,7 +29,8 @@ export function usePluginContext(pluginName: string): PluginContext {
|
||||
// Plugins mostly ship their own strings; app keys resolve, unknown keys
|
||||
// fall through as-is (the app translator returns the key when missing).
|
||||
t: (key, vars) => t(key as MessageKey, vars),
|
||||
navigate: (path) => navigate(path),
|
||||
}),
|
||||
[pluginName, t],
|
||||
[pluginName, t, navigate],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface PluginContext {
|
||||
api: PluginApi;
|
||||
events: PluginEvents;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
/** Navigate the app's SPA router (e.g. back to "/mail" from a page bundle). */
|
||||
navigate: (path: string) => void;
|
||||
}
|
||||
|
||||
export type PluginUnmount = () => void;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { useMe } from "../../lib/auth";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { setTimeFormat } from "../../lib/timeFormat";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
|
||||
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
|
||||
const SECTIONS = ["profile", "security", "accounts", "appearance", "integrations"] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
|
||||
export function Settings() {
|
||||
@@ -45,6 +46,7 @@ export function Settings() {
|
||||
{section === "security" && <SecuritySection />}
|
||||
{section === "accounts" && <AccountsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{section === "integrations" && <IntegrationsSection />}
|
||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
||||
Matches the accounts section width (max-w-5xl) with a gap above it. */}
|
||||
{section === "accounts" && (
|
||||
@@ -595,7 +597,54 @@ function AppearanceSection() {
|
||||
<option value="desc">{t("mail.orderNewest")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.timeFormat")}</span>
|
||||
<select
|
||||
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
||||
value={settings.data?.time_format ?? "24"}
|
||||
onChange={(e) => {
|
||||
const fmt = e.target.value as "12" | "24";
|
||||
setTimeFormat(fmt);
|
||||
patch.mutate({ time_format: fmt });
|
||||
}}
|
||||
>
|
||||
<option value="24">{t("settings.timeFormat24")}</option>
|
||||
<option value="12">{t("settings.timeFormat12")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_JITSI = "https://meet.jit.si/";
|
||||
|
||||
function IntegrationsSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
const [jitsi, setJitsi] = useState<string | null>(null);
|
||||
const value = jitsi ?? settings.data?.jitsi_server ?? "";
|
||||
|
||||
return (
|
||||
<section className="max-w-4xl space-y-6">
|
||||
<h2 className="text-lg font-semibold">{t("settings.integrations")}</h2>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("settings.jitsi")}</h3>
|
||||
<p className="text-sm text-app-muted">{t("settings.jitsiHint")}</p>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={DEFAULT_JITSI}
|
||||
value={value}
|
||||
onChange={(e) => setJitsi(e.target.value)}
|
||||
onBlur={(e) => patch.mutate({ jitsi_server: e.target.value.trim() })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "../i18n";
|
||||
@@ -41,9 +42,11 @@ function wrap(children: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(["plugin-ui-manifest"], MANIFEST); // seed so no network fetch
|
||||
return (
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
<MemoryRouter>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "webmail-plugin-calendar"
|
||||
version = "0.1.0"
|
||||
description = "Calendar — the reference heavy plugin (SPEC Appendix A). Local events, iMIP, CalDAV/ICS."
|
||||
requires-python = ">=3.11"
|
||||
# Plugin owns its dependencies (SPEC §A.2). caldav is only needed for A3.
|
||||
dependencies = [
|
||||
"python-dateutil>=2.9",
|
||||
"icalendar>=5.0,<7",
|
||||
"tzdata",
|
||||
]
|
||||
|
||||
[project.entry-points."webmail.plugins"]
|
||||
calendar = "webmail_plugin_calendar:plugin"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["webmail_plugin_calendar"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""``calendar`` — the reference heavy plugin (SPEC Appendix A).
|
||||
|
||||
A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page
|
||||
beside Mail. Its own ``plg_calendar_*`` tables (RLS-isolated) hold calendars and
|
||||
events; recurrence is expanded at query time. Uses the SDK only — zero core diffs.
|
||||
Phase A1 (local events) is the current scope; iMIP (A2) and CalDAV/ICS (A3) build
|
||||
on the same data model and the SDK's vault + multi-method HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
|
||||
from webmail_plugin_calendar.router import router
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
|
||||
plugin = Plugin(
|
||||
name="calendar",
|
||||
version="0.1.0",
|
||||
builtin=True,
|
||||
router=router,
|
||||
ui_manifest=UiManifest(
|
||||
pages=[
|
||||
Page(
|
||||
route="/calendar",
|
||||
nav=PageNav(icon="calendar", label="Calendar", order=10),
|
||||
bundle="/api/plugins/calendar/ui/page.js",
|
||||
)
|
||||
],
|
||||
),
|
||||
migrations=str(_HERE / "migrations" / "versions"),
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""iMIP invitations (SPEC §A.2): build the ICS + HTML invite and send it from the
|
||||
organizer's mailbox via ``sdk.mail``, plus signed RSVP tokens.
|
||||
|
||||
An invite carries a ``text/calendar; method=REQUEST`` part (so a recipient's own
|
||||
client shows its native Yes/Maybe/No UI) *and* Reply buttons that link back to the
|
||||
public ``/rsvp`` endpoint. The RSVP token is the event coordinates
|
||||
(``user_id``/``event_id``/``email``) sealed with the SDK vault — tamper-proof and
|
||||
opaque, so an external guest can respond without a webmail login.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html as html_mod
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from icalendar import Calendar, Event, vCalAddress, vText
|
||||
|
||||
from app.plugins.sdk import sdk
|
||||
|
||||
_SDK = sdk("calendar")
|
||||
|
||||
RESPONSES = {"accepted": "Yes", "declined": "No", "tentative": "Maybe"}
|
||||
|
||||
|
||||
# --- RSVP tokens (sealed with the vault, so guests need no login) -------------
|
||||
|
||||
def make_token(user_id: uuid.UUID, event_id: uuid.UUID, email: str) -> str:
|
||||
payload = json.dumps({"u": str(user_id), "e": str(event_id), "m": email}).encode()
|
||||
return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode()
|
||||
|
||||
|
||||
def read_token(token: str) -> tuple[uuid.UUID, uuid.UUID, str] | None:
|
||||
try:
|
||||
data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token.encode())))
|
||||
return uuid.UUID(data["u"]), uuid.UUID(data["e"]), str(data["m"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# --- date formatting ----------------------------------------------------------
|
||||
|
||||
def _local(dt: datetime, tz: str) -> datetime:
|
||||
try:
|
||||
return dt.astimezone(ZoneInfo(tz))
|
||||
except Exception:
|
||||
return dt.astimezone(UTC)
|
||||
|
||||
|
||||
def format_when(dtstart: datetime, dtend: datetime | None, tz: str, all_day: bool) -> str:
|
||||
s = _local(dtstart, tz)
|
||||
if all_day:
|
||||
return s.strftime("%A %Y-%m-%d")
|
||||
e = _local(dtend, tz) if dtend else None
|
||||
times = s.strftime("%H:%M") + (f" – {e.strftime('%H:%M')}" if e else "")
|
||||
return f"{s.strftime('%A %Y-%m-%d')} · {times} ({s.tzname() or tz})"
|
||||
|
||||
|
||||
def _subject_when(dtstart: datetime, dtend: datetime | None, tz: str, all_day: bool) -> str:
|
||||
s = _local(dtstart, tz)
|
||||
if all_day:
|
||||
return s.strftime("%a %Y-%m-%d")
|
||||
e = _local(dtend, tz) if dtend else None
|
||||
times = s.strftime("%H:%M") + (f" - {e.strftime('%H:%M')}" if e else "")
|
||||
return f"{s.strftime('%a %Y-%m-%d')} {times} ({s.tzname() or tz})"
|
||||
|
||||
|
||||
# --- ICS ----------------------------------------------------------------------
|
||||
|
||||
def build_ics(
|
||||
*,
|
||||
ical_uid: str,
|
||||
summary: str,
|
||||
description: str,
|
||||
location: str,
|
||||
dtstart: datetime,
|
||||
dtend: datetime | None,
|
||||
organizer: dict[str, str] | None,
|
||||
attendees: list[dict[str, str]],
|
||||
conference_url: str = "",
|
||||
sequence: int = 0,
|
||||
) -> str:
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Webmail//Calendar//EN")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("method", "REQUEST")
|
||||
ev = Event()
|
||||
ev.add("uid", ical_uid)
|
||||
ev.add("dtstamp", datetime.now(UTC))
|
||||
ev.add("dtstart", dtstart)
|
||||
if dtend:
|
||||
ev.add("dtend", dtend)
|
||||
ev.add("summary", summary or "(no title)")
|
||||
desc = description or ""
|
||||
if conference_url:
|
||||
desc = f"{desc}\n\nJoin: {conference_url}".strip()
|
||||
ev.add("url", conference_url)
|
||||
if desc:
|
||||
ev.add("description", desc)
|
||||
if location:
|
||||
ev.add("location", location)
|
||||
ev.add("sequence", sequence)
|
||||
if organizer and organizer.get("email"):
|
||||
org = vCalAddress(f"MAILTO:{organizer['email']}")
|
||||
if organizer.get("name"):
|
||||
org.params["cn"] = vText(organizer["name"])
|
||||
ev.add("organizer", org)
|
||||
for a in attendees:
|
||||
att = vCalAddress(f"MAILTO:{a['email']}")
|
||||
if a.get("name"):
|
||||
att.params["cn"] = vText(a["name"])
|
||||
att.params["role"] = vText("REQ-PARTICIPANT")
|
||||
att.params["partstat"] = vText((a.get("partstat") or "needs-action").upper())
|
||||
att.params["rsvp"] = vText("TRUE")
|
||||
ev.add("attendee", att)
|
||||
cal.add_component(ev)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
|
||||
# --- HTML / text body ---------------------------------------------------------
|
||||
|
||||
def _esc(s: object) -> str:
|
||||
return html_mod.escape(str(s or ""))
|
||||
|
||||
|
||||
def _guest_lines_html(organizer: dict[str, str] | None, attendees: list[dict[str, str]]) -> str:
|
||||
rows = ""
|
||||
if organizer and organizer.get("email"):
|
||||
name = organizer.get("name") or organizer["email"]
|
||||
rows += f'<div>{_esc(name)} <span style="color:#5f6368">– organizer</span></div>'
|
||||
for a in attendees:
|
||||
rows += f'<div>{_esc(a.get("name") or a["email"])}</div>'
|
||||
return rows
|
||||
|
||||
|
||||
def build_html(
|
||||
*,
|
||||
when: str,
|
||||
description: str,
|
||||
location: str,
|
||||
conference_url: str,
|
||||
organizer: dict[str, str] | None,
|
||||
attendees: list[dict[str, str]],
|
||||
rsvp_base: str,
|
||||
token: str,
|
||||
) -> str:
|
||||
join = ""
|
||||
link = ""
|
||||
if conference_url:
|
||||
join = (
|
||||
f'<div style="margin:0 0 22px"><a href="{_esc(conference_url)}" '
|
||||
'style="display:inline-block;background:#1a73e8;color:#fff;text-decoration:none;'
|
||||
'padding:10px 20px;border-radius:6px;font-weight:600">Join Jitsi Meet</a></div>'
|
||||
)
|
||||
link = (
|
||||
'<p style="margin:0 0 4px;font-weight:700">Meeting link</p>'
|
||||
f'<p style="margin:0 0 22px"><a href="{_esc(conference_url)}" '
|
||||
f'style="color:#1a73e8;text-decoration:none">{_esc(conference_url)}</a></p>'
|
||||
)
|
||||
desc = f'<p style="margin:0 0 22px">{_esc(description)}</p>' if description else ""
|
||||
loc = (
|
||||
'<p style="margin:0 0 4px;font-weight:700">Where</p>'
|
||||
f'<p style="margin:0 0 22px;color:#5f6368">{_esc(location)}</p>'
|
||||
if location
|
||||
else ""
|
||||
)
|
||||
|
||||
def btn(resp: str, label: str) -> str:
|
||||
url = f"{rsvp_base}?t={token}&r={resp}"
|
||||
return (
|
||||
f'<a href="{url}" style="display:inline-block;border:1px solid #dadce0;'
|
||||
'border-radius:4px;padding:8px 20px;margin-right:8px;color:#1a73e8;'
|
||||
f'text-decoration:none">{label}</a>'
|
||||
)
|
||||
|
||||
rsvp = btn("accepted", "Yes") + btn("declined", "No") + btn("tentative", "Maybe")
|
||||
return (
|
||||
'<html><body style="margin:0;background:#f1f3f4;font-family:Arial,Helvetica,sans-serif;'
|
||||
'color:#3c4043;line-height:1.5">'
|
||||
'<div style="max-width:600px;margin:0 auto;background:#fff;border:1px solid #e0e0e0;'
|
||||
'border-radius:8px;padding:28px 32px">'
|
||||
f"{join}{link}{desc}"
|
||||
'<p style="margin:0 0 4px;font-weight:700">When</p>'
|
||||
f'<p style="margin:0 0 22px;color:#5f6368">{_esc(when)}</p>'
|
||||
f"{loc}"
|
||||
'<p style="margin:0 0 6px;font-weight:700">Guests</p>'
|
||||
f'<div style="margin:0 0 22px">{_guest_lines_html(organizer, attendees)}</div>'
|
||||
'<p style="margin:0 0 8px"><b>Reply</b> to this invitation</p>'
|
||||
f"<div>{rsvp}</div>"
|
||||
"</div>"
|
||||
'<p style="max-width:600px;margin:16px auto;color:#9aa0a6;font-size:12px;padding:0 4px">'
|
||||
"You are receiving this message because you are a guest of this event. "
|
||||
"Sent by Webmail Calendar.</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def build_text(when: str, description: str, conference_url: str, rsvp_base: str, token: str) -> str:
|
||||
lines = []
|
||||
if conference_url:
|
||||
lines.append(f"Join Jitsi Meet: {conference_url}")
|
||||
lines.append("")
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append("")
|
||||
lines.append(f"When: {when}")
|
||||
lines.append("")
|
||||
lines.append("Reply to this invitation:")
|
||||
lines.append(f" Yes: {rsvp_base}?t={token}&r=accepted")
|
||||
lines.append(f" No: {rsvp_base}?t={token}&r=declined")
|
||||
lines.append(f" Maybe: {rsvp_base}?t={token}&r=tentative")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- send ---------------------------------------------------------------------
|
||||
|
||||
async def send_invites(
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
event_id: uuid.UUID,
|
||||
ical_uid: str,
|
||||
summary: str,
|
||||
description: str,
|
||||
location: str,
|
||||
dtstart: datetime,
|
||||
dtend: datetime | None,
|
||||
tz: str,
|
||||
all_day: bool,
|
||||
organizer: dict[str, str] | None,
|
||||
attendees: list[dict[str, str]],
|
||||
conference_url: str,
|
||||
sequence: int = 0,
|
||||
notify_emails: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Send one iMIP REQUEST per attendee, from the organizer's mailbox.
|
||||
|
||||
The ICS always lists the full ``attendees`` roster; ``notify_emails`` (lower-
|
||||
cased) restricts *who is actually emailed* — used on edit to invite only the
|
||||
newly-added guests without re-notifying everyone.
|
||||
"""
|
||||
if not attendees:
|
||||
return
|
||||
from_email = organizer.get("email") if organizer else None
|
||||
rsvp_base = f"https://{_SDK.settings.app_domain}/api/plugins/calendar/rsvp"
|
||||
when = format_when(dtstart, dtend, tz, all_day)
|
||||
subject_when = _subject_when(dtstart, dtend, tz, all_day)
|
||||
ics = build_ics(
|
||||
ical_uid=ical_uid,
|
||||
summary=summary,
|
||||
description=description,
|
||||
location=location,
|
||||
dtstart=dtstart,
|
||||
dtend=dtend,
|
||||
organizer=organizer,
|
||||
attendees=attendees,
|
||||
conference_url=conference_url,
|
||||
sequence=sequence,
|
||||
)
|
||||
for a in attendees:
|
||||
email = a.get("email")
|
||||
if not email:
|
||||
continue
|
||||
if notify_emails is not None and email.lower() not in notify_emails:
|
||||
continue
|
||||
token = make_token(user_id, event_id, email)
|
||||
html = build_html(
|
||||
when=when,
|
||||
description=description,
|
||||
location=location,
|
||||
conference_url=conference_url,
|
||||
organizer=organizer,
|
||||
attendees=attendees,
|
||||
rsvp_base=rsvp_base,
|
||||
token=token,
|
||||
)
|
||||
text = build_text(when, description, conference_url, rsvp_base, token)
|
||||
subject = f"Invitation: {summary or '(no title)'} - {subject_when} ({email})"
|
||||
await _SDK.mail.send(
|
||||
user_id,
|
||||
to=[email],
|
||||
subject=subject,
|
||||
html=html,
|
||||
text=text,
|
||||
from_email=from_email,
|
||||
ics=ics,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""calendar plugin: calendars + events tables with RLS (SPEC §A.3)
|
||||
|
||||
Revision ID: calendar0001
|
||||
Revises:
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.plugins.rls import enable_rls
|
||||
|
||||
revision: str = "calendar0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE plg_calendar_calendars (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
color text NOT NULL DEFAULT '#3b82f6',
|
||||
kind text NOT NULL DEFAULT 'local', -- local|caldav|ics_subscription
|
||||
source_config jsonb NOT NULL DEFAULT '{}',
|
||||
secret_ct bytea, -- CalDAV creds (vault, §10.2)
|
||||
sync_token text,
|
||||
read_only boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (user_id, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE plg_calendar_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
calendar_id uuid NOT NULL
|
||||
REFERENCES plg_calendar_calendars(id) ON DELETE CASCADE,
|
||||
ical_uid text NOT NULL,
|
||||
sequence int NOT NULL DEFAULT 0,
|
||||
summary text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
location text NOT NULL DEFAULT '',
|
||||
dtstart timestamptz NOT NULL,
|
||||
dtend timestamptz,
|
||||
all_day boolean NOT NULL DEFAULT false,
|
||||
tz text NOT NULL DEFAULT 'UTC',
|
||||
rrule text,
|
||||
exdates timestamptz[] NOT NULL DEFAULT '{}',
|
||||
recurrence_id timestamptz,
|
||||
organizer jsonb,
|
||||
attendees jsonb NOT NULL DEFAULT '[]',
|
||||
status text NOT NULL DEFAULT 'confirmed', -- confirmed|tentative|cancelled
|
||||
my_partstat text,
|
||||
reminders int[] NOT NULL DEFAULT '{}',
|
||||
origin_message_id uuid,
|
||||
raw_ical text,
|
||||
UNIQUE (calendar_id, ical_uid, recurrence_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX ix_plg_calendar_events_user_dtstart "
|
||||
"ON plg_calendar_events (user_id, dtstart)"
|
||||
)
|
||||
enable_rls("plg_calendar_calendars")
|
||||
enable_rls("plg_calendar_events")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS plg_calendar_events")
|
||||
op.execute("DROP TABLE IF EXISTS plg_calendar_calendars")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""calendar: per-event color + show_as (busy/free) (SPEC §A form)
|
||||
|
||||
Revision ID: calendar0002
|
||||
Revises: calendar0001
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "calendar0002"
|
||||
down_revision: str | None = "calendar0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# color NULL = inherit the calendar's colour; show_as maps to iCalendar TRANSP.
|
||||
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN color text")
|
||||
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN show_as text NOT NULL DEFAULT 'busy'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS show_as")
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS color")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""calendar: per-event conference_url (Jitsi Meet video-call link) (SPEC §A form)
|
||||
|
||||
Revision ID: calendar0003
|
||||
Revises: calendar0002
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "calendar0003"
|
||||
down_revision: str | None = "calendar0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Video-call URL (Jitsi Meet room), generated in the event form; empty = none.
|
||||
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN conference_url text NOT NULL DEFAULT ''")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS conference_url")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Range expansion + override merge for the events list (SPEC §A.3).
|
||||
|
||||
Given the base events (``recurrence_id IS NULL``) and detached override rows
|
||||
(``recurrence_id`` set) that overlap a window, produce the flat list of instances
|
||||
the UI renders: expand each base event's RRULE (DST-correct, EXDATE-aware), drop the
|
||||
occurrence an override replaces, and emit the override at its own moved time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from webmail_plugin_calendar.recurrence import expand_occurrences
|
||||
|
||||
Row = Mapping[str, Any]
|
||||
|
||||
|
||||
def _duration(dtstart: datetime, dtend: datetime | None, all_day: bool) -> timedelta:
|
||||
if dtend is not None:
|
||||
return dtend - dtstart
|
||||
return timedelta(days=1) if all_day else timedelta(0)
|
||||
|
||||
|
||||
def _instance(
|
||||
row: Row, start: datetime, end: datetime, recurrence_id: datetime | None
|
||||
) -> dict[str, Any]:
|
||||
origin = row.get("origin_message_id")
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"calendar_id": str(row["calendar_id"]),
|
||||
"ical_uid": row["ical_uid"],
|
||||
"summary": row["summary"],
|
||||
"description": row.get("description") or "",
|
||||
"location": row["location"],
|
||||
"tz": row.get("tz"),
|
||||
"rrule": row.get("rrule"),
|
||||
"reminders": list(row.get("reminders") or []),
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
"all_day": row["all_day"],
|
||||
"status": row["status"],
|
||||
"recurring": row["rrule"] is not None,
|
||||
"recurrence_id": recurrence_id.isoformat() if recurrence_id else None,
|
||||
"color": row["color"],
|
||||
"show_as": row.get("show_as", "busy"),
|
||||
"conference_url": row.get("conference_url", ""),
|
||||
"attendees": row.get("attendees") or [],
|
||||
"organizer": row.get("organizer"),
|
||||
"my_partstat": row.get("my_partstat"),
|
||||
"origin_message_id": str(origin) if origin else None,
|
||||
}
|
||||
|
||||
|
||||
def expand_range(
|
||||
base_rows: list[Row],
|
||||
override_rows: list[Row],
|
||||
range_start: datetime,
|
||||
range_end: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Overrides keyed by (ical_uid, original-occurrence-start in UTC); also emitted
|
||||
# as their own instance at the moved time.
|
||||
replaced: set[tuple[str, datetime]] = set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for o in override_rows:
|
||||
replaced.add((o["ical_uid"], o["recurrence_id"].astimezone(UTC)))
|
||||
if o["status"] == "cancelled":
|
||||
continue
|
||||
dur = _duration(o["dtstart"], o["dtend"], o["all_day"])
|
||||
start, end = o["dtstart"], o["dtstart"] + dur
|
||||
if end > range_start and start < range_end:
|
||||
out.append(_instance(o, start, end, o["recurrence_id"]))
|
||||
|
||||
for b in base_rows:
|
||||
dur = _duration(b["dtstart"], b["dtend"], b["all_day"])
|
||||
occ = expand_occurrences(
|
||||
dtstart=b["dtstart"],
|
||||
duration=dur,
|
||||
rrule=b["rrule"],
|
||||
exdates=list(b["exdates"] or []),
|
||||
range_start=range_start,
|
||||
range_end=range_end,
|
||||
tz=b["tz"],
|
||||
all_day=b["all_day"],
|
||||
)
|
||||
for start, end in occ:
|
||||
if (b["ical_uid"], start.astimezone(UTC)) in replaced:
|
||||
continue # a detached override provides this instance
|
||||
out.append(_instance(b, start, end, start if b["rrule"] else None))
|
||||
|
||||
out.sort(key=lambda i: i["start"])
|
||||
return out
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Recurrence expansion (SPEC §A.3).
|
||||
|
||||
Events store ``dtstart`` in UTC plus the original ``tz``. RRULEs are expanded at
|
||||
query time (no materialization) over the requested range only. DST correctness:
|
||||
a weekly "Monday 10:00 Europe/Warsaw" event stays at 10:00 *local* on both sides of
|
||||
a DST change (its UTC offset shifts). dateutil computes occurrences by naive
|
||||
arithmetic, so we expand on the wall-clock time in the event's own zone, then
|
||||
localize each occurrence back to UTC — that keeps the local time fixed and lets the
|
||||
offset move. Iterations are capped per event; EXDATEs remove instances. Detached
|
||||
overrides (``recurrence_id`` rows) are merged by the caller, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from itertools import islice
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
MAX_INSTANCES = 1000
|
||||
|
||||
|
||||
def _localize_until(rrule: str, zone: ZoneInfo) -> str:
|
||||
"""Rewrite a UTC (``…Z``) ``UNTIL`` to naive wall-clock in ``zone``.
|
||||
|
||||
We expand on naive wall-clock (see module docstring), and dateutil requires
|
||||
``UNTIL`` to match ``DTSTART``'s awareness — so a standards-compliant UTC UNTIL
|
||||
must be converted to the same naive local clock the expansion runs in.
|
||||
"""
|
||||
parts = []
|
||||
for part in rrule.split(";"):
|
||||
if part.startswith("UNTIL=") and part.endswith("Z"):
|
||||
try:
|
||||
dt = datetime.strptime(part[6:-1], "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
|
||||
part = "UNTIL=" + dt.astimezone(zone).replace(tzinfo=None).strftime("%Y%m%dT%H%M%S")
|
||||
except ValueError:
|
||||
pass
|
||||
parts.append(part)
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def expand_occurrences(
|
||||
*,
|
||||
dtstart: datetime,
|
||||
duration: timedelta,
|
||||
rrule: str | None,
|
||||
exdates: list[datetime],
|
||||
range_start: datetime,
|
||||
range_end: datetime,
|
||||
tz: str,
|
||||
all_day: bool = False,
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""Return ``(start, end)`` occurrences (UTC, aware) overlapping ``[range_start,
|
||||
range_end)``. All datetimes in/out are timezone-aware."""
|
||||
# All-day events are date-anchored — expand in UTC so they don't shift.
|
||||
zone = ZoneInfo("UTC") if all_day else ZoneInfo(tz or "UTC")
|
||||
local_start = dtstart.astimezone(zone).replace(tzinfo=None)
|
||||
|
||||
def to_utc(naive: datetime) -> datetime:
|
||||
return naive.replace(tzinfo=zone).astimezone(UTC)
|
||||
|
||||
excluded = {e.astimezone(UTC) for e in exdates}
|
||||
|
||||
if rrule:
|
||||
lo = range_start.astimezone(zone).replace(tzinfo=None) - duration
|
||||
hi = range_end.astimezone(zone).replace(tzinfo=None)
|
||||
rule = rrulestr(_localize_until(rrule, zone), dtstart=local_start)
|
||||
naive_starts = list(islice(rule.between(lo, hi, inc=True), MAX_INSTANCES))
|
||||
else:
|
||||
naive_starts = [local_start]
|
||||
|
||||
out: list[tuple[datetime, datetime]] = []
|
||||
for naive in naive_starts:
|
||||
start = to_utc(naive)
|
||||
if start in excluded:
|
||||
continue
|
||||
end = start + duration
|
||||
if end > range_start and start < range_end: # overlaps the window
|
||||
out.append((start, end))
|
||||
return out
|
||||
@@ -0,0 +1,556 @@
|
||||
"""HTTP surface for the calendar plugin, mounted at /api/plugins/calendar (SPEC §A).
|
||||
|
||||
Phase A1: calendars + events CRUD and the range-expansion events list, all through
|
||||
the RLS-scoped ``sdk.session`` (per-user isolation enforced by Postgres). Recurrence
|
||||
is expanded at query time (see queries.py / recurrence.py). Read-only calendars
|
||||
(ICS subscriptions, A3) reject writes with 403 ``read_only_calendar``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
|
||||
from webmail_plugin_calendar import invites
|
||||
from webmail_plugin_calendar.queries import expand_range
|
||||
from webmail_plugin_calendar.schemas import (
|
||||
CalendarIn,
|
||||
CalendarPatch,
|
||||
EditFollowingIn,
|
||||
EventIn,
|
||||
EventPatch,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_UI = Path(__file__).resolve().parent / "ui"
|
||||
_SDK = sdk("calendar")
|
||||
_log = logging.getLogger("webmail_plugin_calendar")
|
||||
|
||||
|
||||
def _aware(dt: datetime) -> datetime:
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
async def _ensure_default_calendar(session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||
count = (await session.execute(text("SELECT count(*) FROM plg_calendar_calendars"))).scalar()
|
||||
if not count:
|
||||
await session.execute(
|
||||
text("INSERT INTO plg_calendar_calendars (user_id, name) VALUES (:u, 'Personal')"),
|
||||
{"u": str(user_id)},
|
||||
)
|
||||
|
||||
|
||||
async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> None:
|
||||
read_only = (
|
||||
await session.execute(
|
||||
text("SELECT read_only FROM plg_calendar_calendars WHERE id = :id"),
|
||||
{"id": str(calendar_id)},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if read_only is None:
|
||||
raise HTTPException(status_code=404, detail="calendar_not_found")
|
||||
if read_only:
|
||||
raise HTTPException(status_code=403, detail="read_only_calendar")
|
||||
|
||||
|
||||
# --- calendars ---------------------------------------------------------------
|
||||
|
||||
@router.get("/calendars")
|
||||
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
|
||||
async with _SDK.session(user_id) as s:
|
||||
await _ensure_default_calendar(s, user_id)
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT id, name, color, kind, read_only FROM plg_calendar_calendars "
|
||||
"ORDER BY name"
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
return [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"name": r["name"],
|
||||
"color": r["color"],
|
||||
"kind": r["kind"],
|
||||
"read_only": r["read_only"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/calendars", dependencies=[Depends(require_csrf)])
|
||||
async def create_calendar(body: CalendarIn, user_id: CurrentUserId) -> dict[str, str]:
|
||||
try:
|
||||
async with _SDK.session(user_id) as s:
|
||||
new_id = (
|
||||
await s.execute(
|
||||
text(
|
||||
"INSERT INTO plg_calendar_calendars (user_id, name, color) "
|
||||
"VALUES (:u, :n, :c) RETURNING id"
|
||||
),
|
||||
{"u": str(user_id), "n": body.name, "c": body.color},
|
||||
)
|
||||
).scalar_one()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="calendar_exists") from exc
|
||||
return {"id": str(new_id)}
|
||||
|
||||
|
||||
@router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
|
||||
async def patch_calendar(
|
||||
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
|
||||
) -> dict[str, str]:
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
if not fields:
|
||||
return {"status": "noop"}
|
||||
set_sql = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
async with _SDK.session(user_id) as s:
|
||||
result = await s.execute(
|
||||
text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"),
|
||||
{**fields, "id": str(calendar_id)},
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="calendar_not_found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
|
||||
async def delete_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
|
||||
async with _SDK.session(user_id) as s:
|
||||
result = await s.execute(
|
||||
text("DELETE FROM plg_calendar_calendars WHERE id = :id"), {"id": str(calendar_id)}
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="calendar_not_found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- events ------------------------------------------------------------------
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
user_id: CurrentUserId,
|
||||
start: datetime = Query(...),
|
||||
end: datetime = Query(...),
|
||||
) -> dict[str, object]:
|
||||
range_start, range_end = _aware(start), _aware(end)
|
||||
async with _SDK.session(user_id) as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
|
||||
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
|
||||
"e.exdates, e.recurrence_id, e.status, "
|
||||
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
|
||||
"e.attendees, e.organizer, "
|
||||
"COALESCE(e.color, c.color) AS color "
|
||||
"FROM plg_calendar_events e "
|
||||
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
"WHERE e.dtstart < :end"
|
||||
),
|
||||
{"end": range_end},
|
||||
)
|
||||
).mappings().all()
|
||||
base = [
|
||||
r
|
||||
for r in rows
|
||||
if r["recurrence_id"] is None
|
||||
and (r["rrule"] is not None or (r["dtend"] or r["dtstart"]) > range_start)
|
||||
]
|
||||
overrides = [r for r in rows if r["recurrence_id"] is not None]
|
||||
return {"events": expand_range(base, overrides, range_start, range_end)}
|
||||
|
||||
|
||||
async def _insert_event(
|
||||
s: AsyncSession, user_id: uuid.UUID, body: EventIn, attendees: list[dict[str, str]]
|
||||
) -> tuple[uuid.UUID, str]:
|
||||
ical_uid = f"{uuid.uuid4()}@webmail.local"
|
||||
organizer = json.dumps(body.organizer.model_dump()) if body.organizer else None
|
||||
new_id = (
|
||||
await s.execute(
|
||||
text(
|
||||
"INSERT INTO plg_calendar_events "
|
||||
"(user_id, calendar_id, ical_uid, summary, description, location, dtstart, "
|
||||
" dtend, all_day, tz, rrule, reminders, attendees, organizer, color, show_as, "
|
||||
" conference_url) "
|
||||
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
|
||||
" :rrule, :rem, :att, :org, :color, :showas, :conf) RETURNING id"
|
||||
),
|
||||
{
|
||||
"u": str(user_id),
|
||||
"cal": str(body.calendar_id),
|
||||
"uid": ical_uid,
|
||||
"sum": body.summary,
|
||||
"desc": body.description,
|
||||
"loc": body.location,
|
||||
"start": _aware(body.dtstart),
|
||||
"end": _aware(body.dtend) if body.dtend else None,
|
||||
"allday": body.all_day,
|
||||
"tz": body.tz,
|
||||
"rrule": body.rrule,
|
||||
"rem": body.reminders,
|
||||
"att": json.dumps(attendees),
|
||||
"org": organizer,
|
||||
"color": body.color,
|
||||
"showas": body.show_as,
|
||||
"conf": body.conference_url,
|
||||
},
|
||||
)
|
||||
).scalar_one()
|
||||
return new_id, ical_uid
|
||||
|
||||
|
||||
def _merge_attendees(
|
||||
existing: list[dict[str, str]] | None, incoming: list[dict[str, str]]
|
||||
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
"""Merge incoming attendees over existing: preserve prior RSVP status by email,
|
||||
default new guests to needs-action. Returns (merged, newly-added)."""
|
||||
prior = {str(a.get("email", "")).lower(): a for a in (existing or [])}
|
||||
merged: list[dict[str, str]] = []
|
||||
added: list[dict[str, str]] = []
|
||||
for a in incoming:
|
||||
email = a.get("email") or ""
|
||||
if not email:
|
||||
continue
|
||||
match = prior.get(email.lower())
|
||||
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
|
||||
if match is None:
|
||||
added.append(a)
|
||||
merged.append(a)
|
||||
return merged, added
|
||||
|
||||
|
||||
def _strip_recurrence_end(rrule: str) -> str:
|
||||
"""Drop any UNTIL=/COUNT= parts so a fresh UNTIL can be appended."""
|
||||
return ";".join(p for p in rrule.split(";") if not p.startswith(("UNTIL=", "COUNT=")))
|
||||
|
||||
|
||||
async def _send_invites_from(
|
||||
user_id: uuid.UUID,
|
||||
event_id: uuid.UUID,
|
||||
ical_uid: str,
|
||||
body: EventIn,
|
||||
attendees: list[dict[str, str]],
|
||||
*,
|
||||
sequence: int = 0,
|
||||
notify: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Best-effort iMIP send for an event described by ``body`` + ``attendees``."""
|
||||
if not attendees:
|
||||
return
|
||||
try:
|
||||
await invites.send_invites(
|
||||
user_id=user_id,
|
||||
event_id=event_id,
|
||||
ical_uid=ical_uid,
|
||||
summary=body.summary,
|
||||
description=body.description,
|
||||
location=body.location,
|
||||
dtstart=_aware(body.dtstart),
|
||||
dtend=_aware(body.dtend) if body.dtend else None,
|
||||
tz=body.tz,
|
||||
all_day=body.all_day,
|
||||
organizer=body.organizer.model_dump() if body.organizer else None,
|
||||
attendees=attendees,
|
||||
conference_url=body.conference_url,
|
||||
sequence=sequence,
|
||||
notify_emails=notify,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - invitation delivery is best-effort
|
||||
_log.warning("calendar: failed to send invitations for %s", event_id, exc_info=True)
|
||||
|
||||
|
||||
@router.post("/events", dependencies=[Depends(require_csrf)])
|
||||
async def create_event(body: EventIn, user_id: CurrentUserId) -> dict[str, str]:
|
||||
attendees = [a.model_dump() for a in body.attendees]
|
||||
async with _SDK.session(user_id) as s:
|
||||
await _assert_writable(s, body.calendar_id)
|
||||
new_id, ical_uid = await _insert_event(s, user_id, body, attendees)
|
||||
# iMIP: email each guest from the organizer's mailbox (best-effort, after commit).
|
||||
await _send_invites_from(user_id, new_id, ical_uid, body, attendees)
|
||||
return {"id": str(new_id), "ical_uid": ical_uid}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/edit-following", dependencies=[Depends(require_csrf)])
|
||||
async def edit_following(
|
||||
event_id: uuid.UUID, body: EditFollowingIn, user_id: CurrentUserId
|
||||
) -> dict[str, str]:
|
||||
"""Edit a recurring event with "this and following" semantics: occurrences BEFORE
|
||||
the clicked one keep the original series; the clicked one onward become a new
|
||||
series carrying the edits. Editing the first occurrence updates the series in place.
|
||||
"""
|
||||
ev = body.event
|
||||
split = _aware(body.recurrence_start)
|
||||
incoming = [a.model_dump() for a in ev.attendees]
|
||||
async with _SDK.session(user_id) as s:
|
||||
base = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT calendar_id, ical_uid, dtstart, rrule, attendees, sequence "
|
||||
"FROM plg_calendar_events WHERE id = :id"
|
||||
),
|
||||
{"id": str(event_id)},
|
||||
)
|
||||
).mappings().first()
|
||||
if base is None:
|
||||
raise HTTPException(status_code=404, detail="event_not_found")
|
||||
await _assert_writable(s, base["calendar_id"])
|
||||
base_start = _aware(base["dtstart"])
|
||||
|
||||
if base["rrule"] is None or split <= base_start:
|
||||
# First occurrence (or non-recurring): update the whole series in place.
|
||||
merged, added = _merge_attendees(base["attendees"], incoming)
|
||||
organizer = json.dumps(ev.organizer.model_dump()) if ev.organizer else None
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET summary=:sum, description=:desc, "
|
||||
"location=:loc, dtstart=:start, dtend=:end, all_day=:allday, tz=:tz, "
|
||||
"rrule=:rrule, reminders=:rem, attendees=CAST(:att AS jsonb), "
|
||||
"organizer=CAST(:org AS jsonb), color=:color, show_as=:showas, "
|
||||
"conference_url=:conf, sequence=sequence+1 WHERE id=:id"
|
||||
),
|
||||
{
|
||||
"sum": ev.summary, "desc": ev.description, "loc": ev.location,
|
||||
"start": _aware(ev.dtstart), "end": _aware(ev.dtend) if ev.dtend else None,
|
||||
"allday": ev.all_day, "tz": ev.tz, "rrule": ev.rrule,
|
||||
"rem": ev.reminders, "att": json.dumps(merged), "org": organizer,
|
||||
"color": ev.color, "showas": ev.show_as, "conf": ev.conference_url,
|
||||
"id": str(event_id),
|
||||
},
|
||||
)
|
||||
target_id, target_uid = event_id, str(base["ical_uid"])
|
||||
notify: set[str] | None = {str(a["email"]).lower() for a in added}
|
||||
seq = int(base["sequence"]) + 1
|
||||
merged_att = merged
|
||||
else:
|
||||
# Truncate the original series just before the split, then start a new one.
|
||||
until = (split - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
trimmed = _strip_recurrence_end(str(base["rrule"]))
|
||||
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
|
||||
await s.execute(
|
||||
text("UPDATE plg_calendar_events SET rrule=:r WHERE id=:id"),
|
||||
{"r": new_rrule, "id": str(event_id)},
|
||||
)
|
||||
merged_att = [{**a, "partstat": "needs-action"} for a in incoming]
|
||||
target_id, target_uid = await _insert_event(s, user_id, ev, merged_att)
|
||||
notify = None # brand-new series → notify every guest
|
||||
seq = 0
|
||||
|
||||
await _send_invites_from(
|
||||
user_id, target_id, target_uid, ev, merged_att, sequence=seq, notify=notify
|
||||
)
|
||||
return {"status": "ok", "id": str(target_id)}
|
||||
|
||||
|
||||
_PATCH_DATE_FIELDS = {"dtstart", "dtend"}
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}", dependencies=[Depends(require_csrf)])
|
||||
async def patch_event(
|
||||
event_id: uuid.UUID, body: EventPatch, user_id: CurrentUserId
|
||||
) -> dict[str, str]:
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
# Attendees are merged separately (preserve prior RSVP status; find new guests).
|
||||
new_attendees = fields.pop("attendees", None)
|
||||
if not fields and new_attendees is None:
|
||||
return {"status": "noop"}
|
||||
for key in _PATCH_DATE_FIELDS & fields.keys():
|
||||
if fields[key] is not None:
|
||||
fields[key] = _aware(fields[key])
|
||||
|
||||
invite_new: list[dict[str, str]] = []
|
||||
merged_attendees: list[dict[str, str]] | None = None
|
||||
async with _SDK.session(user_id) as s:
|
||||
existing = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT calendar_id, ical_uid, summary, description, location, dtstart, "
|
||||
"dtend, all_day, tz, conference_url, organizer, attendees, sequence "
|
||||
"FROM plg_calendar_events WHERE id = :id"
|
||||
),
|
||||
{"id": str(event_id)},
|
||||
)
|
||||
).mappings().first()
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="event_not_found")
|
||||
await _assert_writable(s, existing["calendar_id"])
|
||||
|
||||
params: dict[str, object] = {"id": str(event_id)}
|
||||
set_parts = [f"{k} = :{k}" for k in fields]
|
||||
params.update(fields)
|
||||
if new_attendees is not None:
|
||||
prior = {
|
||||
str(a.get("email", "")).lower(): a for a in (existing["attendees"] or [])
|
||||
}
|
||||
merged_attendees = []
|
||||
for a in new_attendees:
|
||||
email = a.get("email") or ""
|
||||
if not email:
|
||||
continue
|
||||
match = prior.get(email.lower())
|
||||
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
|
||||
if match is None:
|
||||
invite_new.append(a)
|
||||
merged_attendees.append(a)
|
||||
set_parts.append("attendees = CAST(:attendees AS jsonb)")
|
||||
params["attendees"] = json.dumps(merged_attendees)
|
||||
|
||||
# Bump SEQUENCE on every edit — iMIP re-sends require a higher sequence.
|
||||
await s.execute(
|
||||
text(
|
||||
f"UPDATE plg_calendar_events SET {', '.join(set_parts)}, "
|
||||
"sequence = sequence + 1 WHERE id = :id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
# Email only the newly-added guests; the ICS lists the full (merged) roster.
|
||||
if invite_new and merged_attendees is not None:
|
||||
def eff(key: str) -> object:
|
||||
return fields[key] if key in fields else existing[key]
|
||||
|
||||
try:
|
||||
await invites.send_invites(
|
||||
user_id=user_id,
|
||||
event_id=event_id,
|
||||
ical_uid=str(existing["ical_uid"]),
|
||||
summary=str(eff("summary") or ""),
|
||||
description=str(eff("description") or ""),
|
||||
location=str(eff("location") or ""),
|
||||
dtstart=_aware(eff("dtstart")), # type: ignore[arg-type]
|
||||
dtend=_aware(eff("dtend")) if eff("dtend") else None, # type: ignore[arg-type]
|
||||
tz=str(eff("tz") or "UTC"),
|
||||
all_day=bool(eff("all_day")),
|
||||
organizer=existing["organizer"],
|
||||
attendees=merged_attendees,
|
||||
conference_url=str(eff("conference_url") or ""),
|
||||
sequence=int(existing["sequence"]) + 1,
|
||||
notify_emails={str(a["email"]).lower() for a in invite_new},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - invitation delivery is best-effort
|
||||
_log.warning("calendar: edit invitations failed for %s", event_id, exc_info=True)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}", dependencies=[Depends(require_csrf)])
|
||||
async def delete_event(
|
||||
event_id: uuid.UUID,
|
||||
user_id: CurrentUserId,
|
||||
scope: str = Query("all"),
|
||||
occurrence: datetime | None = Query(None),
|
||||
) -> dict[str, str]:
|
||||
"""Delete an event. For recurring events ``scope`` selects what to remove:
|
||||
``all`` (whole series), ``this`` (add an EXDATE for ``occurrence``), or
|
||||
``following`` (truncate the series just before ``occurrence``)."""
|
||||
async with _SDK.session(user_id) as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
text("SELECT calendar_id, dtstart, rrule FROM plg_calendar_events WHERE id = :id"),
|
||||
{"id": str(event_id)},
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="event_not_found")
|
||||
await _assert_writable(s, row["calendar_id"])
|
||||
|
||||
recurring = row["rrule"] is not None
|
||||
if not recurring or scope == "all" or occurrence is None:
|
||||
await s.execute(
|
||||
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
|
||||
)
|
||||
elif scope == "this":
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events "
|
||||
"SET exdates = array_append(exdates, :occ) WHERE id = :id"
|
||||
),
|
||||
{"occ": _aware(occurrence), "id": str(event_id)},
|
||||
)
|
||||
elif scope == "following":
|
||||
occ = _aware(occurrence)
|
||||
if occ <= _aware(row["dtstart"]):
|
||||
# Truncating from the first occurrence removes everything.
|
||||
await s.execute(
|
||||
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
|
||||
)
|
||||
else:
|
||||
until = (occ - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
trimmed = _strip_recurrence_end(str(row["rrule"]))
|
||||
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
|
||||
await s.execute(
|
||||
text("UPDATE plg_calendar_events SET rrule = :r WHERE id = :id"),
|
||||
{"r": new_rrule, "id": str(event_id)},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="bad_scope")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- RSVP (public: guests respond from the invitation email, no login) --------
|
||||
|
||||
def _rsvp_page(message: str) -> str:
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>Invitation response</title></head>"
|
||||
"<body style='margin:0;font-family:Arial,Helvetica,sans-serif;background:#f1f3f4;"
|
||||
"color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;"
|
||||
"border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center'>"
|
||||
"<h2 style='margin:0 0 12px;font-size:18px'>Webmail Calendar</h2>"
|
||||
f"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rsvp")
|
||||
async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
|
||||
parsed = invites.read_token(t)
|
||||
if parsed is None or r not in invites.RESPONSES:
|
||||
return HTMLResponse(
|
||||
_rsvp_page("This invitation link is invalid or has expired."), status_code=400
|
||||
)
|
||||
user_id, event_id, email = parsed
|
||||
async with _SDK.session(user_id) as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
text("SELECT attendees FROM plg_calendar_events WHERE id = :id"),
|
||||
{"id": str(event_id)},
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return HTMLResponse(_rsvp_page("This event no longer exists."), status_code=404)
|
||||
attendees = list(row["attendees"] or [])
|
||||
matched = False
|
||||
for a in attendees:
|
||||
if str(a.get("email", "")).lower() == email.lower():
|
||||
a["partstat"] = r
|
||||
matched = True
|
||||
if not matched:
|
||||
return HTMLResponse(
|
||||
_rsvp_page("You are not on the guest list for this event."), status_code=404
|
||||
)
|
||||
await s.execute(
|
||||
text("UPDATE plg_calendar_events SET attendees = CAST(:att AS jsonb) WHERE id = :id"),
|
||||
{"att": json.dumps(attendees), "id": str(event_id)},
|
||||
)
|
||||
return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded."))
|
||||
|
||||
|
||||
# --- bundle ------------------------------------------------------------------
|
||||
|
||||
@router.get("/ui/{filename}")
|
||||
async def bundle(filename: str) -> FileResponse:
|
||||
if filename != "page.js":
|
||||
raise HTTPException(status_code=404)
|
||||
return FileResponse(_UI / filename, media_type="text/javascript")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Request models for the calendar API (SPEC §A)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CalendarIn(BaseModel):
|
||||
name: str
|
||||
color: str = "#3b82f6"
|
||||
|
||||
|
||||
class CalendarPatch(BaseModel):
|
||||
name: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class Attendee(BaseModel):
|
||||
email: str
|
||||
name: str = ""
|
||||
# RSVP status: needs-action | accepted | declined | tentative
|
||||
partstat: str = "needs-action"
|
||||
|
||||
|
||||
class EventIn(BaseModel):
|
||||
calendar_id: uuid.UUID
|
||||
summary: str = ""
|
||||
description: str = ""
|
||||
location: str = ""
|
||||
dtstart: datetime
|
||||
dtend: datetime | None = None
|
||||
all_day: bool = False
|
||||
tz: str = "UTC"
|
||||
rrule: str | None = None
|
||||
reminders: list[int] = []
|
||||
attendees: list[Attendee] = [] # stored in A1; iMIP invites are sent in A2
|
||||
organizer: Attendee | None = None
|
||||
color: str | None = None # None = inherit the calendar colour
|
||||
show_as: str = "busy" # busy | free
|
||||
conference_url: str = "" # Jitsi Meet video-call link; empty = none
|
||||
|
||||
|
||||
class EditFollowingIn(BaseModel):
|
||||
# ``recurrence_start`` = the clicked occurrence's original start; ``event`` = the
|
||||
# edited fields. Splits the series so occurrences BEFORE it are untouched.
|
||||
recurrence_start: datetime
|
||||
event: EventIn
|
||||
|
||||
|
||||
class EventPatch(BaseModel):
|
||||
summary: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
dtstart: datetime | None = None
|
||||
dtend: datetime | None = None
|
||||
all_day: bool | None = None
|
||||
tz: str | None = None
|
||||
rrule: str | None = None
|
||||
reminders: list[int] | None = None
|
||||
attendees: list[Attendee] | None = None
|
||||
color: str | None = None
|
||||
show_as: str | None = None
|
||||
conference_url: str | None = None
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 121 KiB |