Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
# ----------------------------------
Expand Down Expand Up @@ -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

9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -176,4 +181,4 @@ flowchart TD
- Webhook-driven payment confirmation
- Reservation-based inventory
- Admin pick/pack + manual tracking
- Then add DHL labels
- Then add DHL labels
47 changes: 43 additions & 4 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
153 changes: 153 additions & 0 deletions docs/mail.md
Original file line number Diff line number Diff line change
@@ -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
```
16 changes: 16 additions & 0 deletions docs/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions src/app/services/mail/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 3 additions & 0 deletions src/app/services/mail/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .imap_smtp_adapter import ImapSmtpMailAdapter

__all__ = ["ImapSmtpMailAdapter"]
Loading
Loading