Configure the invitation mailbox in the UI, not the environment
Putting IMAP settings in environment variables was my call to make and it was the wrong one: it meant editing a file on the server and restarting to change a folder name, and it was never offered as a choice. The mailbox is now a row in mailbox_accounts, edited under Settings → Admin: host, user, password, port, folder, interval, on/off. Administrators only — it is a shared forwarding target, not a per-user account. The password is never returned by any endpoint, the same as the CalDAV account routes, and leaving the field empty on edit keeps the stored one so changing a port does not mean retyping it. The panel shows when the mailbox was last read and what went wrong if it did, because a mailbox that quietly stopped working looks exactly like one that has no mail. Worth stating plainly: the password now lives in the database, in clear text, like the CalDAV, Google and Home Assistant credentials beside it. Unlike those three, an IMAP password opens an entire mailbox. The scheduler job is registered unconditionally now — whether a mailbox exists can change without a restart, and fetch_invites returns immediately when none does. test_scheduler knows about it. Also adds the requests list to the sidebar with accept and decline, 26 translation keys across all ten languages, and tests covering that the password is never returned, that an edit without one keeps it, and that a disabled mailbox counts as unconfigured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9bf7d3797d
commit
a67602dbc2
@@ -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 `<KEY>` 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
|
||||
|
||||
+38
-17
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -235,6 +235,14 @@
|
||||
</div>
|
||||
<div id="group-list-items"></div>
|
||||
</div>
|
||||
|
||||
<!-- Invitations awaiting a decision -->
|
||||
<div class="cal-list hidden" id="invite-list">
|
||||
<div class="cal-list-header">
|
||||
<span data-i18n="invites_title">Anfragen</span>
|
||||
</div>
|
||||
<div id="invite-list-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||
</aside>
|
||||
@@ -904,6 +912,46 @@
|
||||
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
||||
</div>
|
||||
|
||||
<!-- Postfach für Einladungen -->
|
||||
<h4 class="panel-title" style="margin-top:28px"><span data-i18n="invites_mailbox">Einladungs-Postfach</span></h4>
|
||||
<p class="panel-desc" data-i18n="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.</p>
|
||||
<div id="mailbox-status" class="panel-desc"></div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="invites_imap_host">IMAP-Server</label>
|
||||
<input type="text" id="mb-imap-host" spellcheck="false" autocapitalize="none" placeholder="imap.example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="invites_imap_user">Benutzername</label>
|
||||
<input type="text" id="mb-imap-user" spellcheck="false" autocapitalize="none" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="invites_imap_password">Passwort</label>
|
||||
<input type="password" id="mb-imap-password" autocomplete="new-password" data-i18n-placeholder="invites_password_keep" placeholder="Unverändert lassen" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="invites_imap_port">Port</label>
|
||||
<input type="number" id="mb-imap-port" value="993" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="invites_imap_folder">Ordner</label>
|
||||
<input type="text" id="mb-imap-folder" value="INBOX" spellcheck="false" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="invites_poll_minutes">Abrufintervall (Minuten)</label>
|
||||
<input type="number" id="mb-poll-minutes" value="5" min="1" max="1440" />
|
||||
</div>
|
||||
<label class="toggle-label" style="margin-bottom:8px">
|
||||
<input type="checkbox" id="mb-imap-ssl" checked /> <span data-i18n="invites_imap_ssl">SSL/TLS</span>
|
||||
</label>
|
||||
<label class="toggle-label" style="margin-bottom:8px">
|
||||
<input type="checkbox" id="mb-enabled" checked /> <span data-i18n="invites_enabled">Aktiv</span>
|
||||
</label>
|
||||
<div class="admin-theme-actions">
|
||||
<button class="btn btn-primary btn-sm" id="mb-save" data-i18n="save">Speichern</button>
|
||||
<button class="btn btn-secondary btn-sm" id="mb-poll" data-i18n="invites_poll_now">Jetzt abrufen</button>
|
||||
<button class="btn btn-ghost btn-sm" id="mb-delete" data-i18n="invites_mailbox_remove">Postfach entfernen</button>
|
||||
</div>
|
||||
|
||||
<!-- Instanz-Standardtheme -->
|
||||
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
|
||||
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
|
||||
|
||||
+145
-1
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user