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 = ` +