247 lines
11 KiB
Python
247 lines
11 KiB
Python
"""Mail account settings and the mail log (administrators), and sending reports by email (everyone)."""
|
||||
|
|
import csv
|
|||
|
|
import io
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|||
|
|
from fastapi.responses import Response
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
from sqlalchemy import String, cast, func, or_, select
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from .. import mailer
|
|||
|
|
from ..audit import diff, record, snapshot
|
|||
|
|
from ..db import get_db
|
|||
|
|
from ..models import MailLog, MailSettings, User
|
|||
|
|
from ..security import current_user, require
|
|||
|
|
from .reports import FORMATS, KINDS, MIME, render_report, report_lang, workbook_file
|
|||
|
|
from .years import get_year
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/api", tags=["mail"])
|
|||
|
|
|
|||
|
|
PLAIN_FIELDS = ["enabled", "smtp_host", "smtp_port", "smtp_security", "smtp_username", "from_address", "from_name",
|
|||
|
|
"reply_to", "signature", "save_to_sent", "imap_host", "imap_port", "imap_security",
|
|||
|
|
"imap_same_login", "imap_username", "sent_folder"]
|
|||
|
|
SECURITY = ("starttls", "ssl", "none")
|
|||
|
|
MAX_ATTACHMENTS_BYTES = 20 * 1024 * 1024
|
|||
|
|
|
|||
|
|
|
|||
|
|
def settings_out(cfg: MailSettings) -> dict:
|
|||
|
|
out = {f: getattr(cfg, f) for f in PLAIN_FIELDS}
|
|||
|
|
out.update(smtp_password_set=bool(cfg.smtp_password), imap_password_set=bool(cfg.imap_password),
|
|||
|
|
ready=mailer.is_ready(cfg), updated_at=cfg.updated_at.isoformat() if cfg.updated_at else None,
|
|||
|
|
updated_by=cfg.updated_by.name if cfg.updated_by else None)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MailSettingsIn(BaseModel):
|
|||
|
|
enabled: bool | None = None
|
|||
|
|
smtp_host: str | None = Field(default=None, max_length=255)
|
|||
|
|
smtp_port: int | None = Field(default=None, ge=1, le=65535)
|
|||
|
|
smtp_security: str | None = None
|
|||
|
|
smtp_username: str | None = Field(default=None, max_length=255)
|
|||
|
|
smtp_password: str | None = Field(default=None, max_length=500) # empty or missing: keep the saved one
|
|||
|
|
from_address: str | None = Field(default=None, max_length=255)
|
|||
|
|
from_name: str | None = Field(default=None, max_length=255)
|
|||
|
|
reply_to: str | None = Field(default=None, max_length=255)
|
|||
|
|
signature: str | None = Field(default=None, max_length=4000)
|
|||
|
|
save_to_sent: bool | None = None
|
|||
|
|
imap_host: str | None = Field(default=None, max_length=255)
|
|||
|
|
imap_port: int | None = Field(default=None, ge=1, le=65535)
|
|||
|
|
imap_security: str | None = None
|
|||
|
|
imap_same_login: bool | None = None
|
|||
|
|
imap_username: str | None = Field(default=None, max_length=255)
|
|||
|
|
imap_password: str | None = Field(default=None, max_length=500)
|
|||
|
|
sent_folder: str | None = Field(default=None, max_length=255)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/mail/settings")
|
|||
|
|
def read_settings(db: Session = Depends(get_db), _: User = Depends(require("admin"))):
|
|||
|
|
cfg = mailer.get_settings(db)
|
|||
|
|
db.commit()
|
|||
|
|
return settings_out(cfg)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.put("/mail/settings")
|
|||
|
|
def save_settings(body: MailSettingsIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
|
|||
|
|
cfg = mailer.get_settings(db)
|
|||
|
|
before = snapshot(cfg, PLAIN_FIELDS)
|
|||
|
|
data = body.model_dump(exclude_unset=True)
|
|||
|
|
for key in ("smtp_security", "imap_security"):
|
|||
|
|
if key in data and data[key] not in SECURITY:
|
|||
|
|
raise HTTPException(400, "invalid_security")
|
|||
|
|
for key in ("from_address", "reply_to"):
|
|||
|
|
if data.get(key):
|
|||
|
|
try:
|
|||
|
|
mailer.clean_addresses([data[key]])
|
|||
|
|
except mailer.MailError:
|
|||
|
|
raise HTTPException(400, "invalid_address")
|
|||
|
|
for key in PLAIN_FIELDS:
|
|||
|
|
if key in data and data[key] is not None:
|
|||
|
|
setattr(cfg, key, data[key].strip() if isinstance(data[key], str) and key != "signature" else data[key])
|
|||
|
|
changed_passwords = []
|
|||
|
|
for key in ("smtp_password", "imap_password"):
|
|||
|
|
if data.get(key):
|
|||
|
|
setattr(cfg, key, mailer.encrypt(data[key]))
|
|||
|
|
changed_passwords.append(key)
|
|||
|
|
if cfg.enabled and not (cfg.smtp_host and cfg.from_address):
|
|||
|
|
raise HTTPException(400, "mail_incomplete")
|
|||
|
|
cfg.updated_at, cfg.updated_by_id = datetime.now(timezone.utc), admin.id
|
|||
|
|
changes = diff(before, snapshot(cfg, PLAIN_FIELDS))
|
|||
|
|
for key in changed_passwords:
|
|||
|
|
changes[key] = ["•••", "••• (new)"]
|
|||
|
|
record(db, admin, "mail_settings", 1, "update", ", ".join(changes) or "no changes", changes)
|
|||
|
|
db.commit()
|
|||
|
|
return settings_out(cfg)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestIn(BaseModel):
|
|||
|
|
to: str | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/mail/test")
|
|||
|
|
def test_mail(body: TestIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))):
|
|||
|
|
"""Without an address: sign in only. With one: also send a short test message (logged)."""
|
|||
|
|
cfg = mailer.get_settings(db)
|
|||
|
|
if not (cfg.smtp_host and cfg.from_address):
|
|||
|
|
raise HTTPException(400, "mail_incomplete")
|
|||
|
|
if not body.to:
|
|||
|
|
result = mailer.check(cfg)
|
|||
|
|
record(db, admin, "mail_settings", 1, "check", f"smtp {result['smtp']}; imap {result['imap']}")
|
|||
|
|
db.commit()
|
|||
|
|
return result
|
|||
|
|
try:
|
|||
|
|
to = mailer.clean_addresses([body.to])
|
|||
|
|
except mailer.MailError as exc:
|
|||
|
|
raise HTTPException(400, f"invalid_address:{exc.detail}")
|
|||
|
|
log = mailer.send(db, admin, cfg, to=to, cc=[], kind="test", context="test",
|
|||
|
|
subject="Budget & Capex — test", attachments=[],
|
|||
|
|
body="Mensagem de teste enviada pela aplicação Orçamento & Capex.\n"
|
|||
|
|
"Test message sent by the Budget & Capex app.")
|
|||
|
|
return log_out(log)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/mail/status")
|
|||
|
|
def mail_status(db: Session = Depends(get_db), _: User = Depends(current_user)):
|
|||
|
|
cfg = db.get(MailSettings, 1)
|
|||
|
|
return {"enabled": bool(cfg and mailer.is_ready(cfg)), "from": cfg.from_address if cfg else "",
|
|||
|
|
"signature": cfg.signature if cfg else ""}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ReportRef(BaseModel):
|
|||
|
|
kind: str
|
|||
|
|
fmt: str
|
|||
|
|
|
|||
|
|
|
|||
|
|
class EmailIn(BaseModel):
|
|||
|
|
to: list[str] = Field(default_factory=list)
|
|||
|
|
cc: list[str] = Field(default_factory=list)
|
|||
|
|
subject: str = Field(default="", max_length=300)
|
|||
|
|
message: str = Field(default="", max_length=10000)
|
|||
|
|
reports: list[ReportRef] = Field(default_factory=list)
|
|||
|
|
lang: str | None = None
|
|||
|
|
site_id: int | None = None
|
|||
|
|
include_empty: bool = False
|
|||
|
|
copy_me: bool = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/years/{year_id}/email")
|
|||
|
|
def email_reports(year_id: int, body: EmailIn, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
|||
|
|
cfg = db.get(MailSettings, 1)
|
|||
|
|
if not cfg or not mailer.is_ready(cfg):
|
|||
|
|
raise HTTPException(409, "mail_not_configured")
|
|||
|
|
year = get_year(db, year_id)
|
|||
|
|
try:
|
|||
|
|
to = mailer.clean_addresses(body.to)
|
|||
|
|
cc = mailer.clean_addresses(body.cc + ([user.email] if body.copy_me else []))
|
|||
|
|
except mailer.MailError as exc:
|
|||
|
|
raise HTTPException(400, f"invalid_address:{exc.detail}")
|
|||
|
|
cc = [a for a in cc if a.lower() not in {t.lower() for t in to}]
|
|||
|
|
if not to:
|
|||
|
|
raise HTTPException(400, "no_recipients")
|
|||
|
|
if len(to) + len(cc) > mailer.MAX_RECIPIENTS:
|
|||
|
|
raise HTTPException(400, "too_many_recipients")
|
|||
|
|
refs = {(r.kind, r.fmt) for r in body.reports}
|
|||
|
|
if not refs or any(not ((k in KINDS and f in FORMATS and f != "html") or (k, f) == ("workbook", "xlsx"))
|
|||
|
|
for k, f in refs):
|
|||
|
|
raise HTTPException(400, "no_attachments")
|
|||
|
|
lang = report_lang(body.lang, user)
|
|||
|
|
attachments = []
|
|||
|
|
for kind, fmt in sorted(refs):
|
|||
|
|
if kind == "workbook": # the editable budget file
|
|||
|
|
data, name = workbook_file(db, year, lang)
|
|||
|
|
else:
|
|||
|
|
data, name = render_report(db, year, kind, fmt, lang, body.site_id, body.include_empty)
|
|||
|
|
attachments.append(mailer.Attachment(name, MIME[fmt], data))
|
|||
|
|
if sum(len(a.data) for a in attachments) > MAX_ATTACHMENTS_BYTES:
|
|||
|
|
raise HTTPException(400, "file_too_large")
|
|||
|
|
subject = body.subject.strip() or f"{year.department.name} {year.year} — {year.title}"
|
|||
|
|
log = mailer.send(db, user, cfg, to=to, cc=cc, subject=subject, body=body.message, attachments=attachments,
|
|||
|
|
year_id=year.id, context=f"{year.department.name} {year.year}")
|
|||
|
|
if log.status != "sent":
|
|||
|
|
raise HTTPException(502, log.error.split(":")[0] or "smtp_error")
|
|||
|
|
return log_out(log)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def log_out(m: MailLog) -> dict:
|
|||
|
|
return {"id": m.id, "at": m.at.isoformat(), "user": m.user.name if m.user else None,
|
|||
|
|
"user_email": m.user.email if m.user else None, "kind": m.kind, "year_id": m.year_id,
|
|||
|
|
"context": m.context, "sender": m.sender, "to": (m.recipients or {}).get("to", []),
|
|||
|
|
"cc": (m.recipients or {}).get("cc", []), "subject": m.subject, "body": m.body,
|
|||
|
|
"attachments": m.attachments or [], "status": m.status, "error": m.error,
|
|||
|
|
"sent_copy": m.sent_copy, "sent_copy_detail": m.sent_copy_detail, "message_id": m.message_id}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _log_query(q: str | None, status: str | None, user_id: int | None, date_from: str | None, date_to: str | None):
|
|||
|
|
query = select(MailLog)
|
|||
|
|
if status:
|
|||
|
|
query = query.where(MailLog.status == status)
|
|||
|
|
if user_id:
|
|||
|
|
query = query.where(MailLog.user_id == user_id)
|
|||
|
|
if date_from:
|
|||
|
|
query = query.where(MailLog.at >= _date(date_from))
|
|||
|
|
if date_to:
|
|||
|
|
query = query.where(MailLog.at < _date(date_to, end=True))
|
|||
|
|
if q:
|
|||
|
|
like = f"%{q.strip().lower()}%"
|
|||
|
|
query = query.where(or_(func.lower(MailLog.subject).like(like), func.lower(MailLog.context).like(like),
|
|||
|
|
func.lower(cast(MailLog.recipients, String)).like(like)))
|
|||
|
|
return query
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _date(text: str, end: bool = False) -> datetime:
|
|||
|
|
try:
|
|||
|
|
d = datetime.fromisoformat(text[:10]).replace(tzinfo=timezone.utc)
|
|||
|
|
except ValueError:
|
|||
|
|
raise HTTPException(400, "invalid_date")
|
|||
|
|
return d + timedelta(days=1) if end else d
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/mail/log")
|
|||
|
|
def mail_log(q: str | None = None, status: str | None = None, user_id: int | None = None,
|
|||
|
|
date_from: str | None = None, date_to: str | None = None, limit: int = 100, offset: int = 0,
|
|||
|
|
db: Session = Depends(get_db), _: User = Depends(require("admin"))):
|
|||
|
|
query = _log_query(q, status, user_id, date_from, date_to)
|
|||
|
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
|||
|
|
rows = db.scalars(query.order_by(MailLog.at.desc(), MailLog.id.desc())
|
|||
|
|
.limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all()
|
|||
|
|
return {"total": total, "rows": [log_out(m) for m in rows]}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/mail/log.csv")
|
|||
|
|
def mail_log_csv(q: str | None = None, status: str | None = None, user_id: int | None = None,
|
|||
|
|
date_from: str | None = None, date_to: str | None = None,
|
|||
|
|
db: Session = Depends(get_db), _: User = Depends(require("admin"))):
|
|||
|
|
rows = db.scalars(_log_query(q, status, user_id, date_from, date_to).order_by(MailLog.at.desc())).all()
|
|||
|
|
out = io.StringIO()
|
|||
|
|
w = csv.writer(out, delimiter=";")
|
|||
|
|
w.writerow(["date", "user", "kind", "budget", "from", "to", "cc", "subject", "attachments", "status", "error",
|
|||
|
|
"sent_copy", "sent_copy_detail"])
|
|||
|
|
for m in rows:
|
|||
|
|
o = log_out(m)
|
|||
|
|
w.writerow([o["at"], o["user"], o["kind"], o["context"], o["sender"], ", ".join(o["to"]), ", ".join(o["cc"]),
|
|||
|
|
o["subject"], ", ".join(a["name"] for a in o["attachments"]), o["status"], o["error"],
|
|||
|
|
o["sent_copy"], o["sent_copy_detail"]])
|
|||
|
|
return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8",
|
|||
|
|
headers={"Content-Disposition": 'attachment; filename="mail_log.csv"'})
|