From 44eced9e505e041b602f072c7034dc7520845f2d Mon Sep 17 00:00:00 2001 From: Malte Date: Thu, 20 Aug 2026 01:54:05 +0200 Subject: [PATCH 1/8] feat(payments): automate Stripe webhook listener setup Add a Stripe CLI sidecar that forwards payment events and shares its generated webhook secret with the API. Load the mounted secret dynamically so listener reconnections and secret rotations are handled correctly. --- docker-compose.dev.yml | 34 +++++++++++++-- src/app/services/payments/dependencies.py | 10 ++++- src/app/shared/config/settings.py | 2 - src/docker/stripe-listener/Dockerfile | 5 +++ src/docker/stripe-listener/entrypoint.sh | 52 +++++++++++++++++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 src/docker/stripe-listener/Dockerfile create mode 100644 src/docker/stripe-listener/entrypoint.sh diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb69d86..20a26e4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,6 +11,8 @@ services: KEYCLOAK_URL: http://opentaberna-keycloak:8080 KEYCLOAK_PUBLIC_URL: http://localhost:8080 STORAGE_ENDPOINT_URL: http://opentaberna-minio:9000 + volumes: + - stripe_webhook_secret:/run/secrets:ro ports: - "8000:8000" restart: unless-stopped @@ -22,10 +24,33 @@ services: retries: 5 start_period: 20s depends_on: - - opentaberna-db - - opentaberna-redis - - opentaberna-keycloak - - opentaberna-minio + opentaberna-db: + condition: service_started + opentaberna-redis: + condition: service_started + opentaberna-keycloak: + condition: service_started + opentaberna-minio: + condition: service_started + opentaberna-stripe-listener: + condition: service_started + + opentaberna-stripe-listener: + build: + context: . + dockerfile: src/docker/stripe-listener/Dockerfile + image: opentaberna-stripe-listener:latest + env_file: .env + volumes: + - stripe_webhook_secret:/run/secrets + restart: unless-stopped + container_name: opentaberna-stripe-listener + healthcheck: + test: ["CMD-SHELL", "test -s /run/secrets/stripe_webhook_secret"] + interval: 2s + timeout: 2s + retries: 15 + start_period: 5s opentaberna-db: image: postgres:17-alpine @@ -171,3 +196,4 @@ services: volumes: keycloak_data: + stripe_webhook_secret: diff --git a/src/app/services/payments/dependencies.py b/src/app/services/payments/dependencies.py index efdbd3c..a86e372 100644 --- a/src/app/services/payments/dependencies.py +++ b/src/app/services/payments/dependencies.py @@ -10,6 +10,7 @@ from fastapi import Depends from app.shared.config import get_settings +from app.shared.config.loader import load_secret from app.shared.config.settings import Settings from .adapters import PaymentMethod, PaymentProviderAdapter, build_stripe_adapter @@ -30,9 +31,16 @@ async def get_payment_adapter( Returns: Configured PaymentProviderAdapter instance. """ + # The development Stripe CLI sidecar can refresh this mounted secret when + # it reconnects. Read it for each dependency instance instead of retaining + # the value cached when Settings was first constructed. + webhook_secret = ( + load_secret("stripe_webhook_secret") or settings.stripe_webhook_secret + ) + return build_stripe_adapter( secret_key=settings.stripe_secret_key, - webhook_secret=settings.stripe_webhook_secret, + webhook_secret=webhook_secret, payment_methods=[PaymentMethod(m) for m in settings.stripe_payment_methods], bank_transfer_country=settings.stripe_bank_transfer_country, ) diff --git a/src/app/shared/config/settings.py b/src/app/shared/config/settings.py index 79773fe..07fd883 100644 --- a/src/app/shared/config/settings.py +++ b/src/app/shared/config/settings.py @@ -341,8 +341,6 @@ def load_stripe_secret_key(cls, v: str | None) -> str: @classmethod def load_stripe_webhook_secret(cls, v: str | None) -> str: """Load Stripe webhook secret from secrets if available.""" - if v: - return v return load_secret("stripe_webhook_secret") or v or "CHANGE_ME_IN_PRODUCTION" def model_post_init(self, __context: Any) -> None: diff --git a/src/docker/stripe-listener/Dockerfile b/src/docker/stripe-listener/Dockerfile new file mode 100644 index 0000000..2bc80d2 --- /dev/null +++ b/src/docker/stripe-listener/Dockerfile @@ -0,0 +1,5 @@ +FROM stripe/stripe-cli:v1.42.0 + +COPY --chmod=755 src/docker/stripe-listener/entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/docker/stripe-listener/entrypoint.sh b/src/docker/stripe-listener/entrypoint.sh new file mode 100644 index 0000000..3c30b7e --- /dev/null +++ b/src/docker/stripe-listener/entrypoint.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +secret_file=/run/secrets/stripe_webhook_secret +output_fifo=/tmp/stripe-listener-output + +if [ -z "${STRIPE_SECRET_KEY:-}" ]; then + echo "STRIPE_SECRET_KEY must be set in .env" >&2 + exit 1 +fi + +# Do not let the API become healthy with a secret left by an older listener. +rm -f "$secret_file" "$output_fifo" +mkfifo "$output_fifo" + +awk ' + { + print + fflush() + if (match($0, /whsec_[[:alnum:]]+/)) { + secret = substr($0, RSTART, RLENGTH) + print secret > "/run/secrets/stripe_webhook_secret.tmp" + close("/run/secrets/stripe_webhook_secret.tmp") + system("mv /run/secrets/stripe_webhook_secret.tmp /run/secrets/stripe_webhook_secret") + } + } +' < "$output_fifo" & +log_pid=$! + +/bin/stripe listen \ + --api-key "$STRIPE_SECRET_KEY" \ + --events payment_intent.succeeded,payment_intent.payment_failed \ + --forward-to http://opentaberna-api:8000/v1/webhooks/stripe \ + > "$output_fifo" 2>&1 & +stripe_pid=$! + +shutdown() { + kill -TERM "$stripe_pid" 2>/dev/null || true + wait "$stripe_pid" 2>/dev/null || true + kill -TERM "$log_pid" 2>/dev/null || true +} + +trap shutdown INT TERM + +set +e +wait "$stripe_pid" +status=$? +set -e + +kill -TERM "$log_pid" 2>/dev/null || true +wait "$log_pid" 2>/dev/null || true +exit "$status" From 699abff713b4f8f0ee85aa9016267c60222fc88a Mon Sep 17 00:00:00 2001 From: Malte Date: Thu, 20 Aug 2026 01:54:29 +0200 Subject: [PATCH 2/8] docs(payments): document automatic Stripe webhook setup --- .env.example | 9 ++++----- README.md | 9 +++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index ef760f1..8b80880 100644 --- a/.env.example +++ b/.env.example @@ -110,10 +110,10 @@ STRIPE_PAYMENT_METHODS=["card"] # Required for bank transfer payment methods — ISO 3166-1 alpha-2 country code # STRIPE_BANK_TRANSFER_COUNTRY=DE -# Webhook signing secret — generated by `stripe listen` (dev) or Stripe Dashboard (prod) -# Run: stripe listen --forward-to localhost:8001/v1/webhooks/stripe -# and copy the printed "whsec_..." value here. -STRIPE_WEBHOOK_SECRET=whsec_CHANGE_ME +# Webhook signing secret. docker-compose.dev.yml generates and mounts this +# automatically via its Stripe CLI listener. Set it manually only when running +# FastAPI outside Compose or when using a Dashboard-managed endpoint. +# STRIPE_WEBHOOK_SECRET=whsec_CHANGE_ME # ---------------------------------- # Email (Tracking Notifications) @@ -197,4 +197,3 @@ KEYCLOAK_ADMIN_CLIENT_IDS=["opentaberna-admin-ui"] # How long signing keys are cached before being refetched. Keycloak rotates # keys, so this must expire rather than being fetched once at startup. KEYCLOAK_JWKS_CACHE_SECONDS=300 - diff --git a/README.md b/README.md index 20823a3..72d4aee 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,14 @@ To test the Setup: Take a look at the `docker-compose.dev.yml` file. It provides a dev docker setup for local development. Run: ```bash -docker compose up -f docker-compose.dev.yml -d +docker compose -f docker-compose.dev.yml up -d ``` +The development stack also starts a Stripe CLI listener. Set a Stripe test-mode +`STRIPE_SECRET_KEY` in `.env`; the listener forwards payment-intent events to +the API and provides its generated webhook signing secret automatically. No +manual `stripe listen` process or `STRIPE_WEBHOOK_SECRET` copy is required. + # Pipelines This FastAPI can be build and tested via GitHub workflows. There are two available workflows: @@ -176,4 +181,4 @@ flowchart TD - Webhook-driven payment confirmation - Reservation-based inventory - Admin pick/pack + manual tracking -- Then add DHL labels \ No newline at end of file +- Then add DHL labels From 8c47b71857740dfe82182ce215a2890beb6e33d2 Mon Sep 17 00:00:00 2001 From: Malte Date: Wed, 26 Aug 2026 15:19:06 +0200 Subject: [PATCH 3/8] feat(mail): add provider-neutral admin mailbox API Add an IMAP/SMTP adapter for hosted and self-hosted mail servers. Expose admin endpoints for folders, messages, attachments, sending, moving, flagging, and deleting mail. --- .env.example | 17 + src/app/main.py | 4 + src/app/services/mail/__init__.py | 10 + src/app/services/mail/adapters/__init__.py | 3 + .../mail/adapters/imap_smtp_adapter.py | 375 ++++++++++++++++++ src/app/services/mail/adapters/interface.py | 42 ++ src/app/services/mail/models/__init__.py | 1 + src/app/services/mail/models/mail_models.py | 96 +++++ src/app/services/mail/routers/__init__.py | 3 + src/app/services/mail/routers/mail_router.py | 129 ++++++ src/app/services/mail/services/__init__.py | 3 + .../services/mail/services/mail_service.py | 11 + src/app/shared/config/settings.py | 13 + 13 files changed, 707 insertions(+) create mode 100644 src/app/services/mail/__init__.py create mode 100644 src/app/services/mail/adapters/__init__.py create mode 100644 src/app/services/mail/adapters/imap_smtp_adapter.py create mode 100644 src/app/services/mail/adapters/interface.py create mode 100644 src/app/services/mail/models/__init__.py create mode 100644 src/app/services/mail/models/mail_models.py create mode 100644 src/app/services/mail/routers/__init__.py create mode 100644 src/app/services/mail/routers/mail_router.py create mode 100644 src/app/services/mail/services/__init__.py create mode 100644 src/app/services/mail/services/mail_service.py diff --git a/.env.example b/.env.example index 8b80880..c5237ff 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,23 @@ SMTP_USER= SMTP_PASSWORD= EMAIL_FROM=noreply@opentaberna.local +# ---------------------------------- +# Admin mailbox / future webmail (IMAP + SMTP) +# ---------------------------------- +# Works with a hosted provider or a self-hosted server such as Stalwart/Mailcow. +# Microsoft 365 commonly requires an app password or a future Graph/OAuth adapter. +MAIL_PROVIDER=imap_smtp +MAIL_IMAP_HOST= +MAIL_IMAP_PORT=993 +MAIL_IMAP_SSL=true +MAIL_SMTP_HOST= +MAIL_SMTP_PORT=587 +MAIL_SMTP_STARTTLS=true +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM= +MAIL_TIMEOUT_SECONDS=30 + # ---------------------------------- # Object Storage (MinIO / S3) — carrier label files # ---------------------------------- diff --git a/src/app/main.py b/src/app/main.py index a88ef2e..e5083cb 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -13,6 +13,7 @@ from app.services.fulfillment import fulfillment_api_router from app.services.health import health_api_router from app.services.inventory import inventory_api_router +from app.services.mail import mail_api_router from app.services.orders import orders_api_router, webhooks_api_router from app.services.returns import admin_returns_api_router, returns_api_router from app.shared.exceptions import AppException, InternalError @@ -159,6 +160,9 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes app.include_router(returns_api_router, prefix="/v1") app.include_router(admin_returns_api_router, prefix="/v1") +# Provider-neutral mailbox endpoints for the admin webmail client +app.include_router(mail_api_router, prefix="/v1") + # Include health check endpoints (Phase 4.1) app.include_router(health_api_router) diff --git a/src/app/services/mail/__init__.py b/src/app/services/mail/__init__.py new file mode 100644 index 0000000..33fa597 --- /dev/null +++ b/src/app/services/mail/__init__.py @@ -0,0 +1,10 @@ +"""Provider-neutral admin mailbox API.""" + +from fastapi import APIRouter + +from .routers.mail_router import router + +mail_api_router = APIRouter(prefix="/admin/mail", tags=["Admin Mail"]) +mail_api_router.include_router(router) + +__all__ = ["mail_api_router"] diff --git a/src/app/services/mail/adapters/__init__.py b/src/app/services/mail/adapters/__init__.py new file mode 100644 index 0000000..fd2de35 --- /dev/null +++ b/src/app/services/mail/adapters/__init__.py @@ -0,0 +1,3 @@ +from .imap_smtp_adapter import ImapSmtpMailAdapter + +__all__ = ["ImapSmtpMailAdapter"] diff --git a/src/app/services/mail/adapters/imap_smtp_adapter.py b/src/app/services/mail/adapters/imap_smtp_adapter.py new file mode 100644 index 0000000..a3baffb --- /dev/null +++ b/src/app/services/mail/adapters/imap_smtp_adapter.py @@ -0,0 +1,375 @@ +"""Standard-library IMAP/SMTP implementation. + +Blocking protocol clients are isolated in worker threads so FastAPI's event loop +is never held up by a remote mail server. +""" + +import asyncio +import imaplib +import re +import smtplib +import ssl +from datetime import UTC, datetime +from email import policy +from email.header import decode_header, make_header +from email.message import EmailMessage, Message +from email.parser import BytesParser +from email.utils import format_datetime, getaddresses, make_msgid, parsedate_to_datetime + +from app.shared.config.settings import Settings +from app.shared.exceptions import ExternalServiceError, NotFoundError, ValidationError + +from .interface import MailAdapter +from ..models.mail_models import ( + MailAddress, + MailAttachment, + MailFlag, + MailFolder, + MailMessage, + MailMessagePage, + MailMessageSummary, + SendMailRequest, +) + +_IMAP_FLAGS = { + "\\Seen": MailFlag.SEEN, + "\\Answered": MailFlag.ANSWERED, + "\\Flagged": MailFlag.FLAGGED, + "\\Deleted": MailFlag.DELETED, + "\\Draft": MailFlag.DRAFT, +} +_FLAG_TO_IMAP = {value.value: key for key, value in _IMAP_FLAGS.items()} + + +class ImapSmtpMailAdapter(MailAdapter): + def __init__(self, settings: Settings): + self.settings = settings + + async def list_folders(self) -> list[MailFolder]: + return await asyncio.to_thread(self._list_folders) + + async def list_messages( + self, folder: str, offset: int, limit: int, query: str | None + ) -> MailMessagePage: + return await asyncio.to_thread( + self._list_messages, folder, offset, limit, query + ) + + async def get_message(self, folder: str, uid: int) -> MailMessage: + return await asyncio.to_thread(self._get_message, folder, uid) + + async def get_attachment( + self, folder: str, uid: int, part_id: str + ) -> tuple[bytes, str, str]: + return await asyncio.to_thread(self._get_attachment, folder, uid, part_id) + + async def send(self, payload: SendMailRequest) -> str: + return await asyncio.to_thread(self._send, payload) + + async def move(self, folder: str, uid: int, destination: str) -> None: + await asyncio.to_thread(self._move, folder, uid, destination) + + async def update_flags( + self, folder: str, uid: int, add: list[str], remove: list[str] + ) -> None: + await asyncio.to_thread(self._update_flags, folder, uid, add, remove) + + async def delete(self, folder: str, uid: int) -> None: + await asyncio.to_thread(self._delete, folder, uid) + + def _imap(self): + if not self.settings.mail_imap_host: + raise ValidationError(message="Mailbox IMAP server is not configured") + try: + if self.settings.mail_imap_ssl: + client = imaplib.IMAP4_SSL( + self.settings.mail_imap_host, + self.settings.mail_imap_port, + ssl_context=ssl.create_default_context(), + timeout=self.settings.mail_timeout_seconds, + ) + else: + client = imaplib.IMAP4( + self.settings.mail_imap_host, + self.settings.mail_imap_port, + timeout=self.settings.mail_timeout_seconds, + ) + client.login(self.settings.mail_username, self.settings.mail_password) + return client + except (OSError, imaplib.IMAP4.error) as exc: + raise ExternalServiceError( + message="Could not connect to the IMAP server", original_exception=exc + ) from exc + + def _selected(self, folder: str, readonly: bool = True): + client = self._imap() + status, _ = client.select(folder, readonly=readonly) + if status != "OK": + client.logout() + raise NotFoundError(message=f"Mail folder '{folder}' was not found") + return client + + def _list_folders(self) -> list[MailFolder]: + client = self._imap() + try: + status, rows = client.list() + if status != "OK": + raise ExternalServiceError(message="IMAP folder listing failed") + result = [] + pattern = re.compile(rb'^\((.*?)\)\s+"?([^" ]+)"?\s+(.*)$') + for row in rows or []: + match = pattern.match(row) + if not match: + continue + flags, delimiter, name = match.groups() + result.append( + MailFolder( + name=name.strip(b'"').decode("utf-8", "replace"), + delimiter=delimiter.decode("ascii", "replace"), + flags=flags.decode("ascii", "replace").split(), + ) + ) + return result + finally: + client.logout() + + def _list_messages( + self, folder: str, offset: int, limit: int, query: str | None + ) -> MailMessagePage: + client = self._selected(folder) + try: + criteria = ( + "ALL" + if not query + else f'(OR SUBJECT "{_imap_escape(query)}" FROM "{_imap_escape(query)}")' + ) + status, data = client.uid("search", None, criteria) + if status != "OK": + raise ExternalServiceError(message="IMAP message search failed") + uids = [int(value) for value in (data[0] or b"").split()] + uids.reverse() + page = uids[offset : offset + limit] + messages = [self._fetch(client, uid, full=False) for uid in page] + return MailMessagePage( + messages=messages, total=len(uids), offset=offset, limit=limit + ) + finally: + client.logout() + + def _get_message(self, folder: str, uid: int) -> MailMessage: + client = self._selected(folder) + try: + return self._fetch(client, uid, full=True) + finally: + client.logout() + + def _fetch(self, client, uid: int, full: bool) -> MailMessage | MailMessageSummary: + query = ( + "(RFC822 FLAGS RFC822.SIZE)" + if full + else "(BODY.PEEK[HEADER] FLAGS RFC822.SIZE)" + ) + status, data = client.uid("fetch", str(uid), query) + raw = next((item[1] for item in data or [] if isinstance(item, tuple)), None) + if status != "OK" or raw is None: + raise NotFoundError(message=f"Mail message UID {uid} was not found") + meta = next((item[0] for item in data if isinstance(item, tuple)), b"") + message = BytesParser(policy=policy.default).parsebytes(raw) + flag_names = (token.decode() for token in re.findall(rb"\\[A-Za-z]+", meta)) + flags = [_IMAP_FLAGS[name] for name in flag_names if name in _IMAP_FLAGS] + common = dict( + uid=uid, + message_id=message.get("Message-ID"), + subject=_decode(message.get("Subject", "")), + sender=_addresses(message.get_all("From", []))[0] + if _addresses(message.get_all("From", [])) + else None, + recipients=_addresses(message.get_all("To", [])), + sent_at=_date(message.get("Date")), + flags=flags, + size=len(raw), + has_attachments=any(part.get_filename() for part in message.walk()), + ) + if not full: + return MailMessageSummary(**common) + text, html, attachments = _content(message) + return MailMessage( + **common, + cc=_addresses(message.get_all("Cc", [])), + reply_to=_addresses(message.get_all("Reply-To", [])), + text_body=text, + html_body=html, + attachments=attachments, + ) + + def _get_attachment( + self, folder: str, uid: int, part_id: str + ) -> tuple[bytes, str, str]: + message = self._raw_message(folder, uid) + for index, part in enumerate(message.walk(), start=1): + if str(index) == part_id and part.get_filename(): + return ( + part.get_payload(decode=True) or b"", + _decode(part.get_filename() or "attachment"), + part.get_content_type(), + ) + raise NotFoundError(message=f"Attachment part '{part_id}' was not found") + + def _raw_message(self, folder: str, uid: int) -> Message: + client = self._selected(folder) + try: + status, data = client.uid("fetch", str(uid), "(RFC822)") + raw = next( + (item[1] for item in data or [] if isinstance(item, tuple)), None + ) + if status != "OK" or raw is None: + raise NotFoundError(message=f"Mail message UID {uid} was not found") + return BytesParser(policy=policy.default).parsebytes(raw) + finally: + client.logout() + + def _send(self, payload: SendMailRequest) -> str: + if not self.settings.mail_smtp_host: + raise ValidationError(message="Mailbox SMTP server is not configured") + message = EmailMessage() + message_id = make_msgid( + domain=(self.settings.mail_from or self.settings.mail_username).partition( + "@" + )[2] + or None + ) + message["Message-ID"] = message_id + message["Date"] = format_datetime(datetime.now(UTC)) + message["From"] = self.settings.mail_from or self.settings.mail_username + message["To"] = ", ".join(str(v) for v in payload.to) + if payload.cc: + message["Cc"] = ", ".join(str(v) for v in payload.cc) + if payload.reply_to: + message["Reply-To"] = str(payload.reply_to) + if payload.in_reply_to: + message["In-Reply-To"] = payload.in_reply_to + message["Subject"] = payload.subject + message.set_content(payload.text_body or "") + if payload.html_body: + message.add_alternative(payload.html_body, subtype="html") + recipients = [str(v) for v in payload.to + payload.cc + payload.bcc] + try: + with smtplib.SMTP( + self.settings.mail_smtp_host, + self.settings.mail_smtp_port, + timeout=self.settings.mail_timeout_seconds, + ) as smtp: + if self.settings.mail_smtp_starttls: + smtp.starttls(context=ssl.create_default_context()) + if self.settings.mail_username: + smtp.login(self.settings.mail_username, self.settings.mail_password) + smtp.send_message(message, to_addrs=recipients) + except (OSError, smtplib.SMTPException) as exc: + raise ExternalServiceError( + message="Could not send mail through the SMTP server", + original_exception=exc, + ) from exc + return message_id + + def _move(self, folder: str, uid: int, destination: str) -> None: + client = self._selected(folder, readonly=False) + try: + status, _ = client.uid("MOVE", str(uid), destination) + if status != "OK": + # Compatible fallback for servers without RFC 6851 MOVE. + status, _ = client.uid("COPY", str(uid), destination) + if status == "OK": + client.uid("STORE", str(uid), "+FLAGS.SILENT", "(\\Deleted)") + client.expunge() + if status != "OK": + raise ExternalServiceError(message="IMAP move failed") + finally: + client.logout() + + def _update_flags( + self, folder: str, uid: int, add: list[str], remove: list[str] + ) -> None: + client = self._selected(folder, readonly=False) + try: + for operation, values in ( + ("+FLAGS.SILENT", add), + ("-FLAGS.SILENT", remove), + ): + flags = [_FLAG_TO_IMAP[value] for value in values] + if flags: + status, _ = client.uid( + "STORE", str(uid), operation, f"({' '.join(flags)})" + ) + if status != "OK": + raise ExternalServiceError(message="IMAP flag update failed") + finally: + client.logout() + + def _delete(self, folder: str, uid: int) -> None: + client = self._selected(folder, readonly=False) + try: + status, _ = client.uid("STORE", str(uid), "+FLAGS.SILENT", "(\\Deleted)") + if status != "OK": + raise NotFoundError(message=f"Mail message UID {uid} was not found") + client.expunge() + finally: + client.logout() + + +def _decode(value: str) -> str: + try: + return str(make_header(decode_header(value))) + except LookupError, UnicodeError: + return value + + +def _addresses(values: list[str]) -> list[MailAddress]: + return [ + MailAddress(name=_decode(name) or None, address=address) + for name, address in getaddresses(values) + if address + ] + + +def _date(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = parsedate_to_datetime(value) + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed + except TypeError, ValueError: + return None + + +def _content(message: Message) -> tuple[str | None, str | None, list[MailAttachment]]: + text = html = None + attachments = [] + for index, part in enumerate(message.walk(), start=1): + filename = part.get_filename() + if filename: + attachments.append( + MailAttachment( + part_id=str(index), + filename=_decode(filename), + content_type=part.get_content_type(), + size=len(part.get_payload(decode=True) or b""), + inline=part.get_content_disposition() == "inline", + content_id=part.get("Content-ID"), + ) + ) + elif part.get_content_maintype() != "multipart": + try: + value = part.get_content() + except LookupError, UnicodeDecodeError: + value = (part.get_payload(decode=True) or b"").decode( + "utf-8", "replace" + ) + if part.get_content_type() == "text/plain" and text is None: + text = value + if part.get_content_type() == "text/html" and html is None: + html = value + return text, html, attachments + + +def _imap_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/src/app/services/mail/adapters/interface.py b/src/app/services/mail/adapters/interface.py new file mode 100644 index 0000000..a895745 --- /dev/null +++ b/src/app/services/mail/adapters/interface.py @@ -0,0 +1,42 @@ +"""Contract that allows IMAP/SMTP to be replaced by Graph or another provider.""" + +from abc import ABC, abstractmethod + +from ..models.mail_models import ( + MailFolder, + MailMessage, + MailMessagePage, + SendMailRequest, +) + + +class MailAdapter(ABC): + @abstractmethod + async def list_folders(self) -> list[MailFolder]: ... + + @abstractmethod + async def list_messages( + self, folder: str, offset: int, limit: int, query: str | None + ) -> MailMessagePage: ... + + @abstractmethod + async def get_message(self, folder: str, uid: int) -> MailMessage: ... + + @abstractmethod + async def get_attachment( + self, folder: str, uid: int, part_id: str + ) -> tuple[bytes, str, str]: ... + + @abstractmethod + async def send(self, payload: SendMailRequest) -> str: ... + + @abstractmethod + async def move(self, folder: str, uid: int, destination: str) -> None: ... + + @abstractmethod + async def update_flags( + self, folder: str, uid: int, add: list[str], remove: list[str] + ) -> None: ... + + @abstractmethod + async def delete(self, folder: str, uid: int) -> None: ... diff --git a/src/app/services/mail/models/__init__.py b/src/app/services/mail/models/__init__.py new file mode 100644 index 0000000..3709248 --- /dev/null +++ b/src/app/services/mail/models/__init__.py @@ -0,0 +1 @@ +from .mail_models import * # noqa: F403 diff --git a/src/app/services/mail/models/mail_models.py b/src/app/services/mail/models/mail_models.py new file mode 100644 index 0000000..71383eb --- /dev/null +++ b/src/app/services/mail/models/mail_models.py @@ -0,0 +1,96 @@ +"""HTTP schemas shared by mail adapters and the future webmail UI.""" + +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, EmailStr, Field, model_validator + + +class MailFlag(StrEnum): + SEEN = "seen" + ANSWERED = "answered" + FLAGGED = "flagged" + DELETED = "deleted" + DRAFT = "draft" + + +class MailFolder(BaseModel): + name: str + delimiter: str = "/" + flags: list[str] = Field(default_factory=list) + + +class MailAddress(BaseModel): + name: str | None = None + address: EmailStr + + +class MailAttachment(BaseModel): + part_id: str + filename: str + content_type: str + size: int + inline: bool = False + content_id: str | None = None + + +class MailMessageSummary(BaseModel): + uid: int + message_id: str | None = None + subject: str = "" + sender: MailAddress | None = None + recipients: list[MailAddress] = Field(default_factory=list) + sent_at: datetime | None = None + flags: list[MailFlag] = Field(default_factory=list) + size: int = 0 + has_attachments: bool = False + + +class MailMessage(MailMessageSummary): + cc: list[MailAddress] = Field(default_factory=list) + reply_to: list[MailAddress] = Field(default_factory=list) + text_body: str | None = None + html_body: str | None = None + attachments: list[MailAttachment] = Field(default_factory=list) + + +class MailMessagePage(BaseModel): + messages: list[MailMessageSummary] + total: int + offset: int + limit: int + + +class SendMailRequest(BaseModel): + to: list[EmailStr] = Field(min_length=1) + cc: list[EmailStr] = Field(default_factory=list) + bcc: list[EmailStr] = Field(default_factory=list) + subject: str = Field(default="", max_length=998) + text_body: str | None = None + html_body: str | None = None + reply_to: EmailStr | None = None + in_reply_to: str | None = None + + @model_validator(mode="after") + def require_body(self): + if not self.html_body and not self.text_body: + raise ValueError("text_body or html_body is required") + return self + + +class MoveMailRequest(BaseModel): + destination: str = Field(min_length=1, max_length=255) + + +class UpdateFlagsRequest(BaseModel): + add: list[MailFlag] = Field(default_factory=list) + remove: list[MailFlag] = Field(default_factory=list) + + +class SendMailResponse(BaseModel): + message_id: str + + +class MailStatus(BaseModel): + configured: bool + provider: str diff --git a/src/app/services/mail/routers/__init__.py b/src/app/services/mail/routers/__init__.py new file mode 100644 index 0000000..d7d835e --- /dev/null +++ b/src/app/services/mail/routers/__init__.py @@ -0,0 +1,3 @@ +from .mail_router import router + +__all__ = ["router"] diff --git a/src/app/services/mail/routers/mail_router.py b/src/app/services/mail/routers/mail_router.py new file mode 100644 index 0000000..2eeb7e1 --- /dev/null +++ b/src/app/services/mail/routers/mail_router.py @@ -0,0 +1,129 @@ +"""Admin-only REST facade used by a future webmail frontend.""" + +from typing import Annotated +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Query, Response, status + +from app.services.admin.dependencies import require_admin +from app.shared.config import get_settings + +from ..adapters.interface import MailAdapter +from ..models.mail_models import ( + MailFolder, + MailMessage, + MailMessagePage, + MailStatus, + MoveMailRequest, + SendMailRequest, + SendMailResponse, + UpdateFlagsRequest, +) +from ..services import get_mail_adapter + +router = APIRouter(dependencies=[Depends(require_admin)]) +Adapter = Annotated[MailAdapter, Depends(get_mail_adapter)] + + +@router.get( + "/status", response_model=MailStatus, summary="Get mailbox configuration status" +) +async def mailbox_status() -> MailStatus: + settings = get_settings() + return MailStatus( + configured=bool( + settings.mail_imap_host + and settings.mail_smtp_host + and settings.mail_username + ), + provider=settings.mail_provider, + ) + + +@router.get("/folders", response_model=list[MailFolder], summary="List mail folders") +async def list_folders(adapter: Adapter) -> list[MailFolder]: + return await adapter.list_folders() + + +@router.get( + "/folders/{folder:path}/messages", + response_model=MailMessagePage, + summary="List messages", +) +async def list_messages( + folder: str, + adapter: Adapter, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + query: str | None = Query(None, max_length=200), +) -> MailMessagePage: + return await adapter.list_messages(folder, offset, limit, query) + + +@router.get( + "/folders/{folder:path}/messages/{uid}", + response_model=MailMessage, + summary="Read a message", +) +async def get_message(folder: str, uid: int, adapter: Adapter) -> MailMessage: + return await adapter.get_message(folder, uid) + + +@router.get( + "/folders/{folder:path}/messages/{uid}/attachments/{part_id}", + summary="Download an attachment", +) +async def get_attachment( + folder: str, uid: int, part_id: str, adapter: Adapter +) -> Response: + content, filename, content_type = await adapter.get_attachment(folder, uid, part_id) + return Response( + content=content, + media_type=content_type, + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}" + }, + ) + + +@router.post( + "/messages", + response_model=SendMailResponse, + status_code=status.HTTP_201_CREATED, + summary="Send a message", +) +async def send_message(payload: SendMailRequest, adapter: Adapter) -> SendMailResponse: + return SendMailResponse(message_id=await adapter.send(payload)) + + +@router.post( + "/folders/{folder:path}/messages/{uid}/move", + status_code=status.HTTP_204_NO_CONTENT, + summary="Move a message", +) +async def move_message( + folder: str, uid: int, payload: MoveMailRequest, adapter: Adapter +) -> None: + await adapter.move(folder, uid, payload.destination) + + +@router.patch( + "/folders/{folder:path}/messages/{uid}/flags", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update message flags", +) +async def update_flags( + folder: str, uid: int, payload: UpdateFlagsRequest, adapter: Adapter +) -> None: + await adapter.update_flags( + folder, uid, [v.value for v in payload.add], [v.value for v in payload.remove] + ) + + +@router.delete( + "/folders/{folder:path}/messages/{uid}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Permanently delete a message", +) +async def delete_message(folder: str, uid: int, adapter: Adapter) -> None: + await adapter.delete(folder, uid) diff --git a/src/app/services/mail/services/__init__.py b/src/app/services/mail/services/__init__.py new file mode 100644 index 0000000..a6c97b7 --- /dev/null +++ b/src/app/services/mail/services/__init__.py @@ -0,0 +1,3 @@ +from .mail_service import get_mail_adapter + +__all__ = ["get_mail_adapter"] diff --git a/src/app/services/mail/services/mail_service.py b/src/app/services/mail/services/mail_service.py new file mode 100644 index 0000000..2ec2532 --- /dev/null +++ b/src/app/services/mail/services/mail_service.py @@ -0,0 +1,11 @@ +from functools import lru_cache + +from app.shared.config import get_settings + +from ..adapters import ImapSmtpMailAdapter +from ..adapters.interface import MailAdapter + + +@lru_cache +def get_mail_adapter() -> MailAdapter: + return ImapSmtpMailAdapter(get_settings()) diff --git a/src/app/shared/config/settings.py b/src/app/shared/config/settings.py index 07fd883..12be677 100644 --- a/src/app/shared/config/settings.py +++ b/src/app/shared/config/settings.py @@ -207,6 +207,19 @@ class Settings(BaseSettings): description="Envelope sender address for outgoing emails", ) + # Admin mailbox (standard IMAP + SMTP; compatible with hosted and self-hosted mail) + mail_provider: str = Field(default="imap_smtp", description="Mailbox adapter") + mail_imap_host: str = Field(default="", description="IMAP server hostname") + mail_imap_port: int = Field(default=993, description="IMAP server port") + mail_imap_ssl: bool = Field(default=True, description="Use implicit TLS for IMAP") + mail_smtp_host: str = Field(default="", description="Mailbox SMTP hostname") + mail_smtp_port: int = Field(default=587, description="Mailbox SMTP port") + mail_smtp_starttls: bool = Field(default=True, description="Use SMTP STARTTLS") + mail_username: str = Field(default="", description="Mailbox login name") + mail_password: str = Field(default="", description="Mailbox password/app password") + mail_from: str = Field(default="", description="From address; defaults to username") + mail_timeout_seconds: int = Field(default=30, description="Mail server timeout") + # Feature Flags feature_webhooks_enabled: bool = Field(default=False, description="Enable webhooks") From 7d14a48c7f45bdad26a40786c6414bc8b4b7f421 Mon Sep 17 00:00:00 2001 From: Malte Date: Wed, 26 Aug 2026 15:19:15 +0200 Subject: [PATCH 4/8] test(mail): cover mailbox schemas and API integration Test message validation, stable mail flag values, adapter delegation, and generation of Date headers for outgoing messages. --- tests/test_mail_unit.py | 70 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/test_mail_unit.py diff --git a/tests/test_mail_unit.py b/tests/test_mail_unit.py new file mode 100644 index 0000000..da1c68e --- /dev/null +++ b/tests/test_mail_unit.py @@ -0,0 +1,70 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.services.admin.dependencies import require_admin +from app.services.mail import mail_api_router +from app.services.mail.models.mail_models import ( + MailFlag, + MailMessagePage, + SendMailRequest, +) +from app.services.mail.adapters.imap_smtp_adapter import ImapSmtpMailAdapter +from app.shared.config.settings import Settings +from app.services.mail.services import get_mail_adapter + + +def test_send_request_requires_a_body(): + with pytest.raises(ValidationError): + SendMailRequest(to=["admin@example.com"]) + + +def test_send_request_accepts_html_only(): + request = SendMailRequest(to=["admin@example.com"], html_body="

Hello

") + assert request.html_body == "

Hello

" + + +def test_flags_are_stable_api_values(): + assert MailFlag.SEEN.value == "seen" + assert MailFlag.FLAGGED.value == "flagged" + + +def test_list_messages_endpoint_uses_adapter(): + adapter = AsyncMock() + adapter.list_messages.return_value = MailMessagePage( + messages=[], total=0, offset=0, limit=10 + ) + app = FastAPI() + app.include_router(mail_api_router, prefix="/v1") + app.dependency_overrides[require_admin] = lambda: {"sub": "admin"} + app.dependency_overrides[get_mail_adapter] = lambda: adapter + + response = TestClient(app).get("/v1/admin/mail/folders/INBOX/messages?limit=10") + + assert response.status_code == 200 + assert response.json()["messages"] == [] + adapter.list_messages.assert_awaited_once_with("INBOX", 0, 10, None) + + +def test_sent_message_contains_date_header(): + settings = Settings( + mail_smtp_host="mail.example.com", + mail_username="admin", + mail_password="secret", + mail_from="admin@example.com", + ) + smtp = MagicMock() + smtp.__enter__.return_value = smtp + with patch( + "app.services.mail.adapters.imap_smtp_adapter.smtplib.SMTP", + return_value=smtp, + ): + ImapSmtpMailAdapter(settings)._send( + SendMailRequest(to=["admin@example.com"], text_body="Hello") + ) + + sent_message = smtp.send_message.call_args.args[0] + assert sent_message["Date"] is not None From f4d5036f6d5252c73e15ac887b81d5387cf02dca Mon Sep 17 00:00:00 2001 From: Malte Date: Wed, 26 Aug 2026 15:19:19 +0200 Subject: [PATCH 5/8] chore(dev): add GreenMail for local mail testing Provide local SMTP, IMAP, and webmail services with a preconfigured development mailbox for end-to-end testing. --- docker-compose.dev.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 20a26e4..6d24317 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -194,6 +194,19 @@ services: opentaberna-minio: condition: service_healthy + opentaberna-mail: + image: greenmail/standalone:2.1.3 + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all + -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.users=admin:admin@example.com + ports: + - "3025:3025" # SMTP + - "3143:3143" # IMAP + - "8081:8080" # GreenMail web UI + restart: unless-stopped + volumes: keycloak_data: stripe_webhook_secret: From fda920cf74a66342884ef7c67d7c9ba9a97b8184 Mon Sep 17 00:00:00 2001 From: Malte Date: Wed, 26 Aug 2026 15:26:53 +0200 Subject: [PATCH 6/8] refactor(mail): move mailbox logic into service layer Keep mail routers limited to HTTP concerns and delegate mailbox status, response construction, moves, and flag conversion to MailService. Add coverage for router delegation and flag conversion. --- src/app/services/mail/routers/mail_router.py | 53 ++++++-------- src/app/services/mail/services/__init__.py | 4 +- .../services/mail/services/mail_service.py | 72 +++++++++++++++++-- tests/test_mail_unit.py | 28 ++++++-- 4 files changed, 111 insertions(+), 46 deletions(-) diff --git a/src/app/services/mail/routers/mail_router.py b/src/app/services/mail/routers/mail_router.py index 2eeb7e1..fa011f7 100644 --- a/src/app/services/mail/routers/mail_router.py +++ b/src/app/services/mail/routers/mail_router.py @@ -6,9 +6,6 @@ from fastapi import APIRouter, Depends, Query, Response, status from app.services.admin.dependencies import require_admin -from app.shared.config import get_settings - -from ..adapters.interface import MailAdapter from ..models.mail_models import ( MailFolder, MailMessage, @@ -19,30 +16,22 @@ SendMailResponse, UpdateFlagsRequest, ) -from ..services import get_mail_adapter +from ..services import MailService, get_mail_service router = APIRouter(dependencies=[Depends(require_admin)]) -Adapter = Annotated[MailAdapter, Depends(get_mail_adapter)] +Service = Annotated[MailService, Depends(get_mail_service)] @router.get( "/status", response_model=MailStatus, summary="Get mailbox configuration status" ) -async def mailbox_status() -> MailStatus: - settings = get_settings() - return MailStatus( - configured=bool( - settings.mail_imap_host - and settings.mail_smtp_host - and settings.mail_username - ), - provider=settings.mail_provider, - ) +async def mailbox_status(service: Service) -> MailStatus: + return service.status() @router.get("/folders", response_model=list[MailFolder], summary="List mail folders") -async def list_folders(adapter: Adapter) -> list[MailFolder]: - return await adapter.list_folders() +async def list_folders(service: Service) -> list[MailFolder]: + return await service.list_folders() @router.get( @@ -52,12 +41,12 @@ async def list_folders(adapter: Adapter) -> list[MailFolder]: ) async def list_messages( folder: str, - adapter: Adapter, + service: Service, offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), query: str | None = Query(None, max_length=200), ) -> MailMessagePage: - return await adapter.list_messages(folder, offset, limit, query) + return await service.list_messages(folder, offset, limit, query) @router.get( @@ -65,8 +54,8 @@ async def list_messages( response_model=MailMessage, summary="Read a message", ) -async def get_message(folder: str, uid: int, adapter: Adapter) -> MailMessage: - return await adapter.get_message(folder, uid) +async def get_message(folder: str, uid: int, service: Service) -> MailMessage: + return await service.get_message(folder, uid) @router.get( @@ -74,9 +63,9 @@ async def get_message(folder: str, uid: int, adapter: Adapter) -> MailMessage: summary="Download an attachment", ) async def get_attachment( - folder: str, uid: int, part_id: str, adapter: Adapter + folder: str, uid: int, part_id: str, service: Service ) -> Response: - content, filename, content_type = await adapter.get_attachment(folder, uid, part_id) + content, filename, content_type = await service.get_attachment(folder, uid, part_id) return Response( content=content, media_type=content_type, @@ -92,8 +81,8 @@ async def get_attachment( status_code=status.HTTP_201_CREATED, summary="Send a message", ) -async def send_message(payload: SendMailRequest, adapter: Adapter) -> SendMailResponse: - return SendMailResponse(message_id=await adapter.send(payload)) +async def send_message(payload: SendMailRequest, service: Service) -> SendMailResponse: + return await service.send(payload) @router.post( @@ -102,9 +91,9 @@ async def send_message(payload: SendMailRequest, adapter: Adapter) -> SendMailRe summary="Move a message", ) async def move_message( - folder: str, uid: int, payload: MoveMailRequest, adapter: Adapter + folder: str, uid: int, payload: MoveMailRequest, service: Service ) -> None: - await adapter.move(folder, uid, payload.destination) + await service.move(folder, uid, payload) @router.patch( @@ -113,11 +102,9 @@ async def move_message( summary="Update message flags", ) async def update_flags( - folder: str, uid: int, payload: UpdateFlagsRequest, adapter: Adapter + folder: str, uid: int, payload: UpdateFlagsRequest, service: Service ) -> None: - await adapter.update_flags( - folder, uid, [v.value for v in payload.add], [v.value for v in payload.remove] - ) + await service.update_flags(folder, uid, payload) @router.delete( @@ -125,5 +112,5 @@ async def update_flags( status_code=status.HTTP_204_NO_CONTENT, summary="Permanently delete a message", ) -async def delete_message(folder: str, uid: int, adapter: Adapter) -> None: - await adapter.delete(folder, uid) +async def delete_message(folder: str, uid: int, service: Service) -> None: + await service.delete(folder, uid) diff --git a/src/app/services/mail/services/__init__.py b/src/app/services/mail/services/__init__.py index a6c97b7..3f9e59f 100644 --- a/src/app/services/mail/services/__init__.py +++ b/src/app/services/mail/services/__init__.py @@ -1,3 +1,3 @@ -from .mail_service import get_mail_adapter +from .mail_service import MailService, get_mail_service -__all__ = ["get_mail_adapter"] +__all__ = ["MailService", "get_mail_service"] diff --git a/src/app/services/mail/services/mail_service.py b/src/app/services/mail/services/mail_service.py index 2ec2532..41267fc 100644 --- a/src/app/services/mail/services/mail_service.py +++ b/src/app/services/mail/services/mail_service.py @@ -1,11 +1,73 @@ -from functools import lru_cache - from app.shared.config import get_settings +from app.shared.config.settings import Settings from ..adapters import ImapSmtpMailAdapter from ..adapters.interface import MailAdapter +from ..models.mail_models import ( + MailFolder, + MailMessage, + MailMessagePage, + MailStatus, + MoveMailRequest, + SendMailRequest, + SendMailResponse, + UpdateFlagsRequest, +) + + +class MailService: + """Application layer coordinating mailbox use cases.""" + + def __init__(self, adapter: MailAdapter, settings: Settings): + self.adapter = adapter + self.settings = settings + + def status(self) -> MailStatus: + return MailStatus( + configured=bool( + self.settings.mail_imap_host + and self.settings.mail_smtp_host + and self.settings.mail_username + ), + provider=self.settings.mail_provider, + ) + + async def list_folders(self) -> list[MailFolder]: + return await self.adapter.list_folders() + + async def list_messages( + self, folder: str, offset: int, limit: int, query: str | None + ) -> MailMessagePage: + return await self.adapter.list_messages(folder, offset, limit, query) + + async def get_message(self, folder: str, uid: int) -> MailMessage: + return await self.adapter.get_message(folder, uid) + + async def get_attachment( + self, folder: str, uid: int, part_id: str + ) -> tuple[bytes, str, str]: + return await self.adapter.get_attachment(folder, uid, part_id) + + async def send(self, payload: SendMailRequest) -> SendMailResponse: + return SendMailResponse(message_id=await self.adapter.send(payload)) + + async def move(self, folder: str, uid: int, payload: MoveMailRequest) -> None: + await self.adapter.move(folder, uid, payload.destination) + + async def update_flags( + self, folder: str, uid: int, payload: UpdateFlagsRequest + ) -> None: + await self.adapter.update_flags( + folder, + uid, + [flag.value for flag in payload.add], + [flag.value for flag in payload.remove], + ) + + async def delete(self, folder: str, uid: int) -> None: + await self.adapter.delete(folder, uid) -@lru_cache -def get_mail_adapter() -> MailAdapter: - return ImapSmtpMailAdapter(get_settings()) +def get_mail_service() -> MailService: + settings = get_settings() + return MailService(ImapSmtpMailAdapter(settings), settings) diff --git a/tests/test_mail_unit.py b/tests/test_mail_unit.py index da1c68e..5eb560c 100644 --- a/tests/test_mail_unit.py +++ b/tests/test_mail_unit.py @@ -11,10 +11,11 @@ MailFlag, MailMessagePage, SendMailRequest, + UpdateFlagsRequest, ) from app.services.mail.adapters.imap_smtp_adapter import ImapSmtpMailAdapter from app.shared.config.settings import Settings -from app.services.mail.services import get_mail_adapter +from app.services.mail.services import MailService, get_mail_service def test_send_request_requires_a_body(): @@ -32,21 +33,36 @@ def test_flags_are_stable_api_values(): assert MailFlag.FLAGGED.value == "flagged" -def test_list_messages_endpoint_uses_adapter(): - adapter = AsyncMock() - adapter.list_messages.return_value = MailMessagePage( +def test_list_messages_endpoint_uses_service(): + service = AsyncMock(spec=MailService) + service.list_messages.return_value = MailMessagePage( messages=[], total=0, offset=0, limit=10 ) app = FastAPI() app.include_router(mail_api_router, prefix="/v1") app.dependency_overrides[require_admin] = lambda: {"sub": "admin"} - app.dependency_overrides[get_mail_adapter] = lambda: adapter + app.dependency_overrides[get_mail_service] = lambda: service response = TestClient(app).get("/v1/admin/mail/folders/INBOX/messages?limit=10") assert response.status_code == 200 assert response.json()["messages"] == [] - adapter.list_messages.assert_awaited_once_with("INBOX", 0, 10, None) + service.list_messages.assert_awaited_once_with("INBOX", 0, 10, None) + + +@pytest.mark.asyncio +async def test_mail_service_converts_flags_for_adapter(): + adapter = AsyncMock() + settings = Settings() + service = MailService(adapter, settings) + + await service.update_flags( + "INBOX", + 7, + UpdateFlagsRequest(add=[MailFlag.SEEN], remove=[MailFlag.FLAGGED]), + ) + + adapter.update_flags.assert_awaited_once_with("INBOX", 7, ["seen"], ["flagged"]) def test_sent_message_contains_date_header(): From 61272ed7e4731d7b8fa9def851f5e5673fd49fbe Mon Sep 17 00:00:00 2001 From: maltonoloco Date: Wed, 26 Aug 2026 16:35:04 +0200 Subject: [PATCH 7/8] fix(responses): derive generic OpenAPI schemas from concrete types Remove hardcoded payload examples from SuccessResponse and DataResponse so Swagger documents the actual generic type. Document how the mail API uses typed shared responses. --- docs/responses.md | 16 +++++++++++++++ src/app/shared/responses/success.py | 30 +++++++++-------------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/responses.md b/docs/responses.md index cc09aa8..6be2290 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -296,6 +296,22 @@ return not_found(message="User not found", ...) ## FastAPI Integration +## Mail service usage + +The admin mail service follows the shared response contract for every JSON +success response. Status, folder, message, message-page, and send-result models +are returned as `DataResponse[T]`. See [`mail.md`](mail.md) for the complete +endpoint mapping. + +Generic success models deliberately do not define a fixed OpenAPI example for +`data`: its structure depends on `T`. Swagger derives the example from each +concrete specialization, such as `DataResponse[SendMailResponse]`, so documented +fields match the endpoint's actual response. + +Binary attachment downloads and `204 No Content` mutation responses are not +wrapped because they do not contain a JSON resource body. Mail errors continue +to use the shared error models through the global exception handlers. + ### Basic Endpoint ```python diff --git a/src/app/shared/responses/success.py b/src/app/shared/responses/success.py index 3b2e7e2..b551585 100644 --- a/src/app/shared/responses/success.py +++ b/src/app/shared/responses/success.py @@ -6,7 +6,9 @@ """ from typing import Generic, Optional, TypeVar -from pydantic import Field, ConfigDict + +from pydantic import ConfigDict, Field + from .base import BaseResponse # Generic type variable for type-safe responses @@ -31,16 +33,9 @@ class SuccessResponse(BaseResponse, Generic[T]): data: Optional[T] = Field(None, description="Response data of generic type T") - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "message": "User retrieved successfully", - "data": {"id": 1, "name": "John Doe", "email": "john@example.com"}, - "timestamp": "2025-12-07T12:00:00Z", - } - } - ) + # A generic response cannot provide a correct example for an arbitrary T. + # Leaving the example unset lets OpenAPI generate it from the concrete type. + model_config = ConfigDict(json_schema_extra=None) class MessageResponse(BaseResponse): @@ -83,13 +78,6 @@ class DataResponse(SuccessResponse[T], Generic[T]): data: T = Field(..., description="Required response data of generic type T") - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "message": "Data retrieved successfully", - "data": {"id": 1, "value": "example"}, - "timestamp": "2025-12-07T12:00:00Z", - } - } - ) + # Do not hard-code the shape of T. Swagger derives `data` from the concrete + # specialization, e.g. DataResponse[SendMailResponse]. + model_config = ConfigDict(json_schema_extra=None) From 32a809a000306943be41d6bffc2f3b849fdfcb6d Mon Sep 17 00:00:00 2001 From: maltonoloco Date: Wed, 26 Aug 2026 16:35:14 +0200 Subject: [PATCH 8/8] feat(mail): add provider-neutral folder management Add create, rename, and delete folder endpoints backed by IMAP. Refactor mailbox use cases into the functions layer, add protected-folder rules, structured audit logging, explicit model exports, programmatic OpenAPI error documentation, and GreenMail lifecycle coverage. --- .env.example | 2 + docs/mail.md | 153 ++++++++++++++++ .../mail/adapters/imap_smtp_adapter.py | 78 +++++++-- src/app/services/mail/adapters/interface.py | 12 +- src/app/services/mail/dependencies.py | 21 +++ src/app/services/mail/functions/__init__.py | 5 + .../mail/functions/mail_operations.py | 148 ++++++++++++++++ src/app/services/mail/models/__init__.py | 36 +++- src/app/services/mail/models/mail_models.py | 113 +++++++++--- src/app/services/mail/responses/__init__.py | 31 ++++ src/app/services/mail/responses/mail_docs.py | 128 ++++++++++++++ src/app/services/mail/routers/mail_router.py | 158 ++++++++++++++--- src/app/services/mail/services/__init__.py | 3 - .../services/mail/services/mail_service.py | 73 -------- src/app/shared/config/settings.py | 4 + tests/test_mail_integration.py | 83 +++++++++ tests/test_mail_unit.py | 163 ++++++++++++++++-- 17 files changed, 1052 insertions(+), 159 deletions(-) create mode 100644 docs/mail.md create mode 100644 src/app/services/mail/dependencies.py create mode 100644 src/app/services/mail/functions/__init__.py create mode 100644 src/app/services/mail/functions/mail_operations.py create mode 100644 src/app/services/mail/responses/__init__.py create mode 100644 src/app/services/mail/responses/mail_docs.py delete mode 100644 src/app/services/mail/services/__init__.py delete mode 100644 src/app/services/mail/services/mail_service.py create mode 100644 tests/test_mail_integration.py diff --git a/.env.example b/.env.example index c5237ff..8aba3b2 100644 --- a/.env.example +++ b/.env.example @@ -142,6 +142,8 @@ MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM= MAIL_TIMEOUT_SECONDS=30 +# Folder names protected from rename/delete through the admin API. +MAIL_PROTECTED_FOLDERS=["INBOX"] # ---------------------------------- # Object Storage (MinIO / S3) — carrier label files diff --git a/docs/mail.md b/docs/mail.md new file mode 100644 index 0000000..0c8fd87 --- /dev/null +++ b/docs/mail.md @@ -0,0 +1,153 @@ +# Mail Service + +## Overview + +The admin mail service provides a provider-neutral HTTP API for a future +webmail frontend. The current adapter uses IMAP for mailbox access and SMTP for +sending. Hosted and self-hosted standard mail servers can use the same adapter; +other providers can be integrated behind `MailAdapter`. + +All routes are below `/v1/admin/mail` and require an administrator token issued +to an allowed admin frontend client. + +## Architecture + +The mail service follows the project's mini-API layering rules: + +```text +HTTP request + → router (HTTP validation and shared response envelope) + → MailOperations (provider-neutral use case and audit logging) + → MailAdapter (external protocol contract) + → ImapSmtpMailAdapter (IMAP/SMTP implementation) +``` + +- `models/` contains provider-neutral Pydantic request and response schemas. +- `functions/` contains mailbox use cases and structured audit logging. +- `adapters/` contains external mail-server integration. +- `responses/` contains programmatically generated OpenAPI error examples. +- `routers/` contains only FastAPI and HTTP response concerns. + +Message bodies, credentials, and recipient addresses are not written to audit +logs. Mutations record folder, UID, flag names, recipient counts, and generated +message identifiers where applicable. + +## Response contract + +Successful JSON endpoints use the shared `DataResponse[T]` model documented in +[`responses.md`](responses.md). Resource data is always located in `data`: + +```json +{ + "success": true, + "message": "Mail message retrieved", + "timestamp": "2026-08-26T13:30:00Z", + "request_id": null, + "metadata": null, + "data": { + "uid": 1, + "subject": "OpenTaberna development test" + } +} +``` + +Errors use the shared `ErrorResponse` or `ValidationErrorResponse` through the +application's global exception handlers. Endpoint-specific `403`, `404`, `422`, +`500`, and `502` responses are declared in `responses/mail_docs.py`; examples +are serialized from the real shared response models to avoid schema drift. + +Two response types are intentionally not wrapped: + +- attachment downloads return their original binary content and media type; +- successful move, flag, and delete operations return `204 No Content`. + +## Endpoints + +| Method | Path | Successful response | +|---|---|---| +| `GET` | `/status` | `DataResponse[MailStatus]` | +| `GET` | `/folders` | `DataResponse[list[MailFolder]]` | +| `POST` | `/folders` | `DataResponse[MailFolder]` (`201`) | +| `GET` | `/folders/{folder}/messages` | `DataResponse[MailMessagePage]` | +| `GET` | `/folders/{folder}/messages/{uid}` | `DataResponse[MailMessage]` | +| `GET` | `/folders/{folder}/messages/{uid}/attachments/{part_id}` | Binary response | +| `POST` | `/messages` | `DataResponse[SendMailResponse]` | +| `POST` | `/folders/{folder}/messages/{uid}/move` | `204 No Content` | +| `PATCH` | `/folders/{folder}/messages/{uid}/flags` | `204 No Content` | +| `DELETE` | `/folders/{folder}/messages/{uid}` | `204 No Content` | +| `PATCH` | `/folders/{folder}` | `DataResponse[MailFolder]` | +| `DELETE` | `/folders/{folder}` | `204 No Content` | + +## Folder management + +Folder management is exposed through OpenTaberna rather than directly through +provider APIs. This keeps provider credentials on the backend, preserves +Keycloak admin authorization and audit logging, and lets the frontend work with +IMAP/SMTP or a future provider adapter without changing its API calls. + +Create a folder: + +```http +POST /v1/admin/mail/folders +``` + +```json +{"name": "Archive"} +``` + +Rename it: + +```http +PATCH /v1/admin/mail/folders/Archive +``` + +```json +{"name": "Processed"} +``` + +Delete it: + +```http +DELETE /v1/admin/mail/folders/Processed +``` + +`INBOX` is protected from creation, rename, and deletion by default. Additional +provider-specific folders can be protected with `MAIL_PROTECTED_FOLDERS`. + +Message listing retains its IMAP-friendly offset and limit metadata inside the +`data` object: + +```json +{ + "success": true, + "message": "Mail messages retrieved", + "timestamp": "2026-08-26T13:30:00Z", + "request_id": null, + "metadata": null, + "data": { + "messages": [], + "total": 0, + "offset": 0, + "limit": 50 + } +} +``` + +## Development configuration + +The development Compose stack includes GreenMail with SMTP on port `3025`, +IMAP on `3143`, and its web interface on `8081`. Configure the API with the +`MAIL_*` variables listed in `.env.example`. + +Run unit tests with: + +```bash +pytest -q tests/test_mail_unit.py +``` + +Run the opt-in GreenMail round-trip integration test with: + +```bash +docker compose -f docker-compose.dev.yml up -d opentaberna-mail +RUN_MAIL_INTEGRATION_TESTS=1 pytest -q tests/test_mail_integration.py +``` diff --git a/src/app/services/mail/adapters/imap_smtp_adapter.py b/src/app/services/mail/adapters/imap_smtp_adapter.py index a3baffb..e6eef65 100644 --- a/src/app/services/mail/adapters/imap_smtp_adapter.py +++ b/src/app/services/mail/adapters/imap_smtp_adapter.py @@ -48,6 +48,15 @@ def __init__(self, settings: Settings): async def list_folders(self) -> list[MailFolder]: return await asyncio.to_thread(self._list_folders) + async def create_folder(self, name: str) -> MailFolder: + return await asyncio.to_thread(self._create_folder, name) + + async def rename_folder(self, folder: str, name: str) -> MailFolder: + return await asyncio.to_thread(self._rename_folder, folder, name) + + async def delete_folder(self, folder: str) -> None: + await asyncio.to_thread(self._delete_folder, folder) + async def list_messages( self, folder: str, offset: int, limit: int, query: str | None ) -> MailMessagePage: @@ -70,7 +79,7 @@ async def move(self, folder: str, uid: int, destination: str) -> None: await asyncio.to_thread(self._move, folder, uid, destination) async def update_flags( - self, folder: str, uid: int, add: list[str], remove: list[str] + self, folder: str, uid: int, add: list[MailFlag], remove: list[MailFlag] ) -> None: await asyncio.to_thread(self._update_flags, folder, uid, add, remove) @@ -133,20 +142,47 @@ def _list_folders(self) -> list[MailFolder]: finally: client.logout() + def _create_folder(self, name: str) -> MailFolder: + client = self._imap() + try: + status, _ = client.create(name) + if status != "OK": + raise ExternalServiceError( + message=f"Could not create mail folder '{name}'" + ) + return MailFolder(name=name) + finally: + client.logout() + + def _rename_folder(self, folder: str, name: str) -> MailFolder: + client = self._imap() + try: + status, _ = client.rename(folder, name) + if status != "OK": + raise NotFoundError( + message=f"Mail folder '{folder}' could not be renamed" + ) + return MailFolder(name=name) + finally: + client.logout() + + def _delete_folder(self, folder: str) -> None: + client = self._imap() + try: + status, _ = client.delete(folder) + if status != "OK": + raise NotFoundError( + message=f"Mail folder '{folder}' could not be deleted" + ) + finally: + client.logout() + def _list_messages( self, folder: str, offset: int, limit: int, query: str | None ) -> MailMessagePage: client = self._selected(folder) try: - criteria = ( - "ALL" - if not query - else f'(OR SUBJECT "{_imap_escape(query)}" FROM "{_imap_escape(query)}")' - ) - status, data = client.uid("search", None, criteria) - if status != "OK": - raise ExternalServiceError(message="IMAP message search failed") - uids = [int(value) for value in (data[0] or b"").split()] + uids = self._search_uids(client, query) uids.reverse() page = uids[offset : offset + limit] messages = [self._fetch(client, uid, full=False) for uid in page] @@ -156,6 +192,24 @@ def _list_messages( finally: client.logout() + @staticmethod + def _search_uids(client, query: str | None) -> list[int]: + """Search compatibly without requiring provider support for IMAP OR.""" + if not query: + status, data = client.uid("search", None, "ALL") + if status != "OK": + raise ExternalServiceError(message="IMAP message search failed") + return [int(value) for value in (data[0] or b"").split()] + + matches: set[int] = set() + escaped = f'"{_imap_escape(query)}"' + for field in ("SUBJECT", "FROM"): + status, data = client.uid("search", None, field, escaped) + if status != "OK": + raise ExternalServiceError(message="IMAP message search failed") + matches.update(int(value) for value in (data[0] or b"").split()) + return sorted(matches) + def _get_message(self, folder: str, uid: int) -> MailMessage: client = self._selected(folder) try: @@ -287,7 +341,7 @@ def _move(self, folder: str, uid: int, destination: str) -> None: client.logout() def _update_flags( - self, folder: str, uid: int, add: list[str], remove: list[str] + self, folder: str, uid: int, add: list[MailFlag], remove: list[MailFlag] ) -> None: client = self._selected(folder, readonly=False) try: @@ -295,7 +349,7 @@ def _update_flags( ("+FLAGS.SILENT", add), ("-FLAGS.SILENT", remove), ): - flags = [_FLAG_TO_IMAP[value] for value in values] + flags = [_FLAG_TO_IMAP[value.value] for value in values] if flags: status, _ = client.uid( "STORE", str(uid), operation, f"({' '.join(flags)})" diff --git a/src/app/services/mail/adapters/interface.py b/src/app/services/mail/adapters/interface.py index a895745..1c69665 100644 --- a/src/app/services/mail/adapters/interface.py +++ b/src/app/services/mail/adapters/interface.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from ..models.mail_models import ( + MailFlag, MailFolder, MailMessage, MailMessagePage, @@ -14,6 +15,15 @@ class MailAdapter(ABC): @abstractmethod async def list_folders(self) -> list[MailFolder]: ... + @abstractmethod + async def create_folder(self, name: str) -> MailFolder: ... + + @abstractmethod + async def rename_folder(self, folder: str, name: str) -> MailFolder: ... + + @abstractmethod + async def delete_folder(self, folder: str) -> None: ... + @abstractmethod async def list_messages( self, folder: str, offset: int, limit: int, query: str | None @@ -35,7 +45,7 @@ async def move(self, folder: str, uid: int, destination: str) -> None: ... @abstractmethod async def update_flags( - self, folder: str, uid: int, add: list[str], remove: list[str] + self, folder: str, uid: int, add: list[MailFlag], remove: list[MailFlag] ) -> None: ... @abstractmethod diff --git a/src/app/services/mail/dependencies.py b/src/app/services/mail/dependencies.py new file mode 100644 index 0000000..ac64767 --- /dev/null +++ b/src/app/services/mail/dependencies.py @@ -0,0 +1,21 @@ +"""Composition root for mail use-case dependencies.""" + +from typing import Annotated + +from fastapi import Depends + +from app.shared.config import get_settings + +from .adapters import ImapSmtpMailAdapter +from .functions import MailOperations + + +def get_mail_operations() -> MailOperations: + """Build mail operations with the configured provider adapter.""" + settings = get_settings() + return MailOperations(ImapSmtpMailAdapter(settings), settings) + + +MailOperationsDependency = Annotated[MailOperations, Depends(get_mail_operations)] + +__all__ = ["MailOperationsDependency", "get_mail_operations"] diff --git a/src/app/services/mail/functions/__init__.py b/src/app/services/mail/functions/__init__.py new file mode 100644 index 0000000..1f98595 --- /dev/null +++ b/src/app/services/mail/functions/__init__.py @@ -0,0 +1,5 @@ +"""Mail application use cases.""" + +from .mail_operations import MailOperations + +__all__ = ["MailOperations"] diff --git a/src/app/services/mail/functions/mail_operations.py b/src/app/services/mail/functions/mail_operations.py new file mode 100644 index 0000000..d24b695 --- /dev/null +++ b/src/app/services/mail/functions/mail_operations.py @@ -0,0 +1,148 @@ +"""Provider-neutral mailbox use cases with structured audit logging.""" + +from app.shared.config.settings import Settings +from app.shared.logger import get_logger +from app.shared.exceptions import operation_not_allowed + +from ..adapters.interface import MailAdapter +from ..models import ( + CreateFolderRequest, + MailFolder, + MailMessage, + MailMessagePage, + MailStatus, + MoveMailRequest, + RenameFolderRequest, + SendMailRequest, + SendMailResponse, + UpdateFlagsRequest, +) + +logger = get_logger(__name__) + + +class MailOperations: + """Coordinate mail use cases without depending on FastAPI or HTTP models.""" + + def __init__(self, adapter: MailAdapter, settings: Settings): + self.adapter = adapter + self.settings = settings + + def status(self) -> MailStatus: + """Return configuration presence without exposing credentials.""" + return MailStatus( + configured=bool( + self.settings.mail_imap_host + and self.settings.mail_smtp_host + and self.settings.mail_username + ), + provider=self.settings.mail_provider, + ) + + async def list_folders(self) -> list[MailFolder]: + """List folders from the configured mailbox provider.""" + return await self.adapter.list_folders() + + async def create_folder(self, payload: CreateFolderRequest) -> MailFolder: + """Create a provider folder after applying protected-name rules.""" + self._assert_name_available(payload.name) + folder = await self.adapter.create_folder(payload.name) + logger.info("Admin mail folder created", extra={"folder": folder.name}) + return folder + + async def rename_folder( + self, folder: str, payload: RenameFolderRequest + ) -> MailFolder: + """Rename a mutable folder without allowing protected targets.""" + self._assert_mutable(folder, "rename") + self._assert_name_available(payload.name) + renamed = await self.adapter.rename_folder(folder, payload.name) + logger.info( + "Admin mail folder renamed", + extra={"source_folder": folder, "destination_folder": renamed.name}, + ) + return renamed + + async def delete_folder(self, folder: str) -> None: + """Delete a mutable folder and record the administrative action.""" + self._assert_mutable(folder, "delete") + await self.adapter.delete_folder(folder) + logger.info("Admin mail folder deleted", extra={"folder": folder}) + + async def list_messages( + self, folder: str, offset: int, limit: int, query: str | None + ) -> MailMessagePage: + """Return one offset-based page from a mailbox folder.""" + return await self.adapter.list_messages(folder, offset, limit, query) + + async def get_message(self, folder: str, uid: int) -> MailMessage: + """Retrieve a complete provider-normalized message.""" + return await self.adapter.get_message(folder, uid) + + async def get_attachment( + self, folder: str, uid: int, part_id: str + ) -> tuple[bytes, str, str]: + """Retrieve decoded attachment bytes and response metadata.""" + return await self.adapter.get_attachment(folder, uid, part_id) + + async def send(self, payload: SendMailRequest) -> SendMailResponse: + """Submit a message and audit only non-sensitive delivery metadata.""" + message_id = await self.adapter.send(payload) + logger.info( + "Admin mail sent", + extra={ + "message_id": message_id, + "to_count": len(payload.to), + "cc_count": len(payload.cc), + "bcc_count": len(payload.bcc), + }, + ) + return SendMailResponse(message_id=message_id) + + async def move(self, folder: str, uid: int, payload: MoveMailRequest) -> None: + """Move a message and record the mailbox mutation.""" + await self.adapter.move(folder, uid, payload.destination) + logger.info( + "Admin mail moved", + extra={ + "source_folder": folder, + "destination_folder": payload.destination, + "uid": uid, + }, + ) + + async def update_flags( + self, folder: str, uid: int, payload: UpdateFlagsRequest + ) -> None: + """Apply provider-neutral flags and record the mailbox mutation.""" + await self.adapter.update_flags(folder, uid, payload.add, payload.remove) + logger.info( + "Admin mail flags updated", + extra={ + "folder": folder, + "uid": uid, + "added_flags": [flag.value for flag in payload.add], + "removed_flags": [flag.value for flag in payload.remove], + }, + ) + + async def delete(self, folder: str, uid: int) -> None: + """Permanently delete a message and record the mailbox mutation.""" + await self.adapter.delete(folder, uid) + logger.info("Admin mail deleted", extra={"folder": folder, "uid": uid}) + + def _assert_mutable(self, folder: str, operation: str) -> None: + protected = {name.casefold() for name in self.settings.mail_protected_folders} + if folder.casefold() in protected: + raise operation_not_allowed( + operation=f"{operation}_mail_folder", + reason=f"Mail folder '{folder}' is protected", + ) + + def _assert_name_available(self, folder: str) -> None: + protected = {name.casefold() for name in self.settings.mail_protected_folders} + if folder.casefold() in protected: + raise operation_not_allowed( + operation="create_or_rename_mail_folder", + reason=f"Mail folder name '{folder}' is reserved", + ) diff --git a/src/app/services/mail/models/__init__.py b/src/app/services/mail/models/__init__.py index 3709248..c41a0b8 100644 --- a/src/app/services/mail/models/__init__.py +++ b/src/app/services/mail/models/__init__.py @@ -1 +1,35 @@ -from .mail_models import * # noqa: F403 +"""Public API schemas for the mail service.""" + +from .mail_models import ( + CreateFolderRequest, + MailAddress, + MailAttachment, + MailFlag, + MailFolder, + MailMessage, + MailMessagePage, + MailMessageSummary, + MailStatus, + MoveMailRequest, + RenameFolderRequest, + SendMailRequest, + SendMailResponse, + UpdateFlagsRequest, +) + +__all__ = [ + "CreateFolderRequest", + "MailAddress", + "MailAttachment", + "MailFlag", + "MailFolder", + "MailMessage", + "MailMessagePage", + "MailMessageSummary", + "MailStatus", + "MoveMailRequest", + "RenameFolderRequest", + "SendMailRequest", + "SendMailResponse", + "UpdateFlagsRequest", +] diff --git a/src/app/services/mail/models/mail_models.py b/src/app/services/mail/models/mail_models.py index 71383eb..716a17b 100644 --- a/src/app/services/mail/models/mail_models.py +++ b/src/app/services/mail/models/mail_models.py @@ -3,10 +3,12 @@ from datetime import datetime from enum import StrEnum -from pydantic import BaseModel, EmailStr, Field, model_validator +from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator class MailFlag(StrEnum): + """Provider-neutral message flags exposed by the HTTP API.""" + SEEN = "seen" ANSWERED = "answered" FLAGGED = "flagged" @@ -15,38 +17,81 @@ class MailFlag(StrEnum): class MailFolder(BaseModel): - name: str - delimiter: str = "/" - flags: list[str] = Field(default_factory=list) + """A mailbox folder returned by the configured provider.""" + + name: str = Field(..., min_length=1, description="Provider folder name") + delimiter: str = Field(default="/", description="Folder hierarchy delimiter") + flags: list[str] = Field( + default_factory=list, description="Provider capabilities and folder flags" + ) + + +class CreateFolderRequest(BaseModel): + """Request to create a mailbox folder.""" + + name: str = Field(..., min_length=1, max_length=255, description="New folder name") + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + """Trim folder names and reject protocol control characters.""" + value = value.strip() + if not value: + raise ValueError("folder name must not be blank") + if any(character in value for character in ("\x00", "\r", "\n")): + raise ValueError("folder name contains invalid control characters") + return value + + +class RenameFolderRequest(BaseModel): + """Request to rename an existing mailbox folder.""" + + name: str = Field(..., min_length=1, max_length=255, description="New folder name") + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + """Apply the same provider-safe validation as folder creation.""" + return CreateFolderRequest(name=value).name class MailAddress(BaseModel): - name: str | None = None - address: EmailStr + """A validated email address with an optional display name.""" + + name: str | None = Field(default=None, description="Display name") + address: EmailStr = Field(..., description="RFC-compatible email address") class MailAttachment(BaseModel): - part_id: str - filename: str - content_type: str - size: int - inline: bool = False - content_id: str | None = None + """Attachment metadata; content is downloaded from a separate endpoint.""" + + part_id: str = Field(..., min_length=1, description="Provider MIME part identifier") + filename: str = Field(..., min_length=1, description="Original filename") + content_type: str = Field(..., min_length=1, description="MIME content type") + size: int = Field(..., ge=0, description="Decoded attachment size in bytes") + inline: bool = Field(default=False, description="Whether the part is inline") + content_id: str | None = Field(default=None, description="MIME Content-ID") class MailMessageSummary(BaseModel): - uid: int - message_id: str | None = None - subject: str = "" - sender: MailAddress | None = None - recipients: list[MailAddress] = Field(default_factory=list) - sent_at: datetime | None = None - flags: list[MailFlag] = Field(default_factory=list) - size: int = 0 - has_attachments: bool = False + """Message metadata used by mailbox list views.""" + + uid: int = Field(..., ge=1, description="Folder-scoped IMAP UID") + message_id: str | None = Field(default=None, description="RFC Message-ID") + subject: str = Field(default="", description="Decoded subject") + sender: MailAddress | None = Field(default=None, description="Sender") + recipients: list[MailAddress] = Field( + default_factory=list, description="To recipients" + ) + sent_at: datetime | None = Field(default=None, description="Date header timestamp") + flags: list[MailFlag] = Field(default_factory=list, description="Normalized flags") + size: int = Field(default=0, ge=0, description="Message size in bytes") + has_attachments: bool = Field(default=False, description="Attachment indicator") class MailMessage(MailMessageSummary): + """Complete message content returned by the detail endpoint.""" + cc: list[MailAddress] = Field(default_factory=list) reply_to: list[MailAddress] = Field(default_factory=list) text_body: str | None = None @@ -55,13 +100,17 @@ class MailMessage(MailMessageSummary): class MailMessagePage(BaseModel): - messages: list[MailMessageSummary] - total: int - offset: int - limit: int + """Offset-based message page suitable for IMAP UID result sets.""" + + messages: list[MailMessageSummary] = Field(description="Messages in this page") + total: int = Field(..., ge=0, description="Total matching messages") + offset: int = Field(..., ge=0, description="Applied result offset") + limit: int = Field(..., ge=1, le=200, description="Applied page limit") class SendMailRequest(BaseModel): + """Validated message composition request.""" + to: list[EmailStr] = Field(min_length=1) cc: list[EmailStr] = Field(default_factory=list) bcc: list[EmailStr] = Field(default_factory=list) @@ -79,18 +128,26 @@ def require_body(self): class MoveMailRequest(BaseModel): + """Request to move a message into another existing folder.""" + destination: str = Field(min_length=1, max_length=255) class UpdateFlagsRequest(BaseModel): + """Flags to add to or remove from a message.""" + add: list[MailFlag] = Field(default_factory=list) remove: list[MailFlag] = Field(default_factory=list) class SendMailResponse(BaseModel): - message_id: str + """Identity assigned to a successfully submitted outgoing message.""" + + message_id: str = Field(..., min_length=1, description="Generated RFC Message-ID") class MailStatus(BaseModel): - configured: bool - provider: str + """Non-sensitive mailbox adapter configuration status.""" + + configured: bool = Field(description="Whether required mailbox settings exist") + provider: str = Field(..., min_length=1, description="Configured adapter name") diff --git a/src/app/services/mail/responses/__init__.py b/src/app/services/mail/responses/__init__.py new file mode 100644 index 0000000..524e8f2 --- /dev/null +++ b/src/app/services/mail/responses/__init__.py @@ -0,0 +1,31 @@ +"""OpenAPI response documentation for mail endpoints.""" + +from .mail_docs import ( + ATTACHMENT_RESPONSES, + CREATE_FOLDER_RESPONSES, + DELETE_FOLDER_RESPONSES, + DELETE_MESSAGE_RESPONSES, + FOLDER_RESPONSES, + LIST_MESSAGES_RESPONSES, + MESSAGE_RESPONSES, + MOVE_MESSAGE_RESPONSES, + RENAME_FOLDER_RESPONSES, + SEND_MESSAGE_RESPONSES, + STATUS_RESPONSES, + UPDATE_FLAGS_RESPONSES, +) + +__all__ = [ + "ATTACHMENT_RESPONSES", + "CREATE_FOLDER_RESPONSES", + "DELETE_FOLDER_RESPONSES", + "DELETE_MESSAGE_RESPONSES", + "FOLDER_RESPONSES", + "LIST_MESSAGES_RESPONSES", + "MESSAGE_RESPONSES", + "MOVE_MESSAGE_RESPONSES", + "RENAME_FOLDER_RESPONSES", + "SEND_MESSAGE_RESPONSES", + "STATUS_RESPONSES", + "UPDATE_FLAGS_RESPONSES", +] diff --git a/src/app/services/mail/responses/mail_docs.py b/src/app/services/mail/responses/mail_docs.py new file mode 100644 index 0000000..497bcb1 --- /dev/null +++ b/src/app/services/mail/responses/mail_docs.py @@ -0,0 +1,128 @@ +"""Programmatic OpenAPI error documentation for admin mail endpoints.""" + +from app.shared.responses import ErrorResponse, ValidationErrorResponse + + +def _error(status_code: int, error_code: str, category: str, message: str) -> dict: + """Build an example from the same model returned by exception handlers.""" + return ErrorResponse( + status_code=status_code, + error_code=error_code, + error_category=category, + message=message, + ).model_dump(mode="json", exclude_none=True) + + +def _validation(message: str, location: list[str]) -> dict: + """Build a representative request-validation example.""" + return ValidationErrorResponse( + message="Validation failed", + validation_errors=[{"loc": location, "msg": message, "type": "value_error"}], + ).model_dump(mode="json", exclude_none=True) + + +_ACCESS_DENIED = _error(403, "access_denied", "authorization", "Admin access required") +_NOT_FOUND = _error(404, "resource_not_found", "not_found", "Mail resource not found") +_NOT_CONFIGURED = _error( + 422, + "invalid_input", + "validation", + "Mailbox server is not configured", +) +_PROVIDER_FAILURE = _error( + 502, + "external_service_error", + "external_service", + "Could not connect to the mail server", +) +_INTERNAL_FAILURE = _error( + 500, "internal_error", "internal", "An unexpected error occurred" +) +_PROTECTED_FOLDER = _error( + 400, + "business_rule_violation", + "business_rule", + "Protected mail folders cannot be renamed or deleted", +) + +_403 = { + "description": "A valid administrator token from an allowed admin client is required", + "model": ErrorResponse, + "content": {"application/json": {"example": _ACCESS_DENIED}}, +} +_404 = { + "description": "The folder, message, or attachment does not exist", + "model": ErrorResponse, + "content": {"application/json": {"example": _NOT_FOUND}}, +} +_422_CONFIG = { + "description": "Mailbox configuration is incomplete", + "model": ErrorResponse, + "content": {"application/json": {"example": _NOT_CONFIGURED}}, +} +_422_REQUEST = { + "description": "Request validation failed", + "model": ValidationErrorResponse, + "content": { + "application/json": {"example": _validation("Value is invalid", ["body"])} + }, +} +_502 = { + "description": "The configured IMAP or SMTP provider failed", + "model": ErrorResponse, + "content": {"application/json": {"example": _PROVIDER_FAILURE}}, +} +_500 = { + "description": "Unexpected internal server error", + "model": ErrorResponse, + "content": {"application/json": {"example": _INTERNAL_FAILURE}}, +} +_400_FOLDER = { + "description": "The requested operation targets a protected or reserved folder", + "model": ErrorResponse, + "content": {"application/json": {"example": _PROTECTED_FOLDER}}, +} + +STATUS_RESPONSES = {403: _403, 500: _500} +FOLDER_RESPONSES = {403: _403, 422: _422_CONFIG, 502: _502, 500: _500} +CREATE_FOLDER_RESPONSES = { + 400: _400_FOLDER, + 403: _403, + 422: _422_REQUEST, + 502: _502, + 500: _500, +} +RENAME_FOLDER_RESPONSES = { + 400: _400_FOLDER, + 403: _403, + 404: _404, + 422: _422_REQUEST, + 502: _502, + 500: _500, +} +DELETE_FOLDER_RESPONSES = { + 400: _400_FOLDER, + 403: _403, + 404: _404, + 422: _422_CONFIG, + 502: _502, + 500: _500, +} +LIST_MESSAGES_RESPONSES = { + 403: _403, + 404: _404, + 422: _422_REQUEST, + 502: _502, + 500: _500, +} +MESSAGE_RESPONSES = {403: _403, 404: _404, 422: _422_CONFIG, 502: _502, 500: _500} +ATTACHMENT_RESPONSES = MESSAGE_RESPONSES +SEND_MESSAGE_RESPONSES = { + 403: _403, + 422: _422_REQUEST, + 502: _502, + 500: _500, +} +MOVE_MESSAGE_RESPONSES = MESSAGE_RESPONSES +UPDATE_FLAGS_RESPONSES = MESSAGE_RESPONSES +DELETE_MESSAGE_RESPONSES = MESSAGE_RESPONSES diff --git a/src/app/services/mail/routers/mail_router.py b/src/app/services/mail/routers/mail_router.py index fa011f7..2dd9e7d 100644 --- a/src/app/services/mail/routers/mail_router.py +++ b/src/app/services/mail/routers/mail_router.py @@ -1,71 +1,130 @@ """Admin-only REST facade used by a future webmail frontend.""" -from typing import Annotated from urllib.parse import quote from fastapi import APIRouter, Depends, Query, Response, status from app.services.admin.dependencies import require_admin +from app.shared.responses import DataResponse + +from ..dependencies import MailOperationsDependency from ..models.mail_models import ( + CreateFolderRequest, MailFolder, MailMessage, MailMessagePage, MailStatus, MoveMailRequest, + RenameFolderRequest, SendMailRequest, SendMailResponse, UpdateFlagsRequest, ) -from ..services import MailService, get_mail_service +from ..responses import ( + ATTACHMENT_RESPONSES, + CREATE_FOLDER_RESPONSES, + DELETE_FOLDER_RESPONSES, + DELETE_MESSAGE_RESPONSES, + FOLDER_RESPONSES, + LIST_MESSAGES_RESPONSES, + MESSAGE_RESPONSES, + MOVE_MESSAGE_RESPONSES, + RENAME_FOLDER_RESPONSES, + SEND_MESSAGE_RESPONSES, + STATUS_RESPONSES, + UPDATE_FLAGS_RESPONSES, +) router = APIRouter(dependencies=[Depends(require_admin)]) -Service = Annotated[MailService, Depends(get_mail_service)] @router.get( - "/status", response_model=MailStatus, summary="Get mailbox configuration status" + "/status", + response_model=DataResponse[MailStatus], + summary="Get mailbox configuration status", + responses=STATUS_RESPONSES, ) -async def mailbox_status(service: Service) -> MailStatus: - return service.status() +async def mailbox_status( + operations: MailOperationsDependency, +) -> DataResponse[MailStatus]: + return DataResponse[MailStatus]( + data=operations.status(), message="Mailbox configuration status retrieved" + ) -@router.get("/folders", response_model=list[MailFolder], summary="List mail folders") -async def list_folders(service: Service) -> list[MailFolder]: - return await service.list_folders() +@router.get( + "/folders", + response_model=DataResponse[list[MailFolder]], + summary="List mail folders", + responses=FOLDER_RESPONSES, +) +async def list_folders( + operations: MailOperationsDependency, +) -> DataResponse[list[MailFolder]]: + return DataResponse[list[MailFolder]]( + data=await operations.list_folders(), message="Mail folders retrieved" + ) + + +@router.post( + "/folders", + response_model=DataResponse[MailFolder], + status_code=status.HTTP_201_CREATED, + summary="Create a mail folder", + responses=CREATE_FOLDER_RESPONSES, +) +async def create_folder( + payload: CreateFolderRequest, operations: MailOperationsDependency +) -> DataResponse[MailFolder]: + return DataResponse[MailFolder]( + data=await operations.create_folder(payload), message="Mail folder created" + ) @router.get( "/folders/{folder:path}/messages", - response_model=MailMessagePage, + response_model=DataResponse[MailMessagePage], summary="List messages", + responses=LIST_MESSAGES_RESPONSES, ) async def list_messages( folder: str, - service: Service, + operations: MailOperationsDependency, offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), query: str | None = Query(None, max_length=200), -) -> MailMessagePage: - return await service.list_messages(folder, offset, limit, query) +) -> DataResponse[MailMessagePage]: + return DataResponse[MailMessagePage]( + data=await operations.list_messages(folder, offset, limit, query), + message="Mail messages retrieved", + ) @router.get( "/folders/{folder:path}/messages/{uid}", - response_model=MailMessage, + response_model=DataResponse[MailMessage], summary="Read a message", + responses=MESSAGE_RESPONSES, ) -async def get_message(folder: str, uid: int, service: Service) -> MailMessage: - return await service.get_message(folder, uid) +async def get_message( + folder: str, uid: int, operations: MailOperationsDependency +) -> DataResponse[MailMessage]: + return DataResponse[MailMessage]( + data=await operations.get_message(folder, uid), message="Mail message retrieved" + ) @router.get( "/folders/{folder:path}/messages/{uid}/attachments/{part_id}", summary="Download an attachment", + responses=ATTACHMENT_RESPONSES, ) async def get_attachment( - folder: str, uid: int, part_id: str, service: Service + folder: str, uid: int, part_id: str, operations: MailOperationsDependency ) -> Response: - content, filename, content_type = await service.get_attachment(folder, uid, part_id) + content, filename, content_type = await operations.get_attachment( + folder, uid, part_id + ) return Response( content=content, media_type=content_type, @@ -77,40 +136,85 @@ async def get_attachment( @router.post( "/messages", - response_model=SendMailResponse, + response_model=DataResponse[SendMailResponse], status_code=status.HTTP_201_CREATED, summary="Send a message", + responses=SEND_MESSAGE_RESPONSES, ) -async def send_message(payload: SendMailRequest, service: Service) -> SendMailResponse: - return await service.send(payload) +async def send_message( + payload: SendMailRequest, operations: MailOperationsDependency +) -> DataResponse[SendMailResponse]: + return DataResponse[SendMailResponse]( + data=await operations.send(payload), message="Mail message sent" + ) @router.post( "/folders/{folder:path}/messages/{uid}/move", status_code=status.HTTP_204_NO_CONTENT, summary="Move a message", + responses=MOVE_MESSAGE_RESPONSES, ) async def move_message( - folder: str, uid: int, payload: MoveMailRequest, service: Service + folder: str, + uid: int, + payload: MoveMailRequest, + operations: MailOperationsDependency, ) -> None: - await service.move(folder, uid, payload) + await operations.move(folder, uid, payload) @router.patch( "/folders/{folder:path}/messages/{uid}/flags", status_code=status.HTTP_204_NO_CONTENT, summary="Update message flags", + responses=UPDATE_FLAGS_RESPONSES, ) async def update_flags( - folder: str, uid: int, payload: UpdateFlagsRequest, service: Service + folder: str, + uid: int, + payload: UpdateFlagsRequest, + operations: MailOperationsDependency, ) -> None: - await service.update_flags(folder, uid, payload) + await operations.update_flags(folder, uid, payload) @router.delete( "/folders/{folder:path}/messages/{uid}", status_code=status.HTTP_204_NO_CONTENT, summary="Permanently delete a message", + responses=DELETE_MESSAGE_RESPONSES, +) +async def delete_message( + folder: str, uid: int, operations: MailOperationsDependency +) -> None: + await operations.delete(folder, uid) + + +# The catch-all folder routes must remain after message routes so paths such as +# /folders/INBOX/messages/1 are matched by their more specific operation first. +@router.patch( + "/folders/{folder:path}", + response_model=DataResponse[MailFolder], + summary="Rename a mail folder", + responses=RENAME_FOLDER_RESPONSES, +) +async def rename_folder( + folder: str, + payload: RenameFolderRequest, + operations: MailOperationsDependency, +) -> DataResponse[MailFolder]: + return DataResponse[MailFolder]( + data=await operations.rename_folder(folder, payload), + message="Mail folder renamed", + ) + + +@router.delete( + "/folders/{folder:path}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a mail folder", + responses=DELETE_FOLDER_RESPONSES, ) -async def delete_message(folder: str, uid: int, service: Service) -> None: - await service.delete(folder, uid) +async def delete_folder(folder: str, operations: MailOperationsDependency) -> None: + await operations.delete_folder(folder) diff --git a/src/app/services/mail/services/__init__.py b/src/app/services/mail/services/__init__.py deleted file mode 100644 index 3f9e59f..0000000 --- a/src/app/services/mail/services/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .mail_service import MailService, get_mail_service - -__all__ = ["MailService", "get_mail_service"] diff --git a/src/app/services/mail/services/mail_service.py b/src/app/services/mail/services/mail_service.py deleted file mode 100644 index 41267fc..0000000 --- a/src/app/services/mail/services/mail_service.py +++ /dev/null @@ -1,73 +0,0 @@ -from app.shared.config import get_settings -from app.shared.config.settings import Settings - -from ..adapters import ImapSmtpMailAdapter -from ..adapters.interface import MailAdapter -from ..models.mail_models import ( - MailFolder, - MailMessage, - MailMessagePage, - MailStatus, - MoveMailRequest, - SendMailRequest, - SendMailResponse, - UpdateFlagsRequest, -) - - -class MailService: - """Application layer coordinating mailbox use cases.""" - - def __init__(self, adapter: MailAdapter, settings: Settings): - self.adapter = adapter - self.settings = settings - - def status(self) -> MailStatus: - return MailStatus( - configured=bool( - self.settings.mail_imap_host - and self.settings.mail_smtp_host - and self.settings.mail_username - ), - provider=self.settings.mail_provider, - ) - - async def list_folders(self) -> list[MailFolder]: - return await self.adapter.list_folders() - - async def list_messages( - self, folder: str, offset: int, limit: int, query: str | None - ) -> MailMessagePage: - return await self.adapter.list_messages(folder, offset, limit, query) - - async def get_message(self, folder: str, uid: int) -> MailMessage: - return await self.adapter.get_message(folder, uid) - - async def get_attachment( - self, folder: str, uid: int, part_id: str - ) -> tuple[bytes, str, str]: - return await self.adapter.get_attachment(folder, uid, part_id) - - async def send(self, payload: SendMailRequest) -> SendMailResponse: - return SendMailResponse(message_id=await self.adapter.send(payload)) - - async def move(self, folder: str, uid: int, payload: MoveMailRequest) -> None: - await self.adapter.move(folder, uid, payload.destination) - - async def update_flags( - self, folder: str, uid: int, payload: UpdateFlagsRequest - ) -> None: - await self.adapter.update_flags( - folder, - uid, - [flag.value for flag in payload.add], - [flag.value for flag in payload.remove], - ) - - async def delete(self, folder: str, uid: int) -> None: - await self.adapter.delete(folder, uid) - - -def get_mail_service() -> MailService: - settings = get_settings() - return MailService(ImapSmtpMailAdapter(settings), settings) diff --git a/src/app/shared/config/settings.py b/src/app/shared/config/settings.py index 12be677..2203b43 100644 --- a/src/app/shared/config/settings.py +++ b/src/app/shared/config/settings.py @@ -219,6 +219,10 @@ class Settings(BaseSettings): mail_password: str = Field(default="", description="Mailbox password/app password") mail_from: str = Field(default="", description="From address; defaults to username") mail_timeout_seconds: int = Field(default=30, description="Mail server timeout") + mail_protected_folders: list[str] = Field( + default=["INBOX"], + description="Mailbox folders that API clients cannot rename or delete", + ) # Feature Flags feature_webhooks_enabled: bool = Field(default=False, description="Enable webhooks") diff --git a/tests/test_mail_integration.py b/tests/test_mail_integration.py new file mode 100644 index 0000000..3a66831 --- /dev/null +++ b/tests/test_mail_integration.py @@ -0,0 +1,83 @@ +"""Opt-in integration test against the GreenMail development container.""" + +import os +from uuid import uuid4 + +import pytest + +from app.services.mail.adapters import ImapSmtpMailAdapter +from app.services.mail.models import SendMailRequest +from app.shared.config.settings import Settings + +pytestmark = pytest.mark.integration + + +@pytest.mark.skipif( + os.getenv("RUN_MAIL_INTEGRATION_TESTS") != "1", + reason="Set RUN_MAIL_INTEGRATION_TESTS=1 with GreenMail running", +) +@pytest.mark.asyncio +async def test_greenmail_smtp_to_imap_round_trip(): + """Send through SMTP, retrieve through IMAP, then clean up the message.""" + settings = Settings( + mail_imap_host="127.0.0.1", + mail_imap_port=3143, + mail_imap_ssl=False, + mail_smtp_host="127.0.0.1", + mail_smtp_port=3025, + mail_smtp_starttls=False, + mail_username="admin", + mail_password="admin", + mail_from="admin@example.com", + ) + adapter = ImapSmtpMailAdapter(settings) + subject = f"OpenTaberna integration {uuid4()}" + + message_id = await adapter.send( + SendMailRequest( + to=["admin@example.com"], subject=subject, text_body="Round trip" + ) + ) + # GreenMail's deliberately small IMAP implementation rejects some long + # SEARCH strings, so use a stable term and identify the exact result by ID. + page = await adapter.list_messages("INBOX", 0, 50, "OpenTaberna") + received = next(item for item in page.messages if item.message_id == message_id) + + message = await adapter.get_message("INBOX", received.uid) + assert message.subject == subject + assert message.text_body is not None + assert message.text_body.strip() == "Round trip" + + await adapter.delete("INBOX", received.uid) + + +@pytest.mark.skipif( + os.getenv("RUN_MAIL_INTEGRATION_TESTS") != "1", + reason="Set RUN_MAIL_INTEGRATION_TESTS=1 with GreenMail running", +) +@pytest.mark.asyncio +async def test_greenmail_folder_lifecycle(): + """Create, rename, list, and delete a folder through real IMAP commands.""" + settings = Settings( + mail_imap_host="127.0.0.1", + mail_imap_port=3143, + mail_imap_ssl=False, + mail_username="admin", + mail_password="admin", + ) + adapter = ImapSmtpMailAdapter(settings) + suffix = uuid4().hex + original = f"Test-{suffix}" + renamed = f"Renamed-{suffix}" + + created = await adapter.create_folder(original) + updated = await adapter.rename_folder(original, renamed) + folders = await adapter.list_folders() + + assert created.name == original + assert updated.name == renamed + assert renamed in {folder.name for folder in folders} + + await adapter.delete_folder(renamed) + folders = await adapter.list_folders() + assert renamed not in {folder.name for folder in folders} diff --git a/tests/test_mail_unit.py b/tests/test_mail_unit.py index 5eb560c..84b40e0 100644 --- a/tests/test_mail_unit.py +++ b/tests/test_mail_unit.py @@ -1,3 +1,6 @@ +"""Unit tests for mail schemas, use cases, routing, and MIME helpers.""" + +from email.message import EmailMessage from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -5,17 +8,29 @@ from fastapi.testclient import TestClient from pydantic import ValidationError +from app.main import app as main_app from app.services.admin.dependencies import require_admin from app.services.mail import mail_api_router -from app.services.mail.models.mail_models import ( +from app.services.mail.adapters.imap_smtp_adapter import ( + ImapSmtpMailAdapter, + _content, + _date, + _decode, +) +from app.services.mail.dependencies import get_mail_operations +from app.services.mail.functions import MailOperations +from app.services.mail.models import ( + CreateFolderRequest, MailFlag, + MailFolder, MailMessagePage, + MoveMailRequest, + RenameFolderRequest, SendMailRequest, UpdateFlagsRequest, ) -from app.services.mail.adapters.imap_smtp_adapter import ImapSmtpMailAdapter from app.shared.config.settings import Settings -from app.services.mail.services import MailService, get_mail_service +from app.shared.exceptions import BusinessRuleError def test_send_request_requires_a_body(): @@ -33,36 +48,131 @@ def test_flags_are_stable_api_values(): assert MailFlag.FLAGGED.value == "flagged" -def test_list_messages_endpoint_uses_service(): - service = AsyncMock(spec=MailService) - service.list_messages.return_value = MailMessagePage( +def test_folder_names_are_trimmed_and_control_characters_rejected(): + assert CreateFolderRequest(name=" Archive ").name == "Archive" + with pytest.raises(ValidationError): + CreateFolderRequest(name="Bad\nFolder") + + +def test_list_messages_endpoint_wraps_operations_result(): + operations = AsyncMock(spec=MailOperations) + operations.list_messages.return_value = MailMessagePage( messages=[], total=0, offset=0, limit=10 ) app = FastAPI() app.include_router(mail_api_router, prefix="/v1") app.dependency_overrides[require_admin] = lambda: {"sub": "admin"} - app.dependency_overrides[get_mail_service] = lambda: service + app.dependency_overrides[get_mail_operations] = lambda: operations response = TestClient(app).get("/v1/admin/mail/folders/INBOX/messages?limit=10") assert response.status_code == 200 - assert response.json()["messages"] == [] - service.list_messages.assert_awaited_once_with("INBOX", 0, 10, None) + body = response.json() + assert body["success"] is True + assert body["data"]["messages"] == [] + operations.list_messages.assert_awaited_once_with("INBOX", 0, 10, None) @pytest.mark.asyncio -async def test_mail_service_converts_flags_for_adapter(): +async def test_mail_operations_delegates_provider_neutral_flags(): adapter = AsyncMock() - settings = Settings() - service = MailService(adapter, settings) + operations = MailOperations(adapter, Settings()) - await service.update_flags( + await operations.update_flags( "INBOX", 7, UpdateFlagsRequest(add=[MailFlag.SEEN], remove=[MailFlag.FLAGGED]), ) - adapter.update_flags.assert_awaited_once_with("INBOX", 7, ["seen"], ["flagged"]) + adapter.update_flags.assert_awaited_once_with( + "INBOX", 7, [MailFlag.SEEN], [MailFlag.FLAGGED] + ) + + +def test_mail_operations_status_returns_domain_model(): + settings = Settings( + mail_imap_host="imap.example.com", + mail_smtp_host="smtp.example.com", + mail_username="admin", + ) + + result = MailOperations(AsyncMock(), settings).status() + + assert result.configured is True + assert result.provider == "imap_smtp" + + +@pytest.mark.asyncio +async def test_mail_operations_delegates_mutations(): + adapter = AsyncMock() + operations = MailOperations(adapter, Settings()) + + await operations.move("INBOX", 3, MoveMailRequest(destination="Archive")) + await operations.delete("Archive", 3) + + adapter.move.assert_awaited_once_with("INBOX", 3, "Archive") + adapter.delete.assert_awaited_once_with("Archive", 3) + + +@pytest.mark.asyncio +async def test_mail_operations_manage_folders(): + adapter = AsyncMock() + adapter.create_folder.return_value = MailFolder(name="Archive") + adapter.rename_folder.return_value = MailFolder(name="Processed") + operations = MailOperations(adapter, Settings()) + + created = await operations.create_folder(CreateFolderRequest(name="Archive")) + renamed = await operations.rename_folder( + "Archive", RenameFolderRequest(name="Processed") + ) + await operations.delete_folder("Processed") + + assert created.name == "Archive" + assert renamed.name == "Processed" + adapter.create_folder.assert_awaited_once_with("Archive") + adapter.rename_folder.assert_awaited_once_with("Archive", "Processed") + adapter.delete_folder.assert_awaited_once_with("Processed") + + +@pytest.mark.asyncio +async def test_mail_operations_protect_inbox(): + operations = MailOperations(AsyncMock(), Settings()) + + with pytest.raises(BusinessRuleError): + await operations.rename_folder("INBOX", RenameFolderRequest(name="InboxOld")) + with pytest.raises(BusinessRuleError): + await operations.delete_folder("inbox") + with pytest.raises(BusinessRuleError): + await operations.create_folder(CreateFolderRequest(name="INBOX")) + + +def test_message_delete_route_is_not_captured_by_folder_delete(): + operations = AsyncMock(spec=MailOperations) + app = FastAPI() + app.include_router(mail_api_router, prefix="/v1") + app.dependency_overrides[require_admin] = lambda: {"sub": "admin"} + app.dependency_overrides[get_mail_operations] = lambda: operations + + response = TestClient(app).delete("/v1/admin/mail/folders/INBOX/messages/9") + + assert response.status_code == 204 + operations.delete.assert_awaited_once_with("INBOX", 9) + operations.delete_folder.assert_not_awaited() + + +def test_send_openapi_response_uses_concrete_data_schema(): + schema = main_app.openapi() + response_schema = schema["components"]["schemas"]["DataResponse_SendMailResponse_"] + + assert "example" not in response_schema + assert response_schema["properties"]["data"]["$ref"].endswith("/SendMailResponse") + + +def test_mail_openapi_documents_provider_errors(): + operation = main_app.openapi()["paths"]["/v1/admin/mail/folders"]["get"] + assert {"200", "403", "422", "500", "502"} <= set(operation["responses"]) + create = main_app.openapi()["paths"]["/v1/admin/mail/folders"]["post"] + assert {"201", "400", "403", "422", "500", "502"} <= set(create["responses"]) def test_sent_message_contains_date_header(): @@ -84,3 +194,28 @@ def test_sent_message_contains_date_header(): sent_message = smtp.send_message.call_args.args[0] assert sent_message["Date"] is not None + + +def test_mail_parsing_helpers_decode_headers_and_dates(): + assert _decode("=?utf-8?q?Gr=C3=BC=C3=9Fe?=") == "Grüße" + assert _date("Tue, 26 Aug 2026 13:00:00 +0000").year == 2026 + assert _date("not-a-date") is None + + +def test_content_parser_separates_bodies_and_attachments(): + message = EmailMessage() + message.set_content("Plain body") + message.add_alternative("

HTML body

", subtype="html") + message.add_attachment( + b"report data", + maintype="application", + subtype="octet-stream", + filename="report.bin", + ) + + text, html, attachments = _content(message) + + assert text.strip() == "Plain body" + assert html.strip() == "

HTML body

" + assert attachments[0].filename == "report.bin" + assert attachments[0].size == len(b"report data")