diff --git a/.env.example b/.env.example index ef760f1..8aba3b2 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) @@ -126,6 +126,25 @@ 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 +# Folder names protected from rename/delete through the admin API. +MAIL_PROTECTED_FOLDERS=["INBOX"] + # ---------------------------------- # Object Storage (MinIO / S3) — carrier label files # ---------------------------------- @@ -197,4 +216,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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb69d86..6d24317 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 @@ -169,5 +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: 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/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/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..e6eef65 --- /dev/null +++ b/src/app/services/mail/adapters/imap_smtp_adapter.py @@ -0,0 +1,429 @@ +"""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 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: + 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[MailFlag], remove: list[MailFlag] + ) -> 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 _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: + uids = self._search_uids(client, query) + 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() + + @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: + 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[MailFlag], remove: list[MailFlag] + ) -> None: + client = self._selected(folder, readonly=False) + try: + for operation, values in ( + ("+FLAGS.SILENT", add), + ("-FLAGS.SILENT", remove), + ): + flags = [_FLAG_TO_IMAP[value.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..1c69665 --- /dev/null +++ b/src/app/services/mail/adapters/interface.py @@ -0,0 +1,52 @@ +"""Contract that allows IMAP/SMTP to be replaced by Graph or another provider.""" + +from abc import ABC, abstractmethod + +from ..models.mail_models import ( + MailFlag, + MailFolder, + MailMessage, + MailMessagePage, + SendMailRequest, +) + + +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 + ) -> 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[MailFlag], remove: list[MailFlag] + ) -> None: ... + + @abstractmethod + async def delete(self, folder: str, uid: int) -> None: ... 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 new file mode 100644 index 0000000..c41a0b8 --- /dev/null +++ b/src/app/services/mail/models/__init__.py @@ -0,0 +1,35 @@ +"""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 new file mode 100644 index 0000000..716a17b --- /dev/null +++ b/src/app/services/mail/models/mail_models.py @@ -0,0 +1,153 @@ +"""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, field_validator, model_validator + + +class MailFlag(StrEnum): + """Provider-neutral message flags exposed by the HTTP API.""" + + SEEN = "seen" + ANSWERED = "answered" + FLAGGED = "flagged" + DELETED = "deleted" + DRAFT = "draft" + + +class MailFolder(BaseModel): + """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): + """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): + """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): + """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 + html_body: str | None = None + attachments: list[MailAttachment] = Field(default_factory=list) + + +class MailMessagePage(BaseModel): + """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) + 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): + """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): + """Identity assigned to a successfully submitted outgoing message.""" + + message_id: str = Field(..., min_length=1, description="Generated RFC Message-ID") + + +class MailStatus(BaseModel): + """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/__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..2dd9e7d --- /dev/null +++ b/src/app/services/mail/routers/mail_router.py @@ -0,0 +1,220 @@ +"""Admin-only REST facade used by a future webmail frontend.""" + +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 ..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)]) + + +@router.get( + "/status", + response_model=DataResponse[MailStatus], + summary="Get mailbox configuration status", + responses=STATUS_RESPONSES, +) +async def mailbox_status( + operations: MailOperationsDependency, +) -> DataResponse[MailStatus]: + return DataResponse[MailStatus]( + data=operations.status(), message="Mailbox configuration status retrieved" + ) + + +@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=DataResponse[MailMessagePage], + summary="List messages", + responses=LIST_MESSAGES_RESPONSES, +) +async def list_messages( + folder: str, + operations: MailOperationsDependency, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + query: str | None = Query(None, max_length=200), +) -> 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=DataResponse[MailMessage], + summary="Read a message", + responses=MESSAGE_RESPONSES, +) +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, operations: MailOperationsDependency +) -> Response: + content, filename, content_type = await operations.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=DataResponse[SendMailResponse], + status_code=status.HTTP_201_CREATED, + summary="Send a message", + responses=SEND_MESSAGE_RESPONSES, +) +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, + operations: MailOperationsDependency, +) -> None: + 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, + operations: MailOperationsDependency, +) -> None: + 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_folder(folder: str, operations: MailOperationsDependency) -> None: + await operations.delete_folder(folder) 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..2203b43 100644 --- a/src/app/shared/config/settings.py +++ b/src/app/shared/config/settings.py @@ -207,6 +207,23 @@ 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") + 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") @@ -341,8 +358,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/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) 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" 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 new file mode 100644 index 0000000..84b40e0 --- /dev/null +++ b/tests/test_mail_unit.py @@ -0,0 +1,221 @@ +"""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 +from fastapi import FastAPI +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.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.shared.config.settings import Settings +from app.shared.exceptions import BusinessRuleError + + +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_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_operations] = lambda: operations + + response = TestClient(app).get("/v1/admin/mail/folders/INBOX/messages?limit=10") + + assert response.status_code == 200 + 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_operations_delegates_provider_neutral_flags(): + adapter = AsyncMock() + operations = MailOperations(adapter, Settings()) + + await operations.update_flags( + "INBOX", + 7, + UpdateFlagsRequest(add=[MailFlag.SEEN], remove=[MailFlag.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(): + 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 + + +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")