"""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"'})