diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 0bcebcf..c3ad959 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -19,6 +19,14 @@ DbSession = Annotated[AsyncSession, Depends(get_session)] _ALLOWED_ORDER = {"asc", "desc"} _ALLOWED_TIME_FORMAT = {"12", "24"} +_ALLOWED_REMINDER_METHOD = {"alert", "email"} +_ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"} +_ALLOWED_INVITE_FROM = {"contacts", "anyone"} + + +class ReminderPref(BaseModel): + method: str = "alert" # alert = in-app notification, email = email me + minutes: int = 0 # minutes before the event start (0 = at time of event) class SettingsOut(BaseModel): @@ -27,8 +35,12 @@ class SettingsOut(BaseModel): time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide jitsi_server: str = "" # blank => https://meet.jit.si/ default_calendar: str = "" # calendar id new events default to (blank => first) - default_reminders_timed: list[int] = [] # minutes-before defaults for timed events - default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events + default_reminders_timed: list[ReminderPref] = [] # defaults for timed events + default_reminders_allday: list[ReminderPref] = [] # defaults for all-day events + invitation_policy: str = "add_update" # how emailed events update the calendar + invitation_from: str = "contacts" # whose emailed events auto-add (contacts|anyone) + invitation_calendar: str = "" # calendar id emailed events land in (blank => default) + invitation_file: bool = False # also mark the invite message read + filed allowed_image_senders: list[str] = [] @@ -38,8 +50,12 @@ class SettingsPatch(BaseModel): time_format: str | None = None jitsi_server: str | None = None default_calendar: str | None = None - default_reminders_timed: list[int] | None = None - default_reminders_allday: list[int] | None = None + default_reminders_timed: list[ReminderPref] | None = None + default_reminders_allday: list[ReminderPref] | None = None + invitation_policy: str | None = None + invitation_from: str | None = None + invitation_calendar: str | None = None + invitation_file: bool | None = None class QuotaOut(BaseModel): @@ -62,8 +78,24 @@ def add_allowed_image_sender(user: User, email: str) -> None: user.settings = {**user.settings, "allowed_image_senders": current} -def _int_list(value: object) -> list[int]: - return [int(x) for x in value] if isinstance(value, list) else [] +def _reminder_list(value: object) -> list[ReminderPref]: + """Normalize stored reminders. Accepts the legacy ``list[int]`` (minutes only, + method defaults to alert) as well as the ``{method, minutes}`` object form.""" + if not isinstance(value, list): + return [] + out: list[ReminderPref] = [] + for x in value: + if isinstance(x, dict): + method = str(x.get("method", "alert")) + out.append( + ReminderPref( + method=method if method in _ALLOWED_REMINDER_METHOD else "alert", + minutes=int(x.get("minutes", 0)), + ) + ) + else: + out.append(ReminderPref(method="alert", minutes=int(x))) + return out def _settings_out(user: User) -> SettingsOut: @@ -74,8 +106,12 @@ def _settings_out(user: User) -> SettingsOut: time_format=str(s.get("time_format", "24")), jitsi_server=str(s.get("jitsi_server", "")), default_calendar=str(s.get("default_calendar", "")), - default_reminders_timed=_int_list(s.get("default_reminders_timed")), - default_reminders_allday=_int_list(s.get("default_reminders_allday")), + default_reminders_timed=_reminder_list(s.get("default_reminders_timed")), + default_reminders_allday=_reminder_list(s.get("default_reminders_allday")), + invitation_policy=str(s.get("invitation_policy", "add_update")), + invitation_from=str(s.get("invitation_from", "contacts")), + invitation_calendar=str(s.get("invitation_calendar", "")), + invitation_file=bool(s.get("invitation_file", False)), allowed_image_senders=sorted(allowed_image_senders(user)), ) @@ -108,9 +144,21 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) if body.default_calendar is not None: updates["default_calendar"] = body.default_calendar if body.default_reminders_timed is not None: - updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed] + updates["default_reminders_timed"] = [ + r.model_dump() for r in body.default_reminders_timed + ] if body.default_reminders_allday is not None: - updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday] + updates["default_reminders_allday"] = [ + r.model_dump() for r in body.default_reminders_allday + ] + if body.invitation_policy is not None and body.invitation_policy in _ALLOWED_INVITE_POLICY: + updates["invitation_policy"] = body.invitation_policy + if body.invitation_from is not None and body.invitation_from in _ALLOWED_INVITE_FROM: + updates["invitation_from"] = body.invitation_from + if body.invitation_calendar is not None: + updates["invitation_calendar"] = body.invitation_calendar + if body.invitation_file is not None: + updates["invitation_file"] = body.invitation_file user.settings = updates await db.commit() return _settings_out(user) diff --git a/backend/app/plugins/sdk.py b/backend/app/plugins/sdk.py index 3185f68..b4692b4 100644 --- a/backend/app/plugins/sdk.py +++ b/backend/app/plugins/sdk.py @@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.accounts.ssrf import guarded_get from app.config import Settings, get_settings +from app.db import session_scope from app.events import get_event_bus from app.identity.deps import current_user, require_csrf from app.indexer import blobstore @@ -95,6 +96,16 @@ class PluginSdk: across users (see :mod:`app.plugins.db`).""" return plugin_session(user_id) + def public_session(self) -> AbstractAsyncContextManager[AsyncSession]: + """A privileged, NON-RLS session for PUBLIC, unauthenticated endpoints that + resolve their own opaque token and therefore must look up rows before any + user is known (e.g. a public ``.ics`` feed keyed by an unguessable slug). + + There is no row-level safety net here: EVERY query MUST filter explicitly by + the token (or an id derived from it). Never use this for authenticated + handlers — use :meth:`session` so RLS scopes queries to the user.""" + return session_scope() + def kv(self, user_id: uuid.UUID) -> PluginKvStore: """The namespaced KV store for this plugin and ``user_id`` (SPEC §11).""" return PluginKvStore(self.name, user_id) diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index fb09e22..f05e8d0 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -38,6 +38,7 @@ export const en = { "auth.register": "Register", "nav.mail": "Mail", + "nav.back": "Back", "nav.settings": "Settings", "nav.compose": "Compose", "nav.collapseSidebar": "Collapse sidebar", @@ -254,6 +255,23 @@ export const en = { "settings.subscribed": "Subscribed", "settings.comingSoon": "Coming soon", "settings.subscribeError": "Couldn't subscribe to that URL.", + "settings.removeSubscription": "Remove subscription", + "settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?", + "settings.deleteCalendar": "Delete calendar", + "settings.thisCalendar": "this calendar", + "settings.deleteCalendarConfirm": "Delete “{name}” and all of its events? This can't be undone.", + "settings.showAlert": "Show an alert", + "settings.emailMe": "Email me", + "settings.invitations": "Invitations", + "settings.inviteWhen": "When I receive events by email", + "settings.inviteAddUpdate": "Add new events to my calendar and update events that have changed", + "settings.inviteUpdateOnly": "Only update events that I have already added to my calendar", + "settings.inviteNone": "Do not automatically update my calendar", + "settings.addNewFrom": "Add new events from", + "settings.myContacts": "My contacts", + "settings.anyone": "Anyone", + "settings.addToCalendar": "Add to calendar", + "settings.alsoFile": "Also mark the message as read and file it", "settings.integrations": "Integrations", "settings.jitsi": "Jitsi Meet", "settings.jitsiHint": diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 37976c0..3a51e9a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -178,6 +178,10 @@ export const api = { | "default_calendar" | "default_reminders_timed" | "default_reminders_allday" + | "invitation_policy" + | "invitation_from" + | "invitation_calendar" + | "invitation_file" > >, ) => diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1555f83..56ab4c4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -159,14 +159,23 @@ export interface CalendarOut { subscription_url: string | null; } +export interface ReminderPref { + method: "alert" | "email"; + minutes: number; +} + export interface UserSettings { thread_order: "asc" | "desc"; tz: string; time_format: "12" | "24"; jitsi_server: string; default_calendar: string; - default_reminders_timed: number[]; - default_reminders_allday: number[]; + default_reminders_timed: ReminderPref[]; + default_reminders_allday: ReminderPref[]; + invitation_policy: "add_update" | "update_only" | "none"; + invitation_from: "contacts" | "anyone"; + invitation_calendar: string; + invitation_file: boolean; allowed_image_senders: string[]; } diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index a86158e..c3d09d6 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -6,7 +6,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom" import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { useT } from "../../i18n"; import { api, ApiError } from "../../lib/api"; -import type { CalendarOut } from "../../lib/types"; +import type { CalendarOut, ReminderPref } from "../../lib/types"; import { useMe } from "../../lib/auth"; import { useTheme } from "../../lib/theme"; import { setTimeFormat } from "../../lib/timeFormat"; @@ -18,13 +18,19 @@ type Section = (typeof SECTIONS)[number]; export function Settings() { const t = useT(); const params = useParams(); + const [searchParams] = useSearchParams(); + // Where the back button returns to — set by whoever opened Settings (Mail or a + // plugin like Calendar via ?from=). Only accept internal paths; default to Mail. + const rawFrom = searchParams.get("from") ?? ""; + const from = rawFrom.startsWith("/") ? rawFrom : "/mail"; + const fromQuery = `?from=${encodeURIComponent(from)}`; const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section; return (
- - ← {t("nav.mail")} + + ← {t("nav.back")} {t("settings.title")}
@@ -33,7 +39,7 @@ export function Settings() { {SECTIONS.map((s) => ( + + + + ); +} + +// A compact colour picker showing circular swatches only (no text). +function ColorSelect({ value, onChange }: { value: string; onChange: (hex: string) => void }) { + const [open, setOpen] = useState(false); + const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value) + ? CAL_COLORS + : [["Custom", value], ...CAL_COLORS]; + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {palette.map(([name, hex]) => ( +
+ + )} +
+ ); +} + +function ConfirmDialog({ + title, + message, + confirmLabel, + onConfirm, + onCancel, +}: { + title: string; + message: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; +}) { + const t = useT(); + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ ); +} + function SettingsToggle({ checked, onChange, @@ -699,9 +816,41 @@ const TZ_RAW: string[] = (() => { return []; } })(); -const TZ_LIST = TZ_RAW.length +const TZ_ZONES = TZ_RAW.length ? TZ_RAW : ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"]; + +// The GMT offset code (e.g. "GMT+2") for a zone, as shown next to its name. +function tzGmt(zone: string): string { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + timeZoneName: "shortOffset", + }).formatToParts(new Date()); + return parts.find((p) => p.type === "timeZoneName")?.value ?? ""; + } catch { + return ""; + } +} + +function tzLongName(zone: string): string { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + timeZoneName: "long", + }).formatToParts(new Date()); + return parts.find((p) => p.type === "timeZoneName")?.value ?? zone; + } catch { + return zone; + } +} + +// [zone, "Long Name (GMT+2)"] sorted by offset then label, computed once. +const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => { + const gmt = tzGmt(z); + return [z, gmt ? `${tzLongName(z)} (${gmt})` : tzLongName(z)]; +}).sort((a, b) => a[1].localeCompare(b[1])); + const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]; function reminderText(m: number): string { @@ -772,6 +921,11 @@ function CalendarList({ queryClient.invalidateQueries({ queryKey: ["calendars"] }); }, }); + const [removeSub, setRemoveSub] = useState(null); + const removeCal = useMutation({ + mutationFn: (id: string) => api.deleteCalendar(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }), + }); const subscriptions = calendars.filter((c) => c.subscription_url !== null); const accounts: [string, string][] = [ ["Google", "google"], @@ -856,11 +1010,7 @@ function CalendarList({
@@ -888,6 +1038,19 @@ function CalendarList({

{t("settings.subscribeError")}

)}
+ + {removeSub && ( + setRemoveSub(null)} + onConfirm={() => { + removeCal.mutate(removeSub.id); + setRemoveSub(null); + }} + /> + )} ); } @@ -896,7 +1059,9 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa const t = useT(); const queryClient = useQueryClient(); const [calId, setCalId] = useState(calendar?.id ?? null); - const editing = calId !== null; + // Whether we opened an existing calendar to edit — fixed for the form's lifetime, so + // auto-creating on a Publish toggle doesn't flip the "Create calendar" heading. + const startedNew = calendar === null; const [name, setName] = useState(calendar?.name ?? ""); const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]); const [publishToken, setPublishToken] = useState(calendar?.publish_token ?? null); @@ -916,6 +1081,7 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa onSuccess: done, }); const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done }); + const [confirmDel, setConfirmDel] = useState(false); // Turning on a Publish option auto-creates the calendar first (create mode) so a // shareable .ics link can be shown immediately, then flips the public feed on/off. @@ -957,43 +1123,16 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa ); - const colorOptions: [string, string][] = CAL_COLORS.some(([, hex]) => hex === color) - ? CAL_COLORS - : [["Custom", color], ...CAL_COLORS]; - return (
-
- - › - - {editing ? t("settings.editCalendar") : t("settings.createCalendar")} - -
-

- {editing ? t("settings.editCalendar") : t("settings.createCalendar")} + {startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}

{t("settings.color")} -
- - -
+
+ + {confirmDel && ( + setConfirmDel(false)} + onConfirm={() => { + setConfirmDel(false); + del.mutate(); + }} + /> + )}
); } +const REM_SELECT_CLASS = + "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm text-app-fg outline-none focus:border-app-accent"; + function ReminderList({ label, value, onChange, }: { label: string; - value: number[]; - onChange: (v: number[]) => void; + value: ReminderPref[]; + onChange: (v: ReminderPref[]) => void; }) { const t = useT(); + const update = (i: number, patch: Partial) => + onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); return (
{label} -
- {value.map((m, i) => ( -
- update(i, { method: e.target.value as ReminderPref["method"] })} + > + - ))} - + + + +
))} -
+
); } @@ -1120,6 +1292,15 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) { const s = settings.data; const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + // When no default is set, fall back to "Personal" (the bootstrapped calendar) so the + // dropdown shows Personal rather than a generic placeholder; else the first calendar. + const preferredDefault = + calendars.find((c) => c.name === "Personal") ?? calendars[0]; + const defaultCalValue = + s?.default_calendar && calendars.some((c) => c.id === s.default_calendar) + ? s.default_calendar + : (preferredDefault?.id ?? ""); + // Detect and persist the browser time zone on first visit (backend default is UTC). useEffect(() => { if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz }); @@ -1137,9 +1318,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) { value={s?.tz && s.tz !== "UTC" ? s.tz : browserTz} onChange={(e) => patch.mutate({ tz: e.target.value })} > - {TZ_LIST.map((z) => ( - ))} @@ -1153,12 +1334,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) { {t("settings.defaultCalendar")} patch.mutate({ invitation_policy: val })} + /> + {txt} + + ))} + + + +
+ + +
+ +
+ {t("settings.alsoFile")} + patch.mutate({ invitation_file: v })} + /> +
+ + ); } diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py index 4596b6d..c42e670 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py @@ -8,9 +8,9 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars from __future__ import annotations -import base64 import json import logging +import secrets import uuid from datetime import UTC, datetime, timedelta from pathlib import Path @@ -68,20 +68,10 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non # --- calendars --------------------------------------------------------------- -def _make_publish_token(user_id: uuid.UUID, calendar_id: uuid.UUID) -> str: - """Vault-seal {user_id, calendar_id} so the public /public/{token} feed can open - the right RLS session without exposing the ids or needing auth.""" - payload = json.dumps({"u": str(user_id), "c": str(calendar_id)}).encode() - return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode().rstrip("=") - - -def _read_publish_token(token: str) -> tuple[uuid.UUID, uuid.UUID] | None: - try: - pad = "=" * (-len(token) % 4) - data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token + pad))) - return uuid.UUID(data["u"]), uuid.UUID(data["c"]) - except Exception: - return None +def _make_publish_token() -> str: + """A short, unguessable slug for the public /public/{token} .ics feed. Stored in + plg_calendar_calendars.publish_token; the public route resolves it directly.""" + return secrets.token_urlsafe(12) def _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes: @@ -199,7 +189,7 @@ async def patch_calendar( {"id": str(calendar_id)}, ) ).scalar_one_or_none() - token = existing or _make_publish_token(user_id, calendar_id) + token = existing or _make_publish_token() set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"] params["ptok"] = token params["pbusy"] = publish == "busy" @@ -700,23 +690,21 @@ async def delete_event( @router.get("/public/{token}") async def public_calendar(token: str) -> Response: - parsed = _read_publish_token(token) - if parsed is None: - raise HTTPException(status_code=404, detail="not_found") - user_id, cal_id = parsed - async with _SDK.session(user_id) as s: + # Public + unauthenticated: resolve the opaque slug directly (privileged session, + # every query filtered explicitly by the token / its calendar id — no RLS). + async with _SDK.public_session() as s: row = ( await s.execute( text( - "SELECT name, publish_token, publish_busy " - "FROM plg_calendar_calendars WHERE id = :id" + "SELECT id, name, publish_busy FROM plg_calendar_calendars " + "WHERE publish_token = :tok" ), - {"id": str(cal_id)}, + {"tok": token}, ) ).mappings().first() - if row is None or row["publish_token"] is None: + if row is None: raise HTTPException(status_code=404, detail="not_found") - rows = await _calendar_event_rows(s, cal_id) + rows = await _calendar_event_rows(s, row["id"]) return Response( content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]), media_type="text/calendar", diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index f7482b6..32e8cbe 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -1199,8 +1199,9 @@ export default function mount(el, ctx) { state.menuOpen = false; if (a.to) ctx.navigate(a.to); else render(); }); - act("settings", () => ctx.navigate("/settings")); - act("add-calendar", () => ctx.navigate("/settings/calendars?create=1")); + // Pass ?from=/p/calendar so Settings' back button returns here, not to Mail. + act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcalendar")); + act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1")); act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); }); act("new", () => openForm(null)); act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); @@ -1373,8 +1374,11 @@ export default function mount(el, ctx) { HOUR12 = s && s.time_format === "12"; // 24h is the default state.jitsi = (s && s.jitsi_server) || ""; state.defaultCalendar = (s && s.default_calendar) || ""; - state.defaultRemTimed = (s && s.default_reminders_timed) || []; - state.defaultRemAllday = (s && s.default_reminders_allday) || []; + // Reminders are stored as {method, minutes}; event reminders only use minutes. + const remMins = (arr) => + (arr || []).map((x) => (typeof x === "number" ? x : (x && x.minutes) || 0)); + state.defaultRemTimed = remMins(s && s.default_reminders_timed); + state.defaultRemAllday = remMins(s && s.default_reminders_allday); } catch { /* keep defaults (24h, public Jitsi) */ } try { const q = await api.fetch("/settings/quota"); diff --git a/screenshots/calendar-set-4.png b/screenshots/calendar-set-4.png new file mode 100644 index 0000000..23b41e2 Binary files /dev/null and b/screenshots/calendar-set-4.png differ diff --git a/screenshots/contacts-1.png b/screenshots/contacts-1.png new file mode 100644 index 0000000..0ce1233 Binary files /dev/null and b/screenshots/contacts-1.png differ