Files
Budget_Tool_Martinhal/backend/app/routers/mail.py
T

247 lines
11 KiB
Python
Raw Normal View History

2026-10-29 20:40:02 +00:00
"""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"'})