# Ulti Backend

Backend monolithe Go orchestrant la Ulti Suite — alternative souveraine à Google Suite.

## Architecture

```
┌─────────────────────────────────────────────────────────┐
│  nginx (reverse proxy unique, port 80)                  │
├─────────────────────────────────────────────────────────┤
│  ultid (Go monolithe)                                   │
│  ├─ /api/v1/mail      — Ultimail (IMAP/SMTP custom)    │
│  ├─ /api/v1/drive     — Ultidrive (→ Nextcloud WebDAV) │
│  ├─ /api/v1/calendar  — Agenda (→ Nextcloud CalDAV)    │
│  ├─ /api/v1/contacts  — Contacts (→ Nextcloud CardDAV) │
│  ├─ /api/v1/meet      — Ultimeet (→ Jitsi JWT)        │
│  ├─ /api/v1/photos    — Ultiphotos (→ Immich API)     │
│  ├─ /api/v1/admin     — Administration                 │
│  ├─ /api/v1/search    — Recherche transversale         │
│  └─ /ws               — WebSocket realtime             │
├─────────────────────────────────────────────────────────┤
│  Services tiers (Docker)                                │
│  ├─ PostgreSQL 16     — DB partagée                    │
│  ├─ KeyDB             — Cache/sessions                  │
│  ├─ RustFS            — Object storage S3              │
│  ├─ Authentik         — Auth OIDC/SAML                 │
│  ├─ Nextcloud (nginx+FPM) — Drive/Cal/Contacts (/cloud) │
│  ├─ Jitsi             — Visioconférence                │
│  └─ Immich            — Photos/ML                      │
└─────────────────────────────────────────────────────────┘
```

## Quick Start

```bash
# 1. Copy environment file
cp .env.example .env
# Edit secrets once at the top of .env (POSTGRES_PASSWORD, RUSTFS_SECRET_KEY, etc.)
# Defaults use changeme — must match Authentik blueprints (deploy/authentik/blueprints/).

# 2. Start stack (core + modules enabled by flags)
./deploy/compose-up.sh up -d --build
```

**Auto-configured on first start:**

- SQL migrations (`ULTID_AUTO_MIGRATE=true`, embedded in `ultid`)
- Authentik OAuth apps **Ultimail** (`ulti-backend`) and **Nextcloud** via blueprints in `deploy/authentik/blueprints/`
- OIDC issuer for `ultid` via internal nginx: `ULTID_OIDC_ISSUER=http://nginx/auth/application/o/ulti/`

**Frontend suite unifié** (`gmail-interface-clone` — mail + drive + contacts) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost`, puis `pnpm dev` → http://localhost/mail/ et http://localhost/drive/

| Service | URL |
|---------|-----|
| API / Auth | http://localhost |
| Ultimail | http://localhost/mail/ |
| UltiDrive | http://localhost/drive/ |
| Grafana | http://localhost:3002 |

## Development

```bash
# Unit tests (fast, no Docker)
make test
# or: go test ./...

# Integration tests (Docker required — Postgres + MinIO via testcontainers)
cp .env.test.example .env.test   # optional overrides
make test-integration

# Run a single domain or test
make test-integration-mail
go test -tags=integration ./internal/integrationtest/mail/... -run TestMailSettingsCRUD -count=1 -v

# Enable optional suite modules (Nextcloud, Immich, Jitsi)
# ULTI_TEST_NEXTCLOUD=1 ULTI_TEST_NEXTCLOUD_URL=http://localhost:8081 make test-integration
```

Integration tests use a real PostgreSQL database (ephemeral container by default), miniredis, MinIO, and a test OIDC issuer that signs JWTs. Set `ULTI_TEST_INTEGRATION=1` to run them. See `.env.test.example` for all variables.

```bash
# Run locally (needs PG, KeyDB, RustFS, Authentik running; loads .env with {{VAR}} expansion)
go run ./cmd/ultid

# Build
go build -o ultid ./cmd/ultid

# Migrations run automatically on ultid start. To disable: ULTID_AUTO_MIGRATE=false

# Manual migrate (optional)
go run ./cmd/envexpand -in .env -out .env.resolved
source <(grep -v '^#' .env.resolved | sed 's/^/export /')
migrate -path migrations -database "$ULTID_DB_URL" up
```

### Reverse proxy (nginx)

Un seul **nginx** expose l’entrée HTTP (`:80`) et route :

| Chemin | Service |
|--------|---------|
| `/api/*` | ultid |
| `/ws` | ultid (WebSocket) |
| `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000` ; Docker : `suite-frontend:3000`) |

Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).  
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).

### Centralized secrets

Set passwords and keys **once** in the `Secrets` section at the top of `.env`. Derived values reference them with `{{POSTGRES_PASSWORD}}`, `{{RUSTFS_SECRET_KEY}}`, etc. Expansion runs via:

- `./deploy/compose-up.sh` — writes `.env.resolved` for Docker Compose
- `go run ./cmd/envexpand -in .env -out .env.resolved` — manual export for migrate/scripts
- `go run ./cmd/ultid` — expands `.env` in-process before reading config

Runtime secret files are also supported with `*_FILE` variables (example: `ULTID_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret`).

Mail credentials are encrypted at rest with AES-GCM using `MAIL_CREDENTIAL_KEYS` (`key_id:base64key,...`) and `MAIL_ACTIVE_CREDENTIAL_KEY_ID`.
Secret rotation policy is enforced through:

- `SECRET_ROTATION_MAX_AGE`
- `ULTID_OIDC_CLIENT_SECRET_ROTATED_AT`
- `MAIL_CREDENTIAL_KEY_ROTATED_AT`
- `MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT`

### Observability (Prometheus / Grafana)

ultid exposes Prometheus metrics at `/metrics` (see `internal/observability/metrics.go`). The core Docker Compose stack includes Prometheus and Grafana with configs under `deploy/observability/`:

| File | Purpose |
|------|---------|
| `deploy/observability/prometheus/prometheus.yml` | Scrape ultid + self; loads alert rules |
| `deploy/observability/prometheus/alerts.yml` | Alert rules: IMAP sync stalled, outbox backlog, HTTP 5xx rate |
| `deploy/observability/grafana/ultid-baseline.json` | Baseline dashboard (HTTP latency/errors, IMAP sync, outbox, webhooks) |
| `deploy/observability/grafana/provisioning/` | Grafana datasource + dashboard auto-load |

Start with the rest of the stack (`./deploy/compose-up.sh up -d`), then open:

| Service | URL | Notes |
|---------|-----|-------|
| Prometheus | http://localhost:9090 | Targets: `ultid`, `prometheus` |
| Grafana | http://localhost:3002 | Login from `.env` (`GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD`, default `admin` / `admin`); dashboard **Ultid Baseline** under folder **Ultid** |

**Alertmanager** — not included in compose; route labels `service=ultid` and `severity` (`critical`, `warning`) to your on-call channels when you add it.

## Stack

| Component | Technology |
|-----------|-----------|
| Backend | Go 1.25+ (chi, pgx, go-imap, go-smtp) |
| Database | PostgreSQL 16 |
| Cache | KeyDB (Redis-compatible, multi-threaded) |
| Object Storage | RustFS (S3-compatible, Apache 2.0) |
| Auth | Authentik (OIDC/SAML) |
| Files/Cal/Contacts | Nextcloud headless (WebDAV/CalDAV/CardDAV) |
| Video | Jitsi Meet (JWT auth) |
| Photos | Immich (ML classification) |
| Reverse Proxy | nginx (TLS à ajouter via certbot ou autre) |
| Search | PostgreSQL tsvector + GIN |

## Calendar API (mini doc)

Base path: `/api/v1/calendar` (module actif seulement si `NEXTCLOUD_ENABLED=true`).

### Endpoints principaux

| Endpoint | Méthode | Usage |
|----------|---------|-------|
| `/` | `GET` | Lister calendriers |
| `/{calID}/events` | `GET` | Lister événements d'un calendrier |
| `/{calID}/events` | `POST` | Créer événement |
| `/events/*` | `PUT` | Modifier événement avec `If-Match` |
| `/events/*` | `DELETE` | Supprimer événement |
| `/events/response/*` | `POST` | Répondre invitation participant |
| `/freebusy` | `POST` | Obtenir disponibilités (free/busy) |
| `/events/meet-link/*` | `POST` | Générer lien Meet et l'injecter dans l'événement |

`*` représente un chemin CalDAV absolu, ex:
`/remote.php/dav/calendars/{user}/{calendar}/{uid}.ics`

### Event payload (create / update)

```json
{
  "uid": "team-sync-2026-05-23",
  "summary": "Team sync",
  "description": "Weekly project update",
  "location": "Paris office",
  "start": "20260523T090000Z",
  "end": "20260523T100000Z",
  "organizer": "alice@example.com",
  "attendees": [
    { "email": "bob@example.com", "name": "Bob", "status": "NEEDS-ACTION" },
    { "email": "carol@example.com", "name": "Carol", "status": "ACCEPTED" }
  ]
}
```

### Update avec ETag / If-Match

Requête:

```http
PUT /api/v1/calendar/events/remote.php/dav/calendars/alice/work/team-sync-2026-05-23.ics
If-Match: "68031b8f4d18f"
Content-Type: application/json
```

Réponse succès:

```json
{ "etag": "\"68031b8f5f901\"" }
```

Si ETag obsolète, API renvoie `412` avec code `etag_mismatch`.

### Réponse invitation participant

```json
POST /api/v1/calendar/events/response/remote.php/dav/calendars/alice/work/team-sync-2026-05-23.ics
{
  "email": "bob@example.com",
  "response": "accepted",
  "if_match": "\"68031b8f5f901\""
}
```

`response` autorise: `accepted`, `declined`, `tentative`.

### Free/Busy

```json
POST /api/v1/calendar/freebusy
{
  "start": "2026-05-23T08:00:00Z",
  "end": "2026-05-23T18:00:00Z",
  "attendees": ["bob@example.com", "carol@example.com"]
}
```

Réponse (exemple):

```json
{
  "attendees": [
    {
      "email": "bob@example.com",
      "periods": [
        { "start": "20260523T090000Z", "end": "20260523T100000Z", "type": "BUSY" }
      ]
    }
  ]
}
```

### Création lien Meet depuis event

```json
POST /api/v1/calendar/events/meet-link/remote.php/dav/calendars/alice/work/team-sync-2026-05-23.ics
{
  "if_match": "\"68031b8f5f901\""
}
```

Réponse:

```json
{
  "meet_url": "https://your-domain/meet/team-sync-2026-05-23?jwt=...",
  "etag": "\"68031b8f8b102\""
}
```

Si module Meet désactivé (`JITSI_ENABLED=false`), API renvoie `409` avec code `meet_disabled`.

### Erreurs API Calendar

| HTTP | `code` | Cas |
|------|--------|-----|
| `400` | `invalid_request_body` | JSON invalide, champ manquant, email participant invalide, format date invalide |
| `400` | `request_body_too_large` | Payload dépasse limite handler (`256KB`) |
| `401` | `auth.*` | Token manquant/invalide |
| `403` | `auth.forbidden` | Permission calendrier insuffisante (`read`/`write`) |
| `404` | `attendee_not_found` | Email donné absent des `ATTENDEE` de l'événement |
| `409` | `meet_disabled` | Endpoint meet-link appelé alors que Jitsi désactivé |
| `412` | `etag_mismatch` | `If-Match` obsolète sur update event/response/meet-link |
| `500` | `internal_error` | Erreur Nextcloud/CalDAV ou erreur interne backend |

Format standard:

```json
{
  "code": "etag_mismatch",
  "message": "etag does not match current resource version",
  "trace_id": "req_01HZ..."
}
```

## Project Structure

```
├── cmd/ultid/          — Entry point
├── internal/
│   ├── api/            — HTTP handlers (mail, admin, drive, calendar, contacts, meet, photos)
│   ├── mail/           — IMAP sync, SMTP send, rules engine, webhooks
│   ├── nextcloud/      — WebDAV/CalDAV/CardDAV client
│   ├── meet/           — Jitsi JWT generation
│   ├── photos/         — Immich API client
│   ├── auth/           — OIDC verification
│   ├── search/         — Full-text search
│   ├── realtime/       — WebSocket hub
│   └── config/         — Environment config
├── migrations/         — SQL migrations
├── deploy/             — Docker Compose configs
│   ├── docker-compose.yml  — Core stack
│   ├── observability/      — Prometheus alerts + Grafana dashboard
│   ├── nginx/
│   ├── nextcloud/
│   ├── jitsi/
│   └── immich/
├── Dockerfile          — Multi-stage build
└── .env.example
```
