diff --git a/README.md b/README.md index 7124516..d96fe2e 100644 --- a/README.md +++ b/README.md @@ -112,12 +112,7 @@ Configuration is done via environment variables. The `install.sh` script generat | `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs | | `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up | | `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background | -| `IMAP_HOST` | — | Mailbox watched for invitations. Unset disables the whole feature *(optional)* | -| `IMAP_PORT` | `993` | | -| `IMAP_SSL` | `1` | `0` for a plain connection | -| `IMAP_USER` / `IMAP_PASSWORD` | — | Mailbox credentials | -| `IMAP_FOLDER` | `INBOX` | | -| `MAIL_POLL_MINUTES` | `5` | How often the mailbox is read | +| `MAIL_POLL_MINUTES` | `5` | How often the watched mailbox is read | | `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* | Per provider `` listed in `OIDC_PROVIDERS` (variable names are uppercased): @@ -245,7 +240,7 @@ The server runs two periodic jobs in-process: |---|---|---| | `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record | | `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due | -| `mail.invites` | 5 min | Reads the watched mailbox for invitations *(only when `IMAP_HOST` is set)* | +| `mail.invites` | 5 min | Reads the watched mailbox for invitations *(idle until one is configured)* | #### Invitations by mail @@ -259,9 +254,12 @@ That matters when mail is forwarded: the invitation still names the original address, not the mailbox it arrived in, so put the address people actually invite into the user's profile. -Credentials live in the environment rather than the database, so the one secret -that opens a whole mailbox is never returned by any endpoint. One mailbox per -instance; a forwarding target is the intended setup. +The mailbox is set up under **Settings → Invitations** by an administrator: IMAP +host, user, password, folder and how often to check. One mailbox per instance — +it is a shared forwarding target, not a per-user account. The password is stored +the same way CalDAV, Google and Home Assistant credentials are, and no endpoint +returns it. Worth knowing before you point this at a real account: unlike those +three, an IMAP password opens an entire mailbox. Attachments are normally cleaned up the moment an event, calendar or user is deleted; the sweep is the safety net for what gets past that — an external diff --git a/backend/mail_invites.py b/backend/mail_invites.py index 50a7500..cf52b1b 100644 --- a/backend/mail_invites.py +++ b/backend/mail_invites.py @@ -6,9 +6,10 @@ mail can only ever create a row in ``event_invites``. No calendar read path touches that table — an invitation becomes an event when a human accepts it, and not before. -Credentials live in the environment, never in the database. The service already -loads an EnvironmentFile, and this keeps the one secret that grants access to a -whole mailbox out of a table that an endpoint could return. +The mailbox is configured in the UI and stored in ``mailbox_accounts`` — one row +per instance, since it is a shared forwarding target rather than a per-user +account. The password is kept as given, like every other external account here; +no endpoint returns it. Single process assumed, same as the rest of the scheduler (see scheduler.py). """ @@ -18,7 +19,6 @@ from __future__ import annotations import contextlib import imaplib import logging -import os import re import uuid from datetime import datetime, timezone @@ -49,8 +49,15 @@ _ADDR_RE = re.compile(r"^[^@\s<>\",;:]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") _HANDLED_METHODS = ("REQUEST", "CANCEL") -def is_configured() -> bool: - return bool(os.environ.get("IMAP_HOST")) +def get_account(db: Session): + """The configured mailbox, or None. Disabled counts as not configured.""" + return (db.query(models.MailboxAccount) + .filter(models.MailboxAccount.enabled.is_(True)) + .order_by(models.MailboxAccount.id).first()) + + +def is_configured(db: Session) -> bool: + return get_account(db) is not None def _valid_address(addr: str | None) -> str | None: @@ -193,13 +200,10 @@ def ingest_ics(db: Session, raw: bytes) -> bool: # ── Mailbox ─────────────────────────────────────────────── -def _connect() -> imaplib.IMAP4: - host = os.environ["IMAP_HOST"] - port = int(os.environ.get("IMAP_PORT", "993")) - use_ssl = os.environ.get("IMAP_SSL", "1") != "0" - box = (imaplib.IMAP4_SSL(host, port, timeout=30) if use_ssl - else imaplib.IMAP4(host, port, timeout=30)) - box.login(os.environ["IMAP_USER"], os.environ["IMAP_PASSWORD"]) +def _connect(acc) -> imaplib.IMAP4: + box = (imaplib.IMAP4_SSL(acc.imap_host, acc.imap_port, timeout=30) if acc.imap_ssl + else imaplib.IMAP4(acc.imap_host, acc.imap_port, timeout=30)) + box.login(acc.imap_user, acc.imap_password) return box @@ -246,12 +250,29 @@ def _handle_message(db: Session, box: imaplib.IMAP4, uid: bytes) -> None: def fetch_invites(db: Session) -> None: - """Scheduler job: read new mail and store any invitations it holds.""" - if not is_configured(): + """Scheduler job: read new mail and store any invitations it holds. + + Records what happened on the account so the UI can show a mailbox that + stopped working, rather than letting invitations quietly never arrive. + """ + acc = get_account(db) + if acc is None: return - box = _connect() try: - box.select(os.environ.get("IMAP_FOLDER", "INBOX")) + _run(db, acc) + acc.last_error = None + except Exception as exc: + logger.exception("Postfach %s nicht erreichbar", acc.imap_host) + acc.last_error = f"{type(exc).__name__}: {exc}"[:500] + acc.last_run = datetime.now(timezone.utc) + with contextlib.suppress(Exception): + db.commit() + + +def _run(db: Session, acc) -> None: + box = _connect(acc) + try: + box.select(acc.imap_folder or "INBOX") # UID commands, not sequence numbers: those shift when a message is # expunged mid-run and we would read the wrong mail. typ, data = box.uid("SEARCH", None, "UNSEEN") diff --git a/backend/models.py b/backend/models.py index e037991..44de68a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -418,6 +418,46 @@ class UserEmailAddress(Base): user = relationship("User") +class MailboxAccount(Base): + """The mailbox watched for invitations, configured in the UI. + + One row per instance: this is a shared inbox that invitations are forwarded + to, not a per-user account. Administrators manage it. + + The password is stored as given, the same as CalDAVAccount, GoogleAccount + and HomeAssistantAccount — IMAP needs the original to log in, so a hash is + no use. Worth knowing: unlike those three, this one opens an entire mailbox. + No endpoint ever returns it. + + SMTP lives here too so replies to organisers have somewhere to come from. + """ + + __tablename__ = "mailbox_accounts" + + id = Column(Integer, primary_key=True, index=True) + enabled = Column(Boolean, default=True, nullable=False) + + imap_host = Column(String(255), nullable=False) + imap_port = Column(Integer, default=993, nullable=False) + imap_ssl = Column(Boolean, default=True, nullable=False) + imap_user = Column(String(255), nullable=False) + imap_password = Column(String(255), nullable=False) + imap_folder = Column(String(255), default="INBOX", nullable=False) + poll_minutes = Column(Integer, default=5, nullable=False) + + smtp_host = Column(String(255), nullable=True) + smtp_port = Column(Integer, default=587, nullable=True) + smtp_ssl = Column(Boolean, default=False, nullable=True) + smtp_user = Column(String(255), nullable=True) + smtp_password = Column(String(255), nullable=True) + smtp_from = Column(String(255), nullable=True) + + # Surfaced in the UI so a mailbox that stopped working says so, instead of + # invitations quietly never arriving. + last_run = Column(DateTime, nullable=True) + last_error = Column(Text, nullable=True) + + class EventInvite(Base): """An iTIP invitation (METHOD:REQUEST) that arrived by mail. diff --git a/backend/routers/invites_router.py b/backend/routers/invites_router.py index 031778c..f4ded0b 100644 --- a/backend/routers/invites_router.py +++ b/backend/routers/invites_router.py @@ -131,21 +131,134 @@ def dismiss(invite_id: int, db: Session = Depends(get_db), @router.get("/status") -def status(current_user: models.User = Depends(get_current_user)): - return {"imap_configured": mail_invites.is_configured()} +def status(db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user)): + acc = mail_invites.get_account(db) + return { + "imap_configured": acc is not None, + "smtp_configured": bool(acc and acc.smtp_host), + "last_run": acc.last_run.isoformat() if acc and acc.last_run else None, + "last_error": acc.last_error if acc else None, + } + + +# ── Mailbox configuration ───────────────────────────────── +# +# One mailbox per instance: it is a shared forwarding target, not a per-user +# account, so only administrators may see or change it. + + +def _require_admin(user: models.User) -> None: + if not user.is_admin: + raise HTTPException(403, "Nur für Administratoren") + + +def _mailbox_dict(acc) -> dict | None: + if acc is None: + return None + # Passwords are never returned, the same as the CalDAV account endpoints. + return { + "id": acc.id, + "enabled": bool(acc.enabled), + "imap_host": acc.imap_host, + "imap_port": acc.imap_port, + "imap_ssl": bool(acc.imap_ssl), + "imap_user": acc.imap_user, + "imap_folder": acc.imap_folder, + "poll_minutes": acc.poll_minutes, + "has_imap_password": bool(acc.imap_password), + "smtp_host": acc.smtp_host, + "smtp_port": acc.smtp_port, + "smtp_ssl": bool(acc.smtp_ssl), + "smtp_user": acc.smtp_user, + "smtp_from": acc.smtp_from, + "has_smtp_password": bool(acc.smtp_password), + "last_run": acc.last_run.isoformat() if acc.last_run else None, + "last_error": acc.last_error, + } + + +class MailboxUpdate(BaseModel): + enabled: bool = True + imap_host: str + imap_port: int = 993 + imap_ssl: bool = True + imap_user: str + # Optional on edit: left out keeps the stored one, so changing a port does + # not mean retyping the password. + imap_password: Optional[str] = None + imap_folder: str = "INBOX" + poll_minutes: int = 5 + smtp_host: Optional[str] = None + smtp_port: Optional[int] = 587 + smtp_ssl: Optional[bool] = False + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None + smtp_from: Optional[str] = None + + +@router.get("/mailbox") +def get_mailbox(db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user)): + _require_admin(current_user) + return _mailbox_dict( + db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first()) + + +@router.put("/mailbox") +def set_mailbox(data: MailboxUpdate, db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user)): + _require_admin(current_user) + acc = db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first() + if acc is None: + acc = models.MailboxAccount(imap_host="", imap_user="", imap_password="") + db.add(acc) + + if not data.imap_password and not acc.imap_password: + raise HTTPException(422, "Passwort fehlt") + if not 1 <= data.poll_minutes <= 1440: + raise HTTPException(422, "Abrufintervall muss zwischen 1 und 1440 Minuten liegen") + + acc.enabled = data.enabled + acc.imap_host = data.imap_host.strip() + acc.imap_port = data.imap_port + acc.imap_ssl = data.imap_ssl + acc.imap_user = data.imap_user.strip() + if data.imap_password: + acc.imap_password = data.imap_password + acc.imap_folder = (data.imap_folder or "INBOX").strip() + acc.poll_minutes = data.poll_minutes + acc.smtp_host = (data.smtp_host or "").strip() or None + acc.smtp_port = data.smtp_port + acc.smtp_ssl = bool(data.smtp_ssl) + acc.smtp_user = (data.smtp_user or "").strip() or None + if data.smtp_password: + acc.smtp_password = data.smtp_password + acc.smtp_from = (data.smtp_from or "").strip() or None + # Settings changed, so whatever went wrong before is no longer current. + acc.last_error = None + db.commit() + return _mailbox_dict(acc) + + +@router.delete("/mailbox") +def delete_mailbox(db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user)): + _require_admin(current_user) + acc = db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first() + if acc: + db.delete(acc) + db.commit() + return {"ok": True} @router.post("/poll") def poll_now(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)): - """Fetch right now instead of waiting for the scheduler. - - Admin-only: it opens a connection to the shared mailbox, so it is not a - per-user action. - """ - if not current_user.is_admin: - raise HTTPException(403, "Nur für Administratoren") - if not mail_invites.is_configured(): + """Fetch right now instead of waiting for the scheduler.""" + _require_admin(current_user) + if not mail_invites.is_configured(db): raise HTTPException(400, "Kein Postfach konfiguriert") mail_invites.fetch_invites(db) - return {"ok": True} + acc = mail_invites.get_account(db) + return {"ok": True, "last_error": acc.last_error if acc else None} diff --git a/backend/scheduler.py b/backend/scheduler.py index 6cbdcd6..62b7d5d 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -145,12 +145,11 @@ def install_default_jobs() -> None: ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15")) register("attachments.sweep", sweep_hours * 3600, sweep_attachments) register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions) - # Only when a mailbox is configured: an unconditional job would log every - # few minutes that it has nothing to do, and test_scheduler asserts the - # exact job set. - if os.environ.get("IMAP_HOST"): - poll_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5")) - register("mail.invites", poll_minutes * 60, fetch_mail_invites) + # Always registered: the mailbox is configured in the UI, so whether there + # is one to read can change without a restart. fetch_invites returns at once + # when none is set up. + mail_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5")) + register("mail.invites", mail_minutes * 60, fetch_mail_invites) @asynccontextmanager diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py index fc4ea27..30cc15f 100644 --- a/backend/tests/test_invites.py +++ b/backend/tests/test_invites.py @@ -217,3 +217,70 @@ def test_oversized_payload_is_refused(client): _admin_with_email(client) padded = _ics(summary="X" * (mail_invites.MAX_ICS_BYTES + 10)) assert _ingest(padded) is False + + +# ── Mailbox configuration ───────────────────────────────── + + +def _mailbox_payload(**over): + body = {"imap_host": "imap.example.com", "imap_user": "einladungen@example.com", + "imap_password": "geheim", "imap_port": 993, "poll_minutes": 5} + body.update(over) + return body + + +def test_mailbox_password_is_never_returned(client): + token = register_admin(client) + client.put("/api/invites/mailbox", json=_mailbox_payload(), headers=auth(token)) + + body = client.get("/api/invites/mailbox", headers=auth(token)).json() + assert body["imap_host"] == "imap.example.com" + assert body["has_imap_password"] is True + assert "imap_password" not in body + + +def test_editing_without_a_password_keeps_the_stored_one(client): + token = register_admin(client) + client.put("/api/invites/mailbox", json=_mailbox_payload(), headers=auth(token)) + client.put("/api/invites/mailbox", + json=_mailbox_payload(imap_password=None, imap_port=143), + headers=auth(token)) + + db = SessionLocal() + try: + acc = db.query(models.MailboxAccount).one() + assert acc.imap_port == 143 + assert acc.imap_password == "geheim" + finally: + db.close() + + +def test_first_save_requires_a_password(client): + token = register_admin(client) + res = client.put("/api/invites/mailbox", json=_mailbox_payload(imap_password=None), + headers=auth(token)) + assert res.status_code == 422 + + +def test_only_admins_touch_the_mailbox(client): + from conftest import create_user + + admin = register_admin(client) + user = create_user(client, admin, "lisa") + assert client.get("/api/invites/mailbox", headers=auth(user)).status_code == 403 + assert client.put("/api/invites/mailbox", json=_mailbox_payload(), + headers=auth(user)).status_code == 403 + + +def test_disabled_mailbox_counts_as_unconfigured(client): + token = register_admin(client) + client.put("/api/invites/mailbox", json=_mailbox_payload(enabled=False), + headers=auth(token)) + + assert client.get("/api/invites/status", headers=auth(token)).json()["imap_configured"] is False + + db = SessionLocal() + try: + assert mail_invites.get_account(db) is None + finally: + db.close() diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index 010edbd..3fde1bf 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -19,9 +19,10 @@ def _run(fn): def test_default_jobs_are_registered_with_sane_intervals(): scheduler.install_default_jobs() names = {j.name: j for j in scheduler._jobs} - assert set(names) == {"attachments.sweep", "ical.refresh"} + assert set(names) == {"attachments.sweep", "ical.refresh", "mail.invites"} assert names["attachments.sweep"].interval == 24 * 3600 assert names["ical.refresh"].interval == 15 * 60 + assert names["mail.invites"].interval == 5 * 60 # Nothing runs immediately: a restart must not be slowed by maintenance. assert all(j.next_run > time.monotonic() for j in scheduler._jobs) diff --git a/frontend/index.html b/frontend/index.html index aa5d48d..ca1c5d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -235,6 +235,14 @@
+ + + @@ -904,6 +912,46 @@ + +

Einladungs-Postfach

+

Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + +
+

Standard-Theme

Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 58e57f2..92a691c 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -179,6 +179,7 @@ export async function initCalendar() { bindSwipeNavigation(); handleHAOAuthReturn(); loadGroups(); + loadInvites(); try { await fetchAndRender(); } catch (e) { @@ -3717,7 +3718,7 @@ function populateSettings() { const user = JSON.parse(localStorage.getItem('user') || '{}'); const usersNavBtn = document.getElementById('settings-nav-users'); if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin); - if (user.is_admin) { loadUsers(); loadAdminPanel(); } + if (user.is_admin) { loadUsers(); loadAdminPanel(); loadMailbox(); } // Activate panel from URL or fall back to first visible const urlTab = readUrlState().stab; @@ -4772,6 +4773,148 @@ function renderCalendarTable() { }); } +// ── Einladungs-Postfach (nur Admin) ────────────────────── + +function mailboxFields() { + return { + enabled: document.getElementById('mb-enabled').checked, + imap_host: document.getElementById('mb-imap-host').value.trim(), + imap_user: document.getElementById('mb-imap-user').value.trim(), + imap_password: document.getElementById('mb-imap-password').value || null, + imap_port: parseInt(document.getElementById('mb-imap-port').value) || 993, + imap_ssl: document.getElementById('mb-imap-ssl').checked, + imap_folder: document.getElementById('mb-imap-folder').value.trim() || 'INBOX', + poll_minutes: parseInt(document.getElementById('mb-poll-minutes').value) || 5, + }; +} + +function renderMailboxStatus(cfg) { + const el = document.getElementById('mailbox-status'); + if (!el) return; + if (!cfg) { el.textContent = t('invites_mailbox_none'); return; } + // A mailbox that stopped working has to say so, or invitations simply stop + // arriving and nothing explains why. + if (cfg.last_error) { + el.textContent = `${t('invites_last_error')}: ${cfg.last_error}`; + el.style.color = 'var(--danger, #ea4335)'; + } else { + el.textContent = cfg.last_run + ? `${t('invites_last_run')}: ${new Date(cfg.last_run).toLocaleString()}` + : t('invites_never_run'); + el.style.color = ''; + } +} + +async function loadMailbox() { + const host = document.getElementById('mb-imap-host'); + if (!host) return; + let cfg = null; + try { cfg = await api.get('/invites/mailbox'); } catch { return; } + renderMailboxStatus(cfg); + if (!cfg) return; + host.value = cfg.imap_host || ''; + document.getElementById('mb-imap-user').value = cfg.imap_user || ''; + document.getElementById('mb-imap-port').value = cfg.imap_port || 993; + document.getElementById('mb-imap-folder').value = cfg.imap_folder || 'INBOX'; + document.getElementById('mb-poll-minutes').value = cfg.poll_minutes || 5; + document.getElementById('mb-imap-ssl').checked = cfg.imap_ssl !== false; + document.getElementById('mb-enabled').checked = cfg.enabled !== false; + // Left blank on purpose: an empty field means "keep what is stored". + document.getElementById('mb-imap-password').value = ''; +} + +function wireMailbox() { + const save = document.getElementById('mb-save'); + if (!save) return; + save.addEventListener('click', async () => { + try { + const cfg = await api.put('/invites/mailbox', mailboxFields()); + document.getElementById('mb-imap-password').value = ''; + renderMailboxStatus(cfg); + showToast(t('settings_saved')); + } catch (e) { showToast(e.message, true); } + }); + document.getElementById('mb-poll').addEventListener('click', async () => { + try { + const r = await api.post('/invites/poll', {}); + showToast(r.last_error ? r.last_error : t('invites_polled'), !!r.last_error); + await loadMailbox(); + await loadInvites(); + } catch (e) { showToast(e.message, true); } + }); + document.getElementById('mb-delete').addEventListener('click', async () => { + if (!await confirmModal(t('invites_mailbox_remove_confirm'), { danger: true })) return; + try { + await api.delete('/invites/mailbox'); + document.querySelectorAll('#settings-panel-users input[id^="mb-"]').forEach(el => { + if (el.type === 'checkbox') el.checked = true; else el.value = ''; + }); + renderMailboxStatus(null); + showToast(t('settings_saved')); + } catch (e) { showToast(e.message, true); } + }); +} + +// ── Anfragen ───────────────────────────────────────────── + +async function loadInvites() { + const box = document.getElementById('invite-list'); + if (!box) return; + let rows = []; + try { rows = await api.get('/invites'); } catch { rows = []; } + box.classList.toggle('hidden', rows.length === 0); + const items = document.getElementById('invite-list-items'); + items.innerHTML = ''; + for (const inv of rows) { + const row = document.createElement('div'); + row.className = 'cal-item'; + const name = document.createElement('span'); + name.className = 'cal-item-name'; + // textContent throughout: this text came from a stranger's mail. + name.textContent = inv.title; + name.title = `${fmtInviteWhen(inv)}${inv.organizer_name ? ' · ' + inv.organizer_name : ''}`; + row.appendChild(name); + if (inv.status === 'cancelled') { + const tag = document.createElement('span'); + tag.className = 'cal-badge'; + tag.textContent = t('invite_cancelled'); + row.appendChild(tag); + row.appendChild(inviteBtn(inv, 'dismiss', '✕')); + } else { + row.appendChild(inviteBtn(inv, 'accept', '✓')); + row.appendChild(inviteBtn(inv, 'decline', '✕')); + } + items.appendChild(row); + } +} + +function fmtInviteWhen(inv) { + try { + const d = new Date(inv.start); + return inv.all_day ? d.toLocaleDateString() : d.toLocaleString(); + } catch { return inv.start; } +} + +function inviteBtn(inv, action, label) { + const b = document.createElement('button'); + b.className = 'icon-btn mini-btn'; + b.textContent = label; + b.title = t(`invite_${action}`); + b.addEventListener('click', async () => { + try { + if (action === 'dismiss') { + await api.delete(`/invites/${inv.id}`); + } else { + await api.post(`/invites/${inv.id}/respond`, { action }); + showToast(t(action === 'accept' ? 'invite_accepted' : 'invite_declined')); + } + await loadInvites(); + if (action === 'accept') fetchAndRender(true); + } catch (e) { showToast(e.message, true); } + }); + return b; +} + async function loadUsers() { try { const users = await api.get('/users/'); @@ -4992,6 +5135,7 @@ function bindSettingsModal() { // Admin panel (default-theme editor + branding) — buttons bound once. bindAdminPanel(); + wireMailbox(); // Theme export / import (client-side .theme files). const themeExportBtn = document.getElementById('cfg-theme-export'); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 287e667..83da5e4 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -1,5 +1,31 @@ const translations = { de: { + invites_title: 'Anfragen', + invite_accept: 'Annehmen', + invite_decline: 'Ablehnen', + invite_dismiss: 'Entfernen', + invite_cancelled: 'Zurückgezogen', + invite_accepted: 'Einladung angenommen', + invite_declined: 'Einladung abgelehnt', + invites_mailbox: 'Einladungs-Postfach', + invites_mailbox_desc: 'Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.', + invites_mailbox_none: 'Kein Postfach konfiguriert', + invites_mailbox_remove: 'Postfach entfernen', + invites_mailbox_remove_confirm: 'Postfach wirklich entfernen? Bereits empfangene Anfragen bleiben erhalten.', + invites_imap_host: 'IMAP-Server', + invites_imap_user: 'Benutzername', + invites_imap_password: 'Passwort', + invites_imap_port: 'Port', + invites_imap_folder: 'Ordner', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Aktiv', + invites_poll_minutes: 'Abrufintervall (Minuten)', + invites_poll_now: 'Jetzt abrufen', + invites_polled: 'Postfach abgerufen', + invites_password_keep: 'Unverändert lassen', + invites_last_run: 'Zuletzt abgerufen', + invites_last_error: 'Letzter Fehler', + invites_never_run: 'Noch nicht abgerufen', // Common save: 'Speichern', cancel: 'Abbrechen', close: 'Schliessen', create: 'Erstellen', delete: 'Löschen', edit: 'Bearbeiten', @@ -433,6 +459,32 @@ const translations = { }, ch: { + invites_title: 'Aafrage', + invite_accept: 'Aanäh', + invite_decline: 'Ablehne', + invite_dismiss: 'Entfärne', + invite_cancelled: 'Zruggzoge', + invite_accepted: 'Iiladig aagnoh', + invite_declined: 'Iiladig abglehnt', + invites_mailbox: 'Iiladigs-Poschtfach', + invites_mailbox_desc: 'Es Poschtfach, wo Termin-Iiladige akömed. Sie erschiined als Aafrag und wärded erscht zu Termin, wänn du sie aanimmsch. Zuegordnet wird über d Teilnehmer-Adräss vo dr Iiladig — trag d Adräss, unter dere du iiglade wirsch, i dim Profil ii.', + invites_mailbox_none: 'Kes Poschtfach konfiguriert', + invites_mailbox_remove: 'Poschtfach entfärne', + invites_mailbox_remove_confirm: 'Poschtfach würkli entfärne? Scho empfangeni Aafrage blibed erhalte.', + invites_imap_host: 'IMAP-Server', + invites_imap_user: 'Benutzername', + invites_imap_password: 'Passwort', + invites_imap_port: 'Port', + invites_imap_folder: 'Ordner', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Aktiv', + invites_poll_minutes: 'Abruefintervall (Minute)', + invites_poll_now: 'Jetz abruefe', + invites_polled: 'Poschtfach abgruefe', + invites_password_keep: 'Unverändert la', + invites_last_run: 'Zletscht abgruefe', + invites_last_error: 'Letschte Fähler', + invites_never_run: 'No nid abgruefe', // Common save: 'Speicherä', cancel: 'Abbräche', close: 'Schlüsse', create: 'Erstellä', delete: 'Löschä', edit: 'Bearbeitä', @@ -828,6 +880,32 @@ const translations = { }, en: { + invites_title: 'Requests', + invite_accept: 'Accept', + invite_decline: 'Decline', + invite_dismiss: 'Dismiss', + invite_cancelled: 'Withdrawn', + invite_accepted: 'Invitation accepted', + invite_declined: 'Invitation declined', + invites_mailbox: 'Invitation mailbox', + invites_mailbox_desc: 'A mailbox that invitations arrive in. They show up as requests and only become events once you accept them. An invitation is matched by its attendee address — put the address people invite you under in your profile.', + invites_mailbox_none: 'No mailbox configured', + invites_mailbox_remove: 'Remove mailbox', + invites_mailbox_remove_confirm: 'Remove the mailbox? Requests already received are kept.', + invites_imap_host: 'IMAP server', + invites_imap_user: 'Username', + invites_imap_password: 'Password', + invites_imap_port: 'Port', + invites_imap_folder: 'Folder', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Enabled', + invites_poll_minutes: 'Check every (minutes)', + invites_poll_now: 'Check now', + invites_polled: 'Mailbox checked', + invites_password_keep: 'Leave unchanged', + invites_last_run: 'Last checked', + invites_last_error: 'Last error', + invites_never_run: 'Not checked yet', // Common save: 'Save', cancel: 'Cancel', close: 'Close', create: 'Create', delete: 'Delete', edit: 'Edit', @@ -1261,6 +1339,32 @@ const translations = { }, fr: { + invites_title: 'Demandes', + invite_accept: 'Accepter', + invite_decline: 'Refuser', + invite_dismiss: 'Retirer', + invite_cancelled: 'Annulée', + invite_accepted: 'Invitation acceptée', + invite_declined: 'Invitation refusée', + invites_mailbox: 'Boîte aux lettres des invitations', + invites_mailbox_desc: "Une boîte dans laquelle arrivent les invitations. Elles apparaissent comme demandes et ne deviennent des événements qu'une fois acceptées. L'attribution se fait par l'adresse du participant — indiquez dans votre profil l'adresse sous laquelle on vous invite.", + invites_mailbox_none: 'Aucune boîte configurée', + invites_mailbox_remove: 'Supprimer la boîte', + invites_mailbox_remove_confirm: 'Supprimer la boîte ? Les demandes déjà reçues sont conservées.', + invites_imap_host: 'Serveur IMAP', + invites_imap_user: "Nom d'utilisateur", + invites_imap_password: 'Mot de passe', + invites_imap_port: 'Port', + invites_imap_folder: 'Dossier', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Activé', + invites_poll_minutes: 'Intervalle (minutes)', + invites_poll_now: 'Relever maintenant', + invites_polled: 'Boîte relevée', + invites_password_keep: 'Laisser inchangé', + invites_last_run: 'Dernier relevé', + invites_last_error: 'Dernière erreur', + invites_never_run: 'Jamais relevée', // Common save: 'Enregistrer', cancel: 'Annuler', close: 'Fermer', create: 'Créer', delete: 'Supprimer', edit: 'Modifier', @@ -1656,6 +1760,32 @@ const translations = { }, it: { + invites_title: 'Richieste', + invite_accept: 'Accetta', + invite_decline: 'Rifiuta', + invite_dismiss: 'Rimuovi', + invite_cancelled: 'Ritirata', + invite_accepted: 'Invito accettato', + invite_declined: 'Invito rifiutato', + invites_mailbox: 'Casella degli inviti', + invites_mailbox_desc: "Una casella in cui arrivano gli inviti. Compaiono come richieste e diventano eventi solo quando li accetti. L'abbinamento avviene tramite l'indirizzo del partecipante — inserisci nel profilo l'indirizzo con cui vieni invitato.", + invites_mailbox_none: 'Nessuna casella configurata', + invites_mailbox_remove: 'Rimuovi casella', + invites_mailbox_remove_confirm: 'Rimuovere la casella? Le richieste già ricevute restano.', + invites_imap_host: 'Server IMAP', + invites_imap_user: 'Nome utente', + invites_imap_password: 'Password', + invites_imap_port: 'Porta', + invites_imap_folder: 'Cartella', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Attiva', + invites_poll_minutes: 'Intervallo (minuti)', + invites_poll_now: 'Controlla ora', + invites_polled: 'Casella controllata', + invites_password_keep: 'Lascia invariato', + invites_last_run: 'Ultimo controllo', + invites_last_error: 'Ultimo errore', + invites_never_run: 'Mai controllata', // Common save: 'Salva', cancel: 'Annulla', close: 'Chiudi', create: 'Crea', delete: 'Elimina', edit: 'Modifica', @@ -2051,6 +2181,32 @@ const translations = { }, nl: { + invites_title: 'Verzoeken', + invite_accept: 'Accepteren', + invite_decline: 'Weigeren', + invite_dismiss: 'Verwijderen', + invite_cancelled: 'Ingetrokken', + invite_accepted: 'Uitnodiging geaccepteerd', + invite_declined: 'Uitnodiging geweigerd', + invites_mailbox: 'Uitnodigingen-postbus', + invites_mailbox_desc: 'Een postbus waarin uitnodigingen binnenkomen. Ze verschijnen als verzoek en worden pas afspraken zodra je ze accepteert. Toewijzing gebeurt via het deelnemersadres — zet het adres waarop je wordt uitgenodigd in je profiel.', + invites_mailbox_none: 'Geen postbus ingesteld', + invites_mailbox_remove: 'Postbus verwijderen', + invites_mailbox_remove_confirm: 'Postbus verwijderen? Reeds ontvangen verzoeken blijven staan.', + invites_imap_host: 'IMAP-server', + invites_imap_user: 'Gebruikersnaam', + invites_imap_password: 'Wachtwoord', + invites_imap_port: 'Poort', + invites_imap_folder: 'Map', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Actief', + invites_poll_minutes: 'Interval (minuten)', + invites_poll_now: 'Nu ophalen', + invites_polled: 'Postbus opgehaald', + invites_password_keep: 'Ongewijzigd laten', + invites_last_run: 'Laatst opgehaald', + invites_last_error: 'Laatste fout', + invites_never_run: 'Nog niet opgehaald', // Common save: 'Opslaan', cancel: 'Annuleren', close: 'Sluiten', create: 'Aanmaken', delete: 'Verwijderen', edit: 'Bewerken', @@ -2446,6 +2602,32 @@ const translations = { }, da: { + invites_title: 'Anmodninger', + invite_accept: 'Accepter', + invite_decline: 'Afvis', + invite_dismiss: 'Fjern', + invite_cancelled: 'Trukket tilbage', + invite_accepted: 'Invitation accepteret', + invite_declined: 'Invitation afvist', + invites_mailbox: 'Postkasse til invitationer', + invites_mailbox_desc: 'En postkasse hvor invitationer ankommer. De vises som anmodninger og bliver først til begivenheder, når du accepterer dem. Tilknytning sker via deltageradressen — angiv i din profil den adresse, du bliver inviteret på.', + invites_mailbox_none: 'Ingen postkasse konfigureret', + invites_mailbox_remove: 'Fjern postkasse', + invites_mailbox_remove_confirm: 'Fjern postkassen? Allerede modtagne anmodninger bevares.', + invites_imap_host: 'IMAP-server', + invites_imap_user: 'Brugernavn', + invites_imap_password: 'Adgangskode', + invites_imap_port: 'Port', + invites_imap_folder: 'Mappe', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Aktiv', + invites_poll_minutes: 'Interval (minutter)', + invites_poll_now: 'Hent nu', + invites_polled: 'Postkasse hentet', + invites_password_keep: 'Lad være uændret', + invites_last_run: 'Sidst hentet', + invites_last_error: 'Seneste fejl', + invites_never_run: 'Endnu ikke hentet', // Common save: 'Gem', cancel: 'Annullér', close: 'Luk', create: 'Opret', delete: 'Slet', edit: 'Redigér', @@ -2841,6 +3023,32 @@ const translations = { }, no: { + invites_title: 'Forespørsler', + invite_accept: 'Godta', + invite_decline: 'Avslå', + invite_dismiss: 'Fjern', + invite_cancelled: 'Trukket tilbake', + invite_accepted: 'Invitasjon godtatt', + invite_declined: 'Invitasjon avslått', + invites_mailbox: 'Postkasse for invitasjoner', + invites_mailbox_desc: 'En postkasse der invitasjoner kommer inn. De vises som forespørsler og blir først avtaler når du godtar dem. Tilknytning skjer via deltakeradressen — legg inn adressen du blir invitert på i profilen din.', + invites_mailbox_none: 'Ingen postkasse satt opp', + invites_mailbox_remove: 'Fjern postkasse', + invites_mailbox_remove_confirm: 'Fjerne postkassen? Allerede mottatte forespørsler beholdes.', + invites_imap_host: 'IMAP-server', + invites_imap_user: 'Brukernavn', + invites_imap_password: 'Passord', + invites_imap_port: 'Port', + invites_imap_folder: 'Mappe', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Aktiv', + invites_poll_minutes: 'Intervall (minutter)', + invites_poll_now: 'Hent nå', + invites_polled: 'Postkasse hentet', + invites_password_keep: 'La stå uendret', + invites_last_run: 'Sist hentet', + invites_last_error: 'Siste feil', + invites_never_run: 'Ikke hentet ennå', // Common save: 'Lagre', cancel: 'Avbryt', close: 'Lukk', create: 'Opprett', delete: 'Slett', edit: 'Rediger', @@ -3236,6 +3444,32 @@ const translations = { }, sv: { + invites_title: 'Förfrågningar', + invite_accept: 'Acceptera', + invite_decline: 'Avböj', + invite_dismiss: 'Ta bort', + invite_cancelled: 'Återkallad', + invite_accepted: 'Inbjudan accepterad', + invite_declined: 'Inbjudan avböjd', + invites_mailbox: 'Postlåda för inbjudningar', + invites_mailbox_desc: 'En postlåda dit inbjudningar kommer. De visas som förfrågningar och blir händelser först när du accepterar dem. Kopplingen sker via deltagaradressen — ange i din profil den adress du blir inbjuden på.', + invites_mailbox_none: 'Ingen postlåda konfigurerad', + invites_mailbox_remove: 'Ta bort postlåda', + invites_mailbox_remove_confirm: 'Ta bort postlådan? Redan mottagna förfrågningar behålls.', + invites_imap_host: 'IMAP-server', + invites_imap_user: 'Användarnamn', + invites_imap_password: 'Lösenord', + invites_imap_port: 'Port', + invites_imap_folder: 'Mapp', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Aktiv', + invites_poll_minutes: 'Intervall (minuter)', + invites_poll_now: 'Hämta nu', + invites_polled: 'Postlådan hämtad', + invites_password_keep: 'Lämna oförändrat', + invites_last_run: 'Senast hämtad', + invites_last_error: 'Senaste fel', + invites_never_run: 'Inte hämtad ännu', // Common save: 'Spara', cancel: 'Avbryt', close: 'Stäng', create: 'Skapa', delete: 'Radera', edit: 'Redigera', @@ -3631,6 +3865,32 @@ const translations = { }, fi: { + invites_title: 'Pyynnöt', + invite_accept: 'Hyväksy', + invite_decline: 'Hylkää', + invite_dismiss: 'Poista', + invite_cancelled: 'Peruttu', + invite_accepted: 'Kutsu hyväksytty', + invite_declined: 'Kutsu hylätty', + invites_mailbox: 'Kutsujen postilaatikko', + invites_mailbox_desc: 'Postilaatikko, johon kutsut saapuvat. Ne näkyvät pyyntöinä ja muuttuvat tapahtumiksi vasta kun hyväksyt ne. Kohdistus tehdään osallistujan osoitteen perusteella — lisää profiiliisi osoite, jolla sinut kutsutaan.', + invites_mailbox_none: 'Postilaatikkoa ei määritetty', + invites_mailbox_remove: 'Poista postilaatikko', + invites_mailbox_remove_confirm: 'Poistetaanko postilaatikko? Jo vastaanotetut pyynnöt säilyvät.', + invites_imap_host: 'IMAP-palvelin', + invites_imap_user: 'Käyttäjätunnus', + invites_imap_password: 'Salasana', + invites_imap_port: 'Portti', + invites_imap_folder: 'Kansio', + invites_imap_ssl: 'SSL/TLS', + invites_enabled: 'Käytössä', + invites_poll_minutes: 'Hakuväli (minuuttia)', + invites_poll_now: 'Hae nyt', + invites_polled: 'Postilaatikko haettu', + invites_password_keep: 'Jätä ennalleen', + invites_last_run: 'Viimeksi haettu', + invites_last_error: 'Viimeisin virhe', + invites_never_run: 'Ei vielä haettu', // Common save: 'Tallenna', cancel: 'Peruuta', close: 'Sulje', create: 'Luo', delete: 'Poista', edit: 'Muokkaa',