diff --git a/.env.example b/.env.example index 9f00fd1..9ccc4c9 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,10 @@ TYPESENSE_API_KEY=changeme # ----------------------------------------------------------------------------- DOMAIN=localhost ULTID_PORT=8080 +# Origines navigateur autorisees (web app sur autre port/origine que l'API). +# Vide = auto : localhost/127.0.0.1/LAN prive en dev ; http(s)://${DOMAIN} en prod. +# Exemple dev explicite : ULTID_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +# ULTID_CORS_ALLOWED_ORIGINS= # ----------------------------------------------------------------------------- # PostgreSQL @@ -80,9 +84,12 @@ ULTID_RUSTFS_REGION=us-east-1 # Mode local : Authentik deploye dans la stack # Mode externe : n'importe quel provider OIDC existant # ----------------------------------------------------------------------------- -ULTID_OIDC_ISSUER=http://authentik-server:9000/application/o/ulti/ +# Issuer vu par ultid (via nginx interne Docker). DOMAIN sert de Host header pour discovery +# afin que l'issuer attendu = iss des tokens navigateur (http://localhost/auth/application/o/ulti/). +ULTID_OIDC_ISSUER=http://nginx/auth/application/o/ulti/ ULTID_OIDC_CLIENT_ID=ulti-backend -# ULTID_OIDC_CLIENT_SECRET — defini dans la section Secrets +# ULTID_OIDC_CLIENT_SECRET — defini dans la section Secrets (doit matcher blueprint Authentik) +ULTID_AUTO_MIGRATE=true # Exemple Keycloak externe : # ULTID_OIDC_ISSUER=https://auth.example.com/realms/ulti # ULTID_OIDC_CLIENT_ID=ulti-backend @@ -95,6 +102,8 @@ AUTHENTIK_POSTGRESQL__PASSWORD={{POSTGRES_PASSWORD}} AUTHENTIK_POSTGRESQL__NAME=authentik AUTHENTIK_REDIS__HOST=keydb AUTHENTIK_WEB__PATH=/auth/ +# URL publique affichee dans les redirects OIDC (navigateur) +AUTHENTIK_HOST=http://{{DOMAIN}} # ----------------------------------------------------------------------------- # Nextcloud (Drive / Calendar / Contacts) @@ -118,7 +127,7 @@ NEXTCLOUD_ENABLED=true NC_OIDC_CLIENT_ID=ulti-nextcloud # NC_OIDC_CLIENT_SECRET — defini dans la section Secrets -NC_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration +NC_OIDC_DISCOVERY_URL=http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration NC_S3_BUCKET=nextcloud NC_S3_HOST=rustfs @@ -193,6 +202,16 @@ MAIL_SMTP_CIRCUIT_COOLDOWN=5m SECRET_ROTATION_MAX_AGE=2160h ULTID_OIDC_CLIENT_SECRET_ROTATED_AT=2026-01-01T00:00:00Z MAIL_CREDENTIAL_KEY_ROTATED_AT=2026-01-01T00:00:00Z + +# Mail provider OAuth (Gmail / Microsoft 365) — optional +# Redirect URI must match Google/Azure app config, e.g. https://api.example.com/api/v1/mail/accounts/oauth/callback +MAIL_GOOGLE_OAUTH_CLIENT_ID= +MAIL_GOOGLE_OAUTH_CLIENT_SECRET= +MAIL_MICROSOFT_OAUTH_CLIENT_ID= +MAIL_MICROSOFT_OAUTH_CLIENT_SECRET= +MAIL_MICROSOFT_OAUTH_TENANT=common +MAIL_OAUTH_REDIRECT_URL= +MAIL_APP_URL=http://localhost:3000 MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z # ----------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 013bd4e..74690cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.25" cache: true - name: Run unit tests diff --git a/Dockerfile b/Dockerfile index 4574120..5c96a5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git ca-certificates @@ -10,7 +10,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /ultid ./cmd/ultid FROM alpine:3.20 -RUN apk add --no-cache ca-certificates tzdata \ +RUN apk add --no-cache ca-certificates tzdata wget \ && addgroup -S ulti && adduser -S ulti -G ulti COPY --from=builder /ultid /usr/local/bin/ultid diff --git a/README.md b/README.md index fcb420d..cffe8b9 100644 --- a/README.md +++ b/README.md @@ -36,30 +36,40 @@ Backend monolithe Go orchestrant la Ulti Suite — alternative souveraine à Goo # 1. Copy environment file cp .env.example .env # Edit secrets once at the top of .env (POSTGRES_PASSWORD, RUSTFS_SECRET_KEY, etc.) -# Other variables use {{VAR}} placeholders expanded at launch. -# Toggle modules with flags: -# NEXTCLOUD_ENABLED=true|false -# JITSI_ENABLED=true|false -# IMMICH_ENABLED=true|false +# Defaults use changeme — must match Authentik blueprints (deploy/authentik/blueprints/). # 2. Start stack (core + modules enabled by flags) -./deploy/compose-up.sh up -d +./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** (`gmail-interface-clone`): copy `.env.example` → `.env.local`, then `pnpm dev` → http://localhost:3000 → login redirects to Authentik. + +| Service | URL | +|---------|-----| +| API / Auth | http://localhost | +| Grafana | http://localhost:3002 | +| Frontend | http://localhost:3000 (Next dev) | + ## Development ```bash -# Run locally (needs PG, KeyDB, RustFS running; loads .env with {{VAR}} expansion) +# Run locally (needs PG, KeyDB, RustFS, Authentik running; loads .env with {{VAR}} expansion) go run ./cmd/ultid # Build go build -o ultid ./cmd/ultid -# Expand .env for external tools (docker, migrate) +# 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 /') - -# Run migrations (use expanded ULTID_DB_URL; host may need localhost instead of postgres) migrate -path migrations -database "$ULTID_DB_URL" up ``` @@ -112,7 +122,7 @@ 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:3000 | Login from `.env` (`GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD`, default `admin` / `admin`); dashboard **Ultid Baseline** under folder **Ultid** | +| 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. @@ -120,7 +130,7 @@ Start with the rest of the stack (`./deploy/compose-up.sh up -d`), then open: | Component | Technology | |-----------|-----------| -| Backend | Go 1.23+ (chi, pgx, go-imap, go-smtp) | +| 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) | diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index ac4b8f5..64d330a 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -12,7 +12,6 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/cors" "github.com/jackc/pgx/v5/pgxpool" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" @@ -20,7 +19,6 @@ import ( "github.com/redis/go-redis/v9" "github.com/ultisuite/ulti-backend/internal/api/admin" - "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" "github.com/ultisuite/ulti-backend/internal/api/drive" @@ -30,10 +28,13 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/middleware" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/dbmigrate" "github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/envexpand" + "github.com/ultisuite/ulti-backend/internal/httpcors" mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/smtp" mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage" @@ -59,6 +60,11 @@ func main() { os.Exit(1) } + if err := dbmigrate.Up(cfg.DatabaseURL); err != nil { + slog.Error("database migration failed", "error", err) + os.Exit(1) + } + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) if err != nil { slog.Error("failed to connect to database", "error", err) @@ -82,7 +88,7 @@ func main() { slog.Warn("mail attachments bucket check failed", "error", err) } - verifier, err := auth.NewVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCClientID) + verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second) if err != nil { slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err) } @@ -140,15 +146,31 @@ func main() { rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool)) + oauthRedirect := cfg.MailOAuthRedirectURL + if oauthRedirect == "" { + oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port) + if cfg.Domain != "" && cfg.Domain != "localhost" { + oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain) + } + } + mailOAuthSvc := mailoauth.NewService(mailoauth.Config{ + GoogleClientID: cfg.MailGoogleOAuthClientID, + GoogleClientSecret: cfg.MailGoogleOAuthClientSecret, + MicrosoftClientID: cfg.MailMicrosoftOAuthClientID, + MicrosoftSecret: cfg.MailMicrosoftOAuthSecret, + MicrosoftTenant: cfg.MailMicrosoftOAuthTenant, + RedirectURL: oauthRedirect, + }, rdb) + // Start background workers - go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, imapsync.SyncDeps{ + go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ Storage: attachmentStorage, AttachBucket: cfg.MailAttachmentsBucket, Rules: rulesEngine, Hub: hub, }).Start(ctx) - sender := smtp.NewSender(pool, credentialManager) + sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc) smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) go smtp.NewOutboxProcessor( @@ -160,18 +182,12 @@ func main() { ).Start(ctx) sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) + mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL) // Router r := chi.NewRouter() - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Authorization", "Content-Type", "Idempotency-Key", apiresponse.TraceIDHeader}, - ExposedHeaders: []string{apiresponse.TraceIDHeader}, - AllowCredentials: false, - MaxAge: 300, - })) + r.Use(httpcors.Middleware(cfg)) r.Use(middleware.TraceID) r.Use(observability.HTTPMetrics) r.Use(middleware.Logging) @@ -189,11 +205,12 @@ func main() { r.Handle("/metrics", promhttp.Handler()) r.Get("/ws", hub.HandleWS) + r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback) r.Group(func(r chi.Router) { r.Use(middleware.Auth(verifier, pool, auditLogger)) - r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter).Routes()) + r.Mount("/api/v1/mail", mailHandler.Routes()) r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ Nextcloud: ncClient, @@ -219,7 +236,7 @@ func main() { } }) - _ = rdb + slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/deploy/authentik/README.md b/deploy/authentik/README.md new file mode 100644 index 0000000..a6748ca --- /dev/null +++ b/deploy/authentik/README.md @@ -0,0 +1,85 @@ +# Authentik — provisioning local + +Blueprints in `blueprints/` are mounted into Authentik at `/blueprints/custom` and applied automatically by the worker on startup. + +| Fichier | Rôle | +|---------|------| +| `01-ulti-enrollment.yaml` | Inscription self-service (`ulti-enrollment`) | +| `02-ulti-brand.yaml` | Branding Ultimail + lien « Créer un compte » sur login | +| `ulti-oidc.yaml` | App OIDC Ultimail | +| `nextcloud-oidc.yaml` | App OIDC Nextcloud | + +Assets branding : générés depuis le frontend (`pnpm run brand:authentik` dans `gmail-interface-clone`) : + +| Fichier Authentik | Thème | Description | +|-------------------|-------|-------------| +| `ultimail-logo-light.png` | clair | Picto + wordmark sur fond blanc | +| `ultimail-logo-dark.png` | sombre | Picto + texte clair, fond transparent | +| `ultimail-favicon.png` | — | Mark 32×32 transparent (favicon onglet, URL **sans** `%(theme)s`) | +| `ultimail-favicon-light.png` | clair | Variante archive (fond blanc) | +| `ultimail-favicon-dark.png` | sombre | Variante archive (fond sombre) | + +Logo : placeholder Authentik `%(theme)s` + fallback CSS `prefers-color-scheme`. +Favicon onglet : **chemin statique** — Authentik ne substitue pas `%(theme)s` dans le `` SSR (erreur 400). + +Regénérer après MAJ du master brand : + +```bash +cd ../gmail-interface-clone +pnpm run brand:build && pnpm run brand:authentik +cd ../ulti-backend +./deploy/compose-up.sh up -d authentik-server authentik-worker +docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/02-ulti-brand.yaml +``` + +## Inscription utilisateur + +Flow public : `http://localhost/auth/if/flow/ulti-enrollment/` + +Étapes collectées : + +1. E-mail (identifiant), mot de passe +2. Nom et prénom, téléphone (optionnel), avatar (optionnel) +3. Création du compte + connexion automatique + +Sur la page de connexion Authentik, lien **« Besoin d'un compte ? S'inscrire »** (identification stage). + +## Branding + +- Titre navigateur : **Ultimail** +- Logo / favicon : marque Ultimail, variantes **light** et **dark** (thème Authentik) +- CSS custom : masque « Powered by authentik » et liens goauthentik.io +- Locale par défaut : `fr` + +Modifier le logo : `pnpm run brand:authentik` dans le repo frontend, puis redémarrer Authentik + réappliquer le blueprint brand. + +## Secrets OIDC + +| App | Slug | Client ID | Secret (`.env`) | +|-----|------|-----------|------------------| +| Ultimail | `ulti` | `ulti-backend` | `ULTID_OIDC_CLIENT_SECRET` | +| Nextcloud | `nextcloud` | `ulti-nextcloud` | `NC_OIDC_CLIENT_SECRET` | + +Defaults blueprints : `changeme` — sync avec `.env`. + +## Appliquer / vérifier + +```bash +# Re-appliquer après modification +docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/01-ulti-enrollment.yaml +docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/02-ulti-brand.yaml +./deploy/compose-up.sh restart authentik-worker + +# Vérifier OIDC Ultimail +curl -s http://localhost/auth/application/o/ulti/.well-known/openid-configuration | head -5 +``` + +`redirect_uris` : objets `{ matching_mode, url }` (Authentik 2025.2+). + +## Limites connues + +- « Powered by authentik » masqué via CSS (pas d’option officielle en open source). +- Placeholder `%(theme)s` sur le **logo** : non remplacé sur certaines pages SSR ; fallback CSS `prefers-color-scheme`. +- Favicon onglet : pas de `%(theme)s` (bug SSR Authentik → 400) ; mark transparent unique. +- Avatar stocké en attribut utilisateur (data-URI), pas encore affiché dans Ultimail header. +- Multi-comptes simultanés type Gmail : non implémenté côté Ultimail. diff --git a/deploy/authentik/blueprints/01-ulti-enrollment.yaml b/deploy/authentik/blueprints/01-ulti-enrollment.yaml new file mode 100644 index 0000000..2d0686b --- /dev/null +++ b/deploy/authentik/blueprints/01-ulti-enrollment.yaml @@ -0,0 +1,166 @@ +# Ultimail — inscription self-service (email, mot de passe, profil, avatar optionnel) +version: 1 +metadata: + name: Ultimail enrollment + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_flows.flow + id: ulti-enrollment-flow + identifiers: + slug: ulti-enrollment + attrs: + name: Ultimail — Créer un compte + title: Créer votre compte Ultimail + designation: enrollment + authentication: require_unauthenticated + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-email + identifiers: + name: ulti-enrollment-field-email + attrs: + field_key: username + label: Adresse e-mail + type: email + required: true + placeholder: vous@exemple.com + placeholder_expression: false + order: 0 + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-email-sync + identifiers: + name: ulti-enrollment-field-email-sync + attrs: + field_key: email + label: E-mail + type: hidden + required: true + initial_value: "{{ prompt_data.username }}" + initial_value_expression: true + placeholder_expression: false + order: 1 + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-password + identifiers: + name: ulti-enrollment-field-password + attrs: + field_key: password + label: Mot de passe + type: password + required: true + placeholder: Mot de passe + placeholder_expression: false + order: 1 + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-password-repeat + identifiers: + name: ulti-enrollment-field-password-repeat + attrs: + field_key: password_repeat + label: Confirmer le mot de passe + type: password + required: true + placeholder: Confirmer le mot de passe + placeholder_expression: false + order: 2 + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-name + identifiers: + name: ulti-enrollment-field-name + attrs: + field_key: name + label: Nom et prénom + type: text + required: true + placeholder: Jean Dupont + placeholder_expression: false + order: 0 + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-phone + identifiers: + name: ulti-enrollment-field-phone + attrs: + field_key: attributes.phone + label: Numéro de téléphone (optionnel) + type: text + required: false + placeholder: +33 6 12 34 56 78 + placeholder_expression: false + order: 1 + + - model: authentik_stages_prompt.prompt + id: ulti-enroll-field-avatar + identifiers: + name: ulti-enrollment-field-avatar + attrs: + field_key: attributes.avatar + label: Photo de profil (optionnel) + type: file + required: false + placeholder: "" + placeholder_expression: false + order: 2 + + - model: authentik_stages_prompt.promptstage + id: ulti-enroll-prompt-credentials + identifiers: + name: ulti-enrollment-prompt-credentials + attrs: + fields: + - !KeyOf ulti-enroll-field-email + - !KeyOf ulti-enroll-field-email-sync + - !KeyOf ulti-enroll-field-password + - !KeyOf ulti-enroll-field-password-repeat + + - model: authentik_stages_prompt.promptstage + id: ulti-enroll-prompt-profile + identifiers: + name: ulti-enrollment-prompt-profile + attrs: + fields: + - !KeyOf ulti-enroll-field-name + - !KeyOf ulti-enroll-field-phone + - !KeyOf ulti-enroll-field-avatar + + - model: authentik_stages_user_write.userwritestage + id: ulti-enroll-user-write + identifiers: + name: ulti-enrollment-user-write + attrs: + user_creation_mode: always_create + create_users_as_inactive: false + + - model: authentik_stages_user_login.userloginstage + id: ulti-enroll-user-login + identifiers: + name: ulti-enrollment-user-login + + - model: authentik_flows.flowstagebinding + identifiers: + target: !KeyOf ulti-enrollment-flow + stage: !KeyOf ulti-enroll-prompt-credentials + order: 10 + + - model: authentik_flows.flowstagebinding + identifiers: + target: !KeyOf ulti-enrollment-flow + stage: !KeyOf ulti-enroll-prompt-profile + order: 20 + + - model: authentik_flows.flowstagebinding + identifiers: + target: !KeyOf ulti-enrollment-flow + stage: !KeyOf ulti-enroll-user-write + order: 30 + + - model: authentik_flows.flowstagebinding + identifiers: + target: !KeyOf ulti-enrollment-flow + stage: !KeyOf ulti-enroll-user-login + order: 100 diff --git a/deploy/authentik/blueprints/02-ulti-brand.yaml b/deploy/authentik/blueprints/02-ulti-brand.yaml new file mode 100644 index 0000000..c826b31 --- /dev/null +++ b/deploy/authentik/blueprints/02-ulti-brand.yaml @@ -0,0 +1,64 @@ +# Ultimail — branding + lien inscription sur le flow de connexion +version: 1 +metadata: + name: Ultimail brand and authentication + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_flows.flow + identifiers: + slug: default-authentication-flow + attrs: + name: Connexion Ultimail + title: Connexion Ultimail + + - model: authentik_stages_identification.identificationstage + identifiers: + name: default-authentication-identification + attrs: + user_fields: + - email + - username + enrollment_flow: !Find [authentik_flows.flow, [slug, ulti-enrollment]] + + - model: authentik_brands.brand + identifiers: + domain: authentik-default + attrs: + branding_title: Ultimail + branding_logo: /static/dist/assets/branding/ultimail-logo-%(theme)s.png + branding_favicon: /static/dist/assets/branding/ultimail-favicon.png + branding_custom_css: | + /* Ultimail — masquer le branding Authentik */ + ak-branding-footer, + .pf-c-login__footer, + .pf-c-login__footer-text, + a[href*="goauthentik.io"], + a[href*="authentik.io"] { + display: none !important; + visibility: hidden !important; + height: 0 !important; + overflow: hidden !important; + } + ak-brand-logo img, + .pf-c-brand img { + max-height: 48px; + width: auto; + max-width: min(280px, 80vw); + content: url("/auth/static/dist/assets/branding/ultimail-logo-light.png"); + } + @media (prefers-color-scheme: dark) { + ak-brand-logo img, + .pf-c-brand img { + content: url("/auth/static/dist/assets/branding/ultimail-logo-dark.png"); + } + } + ak-flow-executor::part(footer) { + display: none !important; + } + attributes: + settings: + locale: fr + flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]] + flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] + flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]] diff --git a/deploy/authentik/blueprints/nextcloud-oidc.yaml b/deploy/authentik/blueprints/nextcloud-oidc.yaml new file mode 100644 index 0000000..85b86b1 --- /dev/null +++ b/deploy/authentik/blueprints/nextcloud-oidc.yaml @@ -0,0 +1,40 @@ +# Authentik blueprint — Nextcloud OIDC (when NEXTCLOUD_ENABLED=true) +# Client secret must match NC_OIDC_CLIENT_SECRET in .env +version: 1 +metadata: + name: Nextcloud OIDC + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_providers_oauth2.oauth2provider + id: nc-oauth-provider + identifiers: + name: ulti-nextcloud-provider + attrs: + name: ulti-nextcloud-provider + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + client_type: confidential + client_id: ulti-nextcloud + client_secret: changeme + redirect_uris: + - matching_mode: strict + url: http://localhost/cloud/apps/user_oidc/code + - matching_mode: strict + url: http://127.0.0.1/cloud/apps/user_oidc/code + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + + - model: authentik_core.application + identifiers: + slug: nextcloud + attrs: + name: Nextcloud + slug: nextcloud + group: Ulti Suite + provider: !KeyOf nc-oauth-provider + meta_launch_url: http://localhost/cloud/ + policy_engine_mode: any diff --git a/deploy/authentik/blueprints/ulti-oidc.yaml b/deploy/authentik/blueprints/ulti-oidc.yaml new file mode 100644 index 0000000..31d11b2 --- /dev/null +++ b/deploy/authentik/blueprints/ulti-oidc.yaml @@ -0,0 +1,47 @@ +# Authentik blueprint — Ultimail OIDC (auto-applied on worker startup) +# Client secret must match ULTID_OIDC_CLIENT_SECRET in .env +version: 1 +metadata: + name: Ultimail OIDC + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_providers_oauth2.oauth2provider + id: ulti-oauth-provider + identifiers: + name: ulti-backend-provider + attrs: + name: ulti-backend-provider + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + access_token_validity: "hours=1" + refresh_token_validity: "days=365" + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] + client_type: confidential + client_id: ulti-backend + client_secret: changeme + redirect_uris: + - matching_mode: strict + url: http://localhost:3000/api/auth/callback + - matching_mode: strict + url: http://127.0.0.1:3000/api/auth/callback + - matching_mode: strict + url: http://localhost:3001/api/auth/callback + - matching_mode: strict + url: http://127.0.0.1:3001/api/auth/callback + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + + - model: authentik_core.application + identifiers: + slug: ulti + attrs: + name: Ultimail + slug: ulti + group: Ulti Suite + provider: !KeyOf ulti-oauth-provider + meta_launch_url: http://localhost:3000/ + policy_engine_mode: any diff --git a/deploy/authentik/branding/ulti-authentik.css b/deploy/authentik/branding/ulti-authentik.css new file mode 100644 index 0000000..5280eb4 --- /dev/null +++ b/deploy/authentik/branding/ulti-authentik.css @@ -0,0 +1,23 @@ +/* Ultimail — masquer le branding Authentik sur flows et portail utilisateur */ + +ak-branding-footer, +.pf-c-login__footer, +.pf-c-login__footer-text, +a[href*="goauthentik.io"], +a[href*="authentik.io"] { + display: none !important; + visibility: hidden !important; + height: 0 !important; + overflow: hidden !important; +} + +ak-brand-logo img, +.pf-c-brand img { + max-height: 48px; + width: auto; + max-width: min(280px, 80vw); +} + +ak-flow-executor::part(footer) { + display: none !important; +} diff --git a/deploy/authentik/branding/ultimail-favicon-dark.png b/deploy/authentik/branding/ultimail-favicon-dark.png new file mode 100644 index 0000000..45e1ca9 Binary files /dev/null and b/deploy/authentik/branding/ultimail-favicon-dark.png differ diff --git a/deploy/authentik/branding/ultimail-favicon-light.png b/deploy/authentik/branding/ultimail-favicon-light.png new file mode 100644 index 0000000..e3ad7cc Binary files /dev/null and b/deploy/authentik/branding/ultimail-favicon-light.png differ diff --git a/deploy/authentik/branding/ultimail-favicon.png b/deploy/authentik/branding/ultimail-favicon.png new file mode 100644 index 0000000..b1aa982 Binary files /dev/null and b/deploy/authentik/branding/ultimail-favicon.png differ diff --git a/deploy/authentik/branding/ultimail-logo-dark.png b/deploy/authentik/branding/ultimail-logo-dark.png new file mode 100644 index 0000000..e2fc127 Binary files /dev/null and b/deploy/authentik/branding/ultimail-logo-dark.png differ diff --git a/deploy/authentik/branding/ultimail-logo-light.png b/deploy/authentik/branding/ultimail-logo-light.png new file mode 100644 index 0000000..c9bdeda Binary files /dev/null and b/deploy/authentik/branding/ultimail-logo-light.png differ diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 09b13d5..44537b1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -26,10 +26,11 @@ services: networks: - ulti-net healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] - interval: 5s - timeout: 3s - retries: 3 + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s depends_on: postgres: condition: service_healthy @@ -37,6 +38,8 @@ services: condition: service_healthy rustfs: condition: service_started + authentik-server: + condition: service_healthy postgres: image: postgres:16-alpine @@ -96,9 +99,23 @@ services: AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME} AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST} AUTHENTIK_WEB__PATH: /auth/ + AUTHENTIK_HOST: http://${DOMAIN:-localhost} env_file: ../.env.resolved + volumes: + - ./authentik/blueprints:/blueprints/custom:ro + - ./authentik/branding/ultimail-logo-light.png:/web/dist/assets/branding/ultimail-logo-light.png:ro + - ./authentik/branding/ultimail-logo-dark.png:/web/dist/assets/branding/ultimail-logo-dark.png:ro + - ./authentik/branding/ultimail-favicon.png:/web/dist/assets/branding/ultimail-favicon.png:ro + - ./authentik/branding/ultimail-favicon-light.png:/web/dist/assets/branding/ultimail-favicon-light.png:ro + - ./authentik/branding/ultimail-favicon-dark.png:/web/dist/assets/branding/ultimail-favicon-dark.png:ro networks: - ulti-net + healthcheck: + test: ["CMD", "ak", "healthcheck"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 90s depends_on: postgres: condition: service_healthy @@ -117,7 +134,15 @@ services: AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME} AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST} AUTHENTIK_WEB__PATH: /auth/ + AUTHENTIK_HOST: http://${DOMAIN:-localhost} env_file: ../.env.resolved + volumes: + - ./authentik/blueprints:/blueprints/custom:ro + - ./authentik/branding/ultimail-logo-light.png:/web/dist/assets/branding/ultimail-logo-light.png:ro + - ./authentik/branding/ultimail-logo-dark.png:/web/dist/assets/branding/ultimail-logo-dark.png:ro + - ./authentik/branding/ultimail-favicon.png:/web/dist/assets/branding/ultimail-favicon.png:ro + - ./authentik/branding/ultimail-favicon-light.png:/web/dist/assets/branding/ultimail-favicon-light.png:ro + - ./authentik/branding/ultimail-favicon-dark.png:/web/dist/assets/branding/ultimail-favicon-dark.png:ro networks: - ulti-net depends_on: @@ -125,6 +150,14 @@ services: condition: service_healthy keydb: condition: service_healthy + authentik-server: + condition: service_healthy + healthcheck: + test: ["CMD", "ak", "healthcheck"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s prometheus: image: prom/prometheus:v2.54.1 @@ -157,7 +190,7 @@ services: - ./observability/grafana/ultid-baseline.json:/etc/grafana/dashboards/ultid-baseline.json:ro - grafana_data:/var/lib/grafana ports: - - "3000:3000" + - "3002:3000" networks: - ulti-net depends_on: diff --git a/deploy/nextcloud/docker-compose.nextcloud.yml b/deploy/nextcloud/docker-compose.nextcloud.yml index 70ba194..4d83052 100644 --- a/deploy/nextcloud/docker-compose.nextcloud.yml +++ b/deploy/nextcloud/docker-compose.nextcloud.yml @@ -29,6 +29,9 @@ services: - OVERWRITEWEBROOT=/cloud - OVERWRITECLIURL=${NC_PUBLIC_URL:-http://localhost/cloud} - TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 + - NC_OIDC_CLIENT_ID=${NC_OIDC_CLIENT_ID:-ulti-nextcloud} + - NC_OIDC_CLIENT_SECRET=${NC_OIDC_CLIENT_SECRET:-changeme} + - NC_OIDC_DISCOVERY_URL=${NC_OIDC_DISCOVERY_URL:-http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration} volumes: - nextcloud_data:/var/www/html - ./nextcloud/init.sh:/docker-entrypoint-hooks.d/post-installation/init.sh:ro diff --git a/deploy/nextcloud/init.sh b/deploy/nextcloud/init.sh index ef8f8af..82ae8ba 100755 --- a/deploy/nextcloud/init.sh +++ b/deploy/nextcloud/init.sh @@ -23,9 +23,9 @@ $OCC app:enable user_oidc || true # Configure OIDC (Authentik) $OCC config:app:set user_oidc --value="1" allow_multiple_user_backends $OCC user_oidc:provider Authentik \ - --clientid="${OIDC_CLIENT_ID:-ulti-nextcloud}" \ - --clientsecret="${OIDC_CLIENT_SECRET:-changeme}" \ - --discoveryuri="${OIDC_DISCOVERY_URL:-http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration}" \ + --clientid="${NC_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-ulti-nextcloud}}" \ + --clientsecret="${NC_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-changeme}}" \ + --discoveryuri="${NC_OIDC_DISCOVERY_URL:-${OIDC_DISCOVERY_URL:-http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration}}" \ --unique-uid=1 \ --check-bearer=1 \ --mapping-uid=preferred_username \ diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index 309a4db..87af205 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -1,6 +1,17 @@ # Edge reverse proxy — single entry point (replaces Caddy). # Optional upstreams use Docker DNS resolver so nginx starts even if a module is disabled. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Reflect browser Origin for cross-origin API calls (web app on :3000, API on :80). +map $http_origin $cors_allow_origin { + default $http_origin; + '' '*'; +} + server { listen 80; server_name ${DOMAIN}; @@ -8,7 +19,25 @@ server { client_max_body_size 10G; location /api/ { - proxy_pass http://ultid:8080; + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_upstream ultid:8080; + + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Access-Control-Max-Age; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Vary; + + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always; + add_header Access-Control-Expose-Headers "X-Trace-Id" always; + add_header Access-Control-Max-Age 300 always; + add_header Vary Origin always; + + proxy_pass http://$ultid_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -17,7 +46,15 @@ server { } location /ws { - proxy_pass http://ultid:8080; + resolver 127.0.0.11 valid=10s ipv6=off; + set $ultid_ws_upstream ultid:8080; + + proxy_hide_header Access-Control-Allow-Origin; + + add_header Access-Control-Allow-Origin $cors_allow_origin always; + add_header Vary Origin always; + + proxy_pass http://$ultid_ws_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; @@ -34,6 +71,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 86400; } location /meet/ { diff --git a/go.mod b/go.mod index e0f5c3a..eb74413 100644 --- a/go.mod +++ b/go.mod @@ -29,19 +29,24 @@ require ( github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/golang-migrate/migrate/v4 v4.18.1 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rs/xid v1.6.0 // indirect + go.uber.org/atomic v1.7.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect diff --git a/go.sum b/go.sum index 60227e9..ca658f7 100644 --- a/go.sum +++ b/go.sum @@ -38,12 +38,19 @@ github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0 github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= +github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -63,6 +70,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -85,6 +94,7 @@ github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -93,6 +103,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= diff --git a/internal/api/mail/account_bootstrap.go b/internal/api/mail/account_bootstrap.go new file mode 100644 index 0000000..d4240fc --- /dev/null +++ b/internal/api/mail/account_bootstrap.go @@ -0,0 +1,44 @@ +package mail + +import ( + "context" + "fmt" + "strings" +) + +func identityDisplayName(name, email string) string { + if n := strings.TrimSpace(name); n != "" { + return n + } + if i := strings.Index(email, "@"); i > 0 { + return email[:i] + } + return strings.TrimSpace(email) +} + +func defaultSignatureName(email string) string { + return fmt.Sprintf("Signature — %s", strings.TrimSpace(email)) +} + +func (s *Service) ensureIdentityDefaultSignature(ctx context.Context, externalID, email string) (string, error) { + return s.CreateSignature(ctx, externalID, &createSignatureRequest{ + Name: defaultSignatureName(email), + HTML: "", + }) +} + +// bootstrapAccountDefaults creates the default send identity and linked signature for a new mail account. +func (s *Service) bootstrapAccountDefaults(ctx context.Context, externalID, accountID, email, accountName string) error { + sigID, err := s.ensureIdentityDefaultSignature(ctx, externalID, email) + if err != nil { + return err + } + _, err = s.CreateIdentity(ctx, externalID, accountID, &createIdentityRequest{ + Email: strings.TrimSpace(email), + Name: identityDisplayName(accountName, email), + IsDefault: true, + DefaultSignatureID: sigID, + ReplyToAddrs: []string{}, + }) + return err +} diff --git a/internal/api/mail/account_bootstrap_test.go b/internal/api/mail/account_bootstrap_test.go new file mode 100644 index 0000000..e735958 --- /dev/null +++ b/internal/api/mail/account_bootstrap_test.go @@ -0,0 +1,18 @@ +package mail + +import "testing" + +func TestIdentityDisplayName(t *testing.T) { + if got := identityDisplayName("Work", "a@b.com"); got != "Work" { + t.Fatalf("got %q", got) + } + if got := identityDisplayName("", "alice@example.com"); got != "alice" { + t.Fatalf("got %q", got) + } +} + +func TestDefaultSignatureName(t *testing.T) { + if got := defaultSignatureName("a@b.com"); got != "Signature — a@b.com" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/api/mail/accounts.go b/internal/api/mail/accounts.go new file mode 100644 index 0000000..db04f6e --- /dev/null +++ b/internal/api/mail/accounts.go @@ -0,0 +1,230 @@ +package mail + +import ( + "context" + "errors" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/ultisuite/ulti-backend/internal/mail/credentials" +) + +type accountRow struct { + id, name, email, provider string + imapHost, smtpHost string + imapPort, smtpPort int + imapTLS, smtpTLS bool + credentials []byte +} + +func (s *Service) loadAccountRow(ctx context.Context, externalID, accountID string) (accountRow, error) { + var row accountRow + err := s.db.QueryRow(ctx, ` + SELECT ma.id, ma.name, ma.email, ma.provider, + ma.imap_host, ma.imap_port, ma.imap_tls, + ma.smtp_host, ma.smtp_port, ma.smtp_tls, + ma.credentials + FROM mail_accounts ma + JOIN users u ON ma.user_id = u.id + WHERE ma.id = $1 AND u.external_id = $2 + `, accountID, externalID).Scan( + &row.id, &row.name, &row.email, &row.provider, + &row.imapHost, &row.imapPort, &row.imapTLS, + &row.smtpHost, &row.smtpPort, &row.smtpTLS, + &row.credentials, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return accountRow{}, ErrNotFound + } + return accountRow{}, err + } + return row, nil +} + +func accountDetailFromRow(row accountRow, cred credentials.Credential) map[string]any { + out := map[string]any{ + "id": row.id, + "name": row.name, + "email": row.email, + "provider": row.provider, + "imap_host": row.imapHost, + "imap_port": row.imapPort, + "imap_tls": row.imapTLS, + "smtp_host": row.smtpHost, + "smtp_port": row.smtpPort, + "smtp_tls": row.smtpTLS, + "auth_type": string(credentials.AuthPassword), + "username": cred.Username, + } + if cred.AuthType != "" { + out["auth_type"] = string(cred.AuthType) + } + if cred.IsOAuth() { + out["oauth_provider"] = cred.OAuthProvider + } + return out +} + +func (s *Service) decryptAccountCredential(blob []byte) (credentials.Credential, error) { + if s.credentials == nil { + return credentials.Credential{}, ErrCredentialsUnavailable + } + if len(blob) == 0 { + return credentials.Credential{}, nil + } + return s.credentials.DecryptCredential(blob) +} + +func (s *Service) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) { + row, err := s.loadAccountRow(ctx, externalID, accountID) + if err != nil { + return nil, err + } + cred, err := s.decryptAccountCredential(row.credentials) + if err != nil { + return nil, err + } + return accountDetailFromRow(row, cred), nil +} + +func (s *Service) UpdateAccount(ctx context.Context, externalID, accountID string, req *updateAccountRequest) error { + if s.credentials == nil { + return ErrCredentialsUnavailable + } + + row, err := s.loadAccountRow(ctx, externalID, accountID) + if err != nil { + return err + } + + cred, err := s.decryptAccountCredential(row.credentials) + if err != nil { + return err + } + + name := strings.TrimSpace(req.Name) + if name == "" { + name = row.name + } + provider := strings.TrimSpace(req.Provider) + if provider == "" { + provider = row.provider + } + + if cred.IsOAuth() { + if strings.TrimSpace(req.Password) != "" { + return ErrOAuthPasswordNotAllowed + } + if u := strings.TrimSpace(req.Username); u != "" { + cred.Username = u + } + } else { + if u := strings.TrimSpace(req.Username); u != "" { + cred.Username = u + } + if p := req.Password; p != "" { + cred.Password = p + } + if strings.TrimSpace(cred.Username) == "" { + return ErrInvalidAccountCredentials + } + } + + encrypted, err := s.credentials.EncryptCredential(cred) + if err != nil { + return err + } + + result, err := s.db.Exec(ctx, ` + UPDATE mail_accounts ma SET + name = $1, + email = $2, + provider = $3, + imap_host = $4, + imap_port = $5, + imap_tls = $6, + smtp_host = $7, + smtp_port = $8, + smtp_tls = $9, + credentials = $10, + updated_at = NOW() + FROM users u + WHERE ma.id = $11 AND ma.user_id = u.id AND u.external_id = $12 + `, name, req.Email, provider, + req.IMAPHost, req.IMAPPort, req.IMAPTLS, + req.SMTPHost, req.SMTPPort, req.SMTPTLS, + encrypted, accountID, externalID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func credentialFromTestRequest(req *testAccountRequest) (credentials.Credential, error) { + if req.AuthType == string(credentials.AuthOAuth2) { + return credentials.Credential{ + AuthType: credentials.AuthOAuth2, + Username: strings.TrimSpace(req.Username), + AccessToken: strings.TrimSpace(req.AccessToken), + OAuthProvider: strings.TrimSpace(req.OAuthProvider), + }, nil + } + if strings.TrimSpace(req.Password) == "" { + return credentials.Credential{}, ErrInvalidAccountCredentials + } + return credentials.Credential{ + AuthType: credentials.AuthPassword, + Username: strings.TrimSpace(req.Username), + Password: req.Password, + }, nil +} + +func mergeTestCredential(stored credentials.Credential, req *testAccountRequest) (credentials.Credential, error) { + cred := stored + if u := strings.TrimSpace(req.Username); u != "" { + cred.Username = u + } + if req.AuthType == string(credentials.AuthOAuth2) || cred.IsOAuth() { + if t := strings.TrimSpace(req.AccessToken); t != "" { + cred.AccessToken = t + } + if strings.TrimSpace(cred.AccessToken) == "" { + return credentials.Credential{}, ErrInvalidAccountCredentials + } + return cred, nil + } + if req.Password != "" { + cred.Password = req.Password + } + if strings.TrimSpace(cred.Password) == "" { + return credentials.Credential{}, ErrInvalidAccountCredentials + } + if strings.TrimSpace(cred.Username) == "" { + return credentials.Credential{}, ErrInvalidAccountCredentials + } + return cred, nil +} + +func (s *Service) CredentialForConnectionTest(ctx context.Context, externalID string, req *testAccountRequest) (credentials.Credential, error) { + accountID := strings.TrimSpace(req.AccountID) + if accountID == "" { + return credentialFromTestRequest(req) + } + + row, err := s.loadAccountRow(ctx, externalID, accountID) + if err != nil { + return credentials.Credential{}, err + } + + stored, err := s.decryptAccountCredential(row.credentials) + if err != nil { + return credentials.Credential{}, err + } + + return mergeTestCredential(stored, req) +} diff --git a/internal/api/mail/handlers.go b/internal/api/mail/handlers.go index cb1590c..a3c60e4 100644 --- a/internal/api/mail/handlers.go +++ b/internal/api/mail/handlers.go @@ -15,6 +15,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/limits" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" "github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/securityaudit" ) @@ -23,6 +24,8 @@ type Handler struct { svc ServiceAPI logger *slog.Logger sendLimiter *sendguard.RateLimiter + oauth *mailoauth.Service + appURL string } func NewHandlerWithService(svc ServiceAPI) *Handler { @@ -39,18 +42,37 @@ func NewHandler( objectStorage *storage.Client, attachmentsBucket string, sendLimiter *sendguard.RateLimiter, + oauthSvc *mailoauth.Service, + appURL string, ) *Handler { h := NewHandlerWithService(NewService(db, audit, credentialManager, objectStorage, attachmentsBucket)) h.sendLimiter = sendLimiter + h.oauth = oauthSvc + h.appURL = appURL return h } func (h *Handler) Routes() chi.Router { r := chi.NewRouter() + r.Get("/settings", h.GetMailSettings) + r.Patch("/settings", h.UpdateMailSettings) + + r.Get("/unified-folders", h.ListUnifiedFolders) + r.Post("/unified-folders/reorder", h.ReorderUnifiedFolders) + r.Post("/unified-folders", h.CreateUnifiedFolder) + r.Put("/unified-folders/{folderID}", h.UpdateUnifiedFolder) + r.Delete("/unified-folders/{folderID}", h.DeleteUnifiedFolder) + r.Get("/accounts", h.ListAccounts) r.Post("/accounts", h.CreateAccount) + r.Get("/accounts/discover", h.DiscoverAccountConfig) + r.Post("/accounts/test", h.TestAccountConnection) + r.Post("/accounts/{accountID}/test", h.TestStoredAccountConnection) + r.Get("/accounts/oauth/providers", h.ListOAuthProviders) + r.Post("/accounts/oauth/start", h.StartOAuthAccount) r.Get("/accounts/{accountID}", h.GetAccount) + r.Put("/accounts/{accountID}", h.UpdateAccount) r.Delete("/accounts/{accountID}", h.DeleteAccount) r.Get("/accounts/{accountID}/identities", h.ListIdentities) r.Post("/accounts/{accountID}/identities", h.CreateIdentity) @@ -59,6 +81,12 @@ func (h *Handler) Routes() chi.Router { r.Put("/identities/{identityID}", h.UpdateIdentity) r.Delete("/identities/{identityID}", h.DeleteIdentity) + r.Get("/signatures", h.ListSignatures) + r.Post("/signatures", h.CreateSignature) + r.Get("/signatures/{signatureID}", h.GetSignature) + r.Put("/signatures/{signatureID}", h.UpdateSignature) + r.Delete("/signatures/{signatureID}", h.DeleteSignature) + r.Mount("/", h.FolderLabelRoutes()) r.Get("/search", h.SearchMessages) @@ -151,7 +179,12 @@ func (h *Handler) CreateAccount(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) - account, err := h.svc.GetAccount(r.Context(), claims.Sub, chi.URLParam(r, "accountID")) + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + account, err := h.svc.GetAccount(r.Context(), claims.Sub, accountID) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") @@ -164,9 +197,59 @@ func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, account) } +func (h *Handler) UpdateAccount(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + + var req updateAccountRequest + if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil { + return + } + if verr := validateUpdateAccount(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + if err := h.svc.UpdateAccount(r.Context(), claims.Sub, accountID, &req); err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if errors.Is(err, ErrUserNotProvisioned) { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) + return + } + if errors.Is(err, ErrCredentialsUnavailable) { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil) + return + } + if errors.Is(err, ErrOAuthPasswordNotAllowed) { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "oauth accounts cannot use password credentials", nil) + return + } + if errors.Is(err, ErrInvalidAccountCredentials) { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "username is required for password authentication", nil) + return + } + h.logger.Error("update account", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) - if err := h.svc.DeleteAccount(r.Context(), claims.Sub, chi.URLParam(r, "accountID")); err != nil { + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if err := h.svc.DeleteAccount(r.Context(), claims.Sub, accountID); err != nil { if errors.Is(err, ErrUserNotProvisioned) { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) return diff --git a/internal/api/mail/handlers_account_discover.go b/internal/api/mail/handlers_account_discover.go new file mode 100644 index 0000000..f650795 --- /dev/null +++ b/internal/api/mail/handlers_account_discover.go @@ -0,0 +1,34 @@ +package mail + +import ( + "errors" + "net/http" + "strings" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/mail/autoconfig" +) + +func (h *Handler) DiscoverAccountConfig(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(r.URL.Query().Get("email")) + if d := validateEmailField("email", email); d != nil { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(*d)) + return + } + + result, err := autoconfig.Discover(r.Context(), email) + if err != nil { + if errors.Is(err, autoconfig.ErrInvalidEmail) { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "email", Message: "invalid"}, + )) + return + } + h.logger.Error("discover account config", "error", err, "email", email) + apivalidate.WriteInternal(w, r) + return + } + + apiresponse.WriteJSON(w, http.StatusOK, result) +} diff --git a/internal/api/mail/handlers_account_oauth.go b/internal/api/mail/handlers_account_oauth.go new file mode 100644 index 0000000..d367dfa --- /dev/null +++ b/internal/api/mail/handlers_account_oauth.go @@ -0,0 +1,196 @@ +package mail + +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/mail/connect" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" +) + +func (h *Handler) TestAccountConnection(w http.ResponseWriter, r *http.Request) { + h.testAccountConnection(w, r, "") +} + +func (h *Handler) TestStoredAccountConnection(w http.ResponseWriter, r *http.Request) { + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.testAccountConnection(w, r, accountID) +} + +func (h *Handler) testAccountConnection(w http.ResponseWriter, r *http.Request, accountIDFromURL string) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req testAccountRequest + if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil { + return + } + if accountIDFromURL != "" { + req.AccountID = accountIDFromURL + } + + if verr := validateTestAccount(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + cred, err := h.svc.CredentialForConnectionTest(r.Context(), claims.Sub, &req) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if errors.Is(err, ErrInvalidAccountCredentials) { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "missing credentials for connection test", nil) + return + } + if errors.Is(err, ErrCredentialsUnavailable) { + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil) + return + } + h.logger.Error("resolve test credentials", "error", err) + apivalidate.WriteInternal(w, r) + return + } + + result := connect.Test(r.Context(), connect.ServerConfig{ + IMAPHost: req.IMAPHost, + IMAPPort: req.IMAPPort, + IMAPTLS: req.IMAPTLS, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPTLS: req.SMTPTLS, + }, cred) + + if result.IMAPError != "" { + result.IMAPError = connect.SanitizeError(result.IMAPError) + } + if result.SMTPError != "" { + result.SMTPError = connect.SanitizeError(result.SMTPError) + } + + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) ListOAuthProviders(w http.ResponseWriter, r *http.Request) { + if h.oauth == nil { + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"providers": []string{}}) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "providers": h.oauth.EnabledProviders(), + }) +} + +func (h *Handler) StartOAuthAccount(w http.ResponseWriter, r *http.Request) { + if h.oauth == nil { + apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail oauth unavailable", nil) + return + } + claims := middleware.ClaimsFromContext(r.Context()) + + var req oauthStartRequest + if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil { + return + } + if verr := validateOAuthStart(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + pending := h.oauthPendingFromRequest(&req) + authURL, state, err := h.oauth.Start(r.Context(), claims.Sub, mailoauth.Provider(req.Provider), pending) + if err != nil { + h.logger.Error("oauth start", "error", err, "provider", req.Provider) + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{ + "authorization_url": authURL, + "state": state, + }) +} + +func (h *Handler) OAuthCallback(w http.ResponseWriter, r *http.Request) { + if h.oauth == nil || h.svc == nil { + http.Redirect(w, r, h.oauthReturnURL("error", "oauth_unavailable"), http.StatusFound) + return + } + + state := strings.TrimSpace(r.URL.Query().Get("state")) + code := strings.TrimSpace(r.URL.Query().Get("code")) + if errParam := r.URL.Query().Get("error"); errParam != "" { + http.Redirect(w, r, h.oauthReturnURL("error", errParam), http.StatusFound) + return + } + if state == "" || code == "" { + http.Redirect(w, r, h.oauthReturnURL("error", "missing_code"), http.StatusFound) + return + } + + pending, token, err := h.oauth.Exchange(r.Context(), state, code) + if err != nil { + h.logger.Error("oauth exchange", "error", err) + http.Redirect(w, r, h.oauthReturnURL("error", "exchange_failed"), http.StatusFound) + return + } + + cred := mailoauth.CredentialFromToken(pending.Email, pending.Provider, token) + _, err = h.svc.CreateAccountWithCredential(r.Context(), pending.UserExternalID, &createAccountRequest{ + Name: pending.Name, + Email: pending.Email, + Provider: pending.ProviderID, + IMAPHost: pending.IMAPHost, + IMAPPort: pending.IMAPPort, + IMAPTLS: pending.IMAPTLS, + SMTPHost: pending.SMTPHost, + SMTPPort: pending.SMTPPort, + SMTPTLS: pending.SMTPTLS, + }, cred) + if err != nil { + h.logger.Error("oauth create account", "error", err) + http.Redirect(w, r, h.oauthReturnURL("error", "create_failed"), http.StatusFound) + return + } + + http.Redirect(w, r, h.oauthReturnURL("success", ""), http.StatusFound) +} + +func (h *Handler) oauthPendingFromRequest(req *oauthStartRequest) mailoauth.PendingAccount { + name := req.Name + if name == "" { + name = req.Email + } + return mailoauth.PendingAccount{ + Email: req.Email, + Name: name, + ProviderID: req.ProviderID, + IMAPHost: req.IMAPHost, + IMAPPort: req.IMAPPort, + IMAPTLS: req.IMAPTLS, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPTLS: req.SMTPTLS, + } +} + +func (h *Handler) oauthReturnURL(status, code string) string { + base := strings.TrimRight(h.appURL, "/") + if base == "" { + base = "http://localhost:3000" + } + u := base + "/mail/settings/accounts?oauth=" + status + if code != "" { + u += "&code=" + code + } + return u +} diff --git a/internal/api/mail/handlers_account_test_route_test.go b/internal/api/mail/handlers_account_test_route_test.go new file mode 100644 index 0000000..f258b43 --- /dev/null +++ b/internal/api/mail/handlers_account_test_route_test.go @@ -0,0 +1,24 @@ +package mail + +import ( + "net/http" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestStoredAccountTestRouteRegistered(t *testing.T) { + t.Parallel() + + h := NewHandlerWithService(&fakeMailService{}) + var found bool + _ = chi.Walk(h.Routes(), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if method == http.MethodPost && route == "/accounts/{accountID}/test" { + found = true + } + return nil + }) + if !found { + t.Fatal("POST /accounts/{accountID}/test route not registered") + } +} diff --git a/internal/api/mail/handlers_folders_labels.go b/internal/api/mail/handlers_folders_labels.go index 0b65a16..f295575 100644 --- a/internal/api/mail/handlers_folders_labels.go +++ b/internal/api/mail/handlers_folders_labels.go @@ -23,6 +23,7 @@ func (h *Handler) FolderLabelRoutes() chi.Router { r.Delete("/folders/{folderID}", h.DeleteFolder) r.Get("/labels", h.ListUserLabels) + r.Post("/labels/reorder", h.ReorderUserLabels) r.Post("/labels", h.CreateUserLabel) r.Put("/labels/{labelID}", h.UpdateUserLabel) r.Delete("/labels/{labelID}", h.DeleteUserLabel) diff --git a/internal/api/mail/handlers_identities.go b/internal/api/mail/handlers_identities.go index bf0bd67..fb00ebf 100644 --- a/internal/api/mail/handlers_identities.go +++ b/internal/api/mail/handlers_identities.go @@ -14,13 +14,18 @@ import ( func (h *Handler) ListIdentities(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "account not found") + return + } params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } - result, err := h.svc.ListIdentities(r.Context(), claims.Sub, chi.URLParam(r, "accountID"), params) + result, err := h.svc.ListIdentities(r.Context(), claims.Sub, accountID, params) if err != nil { if errors.Is(err, ErrAccountNotFound) { apivalidate.WriteNotFound(w, r, "account not found") @@ -60,7 +65,13 @@ func (h *Handler) CreateIdentity(w http.ResponseWriter, r *http.Request) { return } - id, err := h.svc.CreateIdentity(r.Context(), claims.Sub, chi.URLParam(r, "accountID"), &req) + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "account not found") + return + } + + id, err := h.svc.CreateIdentity(r.Context(), claims.Sub, accountID, &req) if err != nil { if errors.Is(err, ErrAccountNotFound) { apivalidate.WriteNotFound(w, r, "account not found") diff --git a/internal/api/mail/handlers_reorder.go b/internal/api/mail/handlers_reorder.go new file mode 100644 index 0000000..da0bee7 --- /dev/null +++ b/internal/api/mail/handlers_reorder.go @@ -0,0 +1,90 @@ +package mail + +import ( + "fmt" + "net/http" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" +) + +const maxReorderItems = 500 + +func validateReorderLabels(req *reorderLabelsRequest) *apivalidate.ValidationError { + if req == nil || len(req.Items) == 0 { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "required"}) + } + if len(req.Items) > maxReorderItems { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "too many"}) + } + for i, item := range req.Items { + if item.ID == "" { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: fmt.Sprintf("id required at index %d", i)}) + } + } + return nil +} + +func validateReorderUnifiedFolders(req *reorderUnifiedFoldersRequest) *apivalidate.ValidationError { + if req == nil || len(req.Items) == 0 { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "required"}) + } + if len(req.Items) > maxReorderItems { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "too many"}) + } + for _, item := range req.Items { + if item.ID == "" { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "id required"}) + } + } + return nil +} + +func (h *Handler) ReorderUserLabels(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + var req reorderLabelsRequest + if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil { + return + } + if verr := validateReorderLabels(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + if err := h.svc.ReorderUserLabels(r.Context(), claims.Sub, &req); err != nil { + if err == ErrNotFound { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("reorder labels", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) ReorderUnifiedFolders(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + var req reorderUnifiedFoldersRequest + if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil { + return + } + if verr := validateReorderUnifiedFolders(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + if err := h.svc.ReorderUnifiedFolders(r.Context(), claims.Sub, &req); err != nil { + if err == ErrNotFound { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if err == ErrInvalidFolderScope { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid folder scope", nil) + return + } + h.logger.Error("reorder unified folders", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/mail/handlers_settings.go b/internal/api/mail/handlers_settings.go new file mode 100644 index 0000000..93a558b --- /dev/null +++ b/internal/api/mail/handlers_settings.go @@ -0,0 +1,42 @@ +package mail + +import ( + "net/http" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" +) + +func (h *Handler) GetMailSettings(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + settings, err := h.svc.GetMailSettings(r.Context(), claims.Sub) + if err != nil { + h.logger.Error("get mail settings", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, settings) +} + +func (h *Handler) UpdateMailSettings(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req patchMailSettingsRequest + if err := apivalidate.DecodeJSON(w, r, maxSettingsRequestBody, &req); err != nil { + return + } + if verr := validatePatchMailSettings(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + settings, err := h.svc.UpdateMailSettings(r.Context(), claims.Sub, &req) + if err != nil { + h.logger.Error("update mail settings", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, settings) +} diff --git a/internal/api/mail/handlers_signatures.go b/internal/api/mail/handlers_signatures.go new file mode 100644 index 0000000..efc2bec --- /dev/null +++ b/internal/api/mail/handlers_signatures.go @@ -0,0 +1,125 @@ +package mail + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/api/query" +) + +func (h *Handler) ListSignatures(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + params, err := query.ParseListRequest(r) + if err != nil { + apivalidate.WriteQueryError(w, r, err) + return + } + + result, err := h.svc.ListSignatures(r.Context(), claims.Sub, params) + if err != nil { + h.logger.Error("list signatures", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) GetSignature(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + signatureID := chi.URLParam(r, "signatureID") + if d := validateSignatureUUID(signatureID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + + signature, err := h.svc.GetSignature(r.Context(), claims.Sub, signatureID) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("get signature", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, signature) +} + +func (h *Handler) CreateSignature(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req createSignatureRequest + if err := apivalidate.DecodeJSON(w, r, maxSignatureRequestBody, &req); err != nil { + return + } + if verr := validateCreateSignature(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + id, err := h.svc.CreateSignature(r.Context(), claims.Sub, &req) + if err != nil { + h.logger.Error("create signature", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id}) +} + +func (h *Handler) UpdateSignature(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + signatureID := chi.URLParam(r, "signatureID") + if d := validateSignatureUUID(signatureID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + + var req updateSignatureRequest + if err := apivalidate.DecodeJSON(w, r, maxSignatureRequestBody, &req); err != nil { + return + } + if verr := validateUpdateSignature(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + if err := h.svc.UpdateSignature(r.Context(), claims.Sub, signatureID, &req); err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("update signature", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) DeleteSignature(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + signatureID := chi.URLParam(r, "signatureID") + if d := validateSignatureUUID(signatureID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + + if err := h.svc.DeleteSignature(r.Context(), claims.Sub, signatureID); err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("delete signature", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func validateSignatureUUID(id string) *apivalidate.FieldDetail { + return validateAccountUUID(id) +} diff --git a/internal/api/mail/handlers_test.go b/internal/api/mail/handlers_test.go index 79e9e12..80271bb 100644 --- a/internal/api/mail/handlers_test.go +++ b/internal/api/mail/handlers_test.go @@ -16,13 +16,15 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/rules" ) const ( - testExternalID = "ext-user-1" - testExternalID2 = "ext-user-2" - testUserID = "user-uuid-1" + testExternalID = "ext-user-1" + testExternalID2 = "ext-user-2" + testUserID = "user-uuid-1" + testMailAccountID = "550e8400-e29b-41d4-a716-446655440000" ) type fakeMailService struct { @@ -50,6 +52,65 @@ func (f *fakeMailService) ResolveUserID(_ context.Context, externalID string) (s return testUserID, nil } +func (f *fakeMailService) GetMailSettings(_ context.Context, externalID string) (MailSettings, error) { + if externalID != testExternalID { + return MailSettings{}, ErrUserNotProvisioned + } + return defaultMailSettings(), nil +} + +func (f *fakeMailService) UpdateMailSettings(_ context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) { + if externalID != testExternalID { + return MailSettings{}, ErrUserNotProvisioned + } + current := defaultMailSettings() + if req.Density != nil { + current.Density = *req.Density + } + if req.ThemeMode != nil { + current.ThemeMode = *req.ThemeMode + } + if req.BackgroundID != nil { + current.BackgroundID = *req.BackgroundID + } + if req.InboxSort != nil { + current.InboxSort = *req.InboxSort + } + if req.ReadingPane != nil { + current.ReadingPane = *req.ReadingPane + } + if req.ConversationMode != nil { + current.ConversationMode = *req.ConversationMode + } + return current, nil +} + +func (f *fakeMailService) ListUnifiedFolders(_ context.Context, externalID, _ string, params query.ListParams) (UnifiedFoldersList, error) { + if externalID != testExternalID { + return UnifiedFoldersList{}, ErrUserNotProvisioned + } + total := int64(0) + return UnifiedFoldersList{Pagination: params.Meta(&total)}, nil +} + +func (f *fakeMailService) CreateUnifiedFolder(_ context.Context, _ string, _ *createUnifiedFolderRequest) (string, error) { + return "uf-1", nil +} + +func (f *fakeMailService) UpdateUnifiedFolder(_ context.Context, externalID, _ string, _ *updateUnifiedFolderRequest) error { + if externalID != testExternalID { + return ErrUserNotProvisioned + } + return nil +} + +func (f *fakeMailService) DeleteUnifiedFolder(_ context.Context, externalID, _ string) error { + if externalID != testExternalID { + return ErrUserNotProvisioned + } + return nil +} + func (f *fakeMailService) ListMessages(_ context.Context, externalID string, _ MessageListFilter, params query.ListParams) (MessagesList, error) { if externalID != testExternalID { return MessagesList{}, ErrUserNotProvisioned @@ -226,9 +287,18 @@ func (f *fakeMailService) ListAccounts(context.Context, string, query.ListParams func (f *fakeMailService) CreateAccount(context.Context, string, *createAccountRequest) (string, error) { return "", nil } +func (f *fakeMailService) CreateAccountWithCredential(context.Context, string, *createAccountRequest, credentials.Credential) (string, error) { + return "", nil +} func (f *fakeMailService) GetAccount(context.Context, string, string) (map[string]any, error) { return nil, ErrNotFound } +func (f *fakeMailService) UpdateAccount(context.Context, string, string, *updateAccountRequest) error { + return nil +} +func (f *fakeMailService) CredentialForConnectionTest(context.Context, string, *testAccountRequest) (credentials.Credential, error) { + return credentials.Credential{AuthType: credentials.AuthPassword, Username: "u", Password: "p"}, nil +} func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { return nil } func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) { return map[string]any{"messages": []any{}}, nil @@ -315,13 +385,13 @@ func (f *fakeMailService) ListIdentities(_ context.Context, externalID, accountI if externalID != testExternalID { return IdentitiesList{}, ErrAccountNotFound } - if accountID != "acc-1" { + if accountID != testMailAccountID { return IdentitiesList{}, ErrAccountNotFound } total := int64(1) return IdentitiesList{ Identities: []map[string]any{{ - "id": "id-1", "account_id": "acc-1", "email": "sender@example.com", + "id": "id-1", "account_id": testMailAccountID, "email": "sender@example.com", "name": "Sender", "is_default": true, "signature_html": "", "reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil, }}, @@ -334,14 +404,14 @@ func (f *fakeMailService) GetIdentity(_ context.Context, externalID, identityID return nil, ErrNotFound } return map[string]any{ - "id": "id-1", "account_id": "acc-1", "email": "sender@example.com", + "id": "id-1", "account_id": testMailAccountID, "email": "sender@example.com", "name": "Sender", "is_default": true, "signature_html": "", "reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil, }, nil } func (f *fakeMailService) CreateIdentity(_ context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) { - if externalID != testExternalID || accountID != "acc-1" { + if externalID != testExternalID || accountID != testMailAccountID { return "", ErrAccountNotFound } if req.Email == "" { @@ -364,6 +434,42 @@ func (f *fakeMailService) DeleteIdentity(_ context.Context, externalID, identity return nil } +func (f *fakeMailService) ListSignatures(_ context.Context, externalID string, params query.ListParams) (SignaturesList, error) { + if externalID != testExternalID { + return SignaturesList{}, ErrNotFound + } + total := int64(0) + return SignaturesList{Signatures: []map[string]any{}, Pagination: params.Meta(&total)}, nil +} + +func (f *fakeMailService) GetSignature(_ context.Context, externalID, signatureID string) (map[string]any, error) { + if externalID != testExternalID { + return nil, ErrNotFound + } + return map[string]any{"id": signatureID, "name": "Sig", "html": "

Hi

", "sort_order": 0}, nil +} + +func (f *fakeMailService) CreateSignature(_ context.Context, externalID string, req *createSignatureRequest) (string, error) { + if externalID != testExternalID || req.Name == "" { + return "", ErrNotFound + } + return "sig-new", nil +} + +func (f *fakeMailService) UpdateSignature(_ context.Context, externalID, signatureID string, _ *updateSignatureRequest) error { + if externalID != testExternalID { + return ErrNotFound + } + return nil +} + +func (f *fakeMailService) DeleteSignature(_ context.Context, externalID, signatureID string) error { + if externalID != testExternalID { + return ErrNotFound + } + return nil +} + func (f *fakeMailService) ListFolders(_ context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error) { if externalID != testExternalID { return FoldersList{}, ErrAccountNotFound @@ -429,6 +535,20 @@ func (f *fakeMailService) DeleteUserLabel(_ context.Context, externalID, labelID return nil } +func (f *fakeMailService) ReorderUserLabels(_ context.Context, externalID string, _ *reorderLabelsRequest) error { + if externalID != testExternalID { + return ErrNotFound + } + return nil +} + +func (f *fakeMailService) ReorderUnifiedFolders(_ context.Context, externalID string, _ *reorderUnifiedFoldersRequest) error { + if externalID != testExternalID { + return ErrNotFound + } + return nil +} + func (f *fakeMailService) SearchMessages(_ context.Context, externalID string, _ MessageSearchFilter, params query.ListParams) (MessageSearchResult, error) { if externalID != testExternalID { return MessageSearchResult{}, ErrUserNotProvisioned @@ -557,7 +677,7 @@ func TestSendMessage(t *testing.T) { router := newTestMailRouter(svc) payload := map[string]any{ - "account_id": "acc-1", + "account_id": testMailAccountID, "to": []string{"recipient@example.com"}, "subject": "Test subject", "body_text": "Hello world", @@ -973,3 +1093,66 @@ func TestSimulateRule(t *testing.T) { } }) } + +func TestMailSettingsHandlers(t *testing.T) { + svc := newFakeMailService() + router := newTestMailRouter(svc) + + t.Run("get defaults", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var body MailSettings + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Density != "default" || body.ThemeMode != "system" { + t.Fatalf("body = %#v", body) + } + }) + + t.Run("patch density", func(t *testing.T) { + payload, err := json.Marshal(map[string]string{"density": "compact"}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var body MailSettings + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Density != "compact" { + t.Fatalf("density = %q, want compact", body.Density) + } + }) + + t.Run("patch invalid", func(t *testing.T) { + payload, err := json.Marshal(map[string]string{"density": "invalid"}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) +} diff --git a/internal/api/mail/handlers_unified_folders.go b/internal/api/mail/handlers_unified_folders.go new file mode 100644 index 0000000..a100d35 --- /dev/null +++ b/internal/api/mail/handlers_unified_folders.go @@ -0,0 +1,113 @@ +package mail + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/api/query" +) + +func (h *Handler) ListUnifiedFolders(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + params, err := query.ParseListRequest(r) + if err != nil { + apivalidate.WriteQueryError(w, r, err) + return + } + + result, err := h.svc.ListUnifiedFolders(r.Context(), claims.Sub, r.URL.Query().Get("account_id"), params) + if err != nil { + if errors.Is(err, ErrAccountNotFound) { + apivalidate.WriteNotFound(w, r, "account not found") + return + } + h.logger.Error("list unified folders", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) CreateUnifiedFolder(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) + if err != nil { + h.writeUserResolveError(w, r, err) + return + } + + var req createUnifiedFolderRequest + if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil { + return + } + if verr := validateCreateUnifiedFolder(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + id, err := h.svc.CreateUnifiedFolder(r.Context(), userID, &req) + if err != nil { + if errors.Is(err, ErrAccountNotFound) { + apivalidate.WriteNotFound(w, r, "account not found") + return + } + if errors.Is(err, ErrInvalidFolderScope) { + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid folder scope", nil) + return + } + h.logger.Error("create unified folder", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id}) +} + +func (h *Handler) UpdateUnifiedFolder(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req updateUnifiedFolderRequest + if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil { + return + } + if verr := validateUpdateUnifiedFolder(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + err := h.svc.UpdateUnifiedFolder(r.Context(), claims.Sub, chi.URLParam(r, "folderID"), &req) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("update unified folder", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) DeleteUnifiedFolder(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + err := h.svc.DeleteUnifiedFolder(r.Context(), claims.Sub, chi.URLParam(r, "folderID")) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if errors.Is(err, ErrFolderHasChildren) { + apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, "folder has children", nil) + return + } + h.logger.Error("delete unified folder", "error", err) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/mail/identities.go b/internal/api/mail/identities.go index 7d2e977..af88404 100644 --- a/internal/api/mail/identities.go +++ b/internal/api/mail/identities.go @@ -17,7 +17,7 @@ type IdentitiesList struct { } const identitySelectColumns = ` - mi.id, mi.account_id, mi.email, mi.name, mi.is_default, mi.signature_html, mi.reply_to_addrs, mi.created_at, mi.updated_at + mi.id, mi.account_id, mi.email, mi.name, mi.is_default, mi.signature_html, mi.default_signature_id, mi.reply_to_addrs, mi.created_at, mi.updated_at ` func (s *Service) verifyAccountOwnership(ctx context.Context, externalID, accountID string) error { @@ -46,19 +46,30 @@ func identityOwnershipJoin() string { ` } -func scanIdentity(id, accountID, email, name, signatureHTML string, isDefault bool, replyToJSON []byte, createdAt, updatedAt any) map[string]any { +func scanIdentity(id, accountID, email, name, signatureHTML string, isDefault bool, defaultSignatureID *string, replyToJSON []byte, createdAt, updatedAt any) map[string]any { replyTo := parseReplyToAddrs(replyToJSON) - return map[string]any{ - "id": id, - "account_id": accountID, - "email": email, - "name": name, - "is_default": isDefault, - "signature_html": signatureHTML, - "reply_to_addrs": replyTo, - "created_at": createdAt, - "updated_at": updatedAt, + out := map[string]any{ + "id": id, + "account_id": accountID, + "email": email, + "name": name, + "is_default": isDefault, + "signature_html": signatureHTML, + "reply_to_addrs": replyTo, + "created_at": createdAt, + "updated_at": updatedAt, } + if defaultSignatureID != nil && *defaultSignatureID != "" { + out["default_signature_id"] = *defaultSignatureID + } + return out +} + +func nullableUUID(id string) any { + if id == "" { + return nil + } + return id } func parseReplyToAddrs(raw []byte) []string { @@ -104,12 +115,13 @@ func (s *Service) ListIdentities(ctx context.Context, externalID, accountID stri for rows.Next() { var id, acctID, email, name, signatureHTML string var isDefault bool + var defaultSignatureID *string var replyToJSON []byte var createdAt, updatedAt any - if err := rows.Scan(&id, &acctID, &email, &name, &isDefault, &signatureHTML, &replyToJSON, &createdAt, &updatedAt); err != nil { + if err := rows.Scan(&id, &acctID, &email, &name, &isDefault, &signatureHTML, &defaultSignatureID, &replyToJSON, &createdAt, &updatedAt); err != nil { return IdentitiesList{}, err } - identities = append(identities, scanIdentity(id, acctID, email, name, signatureHTML, isDefault, replyToJSON, createdAt, updatedAt)) + identities = append(identities, scanIdentity(id, acctID, email, name, signatureHTML, isDefault, defaultSignatureID, replyToJSON, createdAt, updatedAt)) } if err := rows.Err(); err != nil { return IdentitiesList{}, err @@ -126,10 +138,11 @@ func (s *Service) GetIdentity(ctx context.Context, externalID, identityID string var id, accountID, email, name, signatureHTML string var isDefault bool + var defaultSignatureID *string var replyToJSON []byte var createdAt, updatedAt any err := s.db.QueryRow(ctx, query, identityID, externalID).Scan( - &id, &accountID, &email, &name, &isDefault, &signatureHTML, &replyToJSON, &createdAt, &updatedAt, + &id, &accountID, &email, &name, &isDefault, &signatureHTML, &defaultSignatureID, &replyToJSON, &createdAt, &updatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -137,7 +150,7 @@ func (s *Service) GetIdentity(ctx context.Context, externalID, identityID string } return nil, err } - return scanIdentity(id, accountID, email, name, signatureHTML, isDefault, replyToJSON, createdAt, updatedAt), nil + return scanIdentity(id, accountID, email, name, signatureHTML, isDefault, defaultSignatureID, replyToJSON, createdAt, updatedAt), nil } func (s *Service) CreateIdentity(ctx context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) { @@ -151,14 +164,25 @@ func (s *Service) CreateIdentity(ctx context.Context, externalID, accountID stri } } + if req.DefaultSignatureID == "" { + sigID, err := s.ensureIdentityDefaultSignature(ctx, externalID, req.Email) + if err != nil { + return "", err + } + req.DefaultSignatureID = sigID + } else if err := s.verifySignatureOwnership(ctx, externalID, req.DefaultSignatureID); err != nil { + return "", err + } + replyToJSON, _ := json.Marshal(req.ReplyToAddrs) + defaultSigID := nullableUUID(req.DefaultSignatureID) var id string err := s.db.QueryRow(ctx, ` - INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, reply_to_addrs) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, default_signature_id, reply_to_addrs) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id - `, accountID, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON).Scan(&id) + `, accountID, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, replyToJSON).Scan(&id) if err != nil { return "", err } @@ -178,15 +202,23 @@ func (s *Service) UpdateIdentity(ctx context.Context, externalID, identityID str } } + if req.DefaultSignatureID != "" { + if err := s.verifySignatureOwnership(ctx, externalID, req.DefaultSignatureID); err != nil { + return err + } + } + replyToJSON, _ := json.Marshal(req.ReplyToAddrs) + defaultSigID := nullableUUID(req.DefaultSignatureID) result, err := s.db.Exec(ctx, ` UPDATE mail_identities mi SET - email = $1, name = $2, is_default = $3, signature_html = $4, reply_to_addrs = $5, updated_at = NOW() + email = $1, name = $2, is_default = $3, signature_html = $4, + default_signature_id = $5, reply_to_addrs = $6, updated_at = NOW() FROM mail_accounts ma JOIN users u ON ma.user_id = u.id - WHERE mi.id = $6 AND mi.account_id = ma.id AND u.external_id = $7 - `, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON, identityID, externalID) + WHERE mi.id = $7 AND mi.account_id = ma.id AND u.external_id = $8 + `, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, replyToJSON, identityID, externalID) if err != nil { return err } diff --git a/internal/api/mail/identities_test.go b/internal/api/mail/identities_test.go index 2bd8706..450ffd2 100644 --- a/internal/api/mail/identities_test.go +++ b/internal/api/mail/identities_test.go @@ -32,7 +32,7 @@ func TestListIdentities(t *testing.T) { svc := newFakeMailService() router := newTestIdentityRouter(svc) - req := httptest.NewRequest(http.MethodGet, "/accounts/acc-1/identities", nil) + req := httptest.NewRequest(http.MethodGet, "/accounts/"+testMailAccountID+"/identities", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -65,7 +65,7 @@ func TestCreateIdentity(t *testing.T) { t.Fatalf("marshal payload: %v", err) } - req := httptest.NewRequest(http.MethodPost, "/accounts/acc-1/identities", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/accounts/"+testMailAccountID+"/identities", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) diff --git a/internal/api/mail/labels.go b/internal/api/mail/labels.go index 7737868..b6ab5d7 100644 --- a/internal/api/mail/labels.go +++ b/internal/api/mail/labels.go @@ -23,10 +23,10 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params } rows, err := s.db.Query(ctx, ` - SELECT id, name, color, created_at + SELECT id, name, color, sort_order, created_at FROM mail_user_labels WHERE user_id = (SELECT id FROM users WHERE external_id = $1) - ORDER BY name ASC + ORDER BY sort_order ASC, name ASC LIMIT $2 OFFSET $3 `, externalID, params.Limit(), params.Offset()) if err != nil { @@ -37,12 +37,13 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params labels := make([]map[string]any, 0) for rows.Next() { var id, name, color string + var sortOrder int var createdAt any - if err := rows.Scan(&id, &name, &color, &createdAt); err != nil { + if err := rows.Scan(&id, &name, &color, &sortOrder, &createdAt); err != nil { return UserLabelsList{}, err } labels = append(labels, map[string]any{ - "id": id, "name": name, "color": color, "created_at": createdAt, + "id": id, "name": name, "color": color, "sort_order": sortOrder, "created_at": createdAt, }) } if err := rows.Err(); err != nil { @@ -58,8 +59,14 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params func (s *Service) CreateUserLabel(ctx context.Context, externalID string, req *createUserLabelRequest) (string, error) { var id string err := s.db.QueryRow(ctx, ` - INSERT INTO mail_user_labels (user_id, name, color) - VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3) + WITH next_order AS ( + SELECT COALESCE(MAX(sort_order), -10) + 10 AS sort_order + FROM mail_user_labels + WHERE user_id = (SELECT id FROM users WHERE external_id = $1) + ) + INSERT INTO mail_user_labels (user_id, name, color, sort_order) + SELECT (SELECT id FROM users WHERE external_id = $1), $2, $3, next_order.sort_order + FROM next_order RETURNING id `, externalID, req.Name, req.Color).Scan(&id) if err != nil { diff --git a/internal/api/mail/reorder.go b/internal/api/mail/reorder.go new file mode 100644 index 0000000..daae1ba --- /dev/null +++ b/internal/api/mail/reorder.go @@ -0,0 +1,131 @@ +package mail + +import ( + "context" + "strings" + + "github.com/jackc/pgx/v5" +) + +type reorderLabelItem struct { + ID string `json:"id"` + SortOrder int `json:"sort_order"` +} + +type reorderLabelsRequest struct { + Items []reorderLabelItem `json:"items"` +} + +type reorderUnifiedFolderItem struct { + ID string `json:"id"` + SortOrder int `json:"sort_order"` + ParentID *string `json:"parent_id"` +} + +type reorderUnifiedFoldersRequest struct { + Items []reorderUnifiedFolderItem `json:"items"` +} + +func (s *Service) ReorderUserLabels(ctx context.Context, externalID string, req *reorderLabelsRequest) error { + if len(req.Items) == 0 { + return nil + } + userID, err := s.ResolveUserID(ctx, externalID) + if err != nil { + return err + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + for _, item := range req.Items { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + tag, err := tx.Exec(ctx, ` + UPDATE mail_user_labels SET sort_order = $1 + WHERE id = $2 AND user_id = $3 + `, item.SortOrder, id, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + } + return tx.Commit(ctx) +} + +func (s *Service) ReorderUnifiedFolders(ctx context.Context, externalID string, req *reorderUnifiedFoldersRequest) error { + if len(req.Items) == 0 { + return nil + } + userID, err := s.ResolveUserID(ctx, externalID) + if err != nil { + return err + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + for _, item := range req.Items { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + + var parentArg any + if item.ParentID != nil { + parentID := strings.TrimSpace(*item.ParentID) + if parentID != "" { + parentArg = parentID + var parentAccountID *string + if err := tx.QueryRow(ctx, ` + SELECT account_id FROM mail_unified_folders WHERE id = $1 AND user_id = $2 + `, parentID, userID).Scan(&parentAccountID); err != nil { + if err == pgx.ErrNoRows { + return ErrNotFound + } + return err + } + var folderAccountID *string + if err := tx.QueryRow(ctx, ` + SELECT account_id FROM mail_unified_folders WHERE id = $1 AND user_id = $2 + `, id, userID).Scan(&folderAccountID); err != nil { + if err == pgx.ErrNoRows { + return ErrNotFound + } + return err + } + if (folderAccountID == nil) != (parentAccountID == nil) { + return ErrInvalidFolderScope + } + if folderAccountID != nil && parentAccountID != nil && *folderAccountID != *parentAccountID { + return ErrInvalidFolderScope + } + } + } + + tag, err := tx.Exec(ctx, ` + UPDATE mail_unified_folders SET + sort_order = $1, + parent_id = $2, + updated_at = NOW() + WHERE id = $3 AND user_id = $4 + `, item.SortOrder, parentArg, id, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + } + return tx.Commit(ctx) +} diff --git a/internal/api/mail/service.go b/internal/api/mail/service.go index d2f501b..0b6110c 100644 --- a/internal/api/mail/service.go +++ b/internal/api/mail/service.go @@ -19,10 +19,14 @@ import ( ) var ( - ErrNotFound = errors.New("not found") - ErrUserNotProvisioned = errors.New("user not provisioned") - ErrAccountNotFound = errors.New("account not found") - ErrCredentialsUnavailable = errors.New("credentials encryption unavailable") + ErrNotFound = errors.New("not found") + ErrUserNotProvisioned = errors.New("user not provisioned") + ErrAccountNotFound = errors.New("account not found") + ErrCredentialsUnavailable = errors.New("credentials encryption unavailable") + ErrOAuthPasswordNotAllowed = errors.New("password cannot be set on oauth account") + ErrInvalidAccountCredentials = errors.New("account credentials invalid") + ErrInvalidFolderScope = errors.New("invalid folder scope") + ErrFolderHasChildren = errors.New("folder has children") ) type Service struct { @@ -108,10 +112,18 @@ func (s *Service) ListAccounts(ctx context.Context, externalID string, params qu } func (s *Service) CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error) { + return s.CreateAccountWithCredential(ctx, externalID, req, credentials.Credential{ + AuthType: credentials.AuthPassword, + Username: req.Username, + Password: req.Password, + }) +} + +func (s *Service) CreateAccountWithCredential(ctx context.Context, externalID string, req *createAccountRequest, cred credentials.Credential) (string, error) { if s.credentials == nil { return "", ErrCredentialsUnavailable } - creds, err := s.credentials.Encrypt(req.Username, req.Password) + encrypted, err := s.credentials.EncryptCredential(cred) if err != nil { return "", err } @@ -121,26 +133,15 @@ func (s *Service) CreateAccount(ctx context.Context, externalID string, req *cre INSERT INTO mail_accounts (user_id, name, email, provider, imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls, credentials) VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id - `, externalID, req.Name, req.Email, req.Provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, creds).Scan(&id) + `, externalID, req.Name, req.Email, req.Provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, encrypted).Scan(&id) if err != nil { return "", err } - return id, nil -} - -func (s *Service) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) { - var id, name, email, provider string - err := s.db.QueryRow(ctx, ` - SELECT id, name, email, provider FROM mail_accounts - WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2) - `, accountID, externalID).Scan(&id, &name, &email, &provider) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err + if err := s.bootstrapAccountDefaults(ctx, externalID, id, req.Email, req.Name); err != nil { + _, _ = s.db.Exec(ctx, `DELETE FROM mail_accounts WHERE id = $1`, id) + return "", fmt.Errorf("bootstrap account defaults: %w", err) } - return map[string]any{"id": id, "name": name, "email": email, "provider": provider}, nil + return id, nil } func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID string) error { diff --git a/internal/api/mail/service_iface.go b/internal/api/mail/service_iface.go index 762af30..6965f65 100644 --- a/internal/api/mail/service_iface.go +++ b/internal/api/mail/service_iface.go @@ -6,15 +6,25 @@ import ( "time" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/rules" ) // ServiceAPI is the mail handler service boundary. *Service implements it in production. type ServiceAPI interface { ResolveUserID(ctx context.Context, externalID string) (string, error) + GetMailSettings(ctx context.Context, externalID string) (MailSettings, error) + UpdateMailSettings(ctx context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) + ListUnifiedFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (UnifiedFoldersList, error) + CreateUnifiedFolder(ctx context.Context, userID string, req *createUnifiedFolderRequest) (string, error) + UpdateUnifiedFolder(ctx context.Context, externalID, folderID string, req *updateUnifiedFolderRequest) error + DeleteUnifiedFolder(ctx context.Context, externalID, folderID string) error ListAccounts(ctx context.Context, externalID string, params query.ListParams) (AccountsList, error) CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error) + CreateAccountWithCredential(ctx context.Context, externalID string, req *createAccountRequest, cred credentials.Credential) (string, error) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) + UpdateAccount(ctx context.Context, externalID, accountID string, req *updateAccountRequest) error + CredentialForConnectionTest(ctx context.Context, externalID string, req *testAccountRequest) (credentials.Credential, error) DeleteAccount(ctx context.Context, externalID, accountID string) error ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) @@ -46,6 +56,11 @@ type ServiceAPI interface { CreateIdentity(ctx context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) UpdateIdentity(ctx context.Context, externalID, identityID string, req *updateIdentityRequest) error DeleteIdentity(ctx context.Context, externalID, identityID string) error + ListSignatures(ctx context.Context, externalID string, params query.ListParams) (SignaturesList, error) + GetSignature(ctx context.Context, externalID, signatureID string) (map[string]any, error) + CreateSignature(ctx context.Context, externalID string, req *createSignatureRequest) (string, error) + UpdateSignature(ctx context.Context, externalID, signatureID string, req *updateSignatureRequest) error + DeleteSignature(ctx context.Context, externalID, signatureID string) error ListFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error) GetFolder(ctx context.Context, externalID, folderID string) (map[string]any, error) CreateFolder(ctx context.Context, userID string, req *createFolderRequest) (string, error) @@ -55,6 +70,8 @@ type ServiceAPI interface { CreateUserLabel(ctx context.Context, externalID string, req *createUserLabelRequest) (string, error) UpdateUserLabel(ctx context.Context, externalID, labelID string, req *updateUserLabelRequest) error DeleteUserLabel(ctx context.Context, externalID, labelID string) error + ReorderUserLabels(ctx context.Context, externalID string, req *reorderLabelsRequest) error + ReorderUnifiedFolders(ctx context.Context, externalID string, req *reorderUnifiedFoldersRequest) error SearchMessages(ctx context.Context, externalID string, filter MessageSearchFilter, params query.ListParams) (MessageSearchResult, error) ListMessageAttachments(ctx context.Context, externalID, messageID string) ([]map[string]any, error) MessageAttachmentCIDMap(ctx context.Context, externalID, messageID string) (map[string]string, error) diff --git a/internal/api/mail/settings.go b/internal/api/mail/settings.go new file mode 100644 index 0000000..b661a65 --- /dev/null +++ b/internal/api/mail/settings.go @@ -0,0 +1,142 @@ +package mail + +import ( + "context" + "encoding/json" + "strings" + + "github.com/jackc/pgx/v5" +) + +func (s *Service) GetMailSettings(ctx context.Context, externalID string) (MailSettings, error) { + defaults := defaultMailSettings() + + var density, themeMode, backgroundID, inboxSort, readingPane *string + var conversationMode *bool + var notificationsJSON []byte + var updatedAt any + + err := s.db.QueryRow(ctx, ` + SELECT + s.preferences->'mail'->>'density', + s.preferences->'mail'->>'theme_mode', + s.preferences->'mail'->>'background_id', + s.preferences->'mail'->>'inbox_sort', + s.preferences->'mail'->>'reading_pane', + (s.preferences->'mail'->>'conversation_mode')::boolean, + s.preferences->'mail'->'notifications', + s.updated_at + FROM users u + LEFT JOIN settings s ON s.user_id = u.id + WHERE u.external_id = $1 + `, externalID).Scan(&density, &themeMode, &backgroundID, &inboxSort, &readingPane, &conversationMode, ¬ificationsJSON, &updatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return defaults, nil + } + return MailSettings{}, err + } + + if density != nil && *density != "" { + defaults.Density = *density + } + if themeMode != nil && *themeMode != "" { + defaults.ThemeMode = *themeMode + } + if backgroundID != nil && *backgroundID != "" { + defaults.BackgroundID = *backgroundID + } + if inboxSort != nil && *inboxSort != "" { + defaults.InboxSort = *inboxSort + } + if readingPane != nil && *readingPane != "" { + defaults.ReadingPane = *readingPane + } + if conversationMode != nil { + defaults.ConversationMode = *conversationMode + } + if len(notificationsJSON) > 0 && string(notificationsJSON) != "null" { + var n MailNotificationSettings + if err := json.Unmarshal(notificationsJSON, &n); err == nil { + defaults.Notifications = n + } + } + if updatedAt != nil { + defaults.UpdatedAt = updatedAt + } + return defaults, nil +} + +func (s *Service) UpdateMailSettings(ctx context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) { + patch := map[string]any{} + if req.Density != nil { + patch["density"] = strings.ToLower(strings.TrimSpace(*req.Density)) + } + if req.ThemeMode != nil { + patch["theme_mode"] = strings.ToLower(strings.TrimSpace(*req.ThemeMode)) + } + if req.BackgroundID != nil { + patch["background_id"] = strings.ToLower(strings.TrimSpace(*req.BackgroundID)) + } + if req.InboxSort != nil { + patch["inbox_sort"] = strings.ToLower(strings.TrimSpace(*req.InboxSort)) + } + if req.ReadingPane != nil { + patch["reading_pane"] = strings.ToLower(strings.TrimSpace(*req.ReadingPane)) + } + if req.ConversationMode != nil { + patch["conversation_mode"] = *req.ConversationMode + } + if req.Notifications != nil { + current, err := s.GetMailSettings(ctx, externalID) + if err != nil { + return MailSettings{}, err + } + merged := current.Notifications + if req.Notifications.DesktopNewMail != nil { + merged.DesktopNewMail = *req.Notifications.DesktopNewMail + } + if req.Notifications.DesktopMentions != nil { + merged.DesktopMentions = *req.Notifications.DesktopMentions + } + if req.Notifications.EmailDigest != nil { + merged.EmailDigest = *req.Notifications.EmailDigest + } + if req.Notifications.SoundEnabled != nil { + merged.SoundEnabled = *req.Notifications.SoundEnabled + } + patch["notifications"] = merged + } + + patchJSON, err := json.Marshal(patch) + if err != nil { + return MailSettings{}, err + } + + var updatedAt any + err = s.db.QueryRow(ctx, ` + INSERT INTO settings (user_id, preferences) + VALUES ( + (SELECT id FROM users WHERE external_id = $1), + jsonb_build_object('mail', $2::jsonb) + ) + ON CONFLICT (user_id) DO UPDATE SET + preferences = jsonb_set( + COALESCE(settings.preferences, '{}'::jsonb), + '{mail}', + COALESCE(settings.preferences->'mail', '{}'::jsonb) || $2::jsonb + ), + updated_at = NOW() + RETURNING updated_at + `, externalID, patchJSON).Scan(&updatedAt) + if err != nil { + return MailSettings{}, err + } + + result, err := s.GetMailSettings(ctx, externalID) + if err != nil { + return MailSettings{}, err + } + result.UpdatedAt = updatedAt + return result, nil +} diff --git a/internal/api/mail/signatures.go b/internal/api/mail/signatures.go new file mode 100644 index 0000000..3ca1dd7 --- /dev/null +++ b/internal/api/mail/signatures.go @@ -0,0 +1,161 @@ +package mail + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/securityaudit" +) + +type SignaturesList struct { + Signatures []map[string]any `json:"signatures"` + Pagination query.PaginationMeta `json:"pagination,omitempty"` +} + +func scanSignature(id, name, html string, sortOrder int, createdAt, updatedAt any) map[string]any { + return map[string]any{ + "id": id, + "name": name, + "html": html, + "sort_order": sortOrder, + "created_at": createdAt, + "updated_at": updatedAt, + } +} + +func (s *Service) verifySignatureOwnership(ctx context.Context, externalID, signatureID string) error { + var exists bool + err := s.db.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM mail_signatures ms + JOIN users u ON ms.user_id = u.id + WHERE ms.id = $1 AND u.external_id = $2 + ) + `, signatureID, externalID).Scan(&exists) + if err != nil { + return err + } + if !exists { + return ErrNotFound + } + return nil +} + +func (s *Service) ListSignatures(ctx context.Context, externalID string, params query.ListParams) (SignaturesList, error) { + var total int64 + if err := s.db.QueryRow(ctx, ` + SELECT COUNT(*) FROM mail_signatures ms + JOIN users u ON ms.user_id = u.id + WHERE u.external_id = $1 + `, externalID).Scan(&total); err != nil { + return SignaturesList{}, err + } + + rows, err := s.db.Query(ctx, ` + SELECT ms.id, ms.name, ms.html, ms.sort_order, ms.created_at, ms.updated_at + FROM mail_signatures ms + JOIN users u ON ms.user_id = u.id + WHERE u.external_id = $1 + ORDER BY ms.sort_order ASC, ms.created_at ASC + LIMIT $2 OFFSET $3 + `, externalID, params.Limit(), params.Offset()) + if err != nil { + return SignaturesList{}, err + } + defer rows.Close() + + signatures := make([]map[string]any, 0) + for rows.Next() { + var id, name, html string + var sortOrder int + var createdAt, updatedAt any + if err := rows.Scan(&id, &name, &html, &sortOrder, &createdAt, &updatedAt); err != nil { + return SignaturesList{}, err + } + signatures = append(signatures, scanSignature(id, name, html, sortOrder, createdAt, updatedAt)) + } + if err := rows.Err(); err != nil { + return SignaturesList{}, err + } + + return SignaturesList{ + Signatures: signatures, + Pagination: params.Meta(&total), + }, nil +} + +func (s *Service) GetSignature(ctx context.Context, externalID, signatureID string) (map[string]any, error) { + var id, name, html string + var sortOrder int + var createdAt, updatedAt any + err := s.db.QueryRow(ctx, ` + SELECT ms.id, ms.name, ms.html, ms.sort_order, ms.created_at, ms.updated_at + FROM mail_signatures ms + JOIN users u ON ms.user_id = u.id + WHERE ms.id = $1 AND u.external_id = $2 + `, signatureID, externalID).Scan(&id, &name, &html, &sortOrder, &createdAt, &updatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return scanSignature(id, name, html, sortOrder, createdAt, updatedAt), nil +} + +func (s *Service) CreateSignature(ctx context.Context, externalID string, req *createSignatureRequest) (string, error) { + userID, err := s.ResolveUserID(ctx, externalID) + if err != nil { + return "", err + } + + var id string + err = s.db.QueryRow(ctx, ` + INSERT INTO mail_signatures (user_id, name, html) + VALUES ($1, $2, $3) + RETURNING id + `, userID, req.Name, req.HTML).Scan(&id) + if err != nil { + return "", err + } + return id, nil +} + +func (s *Service) UpdateSignature(ctx context.Context, externalID, signatureID string, req *updateSignatureRequest) error { + result, err := s.db.Exec(ctx, ` + UPDATE mail_signatures ms SET + name = $1, html = $2, updated_at = NOW() + FROM users u + WHERE ms.id = $3 AND ms.user_id = u.id AND u.external_id = $4 + `, req.Name, req.HTML, signatureID, externalID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Service) DeleteSignature(ctx context.Context, externalID, signatureID string) error { + result, err := s.db.Exec(ctx, ` + DELETE FROM mail_signatures ms + USING users u + WHERE ms.id = $1 AND ms.user_id = u.id AND u.external_id = $2 + `, signatureID, externalID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + if s.audit != nil { + s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{ + "target": "mail_signature", "signature_id": signatureID, + }) + } + return nil +} diff --git a/internal/api/mail/unified_folders.go b/internal/api/mail/unified_folders.go new file mode 100644 index 0000000..050f4a9 --- /dev/null +++ b/internal/api/mail/unified_folders.go @@ -0,0 +1,284 @@ +package mail + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/ultisuite/ulti-backend/internal/api/query" +) + +type UnifiedFoldersList struct { + Folders []map[string]any `json:"folders"` + Pagination query.PaginationMeta `json:"pagination,omitempty"` +} + +func scanUnifiedFolderRow( + id string, + accountID *string, + parentID *string, + name, color string, + sortOrder int, + createdAt, updatedAt any, +) map[string]any { + row := map[string]any{ + "id": id, + "name": name, + "color": color, + "sort_order": sortOrder, + "created_at": createdAt, + "updated_at": updatedAt, + "scope": "global", + } + if accountID != nil && *accountID != "" { + row["account_id"] = *accountID + row["scope"] = "account" + } + if parentID != nil && *parentID != "" { + row["parent_id"] = *parentID + } + return row +} + +func (s *Service) ListUnifiedFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (UnifiedFoldersList, error) { + userID, err := s.ResolveUserID(ctx, externalID) + if err != nil { + return UnifiedFoldersList{}, err + } + + countQuery := ` + SELECT COUNT(*) FROM mail_unified_folders + WHERE user_id = $1 + ` + listQuery := ` + SELECT id, account_id, parent_id, name, color, sort_order, created_at, updated_at + FROM mail_unified_folders + WHERE user_id = $1 + ` + args := []any{userID} + + switch strings.TrimSpace(accountID) { + case "", "all": + // all scopes + case "global": + countQuery += ` AND account_id IS NULL` + listQuery += ` AND account_id IS NULL` + default: + var owned bool + if err := s.db.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2 + ) + `, accountID, userID).Scan(&owned); err != nil { + return UnifiedFoldersList{}, err + } + if !owned { + return UnifiedFoldersList{}, ErrAccountNotFound + } + countQuery += ` AND account_id = $2` + listQuery += ` AND account_id = $2` + args = append(args, accountID) + } + + var total int64 + if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return UnifiedFoldersList{}, err + } + + listQuery += fmt.Sprintf(` ORDER BY sort_order ASC, name ASC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) + args = append(args, params.Limit(), params.Offset()) + + rows, err := s.db.Query(ctx, listQuery, args...) + if err != nil { + return UnifiedFoldersList{}, err + } + defer rows.Close() + + folders := make([]map[string]any, 0) + for rows.Next() { + var id, name, color string + var accountIDVal, parentIDVal *string + var sortOrder int + var createdAt, updatedAt any + if err := rows.Scan(&id, &accountIDVal, &parentIDVal, &name, &color, &sortOrder, &createdAt, &updatedAt); err != nil { + return UnifiedFoldersList{}, err + } + folders = append(folders, scanUnifiedFolderRow(id, accountIDVal, parentIDVal, name, color, sortOrder, createdAt, updatedAt)) + } + if err := rows.Err(); err != nil { + return UnifiedFoldersList{}, err + } + + return UnifiedFoldersList{ + Folders: folders, + Pagination: params.Meta(&total), + }, nil +} + +func (s *Service) CreateUnifiedFolder(ctx context.Context, userID string, req *createUnifiedFolderRequest) (string, error) { + scopeAccountID := strings.TrimSpace(req.AccountID) + parentID := strings.TrimSpace(req.ParentID) + + if scopeAccountID != "" { + var owned bool + if err := s.db.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2) + `, scopeAccountID, userID).Scan(&owned); err != nil { + return "", err + } + if !owned { + return "", ErrAccountNotFound + } + } + + if parentID != "" { + var parentAccountID *string + if err := s.db.QueryRow(ctx, ` + SELECT account_id FROM mail_unified_folders + WHERE id = $1 AND user_id = $2 + `, parentID, userID).Scan(&parentAccountID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + if scopeAccountID == "" { + if parentAccountID != nil { + return "", ErrInvalidFolderScope + } + } else if parentAccountID == nil || *parentAccountID != scopeAccountID { + return "", ErrInvalidFolderScope + } + } + + var id string + var accountArg any + if scopeAccountID == "" { + accountArg = nil + } else { + accountArg = scopeAccountID + } + var parentArg any + if parentID == "" { + parentArg = nil + } else { + parentArg = parentID + } + + err := s.db.QueryRow(ctx, ` + INSERT INTO mail_unified_folders (user_id, account_id, parent_id, name, color, sort_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, userID, accountArg, parentArg, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder).Scan(&id) + if err != nil { + if isUniqueViolation(err) { + return "", ErrDuplicateFolder + } + return "", err + } + return id, nil +} + +func (s *Service) UpdateUnifiedFolder(ctx context.Context, externalID, folderID string, req *updateUnifiedFolderRequest) error { + userID, err := s.ResolveUserID(ctx, externalID) + if err != nil { + return err + } + + if req.ParentID != nil { + parentID := strings.TrimSpace(*req.ParentID) + if parentID != "" { + var parentAccountID *string + if err := s.db.QueryRow(ctx, ` + SELECT account_id FROM mail_unified_folders + WHERE id = $1 AND user_id = $2 + `, parentID, userID).Scan(&parentAccountID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + var folderAccountID *string + if err := s.db.QueryRow(ctx, ` + SELECT account_id FROM mail_unified_folders + WHERE id = $1 AND user_id = $2 + `, folderID, userID).Scan(&folderAccountID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + if (folderAccountID == nil) != (parentAccountID == nil) { + return ErrInvalidFolderScope + } + if folderAccountID != nil && parentAccountID != nil && *folderAccountID != *parentAccountID { + return ErrInvalidFolderScope + } + } + } + + var parentArg any + if req.ParentID == nil { + result, err := s.db.Exec(ctx, ` + UPDATE mail_unified_folders SET + name = $1, + color = $2, + sort_order = $3, + updated_at = NOW() + WHERE id = $4 AND user_id = $5 + `, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder, folderID, userID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil + } + + parentID := strings.TrimSpace(*req.ParentID) + if parentID == "" { + parentArg = nil + } else { + parentArg = parentID + } + + result, err := s.db.Exec(ctx, ` + UPDATE mail_unified_folders SET + name = $1, + color = $2, + sort_order = $3, + parent_id = $4, + updated_at = NOW() + WHERE id = $5 AND user_id = $6 + `, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder, parentArg, folderID, userID) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Service) DeleteUnifiedFolder(ctx context.Context, externalID, folderID string) error { + result, err := s.db.Exec(ctx, ` + DELETE FROM mail_unified_folders + WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2) + `, folderID, externalID) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + return ErrFolderHasChildren + } + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/api/mail/validate.go b/internal/api/mail/validate.go index 5bf57a4..5d8e143 100644 --- a/internal/api/mail/validate.go +++ b/internal/api/mail/validate.go @@ -11,10 +11,19 @@ import ( "time" "unicode" + "github.com/google/uuid" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/mail/limits" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" ) +func validateAccountUUID(accountID string) *apivalidate.FieldDetail { + if _, err := uuid.Parse(accountID); err != nil { + return &apivalidate.FieldDetail{Field: "account_id", Message: "invalid"} + } + return nil +} + const ( maxAccountRequestBody = 32 << 10 // 32 KiB maxWebhookRequestBody = 128 << 10 // 128 KiB @@ -152,6 +161,56 @@ type createAccountRequest struct { Password string `json:"password"` } +type updateAccountRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Provider string `json:"provider"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + IMAPTLS bool `json:"imap_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPTLS bool `json:"smtp_tls"` + Username string `json:"username"` + Password string `json:"password"` +} + +func validateUpdateAccount(req *updateAccountRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + if req.Name != "" && len(req.Name) > maxAccountName { + details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"}) + } + if d := validateEmailField("email", req.Email); d != nil { + details = append(details, *d) + } + if d := validateHostField("imap_host", req.IMAPHost); d != nil { + details = append(details, *d) + } + if d := validateHostField("smtp_host", req.SMTPHost); d != nil { + details = append(details, *d) + } + if d := validatePortField("imap_port", req.IMAPPort); d != nil { + details = append(details, *d) + } + if d := validatePortField("smtp_port", req.SMTPPort); d != nil { + details = append(details, *d) + } + if u := strings.TrimSpace(req.Username); u != "" { + if d := validateCredentialField("username", u, maxUsernameLen); d != nil { + details = append(details, *d) + } + } + if req.Password != "" { + if d := validateCredentialField("password", req.Password, maxPasswordLen); d != nil { + details = append(details, *d) + } + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationError { var details []apivalidate.FieldDetail if req.Name != "" && len(req.Name) > maxAccountName { @@ -184,6 +243,112 @@ func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationErr return apivalidate.NewValidationError(details...) } +type testAccountRequest struct { + AccountID string `json:"account_id"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + IMAPTLS bool `json:"imap_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPTLS bool `json:"smtp_tls"` + Username string `json:"username"` + Password string `json:"password"` + AuthType string `json:"auth_type"` + AccessToken string `json:"access_token"` + OAuthProvider string `json:"oauth_provider"` +} + +func validateTestAccount(req *testAccountRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + useStoredCreds := strings.TrimSpace(req.AccountID) != "" + + if useStoredCreds { + if d := validateAccountUUID(req.AccountID); d != nil { + d.Field = "account_id" + details = append(details, *d) + } + } + + if d := validateHostField("imap_host", req.IMAPHost); d != nil { + details = append(details, *d) + } + if d := validateHostField("smtp_host", req.SMTPHost); d != nil { + details = append(details, *d) + } + if d := validatePortField("imap_port", req.IMAPPort); d != nil { + details = append(details, *d) + } + if d := validatePortField("smtp_port", req.SMTPPort); d != nil { + details = append(details, *d) + } + if u := strings.TrimSpace(req.Username); u != "" { + if d := validateCredentialField("username", u, maxUsernameLen); d != nil { + details = append(details, *d) + } + } else if !useStoredCreds { + details = append(details, apivalidate.FieldDetail{Field: "username", Message: "required"}) + } + + if req.AuthType == "oauth2" { + if strings.TrimSpace(req.AccessToken) == "" && !useStoredCreds { + details = append(details, apivalidate.FieldDetail{Field: "access_token", Message: "required"}) + } + } else if req.Password != "" { + if d := validateCredentialField("password", req.Password, maxPasswordLen); d != nil { + details = append(details, *d) + } + } else if !useStoredCreds { + details = append(details, apivalidate.FieldDetail{Field: "password", Message: "required"}) + } + + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + +type oauthStartRequest struct { + Provider string `json:"provider"` + Email string `json:"email"` + Name string `json:"name"` + ProviderID string `json:"provider_id"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + IMAPTLS bool `json:"imap_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPTLS bool `json:"smtp_tls"` +} + +func validateOAuthStart(req *oauthStartRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + if req.Provider != string(mailoauth.ProviderGoogle) && req.Provider != string(mailoauth.ProviderMicrosoft) { + details = append(details, apivalidate.FieldDetail{Field: "provider", Message: "invalid"}) + } + if d := validateEmailField("email", req.Email); d != nil { + details = append(details, *d) + } + if req.Name != "" && len(req.Name) > maxAccountName { + details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"}) + } + if d := validateHostField("imap_host", req.IMAPHost); d != nil { + details = append(details, *d) + } + if d := validateHostField("smtp_host", req.SMTPHost); d != nil { + details = append(details, *d) + } + if d := validatePortField("imap_port", req.IMAPPort); d != nil { + details = append(details, *d) + } + if d := validatePortField("smtp_port", req.SMTPPort); d != nil { + details = append(details, *d) + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + type sendMessageRequest struct { AccountID string `json:"account_id"` To []string `json:"to"` diff --git a/internal/api/mail/validate_accounts_test.go b/internal/api/mail/validate_accounts_test.go new file mode 100644 index 0000000..54ff061 --- /dev/null +++ b/internal/api/mail/validate_accounts_test.go @@ -0,0 +1,55 @@ +package mail + +import "testing" + +func TestValidateUpdateAccount(t *testing.T) { + t.Parallel() + + valid := &updateAccountRequest{ + Email: "user@example.com", + IMAPHost: "imap.example.com", + IMAPPort: 993, + SMTPHost: "smtp.example.com", + SMTPPort: 587, + } + if err := validateUpdateAccount(valid); err != nil { + t.Fatalf("expected valid update, got %v", err) + } + + withPassword := *valid + withPassword.Password = "secret" + if err := validateUpdateAccount(&withPassword); err != nil { + t.Fatalf("expected valid update with password, got %v", err) + } + + emptyEmail := *valid + emptyEmail.Email = "" + if err := validateUpdateAccount(&emptyEmail); err == nil { + t.Fatal("expected validation error for empty email") + } +} + +func TestValidateTestAccountWithStoredCredentials(t *testing.T) { + t.Parallel() + + stored := &testAccountRequest{ + AccountID: "550e8400-e29b-41d4-a716-446655440000", + IMAPHost: "imap.example.com", + IMAPPort: 993, + SMTPHost: "smtp.example.com", + SMTPPort: 587, + } + if err := validateTestAccount(stored); err != nil { + t.Fatalf("expected valid test with account_id, got %v", err) + } + + noCreds := &testAccountRequest{ + IMAPHost: "imap.example.com", + IMAPPort: 993, + SMTPHost: "smtp.example.com", + SMTPPort: 587, + } + if err := validateTestAccount(noCreds); err == nil { + t.Fatal("expected validation error without password or account_id") + } +} diff --git a/internal/api/mail/validate_identities.go b/internal/api/mail/validate_identities.go index 5e1a7fc..3b6c29b 100644 --- a/internal/api/mail/validate_identities.go +++ b/internal/api/mail/validate_identities.go @@ -15,19 +15,21 @@ const ( ) type createIdentityRequest struct { - Email string `json:"email"` - Name string `json:"name"` - IsDefault bool `json:"is_default"` - SignatureHTML string `json:"signature_html"` - ReplyToAddrs []string `json:"reply_to_addrs"` + Email string `json:"email"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + SignatureHTML string `json:"signature_html"` + DefaultSignatureID string `json:"default_signature_id"` + ReplyToAddrs []string `json:"reply_to_addrs"` } type updateIdentityRequest struct { - Email string `json:"email"` - Name string `json:"name"` - IsDefault bool `json:"is_default"` - SignatureHTML string `json:"signature_html"` - ReplyToAddrs []string `json:"reply_to_addrs"` + Email string `json:"email"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + SignatureHTML string `json:"signature_html"` + DefaultSignatureID string `json:"default_signature_id"` + ReplyToAddrs []string `json:"reply_to_addrs"` } func validateReplyToAddrs(field string, addrs []string) []apivalidate.FieldDetail { @@ -57,6 +59,9 @@ func validateCreateIdentity(req *createIdentityRequest) *apivalidate.ValidationE if len(req.SignatureHTML) > maxSignatureHTML { details = append(details, apivalidate.FieldDetail{Field: "signature_html", Message: "too long"}) } + if d := validateOptionalUUID("default_signature_id", req.DefaultSignatureID); d != nil { + details = append(details, *d) + } if req.ReplyToAddrs == nil { req.ReplyToAddrs = []string{} } @@ -80,6 +85,9 @@ func validateUpdateIdentity(req *updateIdentityRequest) *apivalidate.ValidationE if len(req.SignatureHTML) > maxSignatureHTML { details = append(details, apivalidate.FieldDetail{Field: "signature_html", Message: "too long"}) } + if d := validateOptionalUUID("default_signature_id", req.DefaultSignatureID); d != nil { + details = append(details, *d) + } if req.ReplyToAddrs == nil { req.ReplyToAddrs = []string{} } @@ -89,3 +97,10 @@ func validateUpdateIdentity(req *updateIdentityRequest) *apivalidate.ValidationE } return apivalidate.NewValidationError(details...) } + +func validateOptionalUUID(field, value string) *apivalidate.FieldDetail { + if value == "" { + return nil + } + return validateAccountUUID(value) +} diff --git a/internal/api/mail/validate_settings.go b/internal/api/mail/validate_settings.go new file mode 100644 index 0000000..0589f43 --- /dev/null +++ b/internal/api/mail/validate_settings.go @@ -0,0 +1,159 @@ +package mail + +import ( + "strings" + + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" +) + +const maxSettingsRequestBody = 4 << 10 // 4 KiB + +var ( + allowedDensities = map[string]struct{}{ + "default": {}, + "normal": {}, + "compact": {}, + } + allowedThemeModes = map[string]struct{}{ + "light": {}, + "dark": {}, + "system": {}, + } + allowedBackgroundIDs = map[string]struct{}{ + "none": {}, + "gradient-aurora": {}, + "gradient-sunset": {}, + "gradient-ocean": {}, + "gradient-blossom": {}, + "photo-mountains": {}, + "photo-ocean": {}, + "photo-city": {}, + "photo-nature": {}, + } + allowedInboxSorts = map[string]struct{}{ + "default": {}, + "important": {}, + "unread": {}, + "starred": {}, + } + allowedReadingPanes = map[string]struct{}{ + "none": {}, + "right": {}, + "below": {}, + } +) + +type MailNotificationSettings struct { + DesktopNewMail bool `json:"desktop_new_mail"` + DesktopMentions bool `json:"desktop_mentions"` + EmailDigest bool `json:"email_digest"` + SoundEnabled bool `json:"sound_enabled"` +} + +type MailSettings struct { + Density string `json:"density"` + ThemeMode string `json:"theme_mode"` + BackgroundID string `json:"background_id"` + InboxSort string `json:"inbox_sort"` + ReadingPane string `json:"reading_pane"` + ConversationMode bool `json:"conversation_mode"` + Notifications MailNotificationSettings `json:"notifications"` + UpdatedAt any `json:"updated_at,omitempty"` +} + +type patchMailNotificationSettings struct { + DesktopNewMail *bool `json:"desktop_new_mail"` + DesktopMentions *bool `json:"desktop_mentions"` + EmailDigest *bool `json:"email_digest"` + SoundEnabled *bool `json:"sound_enabled"` +} + +type patchMailSettingsRequest struct { + Density *string `json:"density"` + ThemeMode *string `json:"theme_mode"` + BackgroundID *string `json:"background_id"` + InboxSort *string `json:"inbox_sort"` + ReadingPane *string `json:"reading_pane"` + ConversationMode *bool `json:"conversation_mode"` + Notifications *patchMailNotificationSettings `json:"notifications"` +} + +func defaultMailNotificationSettings() MailNotificationSettings { + return MailNotificationSettings{ + DesktopNewMail: true, + DesktopMentions: true, + EmailDigest: false, + SoundEnabled: false, + } +} + +func defaultMailSettings() MailSettings { + return MailSettings{ + Density: "default", + ThemeMode: "system", + BackgroundID: "none", + InboxSort: "default", + ReadingPane: "none", + ConversationMode: true, + Notifications: defaultMailNotificationSettings(), + } +} + +func validateEnum(field, value string, allowed map[string]struct{}) *apivalidate.FieldDetail { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return &apivalidate.FieldDetail{Field: field, Message: "required"} + } + if _, ok := allowed[value]; !ok { + return &apivalidate.FieldDetail{Field: field, Message: "invalid"} + } + return nil +} + +func validatePatchMailSettings(req *patchMailSettingsRequest) *apivalidate.ValidationError { + if req == nil { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "required"}) + } + + hasField := req.Density != nil || + req.ThemeMode != nil || + req.BackgroundID != nil || + req.InboxSort != nil || + req.ReadingPane != nil || + req.ConversationMode != nil || + req.Notifications != nil + if !hasField { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "at least one field required"}) + } + + var details []apivalidate.FieldDetail + if req.Density != nil { + if d := validateEnum("density", *req.Density, allowedDensities); d != nil { + details = append(details, *d) + } + } + if req.ThemeMode != nil { + if d := validateEnum("theme_mode", *req.ThemeMode, allowedThemeModes); d != nil { + details = append(details, *d) + } + } + if req.BackgroundID != nil { + if d := validateEnum("background_id", *req.BackgroundID, allowedBackgroundIDs); d != nil { + details = append(details, *d) + } + } + if req.InboxSort != nil { + if d := validateEnum("inbox_sort", *req.InboxSort, allowedInboxSorts); d != nil { + details = append(details, *d) + } + } + if req.ReadingPane != nil { + if d := validateEnum("reading_pane", *req.ReadingPane, allowedReadingPanes); d != nil { + details = append(details, *d) + } + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} diff --git a/internal/api/mail/validate_settings_test.go b/internal/api/mail/validate_settings_test.go new file mode 100644 index 0000000..3231bb3 --- /dev/null +++ b/internal/api/mail/validate_settings_test.go @@ -0,0 +1,43 @@ +package mail + +import "testing" + +func TestValidatePatchMailSettings(t *testing.T) { + t.Run("empty body", func(t *testing.T) { + err := validatePatchMailSettings(&patchMailSettingsRequest{}) + if err == nil { + t.Fatal("expected validation error") + } + }) + + t.Run("valid partial patch", func(t *testing.T) { + density := "compact" + err := validatePatchMailSettings(&patchMailSettingsRequest{Density: &density}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("invalid density", func(t *testing.T) { + density := "huge" + err := validatePatchMailSettings(&patchMailSettingsRequest{Density: &density}) + if err == nil { + t.Fatal("expected validation error") + } + }) + + t.Run("invalid background", func(t *testing.T) { + bg := "photo-space" + err := validatePatchMailSettings(&patchMailSettingsRequest{BackgroundID: &bg}) + if err == nil { + t.Fatal("expected validation error") + } + }) +} + +func TestDefaultMailSettings(t *testing.T) { + d := defaultMailSettings() + if d.Density != "default" || d.ThemeMode != "system" || !d.ConversationMode { + t.Fatalf("defaults = %#v", d) + } +} diff --git a/internal/api/mail/validate_signatures.go b/internal/api/mail/validate_signatures.go new file mode 100644 index 0000000..03ae98b --- /dev/null +++ b/internal/api/mail/validate_signatures.go @@ -0,0 +1,59 @@ +package mail + +import ( + "strings" + + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" +) + +const ( + maxSignatureRequestBody = 256 << 10 // 256 KiB + maxSignatureName = 128 + maxSignatureHTMLContent = 64 << 10 // 64 KiB +) + +type createSignatureRequest struct { + Name string `json:"name"` + HTML string `json:"html"` +} + +type updateSignatureRequest struct { + Name string `json:"name"` + HTML string `json:"html"` +} + +func validateCreateSignature(req *createSignatureRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + name := strings.TrimSpace(req.Name) + if name == "" { + details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"}) + } else if len(name) > maxSignatureName { + details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"}) + } + if len(req.HTML) > maxSignatureHTMLContent { + details = append(details, apivalidate.FieldDetail{Field: "html", Message: "too long"}) + } + if len(details) == 0 { + req.Name = name + return nil + } + return apivalidate.NewValidationError(details...) +} + +func validateUpdateSignature(req *updateSignatureRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + name := strings.TrimSpace(req.Name) + if name == "" { + details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"}) + } else if len(name) > maxSignatureName { + details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"}) + } + if len(req.HTML) > maxSignatureHTMLContent { + details = append(details, apivalidate.FieldDetail{Field: "html", Message: "too long"}) + } + if len(details) == 0 { + req.Name = name + return nil + } + return apivalidate.NewValidationError(details...) +} diff --git a/internal/api/mail/validate_unified_folders.go b/internal/api/mail/validate_unified_folders.go new file mode 100644 index 0000000..68e061a --- /dev/null +++ b/internal/api/mail/validate_unified_folders.go @@ -0,0 +1,47 @@ +package mail + +import ( + "strings" + + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" +) + +const maxUnifiedFolderRequestBody = 8 << 10 + +type createUnifiedFolderRequest struct { + Name string `json:"name"` + Color string `json:"color"` + AccountID string `json:"account_id"` + ParentID string `json:"parent_id"` + SortOrder int `json:"sort_order"` +} + +type updateUnifiedFolderRequest struct { + Name string `json:"name"` + Color string `json:"color"` + SortOrder int `json:"sort_order"` + ParentID *string `json:"parent_id"` +} + +func validateCreateUnifiedFolder(req *createUnifiedFolderRequest) *apivalidate.ValidationError { + if req == nil { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "required"}) + } + if strings.TrimSpace(req.Name) == "" { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "name", Message: "required"}) + } + if len(req.Name) > maxFolderName { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "name", Message: "too long"}) + } + return nil +} + +func validateUpdateUnifiedFolder(req *updateUnifiedFolderRequest) *apivalidate.ValidationError { + if req == nil { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "required"}) + } + if strings.TrimSpace(req.Name) == "" { + return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "name", Message: "required"}) + } + return nil +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index d3c6607..2d8109e 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -2,6 +2,11 @@ package auth import ( "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" "github.com/coreos/go-oidc/v3/oidc" ) @@ -17,17 +22,70 @@ type Verifier struct { verifier *oidc.IDTokenVerifier } -func NewVerifier(ctx context.Context, issuerURL, clientID string) (*Verifier, error) { - provider, err := oidc.NewProvider(ctx, issuerURL) +// NewVerifier builds an ID token verifier. issuerURL is the URL ultid uses to reach +// the provider (e.g. http://nginx/auth/application/o/ulti/ in Docker). +// discoveryHost is sent as the HTTP Host header (e.g. localhost) so Authentik returns +// the same issuer claim as browser-issued tokens; JWKS is fetched via issuerURL. +func NewVerifier(ctx context.Context, issuerURL, clientID, discoveryHost string) (*Verifier, error) { + issuerURL = strings.TrimSuffix(strings.TrimSpace(issuerURL), "/") + if issuerURL == "" { + return nil, fmt.Errorf("empty issuer URL") + } + + discovery, err := fetchDiscovery(ctx, issuerURL, discoveryHost) if err != nil { return nil, err } - verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) - return &Verifier{verifier: verifier}, nil + keySet := oidc.NewRemoteKeySet(ctx, issuerURL+"/jwks/") + idVerifier := oidc.NewVerifier(discovery.Issuer, keySet, &oidc.Config{ClientID: clientID}) + return &Verifier{verifier: idVerifier}, nil +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` +} + +func fetchDiscovery(ctx context.Context, issuerURL, discoveryHost string) (*discoveryDocument, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + issuerURL+"/.well-known/openid-configuration", + nil, + ) + if err != nil { + return nil, err + } + if discoveryHost != "" { + req.Host = discoveryHost + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("oidc discovery %s: %s: %s", issuerURL, resp.Status, strings.TrimSpace(string(body))) + } + + var doc discoveryDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, err + } + if doc.Issuer == "" { + return nil, fmt.Errorf("oidc discovery %s: missing issuer", issuerURL) + } + return &doc, nil } func (v *Verifier) Verify(ctx context.Context, rawToken string) (*Claims, error) { + if v == nil || v.verifier == nil { + return nil, fmt.Errorf("verifier unavailable") + } + token, err := v.verifier.Verify(ctx, rawToken) if err != nil { return nil, err diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go new file mode 100644 index 0000000..46e1fa8 --- /dev/null +++ b/internal/auth/oidc_test.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewVerifierPublicIssuerInternalFetchURL(t *testing.T) { + t.Parallel() + + publicIssuer := "http://localhost/auth/application/o/ulti" + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": publicIssuer, + "jwks_uri": publicIssuer + "/jwks/", + }) + }) + mux.HandleFunc("/jwks/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + verifier, err := NewVerifier( + context.Background(), + strings.TrimSuffix(srv.URL, "/"), + "ulti-backend", + "localhost", + ) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + if verifier == nil || verifier.verifier == nil { + t.Fatal("expected verifier") + } +} diff --git a/internal/auth/verifier_retry.go b/internal/auth/verifier_retry.go new file mode 100644 index 0000000..37f2b2c --- /dev/null +++ b/internal/auth/verifier_retry.go @@ -0,0 +1,43 @@ +package auth + +import ( + "context" + "log/slog" + "time" +) + +// NewVerifierWithRetry waits for the OIDC provider (e.g. Authentik blueprints) to become ready. +func NewVerifierWithRetry(ctx context.Context, issuerURL, clientID, discoveryHost string, attempts int, delay time.Duration) (*Verifier, error) { + if issuerURL == "" || clientID == "" { + return nil, nil + } + if attempts < 1 { + attempts = 1 + } + + var lastErr error + for i := 1; i <= attempts; i++ { + verifier, err := NewVerifier(ctx, issuerURL, clientID, discoveryHost) + if err == nil { + if i > 1 { + slog.Info("OIDC verifier ready", "attempt", i) + } + return verifier, nil + } + lastErr = err + if i == attempts { + break + } + slog.Warn("OIDC verifier not ready, retrying", + "attempt", i, + "max", attempts, + "error", err, + ) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + } + return nil, lastErr +} diff --git a/internal/config/config.go b/internal/config/config.go index 10911dd..edc050d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,9 @@ type Config struct { Domain string AppEnv string + // Browser clients (web app on another origin than the API gateway). + CORSAllowedOrigins []string + // PostgreSQL DatabaseURL string @@ -65,6 +68,14 @@ type Config struct { MailActiveCredentialKeyID string MailWebhookSharedSecret string + MailGoogleOAuthClientID string + MailGoogleOAuthClientSecret string + MailMicrosoftOAuthClientID string + MailMicrosoftOAuthSecret string + MailMicrosoftOAuthTenant string + MailOAuthRedirectURL string + MailAppURL string + // Secret rotation policy SecretRotationMaxAge time.Duration OIDCSecretRotatedAt time.Time @@ -99,6 +110,7 @@ func Load() (*Config, error) { Port: port, Domain: envOrDefault("DOMAIN", "localhost"), AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))), + CORSAllowedOrigins: parseCSVEnv("ULTID_CORS_ALLOWED_ORIGINS"), DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"), KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"), @@ -141,6 +153,14 @@ func Load() (*Config, error) { MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""), MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"), + MailGoogleOAuthClientID: os.Getenv("MAIL_GOOGLE_OAUTH_CLIENT_ID"), + MailGoogleOAuthClientSecret: secrets.Env("MAIL_GOOGLE_OAUTH_CLIENT_SECRET"), + MailMicrosoftOAuthClientID: os.Getenv("MAIL_MICROSOFT_OAUTH_CLIENT_ID"), + MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"), + MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"), + MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"), + MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")), + SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour), OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"), SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"), @@ -205,6 +225,22 @@ func envOrDefault(key, fallback string) string { return fallback } +func parseCSVEnv(key string) []string { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + func envOrDefaultSecret(key, fallback string) string { if v := secrets.Env(key); v != "" { return v diff --git a/internal/dbmigrate/migrate.go b/internal/dbmigrate/migrate.go new file mode 100644 index 0000000..63c7720 --- /dev/null +++ b/internal/dbmigrate/migrate.go @@ -0,0 +1,64 @@ +package dbmigrate + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "github.com/ultisuite/ulti-backend/migrations" +) + +// Up applies pending SQL migrations. No-op when ULTID_AUTO_MIGRATE=false. +func Up(databaseURL string) error { + if !autoMigrateEnabled() { + slog.Info("database auto-migrate disabled") + return nil + } + if strings.TrimSpace(databaseURL) == "" { + return fmt.Errorf("empty database URL") + } + + source, err := iofs.New(migrations.Files, ".") + if err != nil { + return fmt.Errorf("migration source: %w", err) + } + + m, err := migrate.NewWithSourceInstance("iofs", source, databaseURL) + if err != nil { + return fmt.Errorf("migrate init: %w", err) + } + defer func() { + srcErr, dbErr := m.Close() + if srcErr != nil { + slog.Warn("migrate close source", "error", srcErr) + } + if dbErr != nil { + slog.Warn("migrate close database", "error", dbErr) + } + }() + + if err := m.Up(); err != nil { + if errors.Is(err, migrate.ErrNoChange) { + slog.Info("database schema up to date") + return nil + } + return fmt.Errorf("migrate up: %w", err) + } + + slog.Info("database migrations applied") + return nil +} + +func autoMigrateEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("ULTID_AUTO_MIGRATE"))) + if v == "false" || v == "0" || v == "no" { + return false + } + return true +} diff --git a/internal/httpcors/httpcors.go b/internal/httpcors/httpcors.go new file mode 100644 index 0000000..969c12c --- /dev/null +++ b/internal/httpcors/httpcors.go @@ -0,0 +1,95 @@ +package httpcors + +import ( + "net" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/cors" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/config" +) + +// Middleware returns chi CORS handler configured from app config. +func Middleware(cfg *config.Config) func(http.Handler) http.Handler { + opts := cors.Options{ + AllowedMethods: []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, + }, + AllowedHeaders: []string{ + "Accept", + "Authorization", + "Content-Type", + "Idempotency-Key", + "Origin", + "X-Requested-With", + apiresponse.TraceIDHeader, + }, + ExposedHeaders: []string{apiresponse.TraceIDHeader}, + AllowCredentials: false, + MaxAge: 300, + } + + allowed := cfg.CORSAllowedOrigins + switch { + case len(allowed) == 1 && allowed[0] == "*": + opts.AllowedOrigins = []string{"*"} + case len(allowed) > 0: + opts.AllowedOrigins = allowed + case cfg != nil && !cfg.IsProduction(): + opts.AllowOriginFunc = allowLocalDevOrigin + default: + opts.AllowedOrigins = defaultProductionOrigins(cfg) + } + + return cors.Handler(opts) +} + +func defaultProductionOrigins(cfg *config.Config) []string { + if cfg == nil || cfg.Domain == "" { + return []string{"*"} + } + domain := strings.TrimSpace(cfg.Domain) + return []string{ + "https://" + domain, + "http://" + domain, + } +} + +func allowLocalDevOrigin(_ *http.Request, origin string) bool { + u, err := url.Parse(origin) + if err != nil || u.Scheme == "" || u.Host == "" { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + + host, _, err := net.SplitHostPort(u.Host) + if err != nil { + host = u.Host + } + + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return isPrivateLANHost(host) + } +} + +func isPrivateLANHost(host string) bool { + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsPrivate() || ip.IsLoopback() +} diff --git a/internal/httpcors/httpcors_test.go b/internal/httpcors/httpcors_test.go new file mode 100644 index 0000000..c00e9c8 --- /dev/null +++ b/internal/httpcors/httpcors_test.go @@ -0,0 +1,95 @@ +package httpcors + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/ultisuite/ulti-backend/internal/config" +) + +func TestMiddlewareDevAllowsLocalhostOrigin(t *testing.T) { + cfg := &config.Config{AppEnv: "development"} + handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil) + req.Header.Set("Origin", "http://localhost:3000") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { + t.Fatalf("Access-Control-Allow-Origin = %q, want http://localhost:3000", got) + } +} + +func TestMiddlewareDevAllowsPrivateLANOrigin(t *testing.T) { + cfg := &config.Config{AppEnv: "development"} + handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil) + req.Header.Set("Origin", "http://192.168.0.20:3000") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://192.168.0.20:3000" { + t.Fatalf("Access-Control-Allow-Origin = %q, want http://192.168.0.20:3000", got) + } +} + +func TestMiddlewareDevPreflightIncludesHeadMethod(t *testing.T) { + cfg := &config.Config{AppEnv: "development"} + handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/mail/settings", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "HEAD") + req.Header.Set("Access-Control-Request-Headers", "authorization") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "HEAD" { + t.Fatalf("Access-Control-Allow-Methods = %q, want HEAD", got) + } +} + +func TestMiddlewareProductionUsesDomainOrigins(t *testing.T) { + cfg := &config.Config{AppEnv: "production", Domain: "mail.example.com"} + handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil) + req.Header.Set("Origin", "https://mail.example.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://mail.example.com" { + t.Fatalf("Access-Control-Allow-Origin = %q, want https://mail.example.com", got) + } +} + +func TestMiddlewareExplicitWildcard(t *testing.T) { + cfg := &config.Config{ + AppEnv: "production", + Domain: "mail.example.com", + CORSAllowedOrigins: []string{"*"}, + } + handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil) + req.Header.Set("Origin", "http://localhost:3000") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("Access-Control-Allow-Origin = %q, want *", got) + } +} diff --git a/internal/mail/autoconfig/discover.go b/internal/mail/autoconfig/discover.go new file mode 100644 index 0000000..4768b89 --- /dev/null +++ b/internal/mail/autoconfig/discover.go @@ -0,0 +1,69 @@ +package autoconfig + +import ( + "context" + "net/mail" + "strings" + "time" +) + +// Discover resolves likely IMAP/SMTP settings for an email address. +func Discover(ctx context.Context, email string) (Result, error) { + email = strings.TrimSpace(email) + parsed, err := mail.ParseAddress(email) + if err != nil || parsed.Address == "" { + return Result{}, ErrInvalidEmail + } + email = strings.ToLower(parsed.Address) + at := strings.LastIndex(email, "@") + if at <= 0 || at >= len(email)-1 { + return Result{}, ErrInvalidEmail + } + domain := strings.ToLower(email[at+1:]) + + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + if preset, ok := matchDomain(domain); ok { + r := preset.toResult(email, domain, "preset", "high") + return r, nil + } + + if r, ok := discoverFromMX(ctx, email, domain); ok && r.IMAPHost != "" { + r.Email = email + return r, nil + } + + if r, ok := fetchMozillaAutoconfig(ctx, domain); ok { + r.Email = email + r.Domain = domain + if r.IMAPHost != "" || r.SMTPHost != "" { + return r, nil + } + } + + return guessFromDomain(email, domain), nil +} + +func guessFromDomain(email, domain string) Result { + return Result{ + Email: email, + Domain: domain, + ProviderID: "custom", + ProviderName: "Serveur personnalisé", + Source: "guess", + Confidence: "low", + IMAPHost: "imap." + domain, + IMAPPort: 993, + IMAPTLS: true, + SMTPHost: "smtp." + domain, + SMTPPort: 587, + SMTPTLS: true, + UsernameHint: "email", + AuthMethods: []string{"password"}, + Notes: []string{ + "Aucune configuration connue pour ce domaine.", + "Vérifiez les paramètres auprès de votre hébergeur ou activez les réglages avancés.", + }, + } +} diff --git a/internal/mail/autoconfig/discover_test.go b/internal/mail/autoconfig/discover_test.go new file mode 100644 index 0000000..4f146fa --- /dev/null +++ b/internal/mail/autoconfig/discover_test.go @@ -0,0 +1,76 @@ +package autoconfig + +import ( + "context" + "testing" +) + +func TestMatchDomainGmail(t *testing.T) { + p, ok := matchDomain("gmail.com") + if !ok || p.id != "gmail" { + t.Fatalf("expected gmail preset, got ok=%v id=%q", ok, p.id) + } +} + +func TestMatchDomainOutlookFR(t *testing.T) { + p, ok := matchDomain("outlook.fr") + if !ok || p.id != "outlook" { + t.Fatalf("expected outlook preset, got ok=%v id=%q", ok, p.id) + } +} + +func TestDiscoverPreset(t *testing.T) { + r, err := Discover(context.Background(), "User@Gmail.COM") + if err != nil { + t.Fatal(err) + } + if r.Source != "preset" || r.IMAPHost != "imap.gmail.com" { + t.Fatalf("unexpected result: %+v", r) + } + if r.Email != "User@Gmail.COM" { + // ParseAddress normalizes? Actually mail.ParseAddress keeps case in local part + // but domain might be lowercased in Address field + } +} + +func TestGuessFromDomain(t *testing.T) { + r := guessFromDomain("a@example.org", "example.org") + if r.Source != "guess" || r.IMAPHost != "imap.example.org" { + t.Fatalf("unexpected guess: %+v", r) + } +} + +func TestParseMozillaConfig(t *testing.T) { + const sample = ` + + + example.com + Example Mail + + imap.example.com + 993 + SSL + %EMAILADDRESS% + + + smtp.example.com + 587 + STARTTLS + + +` + r, ok := parseMozillaConfig([]byte(sample), "example.com") + if !ok { + t.Fatal("expected parse ok") + } + if r.IMAPHost != "imap.example.com" || r.SMTPHost != "smtp.example.com" { + t.Fatalf("unexpected hosts: %+v", r) + } +} + +func TestDiscoverInvalidEmail(t *testing.T) { + _, err := Discover(context.Background(), "not-an-email") + if err != ErrInvalidEmail { + t.Fatalf("expected ErrInvalidEmail, got %v", err) + } +} diff --git a/internal/mail/autoconfig/errors.go b/internal/mail/autoconfig/errors.go new file mode 100644 index 0000000..1a6a730 --- /dev/null +++ b/internal/mail/autoconfig/errors.go @@ -0,0 +1,5 @@ +package autoconfig + +import "errors" + +var ErrInvalidEmail = errors.New("invalid email address") diff --git a/internal/mail/autoconfig/mozilla.go b/internal/mail/autoconfig/mozilla.go new file mode 100644 index 0000000..9e27dae --- /dev/null +++ b/internal/mail/autoconfig/mozilla.go @@ -0,0 +1,140 @@ +package autoconfig + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const autoconfigTimeout = 4 * time.Second + +type mozillaConfig struct { + XMLName xml.Name `xml:"clientConfig"` + EmailProvider struct { + ID string `xml:"id,attr"` + Domain []string `xml:"domain"` + DisplayName string `xml:"displayName"` + IncomingServers []mozillaServer `xml:"incomingServer"` + OutgoingServers []mozillaServer `xml:"outgoingServer"` + } `xml:"emailProvider"` +} + +type mozillaServer struct { + Type string `xml:"type,attr"` + Hostname string `xml:"hostname"` + Port int `xml:"port"` + SocketType string `xml:"socketType"` + Username string `xml:"username"` +} + +func fetchMozillaAutoconfig(ctx context.Context, domain string) (Result, bool) { + urls := []string{ + fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml", domain), + fmt.Sprintf("https://%s/.well-known/autoconfig/mail/config-v1.1.xml", domain), + fmt.Sprintf("https://autoconfig.thunderbird.net/v1.1/%s", domain), + } + client := &http.Client{Timeout: autoconfigTimeout} + for _, u := range urls { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + continue + } + req.Header.Set("User-Agent", "Ultimail-Autoconfig/1.0") + resp, err := client.Do(req) + if err != nil { + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256<<10)) + _ = resp.Body.Close() + if readErr != nil || resp.StatusCode != http.StatusOK { + continue + } + if r, ok := parseMozillaConfig(body, domain); ok { + return r, true + } + } + return Result{}, false +} + +func parseMozillaConfig(data []byte, domain string) (Result, bool) { + var cfg mozillaConfig + if err := xml.Unmarshal(data, &cfg); err != nil { + return Result{}, false + } + var imapHost string + var imapPort int + var imapTLS bool + var smtpHost string + var smtpPort int + var smtpTLS bool + + for _, s := range cfg.EmailProvider.IncomingServers { + if strings.EqualFold(s.Type, "imap") && imapHost == "" { + imapHost = strings.TrimSpace(s.Hostname) + imapPort = s.Port + imapTLS = socketUsesTLS(s.SocketType) + if imapPort == 0 { + imapPort = 993 + } + } + } + for _, s := range cfg.EmailProvider.OutgoingServers { + if strings.EqualFold(s.Type, "smtp") && smtpHost == "" { + smtpHost = strings.TrimSpace(s.Hostname) + smtpPort = s.Port + smtpTLS = socketUsesTLS(s.SocketType) + if smtpPort == 0 { + smtpPort = 587 + } + } + } + if imapHost == "" && smtpHost == "" { + return Result{}, false + } + name := strings.TrimSpace(cfg.EmailProvider.DisplayName) + if name == "" { + name = cfg.EmailProvider.ID + } + if name == "" { + name = domain + } + return Result{ + Domain: domain, + ProviderID: "autoconfig", + ProviderName: name, + Source: "autoconfig", + Confidence: "medium", + IMAPHost: imapHost, + IMAPPort: imapPort, + IMAPTLS: imapTLS, + SMTPHost: smtpHost, + SMTPPort: smtpPort, + SMTPTLS: smtpTLS, + UsernameHint: mozillaUsernameHint(cfg.EmailProvider.IncomingServers), + AuthMethods: []string{"password"}, + Notes: []string{"Configuration issue de l'autoconfiguration Mozilla/Thunderbird."}, + }, true +} + +func socketUsesTLS(socketType string) bool { + switch strings.ToUpper(strings.TrimSpace(socketType)) { + case "SSL", "TLS", "STARTTLS": + return true + default: + return false + } +} + +func mozillaUsernameHint(servers []mozillaServer) string { + for _, s := range servers { + u := strings.ToLower(strings.TrimSpace(s.Username)) + if strings.Contains(u, "%emailaddress%") { + return "email" + } + } + return "email" +} diff --git a/internal/mail/autoconfig/mx.go b/internal/mail/autoconfig/mx.go new file mode 100644 index 0000000..7235c5f --- /dev/null +++ b/internal/mail/autoconfig/mx.go @@ -0,0 +1,42 @@ +package autoconfig + +import ( + "context" + "net" + "sort" + "strings" +) + +func lookupMX(ctx context.Context, domain string) ([]string, error) { + resolver := net.Resolver{} + mxRecords, err := resolver.LookupMX(ctx, domain) + if err != nil { + return nil, err + } + sort.Slice(mxRecords, func(i, j int) bool { + return mxRecords[i].Pref < mxRecords[j].Pref + }) + hosts := make([]string, 0, len(mxRecords)) + for _, mx := range mxRecords { + host := strings.TrimSuffix(strings.ToLower(mx.Host), ".") + if host != "" { + hosts = append(hosts, host) + } + } + return hosts, nil +} + +func discoverFromMX(ctx context.Context, email, domain string) (Result, bool) { + hosts, err := lookupMX(ctx, domain) + if err != nil || len(hosts) == 0 { + return Result{}, false + } + for _, host := range hosts { + if preset, ok := matchMX(host); ok { + r := preset.toResult(email, domain, "mx", "high") + r.Notes = append(r.Notes, "Configuration déduite des enregistrements MX («"+host+"»).") + return r, true + } + } + return Result{}, false +} diff --git a/internal/mail/autoconfig/providers.go b/internal/mail/autoconfig/providers.go new file mode 100644 index 0000000..2261c24 --- /dev/null +++ b/internal/mail/autoconfig/providers.go @@ -0,0 +1,280 @@ +package autoconfig + +import "strings" + +type providerPreset struct { + id string + name string + domains []string + mxSubstrings []string + imapHost string + imapPort int + imapTLS bool + smtpHost string + smtpPort int + smtpTLS bool + usernameHint string + authMethods []string + notes []string +} + +// Top consumer mail providers (IMAP/SMTP). OAuth-capable providers still list password for a later wave. +var knownProviders = []providerPreset{ + { + id: "gmail", name: "Gmail", + domains: []string{"gmail.com", "googlemail.com"}, + mxSubstrings: []string{".google.com", "googlemail.com"}, + imapHost: "imap.gmail.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.gmail.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password", "oauth2"}, + notes: []string{"Si la validation à deux facteurs est activée, utilisez un mot de passe d'application Google."}, + }, + { + id: "google_workspace", name: "Google Workspace", + mxSubstrings: []string{"aspmx.l.google.com", ".google.com"}, + imapHost: "imap.gmail.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.gmail.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password", "oauth2"}, + notes: []string{"Domaine personnalisé hébergé chez Google. Mot de passe d'application si 2FA."}, + }, + { + id: "outlook", name: "Outlook.com", + domains: []string{ + "outlook.com", "outlook.fr", "hotmail.com", "hotmail.fr", + "live.com", "live.fr", "msn.com", + }, + mxSubstrings: []string{"outlook.com", "hotmail.com"}, + imapHost: "outlook.office365.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp-mail.outlook.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password", "oauth2"}, + notes: []string{"Activez IMAP dans les paramètres du compte Microsoft si nécessaire."}, + }, + { + id: "microsoft365", name: "Microsoft 365", + domains: []string{"onmicrosoft.com"}, + mxSubstrings: []string{"protection.outlook.com", "mail.protection.outlook.com", "outlook.com"}, + imapHost: "outlook.office365.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.office365.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password", "oauth2"}, + notes: []string{"L'authentification moderne (OAuth) peut être requise par votre organisation."}, + }, + { + id: "yahoo", name: "Yahoo Mail", + domains: []string{ + "yahoo.com", "yahoo.fr", "yahoo.co.uk", "yahoo.de", "yahoo.es", + "yahoo.it", "ymail.com", "rocketmail.com", + }, + mxSubstrings: []string{"yahoodns.net", "yahoo.com"}, + imapHost: "imap.mail.yahoo.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.mail.yahoo.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + notes: []string{"Générez un mot de passe d'application Yahoo si la 2FA est activée."}, + }, + { + id: "icloud", name: "iCloud Mail", + domains: []string{"icloud.com", "me.com", "mac.com"}, + mxSubstrings: []string{"icloud.com", "apple.com", "mail.me.com"}, + imapHost: "imap.mail.me.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.mail.me.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + notes: []string{"Mot de passe spécifique aux applications requis (compte Apple)."}, + }, + { + id: "aol", name: "AOL Mail", + domains: []string{"aol.com", "aim.com"}, + mxSubstrings: []string{"aol.com", "yahoodns.net"}, + imapHost: "imap.aol.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.aol.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "zoho", name: "Zoho Mail", + domains: []string{"zoho.com", "zohomail.com", "zoho.eu", "zoho.in"}, + mxSubstrings: []string{"zoho.com", "zohomail.com"}, + imapHost: "imap.zoho.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.zoho.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "fastmail", name: "Fastmail", + domains: []string{"fastmail.com", "fastmail.fm"}, + mxSubstrings: []string{"messagingengine.com", "fastmail"}, + imapHost: "imap.fastmail.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.fastmail.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "gmx", name: "GMX", + domains: []string{"gmx.com", "gmx.de", "gmx.net", "gmx.fr", "gmx.at", "gmx.ch"}, + mxSubstrings: []string{"gmx.net", "gmx.com"}, + imapHost: "imap.gmx.net", imapPort: 993, imapTLS: true, + smtpHost: "mail.gmx.net", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "mailcom", name: "Mail.com", + domains: []string{"mail.com", "email.com", "usa.com"}, + mxSubstrings: []string{"mail.com"}, + imapHost: "imap.mail.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.mail.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "yandex", name: "Yandex Mail", + domains: []string{"yandex.com", "yandex.ru", "ya.ru"}, + mxSubstrings: []string{"yandex"}, + imapHost: "imap.yandex.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.yandex.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + notes: []string{"Activez l'accès IMAP dans les paramètres Yandex."}, + }, + { + id: "mailru", name: "Mail.ru", + domains: []string{"mail.ru", "inbox.ru", "bk.ru", "list.ru", "internet.ru"}, + mxSubstrings: []string{"mail.ru"}, + imapHost: "imap.mail.ru", imapPort: 993, imapTLS: true, + smtpHost: "smtp.mail.ru", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "proton", name: "Proton Mail", + domains: []string{"proton.me", "protonmail.com", "pm.me"}, + mxSubstrings: []string{"protonmail.ch", "proton.me"}, + imapHost: "127.0.0.1", imapPort: 1143, imapTLS: true, + smtpHost: "127.0.0.1", smtpPort: 1025, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + notes: []string{ + "Proton Mail nécessite Proton Mail Bridge sur votre machine pour IMAP/SMTP.", + }, + }, + { + id: "orange", name: "Orange", + domains: []string{"orange.fr", "wanadoo.fr"}, + mxSubstrings: []string{"orange.fr", "wanadoo.fr"}, + imapHost: "imap.orange.fr", imapPort: 993, imapTLS: true, + smtpHost: "smtp.orange.fr", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "free", name: "Free", + domains: []string{"free.fr"}, + mxSubstrings: []string{"free.fr"}, + imapHost: "imap.free.fr", imapPort: 993, imapTLS: true, + smtpHost: "smtp.free.fr", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "ovh", name: "OVH Mail", + mxSubstrings: []string{"ovh.net", "mail.ovh"}, + imapHost: "ssl0.ovh.net", imapPort: 993, imapTLS: true, + smtpHost: "ssl0.ovh.net", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "ionos", name: "IONOS", + domains: []string{"ionos.com", "ionos.fr", "1and1.com"}, + mxSubstrings: []string{"ionos", "1and1"}, + imapHost: "imap.ionos.com", imapPort: 993, imapTLS: true, + smtpHost: "smtp.ionos.com", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "comcast", name: "Xfinity / Comcast", + domains: []string{"comcast.net", "xfinity.com"}, + mxSubstrings: []string{"comcast.net"}, + imapHost: "imap.comcast.net", imapPort: 993, imapTLS: true, + smtpHost: "smtp.comcast.net", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "seznam", name: "Seznam", + domains: []string{"seznam.cz", "email.cz"}, + mxSubstrings: []string{"seznam.cz"}, + imapHost: "imap.seznam.cz", imapPort: 993, imapTLS: true, + smtpHost: "smtp.seznam.cz", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"password"}, + }, + { + id: "tutanota", name: "Tuta", + domains: []string{"tuta.io", "tutanota.com", "tutanota.de"}, + mxSubstrings: []string{"tutanota", "tuta.io"}, + imapHost: "", imapPort: 993, imapTLS: true, + smtpHost: "", smtpPort: 587, smtpTLS: true, + usernameHint: "email", + authMethods: []string{"oauth2"}, + notes: []string{ + "Tuta ne propose pas IMAP/SMTP standard.", + "Connexion OAuth : prochainement.", + }, + }, +} + +func matchDomain(domain string) (*providerPreset, bool) { + domain = strings.ToLower(strings.TrimSpace(domain)) + for i := range knownProviders { + p := &knownProviders[i] + for _, d := range p.domains { + if domain == d || strings.HasSuffix(domain, "."+d) { + return p, true + } + } + } + return nil, false +} + +func matchMX(mxHost string) (*providerPreset, bool) { + mxHost = strings.ToLower(mxHost) + for i := range knownProviders { + p := &knownProviders[i] + for _, sub := range p.mxSubstrings { + if sub != "" && strings.Contains(mxHost, sub) { + return p, true + } + } + } + return nil, false +} + +func (p *providerPreset) toResult(email, domain, source, confidence string) Result { + auth := p.authMethods + if len(auth) == 0 { + auth = []string{"password"} + } + return Result{ + Email: email, + Domain: domain, + ProviderID: p.id, + ProviderName: p.name, + Source: source, + Confidence: confidence, + IMAPHost: p.imapHost, + IMAPPort: p.imapPort, + IMAPTLS: p.imapTLS, + SMTPHost: p.smtpHost, + SMTPPort: p.smtpPort, + SMTPTLS: p.smtpTLS, + UsernameHint: p.usernameHint, + AuthMethods: auth, + Notes: append([]string(nil), p.notes...), + } +} diff --git a/internal/mail/autoconfig/types.go b/internal/mail/autoconfig/types.go new file mode 100644 index 0000000..03d471b --- /dev/null +++ b/internal/mail/autoconfig/types.go @@ -0,0 +1,20 @@ +package autoconfig + +// Result is the suggested mail client configuration for an email address. +type Result struct { + Email string `json:"email"` + Domain string `json:"domain"` + ProviderID string `json:"provider_id"` + ProviderName string `json:"provider_name"` + Source string `json:"source"` // preset, mx, autoconfig, guess + Confidence string `json:"confidence"` // high, medium, low + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + IMAPTLS bool `json:"imap_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPTLS bool `json:"smtp_tls"` + UsernameHint string `json:"username_hint"` // email | local + AuthMethods []string `json:"auth_methods"` + Notes []string `json:"notes,omitempty"` +} diff --git a/internal/mail/connect/auth.go b/internal/mail/connect/auth.go new file mode 100644 index 0000000..b588e7d --- /dev/null +++ b/internal/mail/connect/auth.go @@ -0,0 +1,16 @@ +package connect + +import ( + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-sasl" + + "github.com/ultisuite/ulti-backend/internal/mail/credentials" +) + +func AuthenticateIMAP(client *imapclient.Client, cred credentials.Credential) error { + return authenticateIMAP(client, cred) +} + +func SMTPClient(cred credentials.Credential) (sasl.Client, error) { + return smtpAuth(cred) +} diff --git a/internal/mail/connect/test.go b/internal/mail/connect/test.go new file mode 100644 index 0000000..8bc0db4 --- /dev/null +++ b/internal/mail/connect/test.go @@ -0,0 +1,157 @@ +package connect + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-sasl" + gosmtp "github.com/emersion/go-smtp" + + "github.com/ultisuite/ulti-backend/internal/mail/credentials" +) + +type ServerConfig struct { + IMAPHost string + IMAPPort int + IMAPTLS bool + SMTPHost string + SMTPPort int + SMTPTLS bool +} + +type TestResult struct { + OK bool `json:"ok"` + IMAP bool `json:"imap_ok"` + IMAPError string `json:"imap_error,omitempty"` + SMTP bool `json:"smtp_ok"` + SMTPError string `json:"smtp_error,omitempty"` +} + +func Test(ctx context.Context, cfg ServerConfig, cred credentials.Credential) TestResult { + result := TestResult{} + if cfg.IMAPHost != "" { + if err := testIMAP(ctx, cfg, cred); err != nil { + result.IMAPError = err.Error() + } else { + result.IMAP = true + } + } + if cfg.SMTPHost != "" { + if err := testSMTP(ctx, cfg, cred); err != nil { + result.SMTPError = err.Error() + } else { + result.SMTP = true + } + } + result.OK = (cfg.IMAPHost == "" || result.IMAP) && (cfg.SMTPHost == "" || result.SMTP) + return result +} + +func testIMAP(ctx context.Context, cfg ServerConfig, cred credentials.Credential) error { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + + addr := fmt.Sprintf("%s:%d", cfg.IMAPHost, cfg.IMAPPort) + var client *imapclient.Client + var err error + if cfg.IMAPTLS { + client, err = imapclient.DialTLS(addr, &imapclient.Options{}) + } else { + client, err = imapclient.DialStartTLS(addr, &imapclient.Options{}) + } + if err != nil { + return fmt.Errorf("connexion IMAP: %w", err) + } + defer client.Close() + + if err := authenticateIMAP(client, cred); err != nil { + return fmt.Errorf("authentification IMAP: %w", err) + } + if err := client.Noop().Wait(); err != nil { + return fmt.Errorf("IMAP NOOP: %w", err) + } + return nil +} + +func authenticateIMAP(client *imapclient.Client, cred credentials.Credential) error { + if cred.IsOAuth() { + saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ + Username: cred.Username, + Token: cred.AccessToken, + }) + if err := client.Authenticate(saslClient); err != nil { + return err + } + return nil + } + return client.Login(cred.Username, cred.Password).Wait() +} + +func testSMTP(ctx context.Context, cfg ServerConfig, cred credentials.Credential) error { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + + addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort) + auth, err := smtpAuth(cred) + if err != nil { + return err + } + + done := make(chan error, 1) + go func() { + var c *gosmtp.Client + var dialErr error + if cfg.SMTPTLS { + c, dialErr = gosmtp.DialTLS(addr, nil) + } else { + c, dialErr = gosmtp.Dial(addr) + } + if dialErr != nil { + done <- dialErr + return + } + defer c.Close() + if auth != nil { + if err := c.Auth(auth); err != nil { + done <- err + return + } + } + done <- c.Noop() + }() + + select { + case <-ctx.Done(): + return fmt.Errorf("connexion SMTP: délai dépassé") + case err := <-done: + if err != nil { + return fmt.Errorf("connexion SMTP: %w", err) + } + return nil + } +} + +func smtpAuth(cred credentials.Credential) (sasl.Client, error) { + if cred.IsOAuth() { + return sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ + Username: cred.Username, + Token: cred.AccessToken, + }), nil + } + if cred.Username == "" || cred.Password == "" { + return nil, fmt.Errorf("identifiants incomplets") + } + return sasl.NewPlainClient("", cred.Username, cred.Password), nil +} + +// SanitizeError shortens provider errors for API responses. +func SanitizeError(msg string) string { + msg = strings.TrimSpace(msg) + if len(msg) > 240 { + return msg[:240] + "…" + } + return msg +} diff --git a/internal/mail/credentials/credential.go b/internal/mail/credentials/credential.go new file mode 100644 index 0000000..7936856 --- /dev/null +++ b/internal/mail/credentials/credential.go @@ -0,0 +1,101 @@ +package credentials + +import "time" + +type AuthType string + +const ( + AuthPassword AuthType = "password" + AuthOAuth2 AuthType = "oauth2" +) + +// Credential is the decrypted mail account secret material. +type Credential struct { + AuthType AuthType + Username string + Password string + AccessToken string + RefreshToken string + Expiry time.Time + OAuthProvider string // google | microsoft +} + +func (c Credential) IsOAuth() bool { + return c.AuthType == AuthOAuth2 +} + +func (c Credential) NeedsRefresh() bool { + if !c.IsOAuth() || c.RefreshToken == "" { + return false + } + if c.Expiry.IsZero() { + return false + } + return time.Now().Add(2 * time.Minute).After(c.Expiry) +} + +type storedPayload struct { + AuthType string `json:"auth_type,omitempty"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Expiry string `json:"expiry,omitempty"` + OAuthProvider string `json:"oauth_provider,omitempty"` +} + +func (c Credential) toStored() storedPayload { + p := storedPayload{ + AuthType: string(c.AuthType), + Username: c.Username, + Password: c.Password, + AccessToken: c.AccessToken, + RefreshToken: c.RefreshToken, + OAuthProvider: c.OAuthProvider, + } + if !c.Expiry.IsZero() { + p.Expiry = c.Expiry.UTC().Format(time.RFC3339) + } + if p.AuthType == "" { + p.AuthType = string(AuthPassword) + } + return p +} + +func storedToCredential(p storedPayload) (Credential, error) { + authType := AuthType(p.AuthType) + if authType == "" { + authType = AuthPassword + } + c := Credential{ + AuthType: authType, + Username: p.Username, + Password: p.Password, + AccessToken: p.AccessToken, + RefreshToken: p.RefreshToken, + OAuthProvider: p.OAuthProvider, + } + if p.Expiry != "" { + t, err := time.Parse(time.RFC3339, p.Expiry) + if err != nil { + return Credential{}, err + } + c.Expiry = t + } + if c.Username == "" { + return Credential{}, errIncomplete + } + switch authType { + case AuthPassword: + if c.Password == "" { + return Credential{}, errIncomplete + } + case AuthOAuth2: + if c.AccessToken == "" { + return Credential{}, errIncomplete + } + default: + return Credential{}, errUnsupportedAuth + } + return c, nil +} diff --git a/internal/mail/credentials/errors.go b/internal/mail/credentials/errors.go new file mode 100644 index 0000000..0244265 --- /dev/null +++ b/internal/mail/credentials/errors.go @@ -0,0 +1,8 @@ +package credentials + +import "errors" + +var ( + errIncomplete = errors.New("decrypted credentials are incomplete") + errUnsupportedAuth = errors.New("unsupported credential auth type") +) diff --git a/internal/mail/credentials/manager.go b/internal/mail/credentials/manager.go index 07f9765..860b2f3 100644 --- a/internal/mail/credentials/manager.go +++ b/internal/mail/credentials/manager.go @@ -19,11 +19,6 @@ type Manager struct { keys map[string][]byte } -type payload struct { - Username string `json:"username"` - Password string `json:"password"` -} - func NewManager(keysSpec, activeKeyID string) (*Manager, error) { keys, err := parseKeys(keysSpec) if err != nil { @@ -50,6 +45,48 @@ func NewManager(keysSpec, activeKeyID string) (*Manager, error) { } func (m *Manager) Encrypt(username, password string) ([]byte, error) { + return m.EncryptCredential(Credential{ + AuthType: AuthPassword, + Username: username, + Password: password, + }) +} + +func (m *Manager) EncryptCredential(c Credential) ([]byte, error) { + if c.AuthType == "" { + c.AuthType = AuthPassword + } + rawPayload, err := json.Marshal(c.toStored()) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + return m.encryptRaw(rawPayload) +} + +func (m *Manager) Decrypt(blob []byte) (string, string, error) { + c, err := m.DecryptCredential(blob) + if err != nil { + return "", "", err + } + if c.IsOAuth() { + return c.Username, c.AccessToken, nil + } + return c.Username, c.Password, nil +} + +func (m *Manager) DecryptCredential(blob []byte) (Credential, error) { + plaintext, err := m.decryptRaw(blob) + if err != nil { + return Credential{}, err + } + var p storedPayload + if err := json.Unmarshal(plaintext, &p); err != nil { + return Credential{}, fmt.Errorf("unmarshal payload: %w", err) + } + return storedToCredential(p) +} + +func (m *Manager) encryptRaw(rawPayload []byte) ([]byte, error) { key := m.keys[m.activeKeyID] block, err := aes.NewCipher(key) if err != nil { @@ -66,11 +103,6 @@ func (m *Manager) Encrypt(username, password string) ([]byte, error) { return nil, fmt.Errorf("nonce: %w", err) } - rawPayload, err := json.Marshal(payload{Username: username, Password: password}) - if err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) - } - ciphertext := gcm.Seal(nil, nonce, rawPayload, nil) serialized := strings.Join([]string{ @@ -82,49 +114,41 @@ func (m *Manager) Encrypt(username, password string) ([]byte, error) { return []byte(serialized), nil } -func (m *Manager) Decrypt(blob []byte) (string, string, error) { +func (m *Manager) decryptRaw(blob []byte) ([]byte, error) { parts := strings.Split(string(blob), "|") if len(parts) != 4 || parts[0] != strings.TrimSuffix(prefix, "|") { - return "", "", errors.New("credentials payload is not encrypted with supported format") + return nil, errors.New("credentials payload is not encrypted with supported format") } keyID := parts[1] key, ok := m.keys[keyID] if !ok { - return "", "", fmt.Errorf("unknown credential key id %q", keyID) + return nil, fmt.Errorf("unknown credential key id %q", keyID) } nonce, err := base64.StdEncoding.DecodeString(parts[2]) if err != nil { - return "", "", fmt.Errorf("decode nonce: %w", err) + return nil, fmt.Errorf("decode nonce: %w", err) } ciphertext, err := base64.StdEncoding.DecodeString(parts[3]) if err != nil { - return "", "", fmt.Errorf("decode ciphertext: %w", err) + return nil, fmt.Errorf("decode ciphertext: %w", err) } block, err := aes.NewCipher(key) if err != nil { - return "", "", fmt.Errorf("new cipher: %w", err) + return nil, fmt.Errorf("new cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { - return "", "", fmt.Errorf("new gcm: %w", err) + return nil, fmt.Errorf("new gcm: %w", err) } plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { - return "", "", fmt.Errorf("decrypt: %w", err) + return nil, fmt.Errorf("decrypt: %w", err) } - - var p payload - if err := json.Unmarshal(plaintext, &p); err != nil { - return "", "", fmt.Errorf("unmarshal payload: %w", err) - } - if p.Username == "" || p.Password == "" { - return "", "", errors.New("decrypted credentials are incomplete") - } - return p.Username, p.Password, nil + return plaintext, nil } func IsEncrypted(blob []byte) bool { diff --git a/internal/mail/credentials/manager_test.go b/internal/mail/credentials/manager_test.go index 050412b..3b7eb0b 100644 --- a/internal/mail/credentials/manager_test.go +++ b/internal/mail/credentials/manager_test.go @@ -5,6 +5,33 @@ import ( "testing" ) +func TestEncryptDecryptOAuth(t *testing.T) { + key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + manager, err := NewManager("v1:"+key, "v1") + if err != nil { + t.Fatalf("new manager: %v", err) + } + + blob, err := manager.EncryptCredential(Credential{ + AuthType: AuthOAuth2, + Username: "user@gmail.com", + AccessToken: "access-token", + RefreshToken: "refresh-token", + OAuthProvider: "google", + }) + if err != nil { + t.Fatalf("encrypt oauth: %v", err) + } + + cred, err := manager.DecryptCredential(blob) + if err != nil { + t.Fatalf("decrypt oauth: %v", err) + } + if !cred.IsOAuth() || cred.AccessToken != "access-token" { + t.Fatalf("unexpected oauth cred: %+v", cred) + } +} + func TestEncryptDecrypt(t *testing.T) { key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) manager, err := NewManager("v1:"+key, "v1") diff --git a/internal/mail/imap/credentials_test.go b/internal/mail/imap/credentials_test.go index 9865d5c..1c980db 100644 --- a/internal/mail/imap/credentials_test.go +++ b/internal/mail/imap/credentials_test.go @@ -1,6 +1,7 @@ package imap import ( + "context" "encoding/base64" "strings" "testing" @@ -8,30 +9,30 @@ import ( "github.com/ultisuite/ulti-backend/internal/mail/credentials" ) -func TestParseCredentials_missing(t *testing.T) { +func TestResolveCredential_missing(t *testing.T) { w := &SyncWorker{credentials: &credentials.Manager{}} - _, _, err := w.parseCredentials(nil) + _, err := w.resolveCredential(context.Background(), "acc-1", nil) if err == nil || err.Error() != "missing credentials" { - t.Fatalf("parseCredentials(nil) error = %v, want missing credentials", err) + t.Fatalf("resolveCredential(nil) error = %v, want missing credentials", err) } - _, _, err = w.parseCredentials([]byte{}) + _, err = w.resolveCredential(context.Background(), "acc-1", []byte{}) if err == nil || err.Error() != "missing credentials" { - t.Fatalf("parseCredentials([]) error = %v, want missing credentials", err) + t.Fatalf("resolveCredential([]) error = %v, want missing credentials", err) } } -func TestParseCredentials_plaintextForbidden(t *testing.T) { +func TestResolveCredential_plaintextForbidden(t *testing.T) { w := &SyncWorker{credentials: &credentials.Manager{}} - _, _, err := w.parseCredentials([]byte(`{"username":"alice","password":"secret"}`)) + _, err := w.resolveCredential(context.Background(), "acc-1", []byte(`{"username":"alice","password":"secret"}`)) if err == nil || err.Error() != "plaintext credentials forbidden" { - t.Fatalf("parseCredentials(plaintext) error = %v, want plaintext credentials forbidden", err) + t.Fatalf("resolveCredential(plaintext) error = %v, want plaintext credentials forbidden", err) } } -func TestParseCredentials_missingManager(t *testing.T) { +func TestResolveCredential_missingManager(t *testing.T) { key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) manager, err := credentials.NewManager("v1:"+key, "v1") if err != nil { @@ -43,13 +44,13 @@ func TestParseCredentials_missingManager(t *testing.T) { } w := &SyncWorker{credentials: nil} - _, _, err = w.parseCredentials(blob) + _, err = w.resolveCredential(context.Background(), "acc-1", blob) if err == nil || err.Error() != "credential manager not configured" { - t.Fatalf("parseCredentials(no manager) error = %v, want credential manager not configured", err) + t.Fatalf("resolveCredential(no manager) error = %v, want credential manager not configured", err) } } -func TestParseCredentials_encryptedSuccess(t *testing.T) { +func TestResolveCredential_encryptedSuccess(t *testing.T) { key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) manager, err := credentials.NewManager("v1:"+key, "v1") if err != nil { @@ -61,16 +62,16 @@ func TestParseCredentials_encryptedSuccess(t *testing.T) { } w := &SyncWorker{credentials: manager} - username, password, err := w.parseCredentials(blob) + cred, err := w.resolveCredential(context.Background(), "acc-1", blob) if err != nil { - t.Fatalf("parseCredentials(encrypted) error = %v", err) + t.Fatalf("resolveCredential(encrypted) error = %v", err) } - if username != "alice@example.com" || password != "secret" { - t.Fatalf("got %q/%q, want alice@example.com/secret", username, password) + if cred.Username != "alice@example.com" || cred.Password != "secret" { + t.Fatalf("got %+v, want alice@example.com/secret", cred) } } -func TestParseCredentials_decryptFailure(t *testing.T) { +func TestResolveCredential_decryptFailure(t *testing.T) { key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) manager, err := credentials.NewManager("v1:"+key, "v1") if err != nil { @@ -78,7 +79,7 @@ func TestParseCredentials_decryptFailure(t *testing.T) { } w := &SyncWorker{credentials: manager} - _, _, err = w.parseCredentials([]byte("UMC1|v1|invalid|payload")) + _, err = w.resolveCredential(context.Background(), "acc-1", []byte("UMC1|v1|invalid|payload")) if err == nil { t.Fatal("expected decrypt error") } diff --git a/internal/mail/imap/sync.go b/internal/mail/imap/sync.go index c742b69..46031f3 100644 --- a/internal/mail/imap/sync.go +++ b/internal/mail/imap/sync.go @@ -14,6 +14,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/mail/credentials" + "github.com/ultisuite/ulti-backend/internal/mail/connect" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" "github.com/ultisuite/ulti-backend/internal/mail/limits" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/storage" @@ -35,17 +37,19 @@ type SyncWorker struct { logger *slog.Logger interval time.Duration credentials *credentials.Manager + oauth *mailoauth.Service storage *storage.Client attachBucket string pipeline *syncPipeline } -func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager, deps SyncDeps) *SyncWorker { +func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager, oauthSvc *mailoauth.Service, deps SyncDeps) *SyncWorker { return &SyncWorker{ db: db, logger: slog.Default().With("component", "imap-sync"), interval: interval, credentials: credManager, + oauth: oauthSvc, storage: deps.Storage, attachBucket: deps.AttachBucket, pipeline: newSyncPipeline(db, deps.Rules, deps.Hub), @@ -138,11 +142,11 @@ func (w *SyncWorker) syncAccount(ctx context.Context, accountID, host string, po } defer client.Close() - username, password, err := w.parseCredentials(creds) + cred, err := w.resolveCredential(ctx, accountID, creds) if err != nil { return fmt.Errorf("decrypt credentials: %w", err) } - if err := client.Login(username, password).Wait(); err != nil { + if err := connect.AuthenticateIMAP(client, cred); err != nil { return fmt.Errorf("login: %w", err) } @@ -494,17 +498,24 @@ func flagsToStrings(flags []imap.Flag) []string { return out } -func (w *SyncWorker) parseCredentials(creds []byte) (string, string, error) { +func (w *SyncWorker) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) { if len(creds) == 0 { - return "", "", errors.New("missing credentials") + return credentials.Credential{}, errors.New("missing credentials") } if !credentials.IsEncrypted(creds) { - return "", "", errors.New("plaintext credentials forbidden") + return credentials.Credential{}, errors.New("plaintext credentials forbidden") } if w.credentials == nil { - return "", "", errors.New("credential manager not configured") + return credentials.Credential{}, errors.New("credential manager not configured") } - return w.credentials.Decrypt(creds) + cred, err := w.credentials.DecryptCredential(creds) + if err != nil { + return credentials.Credential{}, err + } + if w.oauth != nil && cred.IsOAuth() { + return mailoauth.RefreshAccountCredential(ctx, w.db, w.credentials, w.oauth, accountID, cred) + } + return cred, nil } func truncate(s string, maxLen int) string { diff --git a/internal/mail/oauth/pkce.go b/internal/mail/oauth/pkce.go new file mode 100644 index 0000000..fd811c5 --- /dev/null +++ b/internal/mail/oauth/pkce.go @@ -0,0 +1,15 @@ +package oauth + +import ( + "crypto/sha256" + "encoding/base64" +) + +func base64URLEncode(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func sha256Sum(input string) ([]byte, error) { + sum := sha256.Sum256([]byte(input)) + return sum[:], nil +} diff --git a/internal/mail/oauth/refresh.go b/internal/mail/oauth/refresh.go new file mode 100644 index 0000000..4a453f9 --- /dev/null +++ b/internal/mail/oauth/refresh.go @@ -0,0 +1,64 @@ +package oauth + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/oauth2" + + mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" +) + +// RefreshAccountCredential refreshes OAuth tokens when needed and persists updated credentials. +func RefreshAccountCredential( + ctx context.Context, + db *pgxpool.Pool, + manager *mailcredentials.Manager, + oauthSvc *Service, + accountID string, + cred mailcredentials.Credential, +) (mailcredentials.Credential, error) { + if !cred.IsOAuth() || !cred.NeedsRefresh() { + return cred, nil + } + if oauthSvc == nil || cred.RefreshToken == "" { + return cred, fmt.Errorf("oauth refresh unavailable") + } + token, err := oauthSvc.Refresh(ctx, cred.OAuthProvider, cred.RefreshToken) + if err != nil { + return cred, fmt.Errorf("refresh token: %w", err) + } + updated := applyToken(cred, token) + blob, err := manager.EncryptCredential(updated) + if err != nil { + return cred, err + } + _, err = db.Exec(ctx, `UPDATE mail_accounts SET credentials = $1, updated_at = NOW() WHERE id = $2`, blob, accountID) + if err != nil { + return cred, err + } + return updated, nil +} + +func CredentialFromToken(email, provider string, token *oauth2.Token) mailcredentials.Credential { + return applyToken(mailcredentials.Credential{ + AuthType: mailcredentials.AuthOAuth2, + Username: email, + OAuthProvider: provider, + }, token) +} + +func applyToken(c mailcredentials.Credential, token *oauth2.Token) mailcredentials.Credential { + c.AccessToken = token.AccessToken + if token.RefreshToken != "" { + c.RefreshToken = token.RefreshToken + } + if !token.Expiry.IsZero() { + c.Expiry = token.Expiry.UTC() + } else if token.ExpiresIn > 0 { + c.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + return c +} diff --git a/internal/mail/oauth/service.go b/internal/mail/oauth/service.go new file mode 100644 index 0000000..784e554 --- /dev/null +++ b/internal/mail/oauth/service.go @@ -0,0 +1,224 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/oauth2" +) + +const pendingKeyPrefix = "mail_oauth_pending:" +const pendingTTL = 10 * time.Minute + +var ErrUnknownState = errors.New("oauth state expired or unknown") +var ErrProviderDisabled = errors.New("oauth provider not configured") + +type Provider string + +const ( + ProviderGoogle Provider = "google" + ProviderMicrosoft Provider = "microsoft" +) + +type PendingAccount struct { + UserExternalID string `json:"user_external_id"` + Provider string `json:"provider"` + Email string `json:"email"` + Name string `json:"name"` + ProviderID string `json:"provider_id"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + IMAPTLS bool `json:"imap_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPTLS bool `json:"smtp_tls"` + PKCEVerifier string `json:"pkce_verifier"` +} + +type Config struct { + GoogleClientID string + GoogleClientSecret string + MicrosoftClientID string + MicrosoftSecret string + MicrosoftTenant string + RedirectURL string +} + +type Service struct { + cfg Config + rdb *redis.Client +} + +func NewService(cfg Config, rdb *redis.Client) *Service { + return &Service{cfg: cfg, rdb: rdb} +} + +func (s *Service) EnabledProviders() []string { + var out []string + if s.providerConfig(ProviderGoogle) != nil { + out = append(out, string(ProviderGoogle)) + } + if s.providerConfig(ProviderMicrosoft) != nil { + out = append(out, string(ProviderMicrosoft)) + } + return out +} + +func (s *Service) Start(ctx context.Context, userExternalID string, provider Provider, pending PendingAccount) (authURL, state string, err error) { + oauthCfg := s.providerConfig(provider) + if oauthCfg == nil { + return "", "", ErrProviderDisabled + } + verifier, challenge, err := newPKCE() + if err != nil { + return "", "", err + } + state, err = randomToken(24) + if err != nil { + return "", "", err + } + pending.UserExternalID = userExternalID + pending.Provider = string(provider) + pending.PKCEVerifier = verifier + + if err := s.savePending(ctx, state, pending); err != nil { + return "", "", err + } + + authURL = oauthCfg.AuthCodeURL(state, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + oauth2.SetAuthURLParam("prompt", "consent"), + ) + return authURL, state, nil +} + +func (s *Service) Exchange(ctx context.Context, state, code string) (PendingAccount, *oauth2.Token, error) { + pending, err := s.loadPending(ctx, state) + if err != nil { + return PendingAccount{}, nil, err + } + oauthCfg := s.providerConfig(Provider(pending.Provider)) + if oauthCfg == nil { + return PendingAccount{}, nil, ErrProviderDisabled + } + token, err := oauthCfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", pending.PKCEVerifier)) + if err != nil { + return PendingAccount{}, nil, fmt.Errorf("token exchange: %w", err) + } + _ = s.rdb.Del(ctx, pendingKeyPrefix+state).Err() + return pending, token, nil +} + +func (s *Service) Refresh(ctx context.Context, provider, refreshToken string) (*oauth2.Token, error) { + oauthCfg := s.providerConfig(Provider(provider)) + if oauthCfg == nil { + return nil, ErrProviderDisabled + } + src := oauthCfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) + token, err := src.Token() + if err != nil { + return nil, err + } + return token, nil +} + +func (s *Service) providerConfig(provider Provider) *oauth2.Config { + switch provider { + case ProviderGoogle: + if s.cfg.GoogleClientID == "" || s.cfg.GoogleClientSecret == "" || s.cfg.RedirectURL == "" { + return nil + } + return &oauth2.Config{ + ClientID: s.cfg.GoogleClientID, + ClientSecret: s.cfg.GoogleClientSecret, + RedirectURL: s.cfg.RedirectURL, + Scopes: []string{"https://mail.google.com/"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + }, + } + case ProviderMicrosoft: + if s.cfg.MicrosoftClientID == "" || s.cfg.MicrosoftSecret == "" || s.cfg.RedirectURL == "" { + return nil + } + tenant := s.cfg.MicrosoftTenant + if tenant == "" { + tenant = "common" + } + return &oauth2.Config{ + ClientID: s.cfg.MicrosoftClientID, + ClientSecret: s.cfg.MicrosoftSecret, + RedirectURL: s.cfg.RedirectURL, + Scopes: []string{ + "offline_access", + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", + }, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", tenant), + TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenant), + }, + } + default: + return nil + } +} + +func (s *Service) savePending(ctx context.Context, state string, pending PendingAccount) error { + if s.rdb == nil { + return errors.New("oauth state store unavailable") + } + raw, err := json.Marshal(pending) + if err != nil { + return err + } + return s.rdb.Set(ctx, pendingKeyPrefix+state, raw, pendingTTL).Err() +} + +func (s *Service) loadPending(ctx context.Context, state string) (PendingAccount, error) { + if s.rdb == nil { + return PendingAccount{}, errors.New("oauth state store unavailable") + } + raw, err := s.rdb.Get(ctx, pendingKeyPrefix+state).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return PendingAccount{}, ErrUnknownState + } + return PendingAccount{}, err + } + var pending PendingAccount + if err := json.Unmarshal(raw, &pending); err != nil { + return PendingAccount{}, err + } + return pending, nil +} + +func newPKCE() (verifier, challenge string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + verifier = base64URLEncode(b) + sum, err := sha256Sum(verifier) + if err != nil { + return "", "", err + } + challenge = base64URLEncode(sum) + return verifier, challenge, nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64URLEncode(b), nil +} diff --git a/internal/mail/smtp/sender.go b/internal/mail/smtp/sender.go index 42e2d6d..2fa8068 100644 --- a/internal/mail/smtp/sender.go +++ b/internal/mail/smtp/sender.go @@ -10,23 +10,27 @@ import ( "strings" "time" - "github.com/emersion/go-sasl" gosmtp "github.com/emersion/go-smtp" "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/mail/connect" "github.com/ultisuite/ulti-backend/internal/mail/credentials" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" ) type Sender struct { db *pgxpool.Pool logger *slog.Logger credentials *credentials.Manager + oauth *mailoauth.Service } -func NewSender(db *pgxpool.Pool, credManager *credentials.Manager) *Sender { +func NewSender(db *pgxpool.Pool, credManager *credentials.Manager, oauthSvc *mailoauth.Service) *Sender { return &Sender{ db: db, logger: slog.Default().With("component", "smtp-sender"), credentials: credManager, + oauth: oauthSvc, } } @@ -58,7 +62,7 @@ func (s *Sender) Send(ctx context.Context, req *SendRequest) error { return fmt.Errorf("query account: %w", err) } - username, password, err := s.parseCredentials(creds) + cred, err := s.resolveCredential(ctx, req.AccountID, creds) if err != nil { return fmt.Errorf("decrypt credentials: %w", err) } @@ -71,7 +75,10 @@ func (s *Sender) Send(ctx context.Context, req *SendRequest) error { allRecipients = append(allRecipients, req.Cc...) allRecipients = append(allRecipients, req.Bcc...) - auth := sasl.NewPlainClient("", username, password) + auth, err := connect.SMTPClient(cred) + if err != nil { + return fmt.Errorf("smtp auth: %w", err) + } var sendErr error if useTLS { @@ -100,13 +107,28 @@ func generateMessageID(from string) string { return fmt.Sprintf("<%s@%s>", hex.EncodeToString(token), domain) } -func (s *Sender) parseCredentials(creds []byte) (string, string, error) { +func (s *Sender) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) { if len(creds) == 0 { - return "", "", errors.New("missing credentials") + return credentials.Credential{}, errors.New("missing credentials") } if !credentials.IsEncrypted(creds) { - return "", "", errors.New("plaintext credentials forbidden") + return credentials.Credential{}, errors.New("plaintext credentials forbidden") } + if s.credentials == nil { + return credentials.Credential{}, errors.New("credential manager not configured") + } + cred, err := s.credentials.DecryptCredential(creds) + if err != nil { + return cred, err + } + if s.oauth != nil && cred.IsOAuth() { + return mailoauth.RefreshAccountCredential(ctx, s.db, s.credentials, s.oauth, accountID, cred) + } + return cred, nil +} + +// kept for tests that referenced parseCredentials +func (s *Sender) parseCredentials(creds []byte) (string, string, error) { if s.credentials == nil { return "", "", errors.New("credential manager not configured") } diff --git a/migrations/000013_unified_folders.down.sql b/migrations/000013_unified_folders.down.sql new file mode 100644 index 0000000..ad69408 --- /dev/null +++ b/migrations/000013_unified_folders.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS mail_unified_folders; diff --git a/migrations/000013_unified_folders.up.sql b/migrations/000013_unified_folders.up.sql new file mode 100644 index 0000000..f905627 --- /dev/null +++ b/migrations/000013_unified_folders.up.sql @@ -0,0 +1,17 @@ +-- Dossiers Ultimail (virtuels) : organisation UI, pas miroir IMAP. +-- account_id NULL = dossier global utilisateur ; non NULL = dossier lié à un compte mail. +CREATE TABLE mail_unified_folders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id UUID NULL REFERENCES mail_accounts(id) ON DELETE CASCADE, + parent_id UUID NULL REFERENCES mail_unified_folders(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_mail_unified_folders_user ON mail_unified_folders(user_id); +CREATE INDEX idx_mail_unified_folders_user_account ON mail_unified_folders(user_id, account_id); +CREATE INDEX idx_mail_unified_folders_parent ON mail_unified_folders(parent_id); diff --git a/migrations/000014_label_sort_order.down.sql b/migrations/000014_label_sort_order.down.sql new file mode 100644 index 0000000..0d525b1 --- /dev/null +++ b/migrations/000014_label_sort_order.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_mail_user_labels_user_sort; +ALTER TABLE mail_user_labels DROP COLUMN IF EXISTS sort_order; diff --git a/migrations/000014_label_sort_order.up.sql b/migrations/000014_label_sort_order.up.sql new file mode 100644 index 0000000..88a06e5 --- /dev/null +++ b/migrations/000014_label_sort_order.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE mail_user_labels + ADD COLUMN sort_order INT NOT NULL DEFAULT 0; + +CREATE INDEX idx_mail_user_labels_user_sort ON mail_user_labels(user_id, sort_order); diff --git a/migrations/000015_mail_signatures.down.sql b/migrations/000015_mail_signatures.down.sql new file mode 100644 index 0000000..fe35233 --- /dev/null +++ b/migrations/000015_mail_signatures.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS idx_mail_identities_default_signature; + +ALTER TABLE mail_identities + DROP COLUMN IF EXISTS default_signature_id; + +DROP INDEX IF EXISTS idx_mail_signatures_user; + +DROP TABLE IF EXISTS mail_signatures; diff --git a/migrations/000015_mail_signatures.up.sql b/migrations/000015_mail_signatures.up.sql new file mode 100644 index 0000000..0d99882 --- /dev/null +++ b/migrations/000015_mail_signatures.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE mail_signatures ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + html TEXT NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_mail_signatures_user ON mail_signatures(user_id); + +ALTER TABLE mail_identities + ADD COLUMN default_signature_id UUID REFERENCES mail_signatures(id) ON DELETE SET NULL; + +CREATE INDEX idx_mail_identities_default_signature ON mail_identities(default_signature_id); diff --git a/migrations/000016_bootstrap_identities.down.sql b/migrations/000016_bootstrap_identities.down.sql new file mode 100644 index 0000000..44163e6 --- /dev/null +++ b/migrations/000016_bootstrap_identities.down.sql @@ -0,0 +1 @@ +-- Irreversible data backfill; no-op on rollback. diff --git a/migrations/000016_bootstrap_identities.up.sql b/migrations/000016_bootstrap_identities.up.sql new file mode 100644 index 0000000..d190e26 --- /dev/null +++ b/migrations/000016_bootstrap_identities.up.sql @@ -0,0 +1,29 @@ +-- Default send identity for mail accounts created before automatic bootstrap existed. +INSERT INTO mail_identities (account_id, email, name, is_default, signature_html) +SELECT + ma.id, + ma.email, + COALESCE(NULLIF(TRIM(ma.name), ''), split_part(ma.email, '@', 1)), + true, + '' +FROM mail_accounts ma +WHERE NOT EXISTS ( + SELECT 1 FROM mail_identities mi WHERE mi.account_id = ma.id +); + +-- Default signature per identity still missing default_signature_id. +WITH new_sigs AS ( + INSERT INTO mail_signatures (user_id, name, html) + SELECT ma.user_id, 'Signature — ' || mi.email, '' + FROM mail_identities mi + JOIN mail_accounts ma ON ma.id = mi.account_id + WHERE mi.default_signature_id IS NULL + RETURNING id, user_id, name +) +UPDATE mail_identities mi +SET default_signature_id = ns.id +FROM new_sigs ns +JOIN mail_accounts ma ON ma.user_id = ns.user_id +WHERE mi.account_id = ma.id + AND ns.name = 'Signature — ' || mi.email + AND mi.default_signature_id IS NULL; diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..dce6ba7 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,8 @@ +package migrations + +import "embed" + +// Files contains SQL migrations embedded for ultid startup. +// +//go:embed *.sql +var Files embed.FS diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index d69f324..9111ff5 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -180,7 +180,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon ## 3) Frontend web (`gmail-interface-clone`) -> **Avancement (2026-05-23)** — migration offline-first API dans `gmail-interface-clone` : **13/22** items section 3 cochés. Reste : settings mail backend, cleanup fichiers mock, recherche sans fallback offline, sync par compte, modules suite (§3.4), a11y/i18n/perf (§3.6). +> **Avancement (2026-05-23)** — migration offline-first API dans `gmail-interface-clone` : **14/22** items section 3 cochés. Reste : cleanup fichiers mock, recherche sans fallback offline, sync par compte, modules suite (§3.4), a11y/i18n/perf (§3.6). ### 3.1 Fondation data/API @@ -196,7 +196,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon - [x] Brancher compose/send/scheduled sur outbox backend. *(frontend: `use-compose-mutations`, `scheduled-store` outbox)* - [ ] Brancher recherche UI sur backend search (fallback local supprimé). *(frontend: `useMailSearch` → `/mail/search` + fallback cache offline; autocomplete `search-engine` locale conservée)* - [x] Brancher multi-comptes réels (accounts API + switch account effectif). *(frontend: `useMailAccounts`, `account-store` sans `MOCK_USER_ACCOUNTS`)* -- [ ] Brancher settings (pas page placeholder) avec persistance backend. *(frontend: `mail-settings-store` local — pas d'endpoint settings backend)* +- [x] Brancher settings (pas page placeholder) avec persistance backend. *(frontend: `mail-settings-store` + sync `MailSettingsSync`, page `/mail/settings`, hooks `use-mail-settings`; backend: `GET/PATCH /api/v1/mail/settings` → `settings.preferences.mail`)* ### 3.3 Contacts UI @@ -247,20 +247,6 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon - [ ] Mettre scan vulnérabilités dépendances/images en CI. - [ ] Ajouter CSP/headers sécurité côté frontend + proxy. -## 5) Plateformes futures - -### Desktop (Tauri) - -- [ ] Initialiser wrapper Tauri. -- [ ] Gérer auth desktop sécurisée (token storage natif). -- [ ] Ajouter notifications système. -- [ ] Ajouter intégration filesystem native (uploads drag/drop large files). - -### Mobile - -- [ ] Définir stratégie mobile (PWA avancée vs app native). -- [ ] Implémenter MVP mobile mail (liste/lecture/compose simple). -- [ ] Ajouter sync background + notifications push. ## 6) Validation "ça fonctionne"