Backend starting to get good

This commit is contained in:
R3D347HR4Y 2026-05-24 00:03:36 +02:00
parent ed43d7d7dc
commit 665201627b
88 changed files with 5081 additions and 196 deletions

View File

@ -32,6 +32,10 @@ TYPESENSE_API_KEY=changeme
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
DOMAIN=localhost DOMAIN=localhost
ULTID_PORT=8080 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 # PostgreSQL
@ -80,9 +84,12 @@ ULTID_RUSTFS_REGION=us-east-1
# Mode local : Authentik deploye dans la stack # Mode local : Authentik deploye dans la stack
# Mode externe : n'importe quel provider OIDC existant # 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_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 : # Exemple Keycloak externe :
# ULTID_OIDC_ISSUER=https://auth.example.com/realms/ulti # ULTID_OIDC_ISSUER=https://auth.example.com/realms/ulti
# ULTID_OIDC_CLIENT_ID=ulti-backend # ULTID_OIDC_CLIENT_ID=ulti-backend
@ -95,6 +102,8 @@ AUTHENTIK_POSTGRESQL__PASSWORD={{POSTGRES_PASSWORD}}
AUTHENTIK_POSTGRESQL__NAME=authentik AUTHENTIK_POSTGRESQL__NAME=authentik
AUTHENTIK_REDIS__HOST=keydb AUTHENTIK_REDIS__HOST=keydb
AUTHENTIK_WEB__PATH=/auth/ AUTHENTIK_WEB__PATH=/auth/
# URL publique affichee dans les redirects OIDC (navigateur)
AUTHENTIK_HOST=http://{{DOMAIN}}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Nextcloud (Drive / Calendar / Contacts) # Nextcloud (Drive / Calendar / Contacts)
@ -118,7 +127,7 @@ NEXTCLOUD_ENABLED=true
NC_OIDC_CLIENT_ID=ulti-nextcloud NC_OIDC_CLIENT_ID=ulti-nextcloud
# NC_OIDC_CLIENT_SECRET — defini dans la section Secrets # 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_BUCKET=nextcloud
NC_S3_HOST=rustfs NC_S3_HOST=rustfs
@ -193,6 +202,16 @@ MAIL_SMTP_CIRCUIT_COOLDOWN=5m
SECRET_ROTATION_MAX_AGE=2160h SECRET_ROTATION_MAX_AGE=2160h
ULTID_OIDC_CLIENT_SECRET_ROTATED_AT=2026-01-01T00:00:00Z ULTID_OIDC_CLIENT_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
MAIL_CREDENTIAL_KEY_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 MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.23" go-version: "1.25"
cache: true cache: true
- name: Run unit tests - name: Run unit tests

View File

@ -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 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 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 && addgroup -S ulti && adduser -S ulti -G ulti
COPY --from=builder /ultid /usr/local/bin/ultid COPY --from=builder /ultid /usr/local/bin/ultid

View File

@ -36,30 +36,40 @@ Backend monolithe Go orchestrant la Ulti Suite — alternative souveraine à Goo
# 1. Copy environment file # 1. Copy environment file
cp .env.example .env cp .env.example .env
# Edit secrets once at the top of .env (POSTGRES_PASSWORD, RUSTFS_SECRET_KEY, etc.) # Edit secrets once at the top of .env (POSTGRES_PASSWORD, RUSTFS_SECRET_KEY, etc.)
# Other variables use {{VAR}} placeholders expanded at launch. # Defaults use changeme — must match Authentik blueprints (deploy/authentik/blueprints/).
# Toggle modules with flags:
# NEXTCLOUD_ENABLED=true|false
# JITSI_ENABLED=true|false
# IMMICH_ENABLED=true|false
# 2. Start stack (core + modules enabled by flags) # 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 ## Development
```bash ```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 go run ./cmd/ultid
# Build # Build
go build -o ultid ./cmd/ultid 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 go run ./cmd/envexpand -in .env -out .env.resolved
source <(grep -v '^#' .env.resolved | sed 's/^/export /') 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 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 | | Service | URL | Notes |
|---------|-----|-------| |---------|-----|-------|
| Prometheus | http://localhost:9090 | Targets: `ultid`, `prometheus` | | 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. **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 | | 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 | | Database | PostgreSQL 16 |
| Cache | KeyDB (Redis-compatible, multi-threaded) | | Cache | KeyDB (Redis-compatible, multi-threaded) |
| Object Storage | RustFS (S3-compatible, Apache 2.0) | | Object Storage | RustFS (S3-compatible, Apache 2.0) |

View File

@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/credentials"
@ -20,7 +19,6 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/ultisuite/ulti-backend/internal/api/admin" "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/calendar"
"github.com/ultisuite/ulti-backend/internal/api/contacts" "github.com/ultisuite/ulti-backend/internal/api/contacts"
"github.com/ultisuite/ulti-backend/internal/api/drive" "github.com/ultisuite/ulti-backend/internal/api/drive"
@ -30,10 +28,13 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
"github.com/ultisuite/ulti-backend/internal/auth" "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/config"
"github.com/ultisuite/ulti-backend/internal/envexpand" "github.com/ultisuite/ulti-backend/internal/envexpand"
"github.com/ultisuite/ulti-backend/internal/httpcors"
mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials"
imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" 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/rules"
"github.com/ultisuite/ulti-backend/internal/mail/smtp" "github.com/ultisuite/ulti-backend/internal/mail/smtp"
mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage" mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage"
@ -59,6 +60,11 @@ func main() {
os.Exit(1) 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) pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil { if err != nil {
slog.Error("failed to connect to database", "error", err) slog.Error("failed to connect to database", "error", err)
@ -82,7 +88,7 @@ func main() {
slog.Warn("mail attachments bucket check failed", "error", err) 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 { if err != nil {
slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err) 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)) 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 // Start background workers
go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, imapsync.SyncDeps{ go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{
Storage: attachmentStorage, Storage: attachmentStorage,
AttachBucket: cfg.MailAttachmentsBucket, AttachBucket: cfg.MailAttachmentsBucket,
Rules: rulesEngine, Rules: rulesEngine,
Hub: hub, Hub: hub,
}).Start(ctx) }).Start(ctx)
sender := smtp.NewSender(pool, credentialManager) sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc)
smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown)
guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) guardedSender := smtp.NewGuardedSender(sender, smtpCircuit)
go smtp.NewOutboxProcessor( go smtp.NewOutboxProcessor(
@ -160,18 +182,12 @@ func main() {
).Start(ctx) ).Start(ctx)
sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst)
mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL)
// Router // Router
r := chi.NewRouter() r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{ r.Use(httpcors.Middleware(cfg))
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(middleware.TraceID) r.Use(middleware.TraceID)
r.Use(observability.HTTPMetrics) r.Use(observability.HTTPMetrics)
r.Use(middleware.Logging) r.Use(middleware.Logging)
@ -189,11 +205,12 @@ func main() {
r.Handle("/metrics", promhttp.Handler()) r.Handle("/metrics", promhttp.Handler())
r.Get("/ws", hub.HandleWS) r.Get("/ws", hub.HandleWS)
r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.Auth(verifier, pool, auditLogger)) 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.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ r.Get("/api/v1/search", search.NewHandler(pool, search.Options{
Nextcloud: ncClient, Nextcloud: ncClient,
@ -219,7 +236,7 @@ func main() {
} }
}) })
_ = rdb slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect)
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port), Addr: fmt.Sprintf(":%d", cfg.Port),

View File

@ -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 `<link rel="icon">` 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 doption 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.

View File

@ -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

View File

@ -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]]

View File

@ -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

View File

@ -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

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -26,10 +26,11 @@ services:
networks: networks:
- ulti-net - ulti-net
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
interval: 5s interval: 10s
timeout: 3s timeout: 5s
retries: 3 retries: 5
start_period: 30s
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -37,6 +38,8 @@ services:
condition: service_healthy condition: service_healthy
rustfs: rustfs:
condition: service_started condition: service_started
authentik-server:
condition: service_healthy
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@ -96,9 +99,23 @@ services:
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME} AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME}
AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST} AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST}
AUTHENTIK_WEB__PATH: /auth/ AUTHENTIK_WEB__PATH: /auth/
AUTHENTIK_HOST: http://${DOMAIN:-localhost}
env_file: ../.env.resolved 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: networks:
- ulti-net - ulti-net
healthcheck:
test: ["CMD", "ak", "healthcheck"]
interval: 15s
timeout: 10s
retries: 10
start_period: 90s
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -117,7 +134,15 @@ services:
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME} AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME}
AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST} AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST}
AUTHENTIK_WEB__PATH: /auth/ AUTHENTIK_WEB__PATH: /auth/
AUTHENTIK_HOST: http://${DOMAIN:-localhost}
env_file: ../.env.resolved 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: networks:
- ulti-net - ulti-net
depends_on: depends_on:
@ -125,6 +150,14 @@ services:
condition: service_healthy condition: service_healthy
keydb: keydb:
condition: service_healthy condition: service_healthy
authentik-server:
condition: service_healthy
healthcheck:
test: ["CMD", "ak", "healthcheck"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
prometheus: prometheus:
image: prom/prometheus:v2.54.1 image: prom/prometheus:v2.54.1
@ -157,7 +190,7 @@ services:
- ./observability/grafana/ultid-baseline.json:/etc/grafana/dashboards/ultid-baseline.json:ro - ./observability/grafana/ultid-baseline.json:/etc/grafana/dashboards/ultid-baseline.json:ro
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
ports: ports:
- "3000:3000" - "3002:3000"
networks: networks:
- ulti-net - ulti-net
depends_on: depends_on:

View File

@ -29,6 +29,9 @@ services:
- OVERWRITEWEBROOT=/cloud - OVERWRITEWEBROOT=/cloud
- OVERWRITECLIURL=${NC_PUBLIC_URL:-http://localhost/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 - 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: volumes:
- nextcloud_data:/var/www/html - nextcloud_data:/var/www/html
- ./nextcloud/init.sh:/docker-entrypoint-hooks.d/post-installation/init.sh:ro - ./nextcloud/init.sh:/docker-entrypoint-hooks.d/post-installation/init.sh:ro

View File

@ -23,9 +23,9 @@ $OCC app:enable user_oidc || true
# Configure OIDC (Authentik) # Configure OIDC (Authentik)
$OCC config:app:set user_oidc --value="1" allow_multiple_user_backends $OCC config:app:set user_oidc --value="1" allow_multiple_user_backends
$OCC user_oidc:provider Authentik \ $OCC user_oidc:provider Authentik \
--clientid="${OIDC_CLIENT_ID:-ulti-nextcloud}" \ --clientid="${NC_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-ulti-nextcloud}}" \
--clientsecret="${OIDC_CLIENT_SECRET:-changeme}" \ --clientsecret="${NC_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-changeme}}" \
--discoveryuri="${OIDC_DISCOVERY_URL:-http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration}" \ --discoveryuri="${NC_OIDC_DISCOVERY_URL:-${OIDC_DISCOVERY_URL:-http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration}}" \
--unique-uid=1 \ --unique-uid=1 \
--check-bearer=1 \ --check-bearer=1 \
--mapping-uid=preferred_username \ --mapping-uid=preferred_username \

View File

@ -1,6 +1,17 @@
# Edge reverse proxy — single entry point (replaces Caddy). # Edge reverse proxy — single entry point (replaces Caddy).
# Optional upstreams use Docker DNS resolver so nginx starts even if a module is disabled. # 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 { server {
listen 80; listen 80;
server_name ${DOMAIN}; server_name ${DOMAIN};
@ -8,7 +19,25 @@ server {
client_max_body_size 10G; client_max_body_size 10G;
location /api/ { 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_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -17,7 +46,15 @@ server {
} }
location /ws { 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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@ -34,6 +71,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location /meet/ {

5
go.mod
View File

@ -29,19 +29,24 @@ require (
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/goccy/go-json v0.10.3 // 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/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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/text v0.2.0 // 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/minio/md5-simd v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/rs/xid v1.6.0 // 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 go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.43.0 // indirect

12
go.sum
View File

@ -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/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 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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= 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/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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -15,6 +15,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/limits" "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/mail/storage"
"github.com/ultisuite/ulti-backend/internal/securityaudit" "github.com/ultisuite/ulti-backend/internal/securityaudit"
) )
@ -23,6 +24,8 @@ type Handler struct {
svc ServiceAPI svc ServiceAPI
logger *slog.Logger logger *slog.Logger
sendLimiter *sendguard.RateLimiter sendLimiter *sendguard.RateLimiter
oauth *mailoauth.Service
appURL string
} }
func NewHandlerWithService(svc ServiceAPI) *Handler { func NewHandlerWithService(svc ServiceAPI) *Handler {
@ -39,18 +42,37 @@ func NewHandler(
objectStorage *storage.Client, objectStorage *storage.Client,
attachmentsBucket string, attachmentsBucket string,
sendLimiter *sendguard.RateLimiter, sendLimiter *sendguard.RateLimiter,
oauthSvc *mailoauth.Service,
appURL string,
) *Handler { ) *Handler {
h := NewHandlerWithService(NewService(db, audit, credentialManager, objectStorage, attachmentsBucket)) h := NewHandlerWithService(NewService(db, audit, credentialManager, objectStorage, attachmentsBucket))
h.sendLimiter = sendLimiter h.sendLimiter = sendLimiter
h.oauth = oauthSvc
h.appURL = appURL
return h return h
} }
func (h *Handler) Routes() chi.Router { func (h *Handler) Routes() chi.Router {
r := chi.NewRouter() 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.Get("/accounts", h.ListAccounts)
r.Post("/accounts", h.CreateAccount) 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.Get("/accounts/{accountID}", h.GetAccount)
r.Put("/accounts/{accountID}", h.UpdateAccount)
r.Delete("/accounts/{accountID}", h.DeleteAccount) r.Delete("/accounts/{accountID}", h.DeleteAccount)
r.Get("/accounts/{accountID}/identities", h.ListIdentities) r.Get("/accounts/{accountID}/identities", h.ListIdentities)
r.Post("/accounts/{accountID}/identities", h.CreateIdentity) r.Post("/accounts/{accountID}/identities", h.CreateIdentity)
@ -59,6 +81,12 @@ func (h *Handler) Routes() chi.Router {
r.Put("/identities/{identityID}", h.UpdateIdentity) r.Put("/identities/{identityID}", h.UpdateIdentity)
r.Delete("/identities/{identityID}", h.DeleteIdentity) 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.Mount("/", h.FolderLabelRoutes())
r.Get("/search", h.SearchMessages) 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) { func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) 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 err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found") 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) 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) { func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) 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) { if errors.Is(err, ErrUserNotProvisioned) {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil)
return return

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -23,6 +23,7 @@ func (h *Handler) FolderLabelRoutes() chi.Router {
r.Delete("/folders/{folderID}", h.DeleteFolder) r.Delete("/folders/{folderID}", h.DeleteFolder)
r.Get("/labels", h.ListUserLabels) r.Get("/labels", h.ListUserLabels)
r.Post("/labels/reorder", h.ReorderUserLabels)
r.Post("/labels", h.CreateUserLabel) r.Post("/labels", h.CreateUserLabel)
r.Put("/labels/{labelID}", h.UpdateUserLabel) r.Put("/labels/{labelID}", h.UpdateUserLabel)
r.Delete("/labels/{labelID}", h.DeleteUserLabel) r.Delete("/labels/{labelID}", h.DeleteUserLabel)

View File

@ -14,13 +14,18 @@ import (
func (h *Handler) ListIdentities(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListIdentities(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) 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) params, err := query.ParseListRequest(r)
if err != nil { if err != nil {
apivalidate.WriteQueryError(w, r, err) apivalidate.WriteQueryError(w, r, err)
return 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 err != nil {
if errors.Is(err, ErrAccountNotFound) { if errors.Is(err, ErrAccountNotFound) {
apivalidate.WriteNotFound(w, r, "account not found") apivalidate.WriteNotFound(w, r, "account not found")
@ -60,7 +65,13 @@ func (h *Handler) CreateIdentity(w http.ResponseWriter, r *http.Request) {
return 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 err != nil {
if errors.Is(err, ErrAccountNotFound) { if errors.Is(err, ErrAccountNotFound) {
apivalidate.WriteNotFound(w, r, "account not found") apivalidate.WriteNotFound(w, r, "account not found")

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -16,6 +16,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/rules"
) )
@ -23,6 +24,7 @@ const (
testExternalID = "ext-user-1" testExternalID = "ext-user-1"
testExternalID2 = "ext-user-2" testExternalID2 = "ext-user-2"
testUserID = "user-uuid-1" testUserID = "user-uuid-1"
testMailAccountID = "550e8400-e29b-41d4-a716-446655440000"
) )
type fakeMailService struct { type fakeMailService struct {
@ -50,6 +52,65 @@ func (f *fakeMailService) ResolveUserID(_ context.Context, externalID string) (s
return testUserID, nil 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) { func (f *fakeMailService) ListMessages(_ context.Context, externalID string, _ MessageListFilter, params query.ListParams) (MessagesList, error) {
if externalID != testExternalID { if externalID != testExternalID {
return MessagesList{}, ErrUserNotProvisioned 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) { func (f *fakeMailService) CreateAccount(context.Context, string, *createAccountRequest) (string, error) {
return "", nil 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) { func (f *fakeMailService) GetAccount(context.Context, string, string) (map[string]any, error) {
return nil, ErrNotFound 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) DeleteAccount(context.Context, string, string) error { return nil }
func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) { func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) {
return map[string]any{"messages": []any{}}, nil return map[string]any{"messages": []any{}}, nil
@ -315,13 +385,13 @@ func (f *fakeMailService) ListIdentities(_ context.Context, externalID, accountI
if externalID != testExternalID { if externalID != testExternalID {
return IdentitiesList{}, ErrAccountNotFound return IdentitiesList{}, ErrAccountNotFound
} }
if accountID != "acc-1" { if accountID != testMailAccountID {
return IdentitiesList{}, ErrAccountNotFound return IdentitiesList{}, ErrAccountNotFound
} }
total := int64(1) total := int64(1)
return IdentitiesList{ return IdentitiesList{
Identities: []map[string]any{{ 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": "", "name": "Sender", "is_default": true, "signature_html": "",
"reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil, "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 nil, ErrNotFound
} }
return map[string]any{ 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": "", "name": "Sender", "is_default": true, "signature_html": "",
"reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil, "reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil,
}, nil }, nil
} }
func (f *fakeMailService) CreateIdentity(_ context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) { 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 return "", ErrAccountNotFound
} }
if req.Email == "" { if req.Email == "" {
@ -364,6 +434,42 @@ func (f *fakeMailService) DeleteIdentity(_ context.Context, externalID, identity
return nil 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": "<p>Hi</p>", "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) { func (f *fakeMailService) ListFolders(_ context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error) {
if externalID != testExternalID { if externalID != testExternalID {
return FoldersList{}, ErrAccountNotFound return FoldersList{}, ErrAccountNotFound
@ -429,6 +535,20 @@ func (f *fakeMailService) DeleteUserLabel(_ context.Context, externalID, labelID
return nil 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) { func (f *fakeMailService) SearchMessages(_ context.Context, externalID string, _ MessageSearchFilter, params query.ListParams) (MessageSearchResult, error) {
if externalID != testExternalID { if externalID != testExternalID {
return MessageSearchResult{}, ErrUserNotProvisioned return MessageSearchResult{}, ErrUserNotProvisioned
@ -557,7 +677,7 @@ func TestSendMessage(t *testing.T) {
router := newTestMailRouter(svc) router := newTestMailRouter(svc)
payload := map[string]any{ payload := map[string]any{
"account_id": "acc-1", "account_id": testMailAccountID,
"to": []string{"recipient@example.com"}, "to": []string{"recipient@example.com"},
"subject": "Test subject", "subject": "Test subject",
"body_text": "Hello world", "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())
}
})
}

View File

@ -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)
}

View File

@ -17,7 +17,7 @@ type IdentitiesList struct {
} }
const identitySelectColumns = ` 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 { func (s *Service) verifyAccountOwnership(ctx context.Context, externalID, accountID string) error {
@ -46,9 +46,9 @@ 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) replyTo := parseReplyToAddrs(replyToJSON)
return map[string]any{ out := map[string]any{
"id": id, "id": id,
"account_id": accountID, "account_id": accountID,
"email": email, "email": email,
@ -59,6 +59,17 @@ func scanIdentity(id, accountID, email, name, signatureHTML string, isDefault bo
"created_at": createdAt, "created_at": createdAt,
"updated_at": updatedAt, "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 { func parseReplyToAddrs(raw []byte) []string {
@ -104,12 +115,13 @@ func (s *Service) ListIdentities(ctx context.Context, externalID, accountID stri
for rows.Next() { for rows.Next() {
var id, acctID, email, name, signatureHTML string var id, acctID, email, name, signatureHTML string
var isDefault bool var isDefault bool
var defaultSignatureID *string
var replyToJSON []byte var replyToJSON []byte
var createdAt, updatedAt any 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 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 { if err := rows.Err(); err != nil {
return IdentitiesList{}, err 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 id, accountID, email, name, signatureHTML string
var isDefault bool var isDefault bool
var defaultSignatureID *string
var replyToJSON []byte var replyToJSON []byte
var createdAt, updatedAt any var createdAt, updatedAt any
err := s.db.QueryRow(ctx, query, identityID, externalID).Scan( 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -137,7 +150,7 @@ func (s *Service) GetIdentity(ctx context.Context, externalID, identityID string
} }
return nil, err 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) { 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) replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
defaultSigID := nullableUUID(req.DefaultSignatureID)
var id string var id string
err := s.db.QueryRow(ctx, ` err := s.db.QueryRow(ctx, `
INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, reply_to_addrs) 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) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id 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 { if err != nil {
return "", err 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) replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
defaultSigID := nullableUUID(req.DefaultSignatureID)
result, err := s.db.Exec(ctx, ` result, err := s.db.Exec(ctx, `
UPDATE mail_identities mi SET 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 FROM mail_accounts ma
JOIN users u ON ma.user_id = u.id JOIN users u ON ma.user_id = u.id
WHERE mi.id = $6 AND mi.account_id = ma.id AND u.external_id = $7 WHERE mi.id = $7 AND mi.account_id = ma.id AND u.external_id = $8
`, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON, identityID, externalID) `, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, replyToJSON, identityID, externalID)
if err != nil { if err != nil {
return err return err
} }

View File

@ -32,7 +32,7 @@ func TestListIdentities(t *testing.T) {
svc := newFakeMailService() svc := newFakeMailService()
router := newTestIdentityRouter(svc) 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() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@ -65,7 +65,7 @@ func TestCreateIdentity(t *testing.T) {
t.Fatalf("marshal payload: %v", err) 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") req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)

View File

@ -23,10 +23,10 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params
} }
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT id, name, color, created_at SELECT id, name, color, sort_order, created_at
FROM mail_user_labels FROM mail_user_labels
WHERE user_id = (SELECT id FROM users WHERE external_id = $1) 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 LIMIT $2 OFFSET $3
`, externalID, params.Limit(), params.Offset()) `, externalID, params.Limit(), params.Offset())
if err != nil { if err != nil {
@ -37,12 +37,13 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params
labels := make([]map[string]any, 0) labels := make([]map[string]any, 0)
for rows.Next() { for rows.Next() {
var id, name, color string var id, name, color string
var sortOrder int
var createdAt any 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 return UserLabelsList{}, err
} }
labels = append(labels, map[string]any{ 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 { 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) { func (s *Service) CreateUserLabel(ctx context.Context, externalID string, req *createUserLabelRequest) (string, error) {
var id string var id string
err := s.db.QueryRow(ctx, ` err := s.db.QueryRow(ctx, `
INSERT INTO mail_user_labels (user_id, name, color) WITH next_order AS (
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3) 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 RETURNING id
`, externalID, req.Name, req.Color).Scan(&id) `, externalID, req.Name, req.Color).Scan(&id)
if err != nil { if err != nil {

View File

@ -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)
}

View File

@ -23,6 +23,10 @@ var (
ErrUserNotProvisioned = errors.New("user not provisioned") ErrUserNotProvisioned = errors.New("user not provisioned")
ErrAccountNotFound = errors.New("account not found") ErrAccountNotFound = errors.New("account not found")
ErrCredentialsUnavailable = errors.New("credentials encryption unavailable") 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 { 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) { 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 { if s.credentials == nil {
return "", ErrCredentialsUnavailable return "", ErrCredentialsUnavailable
} }
creds, err := s.credentials.Encrypt(req.Username, req.Password) encrypted, err := s.credentials.EncryptCredential(cred)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -121,28 +133,17 @@ 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) 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) VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id 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 { if err != nil {
return "", err return "", 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 id, nil 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
}
return map[string]any{"id": id, "name": name, "email": email, "provider": provider}, nil
}
func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID string) error { func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID string) error {
userID, err := s.ResolveUserID(ctx, externalID) userID, err := s.ResolveUserID(ctx, externalID)
if err != nil { if err != nil {

View File

@ -6,15 +6,25 @@ import (
"time" "time"
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/rules"
) )
// ServiceAPI is the mail handler service boundary. *Service implements it in production. // ServiceAPI is the mail handler service boundary. *Service implements it in production.
type ServiceAPI interface { type ServiceAPI interface {
ResolveUserID(ctx context.Context, externalID string) (string, error) 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) ListAccounts(ctx context.Context, externalID string, params query.ListParams) (AccountsList, error)
CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, 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) 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 DeleteAccount(ctx context.Context, externalID, accountID string) error
ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, 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) 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) CreateIdentity(ctx context.Context, externalID, accountID string, req *createIdentityRequest) (string, error)
UpdateIdentity(ctx context.Context, externalID, identityID string, req *updateIdentityRequest) error UpdateIdentity(ctx context.Context, externalID, identityID string, req *updateIdentityRequest) error
DeleteIdentity(ctx context.Context, externalID, identityID string) 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) ListFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error)
GetFolder(ctx context.Context, externalID, folderID string) (map[string]any, error) GetFolder(ctx context.Context, externalID, folderID string) (map[string]any, error)
CreateFolder(ctx context.Context, userID string, req *createFolderRequest) (string, 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) CreateUserLabel(ctx context.Context, externalID string, req *createUserLabelRequest) (string, error)
UpdateUserLabel(ctx context.Context, externalID, labelID string, req *updateUserLabelRequest) error UpdateUserLabel(ctx context.Context, externalID, labelID string, req *updateUserLabelRequest) error
DeleteUserLabel(ctx context.Context, externalID, labelID string) 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) SearchMessages(ctx context.Context, externalID string, filter MessageSearchFilter, params query.ListParams) (MessageSearchResult, error)
ListMessageAttachments(ctx context.Context, externalID, messageID string) ([]map[string]any, error) ListMessageAttachments(ctx context.Context, externalID, messageID string) ([]map[string]any, error)
MessageAttachmentCIDMap(ctx context.Context, externalID, messageID string) (map[string]string, error) MessageAttachmentCIDMap(ctx context.Context, externalID, messageID string) (map[string]string, error)

View File

@ -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, &notificationsJSON, &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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -11,10 +11,19 @@ import (
"time" "time"
"unicode" "unicode"
"github.com/google/uuid"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/mail/limits" "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 ( const (
maxAccountRequestBody = 32 << 10 // 32 KiB maxAccountRequestBody = 32 << 10 // 32 KiB
maxWebhookRequestBody = 128 << 10 // 128 KiB maxWebhookRequestBody = 128 << 10 // 128 KiB
@ -152,6 +161,56 @@ type createAccountRequest struct {
Password string `json:"password"` 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 { func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail var details []apivalidate.FieldDetail
if req.Name != "" && len(req.Name) > maxAccountName { if req.Name != "" && len(req.Name) > maxAccountName {
@ -184,6 +243,112 @@ func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationErr
return apivalidate.NewValidationError(details...) 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 { type sendMessageRequest struct {
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
To []string `json:"to"` To []string `json:"to"`

View File

@ -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")
}
}

View File

@ -19,6 +19,7 @@ type createIdentityRequest struct {
Name string `json:"name"` Name string `json:"name"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
SignatureHTML string `json:"signature_html"` SignatureHTML string `json:"signature_html"`
DefaultSignatureID string `json:"default_signature_id"`
ReplyToAddrs []string `json:"reply_to_addrs"` ReplyToAddrs []string `json:"reply_to_addrs"`
} }
@ -27,6 +28,7 @@ type updateIdentityRequest struct {
Name string `json:"name"` Name string `json:"name"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
SignatureHTML string `json:"signature_html"` SignatureHTML string `json:"signature_html"`
DefaultSignatureID string `json:"default_signature_id"`
ReplyToAddrs []string `json:"reply_to_addrs"` ReplyToAddrs []string `json:"reply_to_addrs"`
} }
@ -57,6 +59,9 @@ func validateCreateIdentity(req *createIdentityRequest) *apivalidate.ValidationE
if len(req.SignatureHTML) > maxSignatureHTML { if len(req.SignatureHTML) > maxSignatureHTML {
details = append(details, apivalidate.FieldDetail{Field: "signature_html", Message: "too long"}) 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 { if req.ReplyToAddrs == nil {
req.ReplyToAddrs = []string{} req.ReplyToAddrs = []string{}
} }
@ -80,6 +85,9 @@ func validateUpdateIdentity(req *updateIdentityRequest) *apivalidate.ValidationE
if len(req.SignatureHTML) > maxSignatureHTML { if len(req.SignatureHTML) > maxSignatureHTML {
details = append(details, apivalidate.FieldDetail{Field: "signature_html", Message: "too long"}) 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 { if req.ReplyToAddrs == nil {
req.ReplyToAddrs = []string{} req.ReplyToAddrs = []string{}
} }
@ -89,3 +97,10 @@ func validateUpdateIdentity(req *updateIdentityRequest) *apivalidate.ValidationE
} }
return apivalidate.NewValidationError(details...) return apivalidate.NewValidationError(details...)
} }
func validateOptionalUUID(field, value string) *apivalidate.FieldDetail {
if value == "" {
return nil
}
return validateAccountUUID(value)
}

View File

@ -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...)
}

View File

@ -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)
}
}

View File

@ -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...)
}

View File

@ -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
}

View File

@ -2,6 +2,11 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
) )
@ -17,17 +22,70 @@ type Verifier struct {
verifier *oidc.IDTokenVerifier verifier *oidc.IDTokenVerifier
} }
func NewVerifier(ctx context.Context, issuerURL, clientID string) (*Verifier, error) { // NewVerifier builds an ID token verifier. issuerURL is the URL ultid uses to reach
provider, err := oidc.NewProvider(ctx, issuerURL) // 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 { if err != nil {
return nil, err return nil, err
} }
verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) keySet := oidc.NewRemoteKeySet(ctx, issuerURL+"/jwks/")
return &Verifier{verifier: verifier}, nil 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) { 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) token, err := v.verifier.Verify(ctx, rawToken)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -15,6 +15,9 @@ type Config struct {
Domain string Domain string
AppEnv string AppEnv string
// Browser clients (web app on another origin than the API gateway).
CORSAllowedOrigins []string
// PostgreSQL // PostgreSQL
DatabaseURL string DatabaseURL string
@ -65,6 +68,14 @@ type Config struct {
MailActiveCredentialKeyID string MailActiveCredentialKeyID string
MailWebhookSharedSecret string MailWebhookSharedSecret string
MailGoogleOAuthClientID string
MailGoogleOAuthClientSecret string
MailMicrosoftOAuthClientID string
MailMicrosoftOAuthSecret string
MailMicrosoftOAuthTenant string
MailOAuthRedirectURL string
MailAppURL string
// Secret rotation policy // Secret rotation policy
SecretRotationMaxAge time.Duration SecretRotationMaxAge time.Duration
OIDCSecretRotatedAt time.Time OIDCSecretRotatedAt time.Time
@ -99,6 +110,7 @@ func Load() (*Config, error) {
Port: port, Port: port,
Domain: envOrDefault("DOMAIN", "localhost"), Domain: envOrDefault("DOMAIN", "localhost"),
AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))), 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"), DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"),
KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"), KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"),
@ -141,6 +153,14 @@ func Load() (*Config, error) {
MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""), MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""),
MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"), 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), SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour),
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"), OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),
SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"), SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"),
@ -205,6 +225,22 @@ func envOrDefault(key, fallback string) string {
return fallback 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 { func envOrDefaultSecret(key, fallback string) string {
if v := secrets.Env(key); v != "" { if v := secrets.Env(key); v != "" {
return v return v

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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.",
},
}
}

View File

@ -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 = `<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<domain>example.com</domain>
<displayName>Example Mail</displayName>
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
</outgoingServer>
</emailProvider>
</clientConfig>`
r, ok := parseMozillaConfig([]byte(sample), "example.com")
if !ok {
t.Fatal("expected parse ok")
}
if r.IMAPHost != "imap.example.com" || r.SMTPHost != "smtp.example.com" {
t.Fatalf("unexpected hosts: %+v", r)
}
}
func TestDiscoverInvalidEmail(t *testing.T) {
_, err := Discover(context.Background(), "not-an-email")
if err != ErrInvalidEmail {
t.Fatalf("expected ErrInvalidEmail, got %v", err)
}
}

View File

@ -0,0 +1,5 @@
package autoconfig
import "errors"
var ErrInvalidEmail = errors.New("invalid email address")

View File

@ -0,0 +1,140 @@
package autoconfig
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const autoconfigTimeout = 4 * time.Second
type mozillaConfig struct {
XMLName xml.Name `xml:"clientConfig"`
EmailProvider struct {
ID string `xml:"id,attr"`
Domain []string `xml:"domain"`
DisplayName string `xml:"displayName"`
IncomingServers []mozillaServer `xml:"incomingServer"`
OutgoingServers []mozillaServer `xml:"outgoingServer"`
} `xml:"emailProvider"`
}
type mozillaServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
}
func fetchMozillaAutoconfig(ctx context.Context, domain string) (Result, bool) {
urls := []string{
fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml", domain),
fmt.Sprintf("https://%s/.well-known/autoconfig/mail/config-v1.1.xml", domain),
fmt.Sprintf("https://autoconfig.thunderbird.net/v1.1/%s", domain),
}
client := &http.Client{Timeout: autoconfigTimeout}
for _, u := range urls {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "Ultimail-Autoconfig/1.0")
resp, err := client.Do(req)
if err != nil {
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256<<10))
_ = resp.Body.Close()
if readErr != nil || resp.StatusCode != http.StatusOK {
continue
}
if r, ok := parseMozillaConfig(body, domain); ok {
return r, true
}
}
return Result{}, false
}
func parseMozillaConfig(data []byte, domain string) (Result, bool) {
var cfg mozillaConfig
if err := xml.Unmarshal(data, &cfg); err != nil {
return Result{}, false
}
var imapHost string
var imapPort int
var imapTLS bool
var smtpHost string
var smtpPort int
var smtpTLS bool
for _, s := range cfg.EmailProvider.IncomingServers {
if strings.EqualFold(s.Type, "imap") && imapHost == "" {
imapHost = strings.TrimSpace(s.Hostname)
imapPort = s.Port
imapTLS = socketUsesTLS(s.SocketType)
if imapPort == 0 {
imapPort = 993
}
}
}
for _, s := range cfg.EmailProvider.OutgoingServers {
if strings.EqualFold(s.Type, "smtp") && smtpHost == "" {
smtpHost = strings.TrimSpace(s.Hostname)
smtpPort = s.Port
smtpTLS = socketUsesTLS(s.SocketType)
if smtpPort == 0 {
smtpPort = 587
}
}
}
if imapHost == "" && smtpHost == "" {
return Result{}, false
}
name := strings.TrimSpace(cfg.EmailProvider.DisplayName)
if name == "" {
name = cfg.EmailProvider.ID
}
if name == "" {
name = domain
}
return Result{
Domain: domain,
ProviderID: "autoconfig",
ProviderName: name,
Source: "autoconfig",
Confidence: "medium",
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPTLS: imapTLS,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPTLS: smtpTLS,
UsernameHint: mozillaUsernameHint(cfg.EmailProvider.IncomingServers),
AuthMethods: []string{"password"},
Notes: []string{"Configuration issue de l'autoconfiguration Mozilla/Thunderbird."},
}, true
}
func socketUsesTLS(socketType string) bool {
switch strings.ToUpper(strings.TrimSpace(socketType)) {
case "SSL", "TLS", "STARTTLS":
return true
default:
return false
}
}
func mozillaUsernameHint(servers []mozillaServer) string {
for _, s := range servers {
u := strings.ToLower(strings.TrimSpace(s.Username))
if strings.Contains(u, "%emailaddress%") {
return "email"
}
}
return "email"
}

View File

@ -0,0 +1,42 @@
package autoconfig
import (
"context"
"net"
"sort"
"strings"
)
func lookupMX(ctx context.Context, domain string) ([]string, error) {
resolver := net.Resolver{}
mxRecords, err := resolver.LookupMX(ctx, domain)
if err != nil {
return nil, err
}
sort.Slice(mxRecords, func(i, j int) bool {
return mxRecords[i].Pref < mxRecords[j].Pref
})
hosts := make([]string, 0, len(mxRecords))
for _, mx := range mxRecords {
host := strings.TrimSuffix(strings.ToLower(mx.Host), ".")
if host != "" {
hosts = append(hosts, host)
}
}
return hosts, nil
}
func discoverFromMX(ctx context.Context, email, domain string) (Result, bool) {
hosts, err := lookupMX(ctx, domain)
if err != nil || len(hosts) == 0 {
return Result{}, false
}
for _, host := range hosts {
if preset, ok := matchMX(host); ok {
r := preset.toResult(email, domain, "mx", "high")
r.Notes = append(r.Notes, "Configuration déduite des enregistrements MX («"+host+"»).")
return r, true
}
}
return Result{}, false
}

View File

@ -0,0 +1,280 @@
package autoconfig
import "strings"
type providerPreset struct {
id string
name string
domains []string
mxSubstrings []string
imapHost string
imapPort int
imapTLS bool
smtpHost string
smtpPort int
smtpTLS bool
usernameHint string
authMethods []string
notes []string
}
// Top consumer mail providers (IMAP/SMTP). OAuth-capable providers still list password for a later wave.
var knownProviders = []providerPreset{
{
id: "gmail", name: "Gmail",
domains: []string{"gmail.com", "googlemail.com"},
mxSubstrings: []string{".google.com", "googlemail.com"},
imapHost: "imap.gmail.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.gmail.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password", "oauth2"},
notes: []string{"Si la validation à deux facteurs est activée, utilisez un mot de passe d'application Google."},
},
{
id: "google_workspace", name: "Google Workspace",
mxSubstrings: []string{"aspmx.l.google.com", ".google.com"},
imapHost: "imap.gmail.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.gmail.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password", "oauth2"},
notes: []string{"Domaine personnalisé hébergé chez Google. Mot de passe d'application si 2FA."},
},
{
id: "outlook", name: "Outlook.com",
domains: []string{
"outlook.com", "outlook.fr", "hotmail.com", "hotmail.fr",
"live.com", "live.fr", "msn.com",
},
mxSubstrings: []string{"outlook.com", "hotmail.com"},
imapHost: "outlook.office365.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp-mail.outlook.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password", "oauth2"},
notes: []string{"Activez IMAP dans les paramètres du compte Microsoft si nécessaire."},
},
{
id: "microsoft365", name: "Microsoft 365",
domains: []string{"onmicrosoft.com"},
mxSubstrings: []string{"protection.outlook.com", "mail.protection.outlook.com", "outlook.com"},
imapHost: "outlook.office365.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.office365.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password", "oauth2"},
notes: []string{"L'authentification moderne (OAuth) peut être requise par votre organisation."},
},
{
id: "yahoo", name: "Yahoo Mail",
domains: []string{
"yahoo.com", "yahoo.fr", "yahoo.co.uk", "yahoo.de", "yahoo.es",
"yahoo.it", "ymail.com", "rocketmail.com",
},
mxSubstrings: []string{"yahoodns.net", "yahoo.com"},
imapHost: "imap.mail.yahoo.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.mail.yahoo.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
notes: []string{"Générez un mot de passe d'application Yahoo si la 2FA est activée."},
},
{
id: "icloud", name: "iCloud Mail",
domains: []string{"icloud.com", "me.com", "mac.com"},
mxSubstrings: []string{"icloud.com", "apple.com", "mail.me.com"},
imapHost: "imap.mail.me.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.mail.me.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
notes: []string{"Mot de passe spécifique aux applications requis (compte Apple)."},
},
{
id: "aol", name: "AOL Mail",
domains: []string{"aol.com", "aim.com"},
mxSubstrings: []string{"aol.com", "yahoodns.net"},
imapHost: "imap.aol.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.aol.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "zoho", name: "Zoho Mail",
domains: []string{"zoho.com", "zohomail.com", "zoho.eu", "zoho.in"},
mxSubstrings: []string{"zoho.com", "zohomail.com"},
imapHost: "imap.zoho.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.zoho.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "fastmail", name: "Fastmail",
domains: []string{"fastmail.com", "fastmail.fm"},
mxSubstrings: []string{"messagingengine.com", "fastmail"},
imapHost: "imap.fastmail.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.fastmail.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "gmx", name: "GMX",
domains: []string{"gmx.com", "gmx.de", "gmx.net", "gmx.fr", "gmx.at", "gmx.ch"},
mxSubstrings: []string{"gmx.net", "gmx.com"},
imapHost: "imap.gmx.net", imapPort: 993, imapTLS: true,
smtpHost: "mail.gmx.net", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "mailcom", name: "Mail.com",
domains: []string{"mail.com", "email.com", "usa.com"},
mxSubstrings: []string{"mail.com"},
imapHost: "imap.mail.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.mail.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "yandex", name: "Yandex Mail",
domains: []string{"yandex.com", "yandex.ru", "ya.ru"},
mxSubstrings: []string{"yandex"},
imapHost: "imap.yandex.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.yandex.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
notes: []string{"Activez l'accès IMAP dans les paramètres Yandex."},
},
{
id: "mailru", name: "Mail.ru",
domains: []string{"mail.ru", "inbox.ru", "bk.ru", "list.ru", "internet.ru"},
mxSubstrings: []string{"mail.ru"},
imapHost: "imap.mail.ru", imapPort: 993, imapTLS: true,
smtpHost: "smtp.mail.ru", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "proton", name: "Proton Mail",
domains: []string{"proton.me", "protonmail.com", "pm.me"},
mxSubstrings: []string{"protonmail.ch", "proton.me"},
imapHost: "127.0.0.1", imapPort: 1143, imapTLS: true,
smtpHost: "127.0.0.1", smtpPort: 1025, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
notes: []string{
"Proton Mail nécessite Proton Mail Bridge sur votre machine pour IMAP/SMTP.",
},
},
{
id: "orange", name: "Orange",
domains: []string{"orange.fr", "wanadoo.fr"},
mxSubstrings: []string{"orange.fr", "wanadoo.fr"},
imapHost: "imap.orange.fr", imapPort: 993, imapTLS: true,
smtpHost: "smtp.orange.fr", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "free", name: "Free",
domains: []string{"free.fr"},
mxSubstrings: []string{"free.fr"},
imapHost: "imap.free.fr", imapPort: 993, imapTLS: true,
smtpHost: "smtp.free.fr", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "ovh", name: "OVH Mail",
mxSubstrings: []string{"ovh.net", "mail.ovh"},
imapHost: "ssl0.ovh.net", imapPort: 993, imapTLS: true,
smtpHost: "ssl0.ovh.net", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "ionos", name: "IONOS",
domains: []string{"ionos.com", "ionos.fr", "1and1.com"},
mxSubstrings: []string{"ionos", "1and1"},
imapHost: "imap.ionos.com", imapPort: 993, imapTLS: true,
smtpHost: "smtp.ionos.com", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "comcast", name: "Xfinity / Comcast",
domains: []string{"comcast.net", "xfinity.com"},
mxSubstrings: []string{"comcast.net"},
imapHost: "imap.comcast.net", imapPort: 993, imapTLS: true,
smtpHost: "smtp.comcast.net", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "seznam", name: "Seznam",
domains: []string{"seznam.cz", "email.cz"},
mxSubstrings: []string{"seznam.cz"},
imapHost: "imap.seznam.cz", imapPort: 993, imapTLS: true,
smtpHost: "smtp.seznam.cz", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"password"},
},
{
id: "tutanota", name: "Tuta",
domains: []string{"tuta.io", "tutanota.com", "tutanota.de"},
mxSubstrings: []string{"tutanota", "tuta.io"},
imapHost: "", imapPort: 993, imapTLS: true,
smtpHost: "", smtpPort: 587, smtpTLS: true,
usernameHint: "email",
authMethods: []string{"oauth2"},
notes: []string{
"Tuta ne propose pas IMAP/SMTP standard.",
"Connexion OAuth : prochainement.",
},
},
}
func matchDomain(domain string) (*providerPreset, bool) {
domain = strings.ToLower(strings.TrimSpace(domain))
for i := range knownProviders {
p := &knownProviders[i]
for _, d := range p.domains {
if domain == d || strings.HasSuffix(domain, "."+d) {
return p, true
}
}
}
return nil, false
}
func matchMX(mxHost string) (*providerPreset, bool) {
mxHost = strings.ToLower(mxHost)
for i := range knownProviders {
p := &knownProviders[i]
for _, sub := range p.mxSubstrings {
if sub != "" && strings.Contains(mxHost, sub) {
return p, true
}
}
}
return nil, false
}
func (p *providerPreset) toResult(email, domain, source, confidence string) Result {
auth := p.authMethods
if len(auth) == 0 {
auth = []string{"password"}
}
return Result{
Email: email,
Domain: domain,
ProviderID: p.id,
ProviderName: p.name,
Source: source,
Confidence: confidence,
IMAPHost: p.imapHost,
IMAPPort: p.imapPort,
IMAPTLS: p.imapTLS,
SMTPHost: p.smtpHost,
SMTPPort: p.smtpPort,
SMTPTLS: p.smtpTLS,
UsernameHint: p.usernameHint,
AuthMethods: auth,
Notes: append([]string(nil), p.notes...),
}
}

View File

@ -0,0 +1,20 @@
package autoconfig
// Result is the suggested mail client configuration for an email address.
type Result struct {
Email string `json:"email"`
Domain string `json:"domain"`
ProviderID string `json:"provider_id"`
ProviderName string `json:"provider_name"`
Source string `json:"source"` // preset, mx, autoconfig, guess
Confidence string `json:"confidence"` // high, medium, low
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
IMAPTLS bool `json:"imap_tls"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPTLS bool `json:"smtp_tls"`
UsernameHint string `json:"username_hint"` // email | local
AuthMethods []string `json:"auth_methods"`
Notes []string `json:"notes,omitempty"`
}

View File

@ -0,0 +1,16 @@
package connect
import (
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-sasl"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
)
func AuthenticateIMAP(client *imapclient.Client, cred credentials.Credential) error {
return authenticateIMAP(client, cred)
}
func SMTPClient(cred credentials.Credential) (sasl.Client, error) {
return smtpAuth(cred)
}

View File

@ -0,0 +1,157 @@
package connect
import (
"context"
"fmt"
"strings"
"time"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
)
type ServerConfig struct {
IMAPHost string
IMAPPort int
IMAPTLS bool
SMTPHost string
SMTPPort int
SMTPTLS bool
}
type TestResult struct {
OK bool `json:"ok"`
IMAP bool `json:"imap_ok"`
IMAPError string `json:"imap_error,omitempty"`
SMTP bool `json:"smtp_ok"`
SMTPError string `json:"smtp_error,omitempty"`
}
func Test(ctx context.Context, cfg ServerConfig, cred credentials.Credential) TestResult {
result := TestResult{}
if cfg.IMAPHost != "" {
if err := testIMAP(ctx, cfg, cred); err != nil {
result.IMAPError = err.Error()
} else {
result.IMAP = true
}
}
if cfg.SMTPHost != "" {
if err := testSMTP(ctx, cfg, cred); err != nil {
result.SMTPError = err.Error()
} else {
result.SMTP = true
}
}
result.OK = (cfg.IMAPHost == "" || result.IMAP) && (cfg.SMTPHost == "" || result.SMTP)
return result
}
func testIMAP(ctx context.Context, cfg ServerConfig, cred credentials.Credential) error {
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
addr := fmt.Sprintf("%s:%d", cfg.IMAPHost, cfg.IMAPPort)
var client *imapclient.Client
var err error
if cfg.IMAPTLS {
client, err = imapclient.DialTLS(addr, &imapclient.Options{})
} else {
client, err = imapclient.DialStartTLS(addr, &imapclient.Options{})
}
if err != nil {
return fmt.Errorf("connexion IMAP: %w", err)
}
defer client.Close()
if err := authenticateIMAP(client, cred); err != nil {
return fmt.Errorf("authentification IMAP: %w", err)
}
if err := client.Noop().Wait(); err != nil {
return fmt.Errorf("IMAP NOOP: %w", err)
}
return nil
}
func authenticateIMAP(client *imapclient.Client, cred credentials.Credential) error {
if cred.IsOAuth() {
saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: cred.Username,
Token: cred.AccessToken,
})
if err := client.Authenticate(saslClient); err != nil {
return err
}
return nil
}
return client.Login(cred.Username, cred.Password).Wait()
}
func testSMTP(ctx context.Context, cfg ServerConfig, cred credentials.Credential) error {
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
auth, err := smtpAuth(cred)
if err != nil {
return err
}
done := make(chan error, 1)
go func() {
var c *gosmtp.Client
var dialErr error
if cfg.SMTPTLS {
c, dialErr = gosmtp.DialTLS(addr, nil)
} else {
c, dialErr = gosmtp.Dial(addr)
}
if dialErr != nil {
done <- dialErr
return
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
done <- err
return
}
}
done <- c.Noop()
}()
select {
case <-ctx.Done():
return fmt.Errorf("connexion SMTP: délai dépassé")
case err := <-done:
if err != nil {
return fmt.Errorf("connexion SMTP: %w", err)
}
return nil
}
}
func smtpAuth(cred credentials.Credential) (sasl.Client, error) {
if cred.IsOAuth() {
return sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: cred.Username,
Token: cred.AccessToken,
}), nil
}
if cred.Username == "" || cred.Password == "" {
return nil, fmt.Errorf("identifiants incomplets")
}
return sasl.NewPlainClient("", cred.Username, cred.Password), nil
}
// SanitizeError shortens provider errors for API responses.
func SanitizeError(msg string) string {
msg = strings.TrimSpace(msg)
if len(msg) > 240 {
return msg[:240] + "…"
}
return msg
}

View File

@ -0,0 +1,101 @@
package credentials
import "time"
type AuthType string
const (
AuthPassword AuthType = "password"
AuthOAuth2 AuthType = "oauth2"
)
// Credential is the decrypted mail account secret material.
type Credential struct {
AuthType AuthType
Username string
Password string
AccessToken string
RefreshToken string
Expiry time.Time
OAuthProvider string // google | microsoft
}
func (c Credential) IsOAuth() bool {
return c.AuthType == AuthOAuth2
}
func (c Credential) NeedsRefresh() bool {
if !c.IsOAuth() || c.RefreshToken == "" {
return false
}
if c.Expiry.IsZero() {
return false
}
return time.Now().Add(2 * time.Minute).After(c.Expiry)
}
type storedPayload struct {
AuthType string `json:"auth_type,omitempty"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
Expiry string `json:"expiry,omitempty"`
OAuthProvider string `json:"oauth_provider,omitempty"`
}
func (c Credential) toStored() storedPayload {
p := storedPayload{
AuthType: string(c.AuthType),
Username: c.Username,
Password: c.Password,
AccessToken: c.AccessToken,
RefreshToken: c.RefreshToken,
OAuthProvider: c.OAuthProvider,
}
if !c.Expiry.IsZero() {
p.Expiry = c.Expiry.UTC().Format(time.RFC3339)
}
if p.AuthType == "" {
p.AuthType = string(AuthPassword)
}
return p
}
func storedToCredential(p storedPayload) (Credential, error) {
authType := AuthType(p.AuthType)
if authType == "" {
authType = AuthPassword
}
c := Credential{
AuthType: authType,
Username: p.Username,
Password: p.Password,
AccessToken: p.AccessToken,
RefreshToken: p.RefreshToken,
OAuthProvider: p.OAuthProvider,
}
if p.Expiry != "" {
t, err := time.Parse(time.RFC3339, p.Expiry)
if err != nil {
return Credential{}, err
}
c.Expiry = t
}
if c.Username == "" {
return Credential{}, errIncomplete
}
switch authType {
case AuthPassword:
if c.Password == "" {
return Credential{}, errIncomplete
}
case AuthOAuth2:
if c.AccessToken == "" {
return Credential{}, errIncomplete
}
default:
return Credential{}, errUnsupportedAuth
}
return c, nil
}

View File

@ -0,0 +1,8 @@
package credentials
import "errors"
var (
errIncomplete = errors.New("decrypted credentials are incomplete")
errUnsupportedAuth = errors.New("unsupported credential auth type")
)

View File

@ -19,11 +19,6 @@ type Manager struct {
keys map[string][]byte keys map[string][]byte
} }
type payload struct {
Username string `json:"username"`
Password string `json:"password"`
}
func NewManager(keysSpec, activeKeyID string) (*Manager, error) { func NewManager(keysSpec, activeKeyID string) (*Manager, error) {
keys, err := parseKeys(keysSpec) keys, err := parseKeys(keysSpec)
if err != nil { if err != nil {
@ -50,6 +45,48 @@ func NewManager(keysSpec, activeKeyID string) (*Manager, error) {
} }
func (m *Manager) Encrypt(username, password string) ([]byte, error) { func (m *Manager) Encrypt(username, password string) ([]byte, error) {
return m.EncryptCredential(Credential{
AuthType: AuthPassword,
Username: username,
Password: password,
})
}
func (m *Manager) EncryptCredential(c Credential) ([]byte, error) {
if c.AuthType == "" {
c.AuthType = AuthPassword
}
rawPayload, err := json.Marshal(c.toStored())
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
return m.encryptRaw(rawPayload)
}
func (m *Manager) Decrypt(blob []byte) (string, string, error) {
c, err := m.DecryptCredential(blob)
if err != nil {
return "", "", err
}
if c.IsOAuth() {
return c.Username, c.AccessToken, nil
}
return c.Username, c.Password, nil
}
func (m *Manager) DecryptCredential(blob []byte) (Credential, error) {
plaintext, err := m.decryptRaw(blob)
if err != nil {
return Credential{}, err
}
var p storedPayload
if err := json.Unmarshal(plaintext, &p); err != nil {
return Credential{}, fmt.Errorf("unmarshal payload: %w", err)
}
return storedToCredential(p)
}
func (m *Manager) encryptRaw(rawPayload []byte) ([]byte, error) {
key := m.keys[m.activeKeyID] key := m.keys[m.activeKeyID]
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
@ -66,11 +103,6 @@ func (m *Manager) Encrypt(username, password string) ([]byte, error) {
return nil, fmt.Errorf("nonce: %w", err) return nil, fmt.Errorf("nonce: %w", err)
} }
rawPayload, err := json.Marshal(payload{Username: username, Password: password})
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, rawPayload, nil) ciphertext := gcm.Seal(nil, nonce, rawPayload, nil)
serialized := strings.Join([]string{ serialized := strings.Join([]string{
@ -82,49 +114,41 @@ func (m *Manager) Encrypt(username, password string) ([]byte, error) {
return []byte(serialized), nil return []byte(serialized), nil
} }
func (m *Manager) Decrypt(blob []byte) (string, string, error) { func (m *Manager) decryptRaw(blob []byte) ([]byte, error) {
parts := strings.Split(string(blob), "|") parts := strings.Split(string(blob), "|")
if len(parts) != 4 || parts[0] != strings.TrimSuffix(prefix, "|") { if len(parts) != 4 || parts[0] != strings.TrimSuffix(prefix, "|") {
return "", "", errors.New("credentials payload is not encrypted with supported format") return nil, errors.New("credentials payload is not encrypted with supported format")
} }
keyID := parts[1] keyID := parts[1]
key, ok := m.keys[keyID] key, ok := m.keys[keyID]
if !ok { if !ok {
return "", "", fmt.Errorf("unknown credential key id %q", keyID) return nil, fmt.Errorf("unknown credential key id %q", keyID)
} }
nonce, err := base64.StdEncoding.DecodeString(parts[2]) nonce, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil { if err != nil {
return "", "", fmt.Errorf("decode nonce: %w", err) return nil, fmt.Errorf("decode nonce: %w", err)
} }
ciphertext, err := base64.StdEncoding.DecodeString(parts[3]) ciphertext, err := base64.StdEncoding.DecodeString(parts[3])
if err != nil { if err != nil {
return "", "", fmt.Errorf("decode ciphertext: %w", err) return nil, fmt.Errorf("decode ciphertext: %w", err)
} }
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
return "", "", fmt.Errorf("new cipher: %w", err) return nil, fmt.Errorf("new cipher: %w", err)
} }
gcm, err := cipher.NewGCM(block) gcm, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return "", "", fmt.Errorf("new gcm: %w", err) return nil, fmt.Errorf("new gcm: %w", err)
} }
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil { if err != nil {
return "", "", fmt.Errorf("decrypt: %w", err) return nil, fmt.Errorf("decrypt: %w", err)
} }
return plaintext, nil
var p payload
if err := json.Unmarshal(plaintext, &p); err != nil {
return "", "", fmt.Errorf("unmarshal payload: %w", err)
}
if p.Username == "" || p.Password == "" {
return "", "", errors.New("decrypted credentials are incomplete")
}
return p.Username, p.Password, nil
} }
func IsEncrypted(blob []byte) bool { func IsEncrypted(blob []byte) bool {

View File

@ -5,6 +5,33 @@ import (
"testing" "testing"
) )
func TestEncryptDecryptOAuth(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
manager, err := NewManager("v1:"+key, "v1")
if err != nil {
t.Fatalf("new manager: %v", err)
}
blob, err := manager.EncryptCredential(Credential{
AuthType: AuthOAuth2,
Username: "user@gmail.com",
AccessToken: "access-token",
RefreshToken: "refresh-token",
OAuthProvider: "google",
})
if err != nil {
t.Fatalf("encrypt oauth: %v", err)
}
cred, err := manager.DecryptCredential(blob)
if err != nil {
t.Fatalf("decrypt oauth: %v", err)
}
if !cred.IsOAuth() || cred.AccessToken != "access-token" {
t.Fatalf("unexpected oauth cred: %+v", cred)
}
}
func TestEncryptDecrypt(t *testing.T) { func TestEncryptDecrypt(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
manager, err := NewManager("v1:"+key, "v1") manager, err := NewManager("v1:"+key, "v1")

View File

@ -1,6 +1,7 @@
package imap package imap
import ( import (
"context"
"encoding/base64" "encoding/base64"
"strings" "strings"
"testing" "testing"
@ -8,30 +9,30 @@ import (
"github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/credentials"
) )
func TestParseCredentials_missing(t *testing.T) { func TestResolveCredential_missing(t *testing.T) {
w := &SyncWorker{credentials: &credentials.Manager{}} w := &SyncWorker{credentials: &credentials.Manager{}}
_, _, err := w.parseCredentials(nil) _, err := w.resolveCredential(context.Background(), "acc-1", nil)
if err == nil || err.Error() != "missing credentials" { if err == nil || err.Error() != "missing credentials" {
t.Fatalf("parseCredentials(nil) error = %v, want missing credentials", err) t.Fatalf("resolveCredential(nil) error = %v, want missing credentials", err)
} }
_, _, err = w.parseCredentials([]byte{}) _, err = w.resolveCredential(context.Background(), "acc-1", []byte{})
if err == nil || err.Error() != "missing credentials" { if err == nil || err.Error() != "missing credentials" {
t.Fatalf("parseCredentials([]) error = %v, want missing credentials", err) t.Fatalf("resolveCredential([]) error = %v, want missing credentials", err)
} }
} }
func TestParseCredentials_plaintextForbidden(t *testing.T) { func TestResolveCredential_plaintextForbidden(t *testing.T) {
w := &SyncWorker{credentials: &credentials.Manager{}} w := &SyncWorker{credentials: &credentials.Manager{}}
_, _, err := w.parseCredentials([]byte(`{"username":"alice","password":"secret"}`)) _, err := w.resolveCredential(context.Background(), "acc-1", []byte(`{"username":"alice","password":"secret"}`))
if err == nil || err.Error() != "plaintext credentials forbidden" { if err == nil || err.Error() != "plaintext credentials forbidden" {
t.Fatalf("parseCredentials(plaintext) error = %v, want plaintext credentials forbidden", err) t.Fatalf("resolveCredential(plaintext) error = %v, want plaintext credentials forbidden", err)
} }
} }
func TestParseCredentials_missingManager(t *testing.T) { func TestResolveCredential_missingManager(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
manager, err := credentials.NewManager("v1:"+key, "v1") manager, err := credentials.NewManager("v1:"+key, "v1")
if err != nil { if err != nil {
@ -43,13 +44,13 @@ func TestParseCredentials_missingManager(t *testing.T) {
} }
w := &SyncWorker{credentials: nil} w := &SyncWorker{credentials: nil}
_, _, err = w.parseCredentials(blob) _, err = w.resolveCredential(context.Background(), "acc-1", blob)
if err == nil || err.Error() != "credential manager not configured" { if err == nil || err.Error() != "credential manager not configured" {
t.Fatalf("parseCredentials(no manager) error = %v, want credential manager not configured", err) t.Fatalf("resolveCredential(no manager) error = %v, want credential manager not configured", err)
} }
} }
func TestParseCredentials_encryptedSuccess(t *testing.T) { func TestResolveCredential_encryptedSuccess(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
manager, err := credentials.NewManager("v1:"+key, "v1") manager, err := credentials.NewManager("v1:"+key, "v1")
if err != nil { if err != nil {
@ -61,16 +62,16 @@ func TestParseCredentials_encryptedSuccess(t *testing.T) {
} }
w := &SyncWorker{credentials: manager} w := &SyncWorker{credentials: manager}
username, password, err := w.parseCredentials(blob) cred, err := w.resolveCredential(context.Background(), "acc-1", blob)
if err != nil { if err != nil {
t.Fatalf("parseCredentials(encrypted) error = %v", err) t.Fatalf("resolveCredential(encrypted) error = %v", err)
} }
if username != "alice@example.com" || password != "secret" { if cred.Username != "alice@example.com" || cred.Password != "secret" {
t.Fatalf("got %q/%q, want alice@example.com/secret", username, password) t.Fatalf("got %+v, want alice@example.com/secret", cred)
} }
} }
func TestParseCredentials_decryptFailure(t *testing.T) { func TestResolveCredential_decryptFailure(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
manager, err := credentials.NewManager("v1:"+key, "v1") manager, err := credentials.NewManager("v1:"+key, "v1")
if err != nil { if err != nil {
@ -78,7 +79,7 @@ func TestParseCredentials_decryptFailure(t *testing.T) {
} }
w := &SyncWorker{credentials: manager} w := &SyncWorker{credentials: manager}
_, _, err = w.parseCredentials([]byte("UMC1|v1|invalid|payload")) _, err = w.resolveCredential(context.Background(), "acc-1", []byte("UMC1|v1|invalid|payload"))
if err == nil { if err == nil {
t.Fatal("expected decrypt error") t.Fatal("expected decrypt error")
} }

View File

@ -14,6 +14,8 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/connect"
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
"github.com/ultisuite/ulti-backend/internal/mail/limits" "github.com/ultisuite/ulti-backend/internal/mail/limits"
"github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/mail/storage"
@ -35,17 +37,19 @@ type SyncWorker struct {
logger *slog.Logger logger *slog.Logger
interval time.Duration interval time.Duration
credentials *credentials.Manager credentials *credentials.Manager
oauth *mailoauth.Service
storage *storage.Client storage *storage.Client
attachBucket string attachBucket string
pipeline *syncPipeline pipeline *syncPipeline
} }
func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager, deps SyncDeps) *SyncWorker { func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager, oauthSvc *mailoauth.Service, deps SyncDeps) *SyncWorker {
return &SyncWorker{ return &SyncWorker{
db: db, db: db,
logger: slog.Default().With("component", "imap-sync"), logger: slog.Default().With("component", "imap-sync"),
interval: interval, interval: interval,
credentials: credManager, credentials: credManager,
oauth: oauthSvc,
storage: deps.Storage, storage: deps.Storage,
attachBucket: deps.AttachBucket, attachBucket: deps.AttachBucket,
pipeline: newSyncPipeline(db, deps.Rules, deps.Hub), pipeline: newSyncPipeline(db, deps.Rules, deps.Hub),
@ -138,11 +142,11 @@ func (w *SyncWorker) syncAccount(ctx context.Context, accountID, host string, po
} }
defer client.Close() defer client.Close()
username, password, err := w.parseCredentials(creds) cred, err := w.resolveCredential(ctx, accountID, creds)
if err != nil { if err != nil {
return fmt.Errorf("decrypt credentials: %w", err) return fmt.Errorf("decrypt credentials: %w", err)
} }
if err := client.Login(username, password).Wait(); err != nil { if err := connect.AuthenticateIMAP(client, cred); err != nil {
return fmt.Errorf("login: %w", err) return fmt.Errorf("login: %w", err)
} }
@ -494,17 +498,24 @@ func flagsToStrings(flags []imap.Flag) []string {
return out return out
} }
func (w *SyncWorker) parseCredentials(creds []byte) (string, string, error) { func (w *SyncWorker) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) {
if len(creds) == 0 { if len(creds) == 0 {
return "", "", errors.New("missing credentials") return credentials.Credential{}, errors.New("missing credentials")
} }
if !credentials.IsEncrypted(creds) { if !credentials.IsEncrypted(creds) {
return "", "", errors.New("plaintext credentials forbidden") return credentials.Credential{}, errors.New("plaintext credentials forbidden")
} }
if w.credentials == nil { if w.credentials == nil {
return "", "", errors.New("credential manager not configured") return credentials.Credential{}, errors.New("credential manager not configured")
} }
return w.credentials.Decrypt(creds) cred, err := w.credentials.DecryptCredential(creds)
if err != nil {
return credentials.Credential{}, err
}
if w.oauth != nil && cred.IsOAuth() {
return mailoauth.RefreshAccountCredential(ctx, w.db, w.credentials, w.oauth, accountID, cred)
}
return cred, nil
} }
func truncate(s string, maxLen int) string { func truncate(s string, maxLen int) string {

View File

@ -0,0 +1,15 @@
package oauth
import (
"crypto/sha256"
"encoding/base64"
)
func base64URLEncode(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func sha256Sum(input string) ([]byte, error) {
sum := sha256.Sum256([]byte(input))
return sum[:], nil
}

View File

@ -0,0 +1,64 @@
package oauth
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/oauth2"
mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials"
)
// RefreshAccountCredential refreshes OAuth tokens when needed and persists updated credentials.
func RefreshAccountCredential(
ctx context.Context,
db *pgxpool.Pool,
manager *mailcredentials.Manager,
oauthSvc *Service,
accountID string,
cred mailcredentials.Credential,
) (mailcredentials.Credential, error) {
if !cred.IsOAuth() || !cred.NeedsRefresh() {
return cred, nil
}
if oauthSvc == nil || cred.RefreshToken == "" {
return cred, fmt.Errorf("oauth refresh unavailable")
}
token, err := oauthSvc.Refresh(ctx, cred.OAuthProvider, cred.RefreshToken)
if err != nil {
return cred, fmt.Errorf("refresh token: %w", err)
}
updated := applyToken(cred, token)
blob, err := manager.EncryptCredential(updated)
if err != nil {
return cred, err
}
_, err = db.Exec(ctx, `UPDATE mail_accounts SET credentials = $1, updated_at = NOW() WHERE id = $2`, blob, accountID)
if err != nil {
return cred, err
}
return updated, nil
}
func CredentialFromToken(email, provider string, token *oauth2.Token) mailcredentials.Credential {
return applyToken(mailcredentials.Credential{
AuthType: mailcredentials.AuthOAuth2,
Username: email,
OAuthProvider: provider,
}, token)
}
func applyToken(c mailcredentials.Credential, token *oauth2.Token) mailcredentials.Credential {
c.AccessToken = token.AccessToken
if token.RefreshToken != "" {
c.RefreshToken = token.RefreshToken
}
if !token.Expiry.IsZero() {
c.Expiry = token.Expiry.UTC()
} else if token.ExpiresIn > 0 {
c.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
}
return c
}

View File

@ -0,0 +1,224 @@
package oauth
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/oauth2"
)
const pendingKeyPrefix = "mail_oauth_pending:"
const pendingTTL = 10 * time.Minute
var ErrUnknownState = errors.New("oauth state expired or unknown")
var ErrProviderDisabled = errors.New("oauth provider not configured")
type Provider string
const (
ProviderGoogle Provider = "google"
ProviderMicrosoft Provider = "microsoft"
)
type PendingAccount struct {
UserExternalID string `json:"user_external_id"`
Provider string `json:"provider"`
Email string `json:"email"`
Name string `json:"name"`
ProviderID string `json:"provider_id"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
IMAPTLS bool `json:"imap_tls"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPTLS bool `json:"smtp_tls"`
PKCEVerifier string `json:"pkce_verifier"`
}
type Config struct {
GoogleClientID string
GoogleClientSecret string
MicrosoftClientID string
MicrosoftSecret string
MicrosoftTenant string
RedirectURL string
}
type Service struct {
cfg Config
rdb *redis.Client
}
func NewService(cfg Config, rdb *redis.Client) *Service {
return &Service{cfg: cfg, rdb: rdb}
}
func (s *Service) EnabledProviders() []string {
var out []string
if s.providerConfig(ProviderGoogle) != nil {
out = append(out, string(ProviderGoogle))
}
if s.providerConfig(ProviderMicrosoft) != nil {
out = append(out, string(ProviderMicrosoft))
}
return out
}
func (s *Service) Start(ctx context.Context, userExternalID string, provider Provider, pending PendingAccount) (authURL, state string, err error) {
oauthCfg := s.providerConfig(provider)
if oauthCfg == nil {
return "", "", ErrProviderDisabled
}
verifier, challenge, err := newPKCE()
if err != nil {
return "", "", err
}
state, err = randomToken(24)
if err != nil {
return "", "", err
}
pending.UserExternalID = userExternalID
pending.Provider = string(provider)
pending.PKCEVerifier = verifier
if err := s.savePending(ctx, state, pending); err != nil {
return "", "", err
}
authURL = oauthCfg.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("prompt", "consent"),
)
return authURL, state, nil
}
func (s *Service) Exchange(ctx context.Context, state, code string) (PendingAccount, *oauth2.Token, error) {
pending, err := s.loadPending(ctx, state)
if err != nil {
return PendingAccount{}, nil, err
}
oauthCfg := s.providerConfig(Provider(pending.Provider))
if oauthCfg == nil {
return PendingAccount{}, nil, ErrProviderDisabled
}
token, err := oauthCfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", pending.PKCEVerifier))
if err != nil {
return PendingAccount{}, nil, fmt.Errorf("token exchange: %w", err)
}
_ = s.rdb.Del(ctx, pendingKeyPrefix+state).Err()
return pending, token, nil
}
func (s *Service) Refresh(ctx context.Context, provider, refreshToken string) (*oauth2.Token, error) {
oauthCfg := s.providerConfig(Provider(provider))
if oauthCfg == nil {
return nil, ErrProviderDisabled
}
src := oauthCfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
token, err := src.Token()
if err != nil {
return nil, err
}
return token, nil
}
func (s *Service) providerConfig(provider Provider) *oauth2.Config {
switch provider {
case ProviderGoogle:
if s.cfg.GoogleClientID == "" || s.cfg.GoogleClientSecret == "" || s.cfg.RedirectURL == "" {
return nil
}
return &oauth2.Config{
ClientID: s.cfg.GoogleClientID,
ClientSecret: s.cfg.GoogleClientSecret,
RedirectURL: s.cfg.RedirectURL,
Scopes: []string{"https://mail.google.com/"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
},
}
case ProviderMicrosoft:
if s.cfg.MicrosoftClientID == "" || s.cfg.MicrosoftSecret == "" || s.cfg.RedirectURL == "" {
return nil
}
tenant := s.cfg.MicrosoftTenant
if tenant == "" {
tenant = "common"
}
return &oauth2.Config{
ClientID: s.cfg.MicrosoftClientID,
ClientSecret: s.cfg.MicrosoftSecret,
RedirectURL: s.cfg.RedirectURL,
Scopes: []string{
"offline_access",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
},
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", tenant),
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenant),
},
}
default:
return nil
}
}
func (s *Service) savePending(ctx context.Context, state string, pending PendingAccount) error {
if s.rdb == nil {
return errors.New("oauth state store unavailable")
}
raw, err := json.Marshal(pending)
if err != nil {
return err
}
return s.rdb.Set(ctx, pendingKeyPrefix+state, raw, pendingTTL).Err()
}
func (s *Service) loadPending(ctx context.Context, state string) (PendingAccount, error) {
if s.rdb == nil {
return PendingAccount{}, errors.New("oauth state store unavailable")
}
raw, err := s.rdb.Get(ctx, pendingKeyPrefix+state).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return PendingAccount{}, ErrUnknownState
}
return PendingAccount{}, err
}
var pending PendingAccount
if err := json.Unmarshal(raw, &pending); err != nil {
return PendingAccount{}, err
}
return pending, nil
}
func newPKCE() (verifier, challenge string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
verifier = base64URLEncode(b)
sum, err := sha256Sum(verifier)
if err != nil {
return "", "", err
}
challenge = base64URLEncode(sum)
return verifier, challenge, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64URLEncode(b), nil
}

View File

@ -10,23 +10,27 @@ import (
"strings" "strings"
"time" "time"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp" gosmtp "github.com/emersion/go-smtp"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/connect"
"github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/credentials"
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
) )
type Sender struct { type Sender struct {
db *pgxpool.Pool db *pgxpool.Pool
logger *slog.Logger logger *slog.Logger
credentials *credentials.Manager credentials *credentials.Manager
oauth *mailoauth.Service
} }
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager) *Sender { func NewSender(db *pgxpool.Pool, credManager *credentials.Manager, oauthSvc *mailoauth.Service) *Sender {
return &Sender{ return &Sender{
db: db, db: db,
logger: slog.Default().With("component", "smtp-sender"), logger: slog.Default().With("component", "smtp-sender"),
credentials: credManager, credentials: credManager,
oauth: oauthSvc,
} }
} }
@ -58,7 +62,7 @@ func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
return fmt.Errorf("query account: %w", err) return fmt.Errorf("query account: %w", err)
} }
username, password, err := s.parseCredentials(creds) cred, err := s.resolveCredential(ctx, req.AccountID, creds)
if err != nil { if err != nil {
return fmt.Errorf("decrypt credentials: %w", err) return fmt.Errorf("decrypt credentials: %w", err)
} }
@ -71,7 +75,10 @@ func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
allRecipients = append(allRecipients, req.Cc...) allRecipients = append(allRecipients, req.Cc...)
allRecipients = append(allRecipients, req.Bcc...) allRecipients = append(allRecipients, req.Bcc...)
auth := sasl.NewPlainClient("", username, password) auth, err := connect.SMTPClient(cred)
if err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
var sendErr error var sendErr error
if useTLS { if useTLS {
@ -100,13 +107,28 @@ func generateMessageID(from string) string {
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(token), domain) return fmt.Sprintf("<%s@%s>", hex.EncodeToString(token), domain)
} }
func (s *Sender) parseCredentials(creds []byte) (string, string, error) { func (s *Sender) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) {
if len(creds) == 0 { if len(creds) == 0 {
return "", "", errors.New("missing credentials") return credentials.Credential{}, errors.New("missing credentials")
} }
if !credentials.IsEncrypted(creds) { if !credentials.IsEncrypted(creds) {
return "", "", errors.New("plaintext credentials forbidden") return credentials.Credential{}, errors.New("plaintext credentials forbidden")
} }
if s.credentials == nil {
return credentials.Credential{}, errors.New("credential manager not configured")
}
cred, err := s.credentials.DecryptCredential(creds)
if err != nil {
return cred, err
}
if s.oauth != nil && cred.IsOAuth() {
return mailoauth.RefreshAccountCredential(ctx, s.db, s.credentials, s.oauth, accountID, cred)
}
return cred, nil
}
// kept for tests that referenced parseCredentials
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
if s.credentials == nil { if s.credentials == nil {
return "", "", errors.New("credential manager not configured") return "", "", errors.New("credential manager not configured")
} }

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS mail_unified_folders;

View File

@ -0,0 +1,17 @@
-- Dossiers Ultimail (virtuels) : organisation UI, pas miroir IMAP.
-- account_id NULL = dossier global utilisateur ; non NULL = dossier lié à un compte mail.
CREATE TABLE mail_unified_folders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id UUID NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
parent_id UUID NULL REFERENCES mail_unified_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mail_unified_folders_user ON mail_unified_folders(user_id);
CREATE INDEX idx_mail_unified_folders_user_account ON mail_unified_folders(user_id, account_id);
CREATE INDEX idx_mail_unified_folders_parent ON mail_unified_folders(parent_id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_mail_user_labels_user_sort;
ALTER TABLE mail_user_labels DROP COLUMN IF EXISTS sort_order;

View File

@ -0,0 +1,4 @@
ALTER TABLE mail_user_labels
ADD COLUMN sort_order INT NOT NULL DEFAULT 0;
CREATE INDEX idx_mail_user_labels_user_sort ON mail_user_labels(user_id, sort_order);

View File

@ -0,0 +1,8 @@
DROP INDEX IF EXISTS idx_mail_identities_default_signature;
ALTER TABLE mail_identities
DROP COLUMN IF EXISTS default_signature_id;
DROP INDEX IF EXISTS idx_mail_signatures_user;
DROP TABLE IF EXISTS mail_signatures;

View File

@ -0,0 +1,16 @@
CREATE TABLE mail_signatures (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
html TEXT NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mail_signatures_user ON mail_signatures(user_id);
ALTER TABLE mail_identities
ADD COLUMN default_signature_id UUID REFERENCES mail_signatures(id) ON DELETE SET NULL;
CREATE INDEX idx_mail_identities_default_signature ON mail_identities(default_signature_id);

View File

@ -0,0 +1 @@
-- Irreversible data backfill; no-op on rollback.

View File

@ -0,0 +1,29 @@
-- Default send identity for mail accounts created before automatic bootstrap existed.
INSERT INTO mail_identities (account_id, email, name, is_default, signature_html)
SELECT
ma.id,
ma.email,
COALESCE(NULLIF(TRIM(ma.name), ''), split_part(ma.email, '@', 1)),
true,
''
FROM mail_accounts ma
WHERE NOT EXISTS (
SELECT 1 FROM mail_identities mi WHERE mi.account_id = ma.id
);
-- Default signature per identity still missing default_signature_id.
WITH new_sigs AS (
INSERT INTO mail_signatures (user_id, name, html)
SELECT ma.user_id, 'Signature — ' || mi.email, ''
FROM mail_identities mi
JOIN mail_accounts ma ON ma.id = mi.account_id
WHERE mi.default_signature_id IS NULL
RETURNING id, user_id, name
)
UPDATE mail_identities mi
SET default_signature_id = ns.id
FROM new_sigs ns
JOIN mail_accounts ma ON ma.user_id = ns.user_id
WHERE mi.account_id = ma.id
AND ns.name = 'Signature — ' || mi.email
AND mi.default_signature_id IS NULL;

8
migrations/embed.go Normal file
View File

@ -0,0 +1,8 @@
package migrations
import "embed"
// Files contains SQL migrations embedded for ultid startup.
//
//go:embed *.sql
var Files embed.FS

View File

@ -180,7 +180,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
## 3) Frontend web (`gmail-interface-clone`) ## 3) Frontend web (`gmail-interface-clone`)
> **Avancement (2026-05-23)** — migration offline-first API dans `gmail-interface-clone` : **13/22** items section 3 cochés. Reste : settings mail backend, cleanup fichiers mock, recherche sans fallback offline, sync par compte, modules suite (§3.4), a11y/i18n/perf (§3.6). > **Avancement (2026-05-23)** — migration offline-first API dans `gmail-interface-clone` : **14/22** items section 3 cochés. Reste : cleanup fichiers mock, recherche sans fallback offline, sync par compte, modules suite (§3.4), a11y/i18n/perf (§3.6).
### 3.1 Fondation data/API ### 3.1 Fondation data/API
@ -196,7 +196,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
- [x] Brancher compose/send/scheduled sur outbox backend. *(frontend: `use-compose-mutations`, `scheduled-store` outbox)* - [x] Brancher compose/send/scheduled sur outbox backend. *(frontend: `use-compose-mutations`, `scheduled-store` outbox)*
- [ ] Brancher recherche UI sur backend search (fallback local supprimé). *(frontend: `useMailSearch``/mail/search` + fallback cache offline; autocomplete `search-engine` locale conservée)* - [ ] Brancher recherche UI sur backend search (fallback local supprimé). *(frontend: `useMailSearch``/mail/search` + fallback cache offline; autocomplete `search-engine` locale conservée)*
- [x] Brancher multi-comptes réels (accounts API + switch account effectif). *(frontend: `useMailAccounts`, `account-store` sans `MOCK_USER_ACCOUNTS`)* - [x] Brancher multi-comptes réels (accounts API + switch account effectif). *(frontend: `useMailAccounts`, `account-store` sans `MOCK_USER_ACCOUNTS`)*
- [ ] Brancher settings (pas page placeholder) avec persistance backend. *(frontend: `mail-settings-store` local — pas d'endpoint settings backend)* - [x] Brancher settings (pas page placeholder) avec persistance backend. *(frontend: `mail-settings-store` + sync `MailSettingsSync`, page `/mail/settings`, hooks `use-mail-settings`; backend: `GET/PATCH /api/v1/mail/settings``settings.preferences.mail`)*
### 3.3 Contacts UI ### 3.3 Contacts UI
@ -247,20 +247,6 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
- [ ] Mettre scan vulnérabilités dépendances/images en CI. - [ ] Mettre scan vulnérabilités dépendances/images en CI.
- [ ] Ajouter CSP/headers sécurité côté frontend + proxy. - [ ] Ajouter CSP/headers sécurité côté frontend + proxy.
## 5) Plateformes futures
### Desktop (Tauri)
- [ ] Initialiser wrapper Tauri.
- [ ] Gérer auth desktop sécurisée (token storage natif).
- [ ] Ajouter notifications système.
- [ ] Ajouter intégration filesystem native (uploads drag/drop large files).
### Mobile
- [ ] Définir stratégie mobile (PWA avancée vs app native).
- [ ] Implémenter MVP mobile mail (liste/lecture/compose simple).
- [ ] Ajouter sync background + notifications push.
## 6) Validation "ça fonctionne" ## 6) Validation "ça fonctionne"