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>
265 lines
9.4 KiB
Python
265 lines
9.4 KiB
Python
"""Invitations that arrived by mail: list them, accept or decline, dismiss.
|
|
|
|
The decision is the only thing that turns an invitation into a calendar entry.
|
|
Everything a mail can do on its own stops at the event_invites table.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
import mail_invites
|
|
import models
|
|
import permissions
|
|
from auth import get_current_user
|
|
from database import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _invite_dict(inv: models.EventInvite) -> dict:
|
|
return {
|
|
"id": inv.id,
|
|
"uid": inv.uid,
|
|
"status": inv.status,
|
|
"title": inv.title,
|
|
"start": inv.start,
|
|
"end": inv.end,
|
|
"all_day": bool(inv.all_day),
|
|
"location": inv.location,
|
|
"description": inv.description,
|
|
"rrule": inv.rrule,
|
|
"organizer_email": inv.organizer_email,
|
|
"organizer_name": inv.organizer_name,
|
|
"attendee_email": inv.attendee_email,
|
|
"reply_state": inv.reply_state,
|
|
"received_at": inv.received_at.isoformat() if inv.received_at else None,
|
|
}
|
|
|
|
|
|
def _own_invite(db: Session, user: models.User, invite_id: int) -> models.EventInvite:
|
|
inv = (db.query(models.EventInvite)
|
|
.filter(models.EventInvite.id == invite_id,
|
|
models.EventInvite.user_id == user.id).first())
|
|
if inv is None:
|
|
raise HTTPException(404, "Einladung nicht gefunden")
|
|
return inv
|
|
|
|
|
|
def _default_invite_calendar(db: Session, user: models.User) -> models.LocalCalendar:
|
|
"""Where an accepted invitation lands when the client names no calendar.
|
|
|
|
The user's own, non-birthday calendar with the lowest id — the same one they
|
|
would have picked by hand in almost every case. A birthday calendar is
|
|
excluded because the server injects reminders into those.
|
|
"""
|
|
cal = (db.query(models.LocalCalendar)
|
|
.filter(models.LocalCalendar.user_id == user.id,
|
|
models.LocalCalendar.is_birthday.isnot(True))
|
|
.order_by(models.LocalCalendar.id).first())
|
|
if cal:
|
|
return cal
|
|
cal = models.LocalCalendar(user_id=user.id, name="Einladungen", color="#4285f4")
|
|
db.add(cal)
|
|
db.flush()
|
|
return cal
|
|
|
|
|
|
@router.get("")
|
|
def list_invites(db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user)):
|
|
"""Everything still awaiting a decision, plus withdrawn ones.
|
|
|
|
Cancelled invitations stay visible on purpose: a withdrawal the user never
|
|
sees is worse than one more row to dismiss.
|
|
"""
|
|
rows = (db.query(models.EventInvite)
|
|
.filter(models.EventInvite.user_id == current_user.id,
|
|
models.EventInvite.status.in_(("pending", "cancelled")))
|
|
.order_by(models.EventInvite.start).all())
|
|
return [_invite_dict(i) for i in rows]
|
|
|
|
|
|
class InviteRespond(BaseModel):
|
|
action: str # accept | decline
|
|
calendar_id: Optional[int] = None
|
|
|
|
|
|
@router.post("/{invite_id}/respond")
|
|
def respond(invite_id: int, data: InviteRespond,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user)):
|
|
inv = _own_invite(db, current_user, invite_id)
|
|
if data.action not in ("accept", "decline"):
|
|
raise HTTPException(422, "action muss accept oder decline sein")
|
|
if inv.status == "cancelled":
|
|
raise HTTPException(409, "Die Einladung wurde zurückgezogen.")
|
|
|
|
cal = None
|
|
if data.action == "accept":
|
|
# The target is checked server-side; a client cannot name a calendar it
|
|
# may not write to.
|
|
cal = (permissions.accessible_local_calendar(db, current_user, data.calendar_id,
|
|
require_write=True)
|
|
if data.calendar_id else _default_invite_calendar(db, current_user))
|
|
mail_invites.upsert_event(db, inv, cal)
|
|
inv.status = "accepted"
|
|
else:
|
|
mail_invites.drop_event(db, inv)
|
|
inv.status = "declined"
|
|
|
|
# Committed before any mail is attempted: a broken SMTP must never roll back
|
|
# a decision the user already made.
|
|
db.commit()
|
|
return {"ok": True, "status": inv.status, "reply_state": inv.reply_state,
|
|
"calendar_id": cal.id if cal else None}
|
|
|
|
|
|
@router.delete("/{invite_id}")
|
|
def dismiss(invite_id: int, db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user)):
|
|
"""Clear an invitation from the list. An accepted event stays."""
|
|
inv = _own_invite(db, current_user, invite_id)
|
|
db.delete(inv)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/status")
|
|
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."""
|
|
_require_admin(current_user)
|
|
if not mail_invites.is_configured(db):
|
|
raise HTTPException(400, "Kein Postfach konfiguriert")
|
|
mail_invites.fetch_invites(db)
|
|
acc = mail_invites.get_account(db)
|
|
return {"ok": True, "last_error": acc.last_error if acc else None}
|