Backend starting to get good
This commit is contained in:
parent
ed43d7d7dc
commit
665201627b
25
.env.example
25
.env.example
@ -32,6 +32,10 @@ TYPESENSE_API_KEY=changeme
|
||||
# -----------------------------------------------------------------------------
|
||||
DOMAIN=localhost
|
||||
ULTID_PORT=8080
|
||||
# Origines navigateur autorisees (web app sur autre port/origine que l'API).
|
||||
# Vide = auto : localhost/127.0.0.1/LAN prive en dev ; http(s)://${DOMAIN} en prod.
|
||||
# Exemple dev explicite : ULTID_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
# ULTID_CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PostgreSQL
|
||||
@ -80,9 +84,12 @@ ULTID_RUSTFS_REGION=us-east-1
|
||||
# Mode local : Authentik deploye dans la stack
|
||||
# Mode externe : n'importe quel provider OIDC existant
|
||||
# -----------------------------------------------------------------------------
|
||||
ULTID_OIDC_ISSUER=http://authentik-server:9000/application/o/ulti/
|
||||
# Issuer vu par ultid (via nginx interne Docker). DOMAIN sert de Host header pour discovery
|
||||
# afin que l'issuer attendu = iss des tokens navigateur (http://localhost/auth/application/o/ulti/).
|
||||
ULTID_OIDC_ISSUER=http://nginx/auth/application/o/ulti/
|
||||
ULTID_OIDC_CLIENT_ID=ulti-backend
|
||||
# ULTID_OIDC_CLIENT_SECRET — defini dans la section Secrets
|
||||
# ULTID_OIDC_CLIENT_SECRET — defini dans la section Secrets (doit matcher blueprint Authentik)
|
||||
ULTID_AUTO_MIGRATE=true
|
||||
# Exemple Keycloak externe :
|
||||
# ULTID_OIDC_ISSUER=https://auth.example.com/realms/ulti
|
||||
# ULTID_OIDC_CLIENT_ID=ulti-backend
|
||||
@ -95,6 +102,8 @@ AUTHENTIK_POSTGRESQL__PASSWORD={{POSTGRES_PASSWORD}}
|
||||
AUTHENTIK_POSTGRESQL__NAME=authentik
|
||||
AUTHENTIK_REDIS__HOST=keydb
|
||||
AUTHENTIK_WEB__PATH=/auth/
|
||||
# URL publique affichee dans les redirects OIDC (navigateur)
|
||||
AUTHENTIK_HOST=http://{{DOMAIN}}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nextcloud (Drive / Calendar / Contacts)
|
||||
@ -118,7 +127,7 @@ NEXTCLOUD_ENABLED=true
|
||||
|
||||
NC_OIDC_CLIENT_ID=ulti-nextcloud
|
||||
# NC_OIDC_CLIENT_SECRET — defini dans la section Secrets
|
||||
NC_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration
|
||||
NC_OIDC_DISCOVERY_URL=http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration
|
||||
|
||||
NC_S3_BUCKET=nextcloud
|
||||
NC_S3_HOST=rustfs
|
||||
@ -193,6 +202,16 @@ MAIL_SMTP_CIRCUIT_COOLDOWN=5m
|
||||
SECRET_ROTATION_MAX_AGE=2160h
|
||||
ULTID_OIDC_CLIENT_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
MAIL_CREDENTIAL_KEY_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
|
||||
# Mail provider OAuth (Gmail / Microsoft 365) — optional
|
||||
# Redirect URI must match Google/Azure app config, e.g. https://api.example.com/api/v1/mail/accounts/oauth/callback
|
||||
MAIL_GOOGLE_OAUTH_CLIENT_ID=
|
||||
MAIL_GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
MAIL_MICROSOFT_OAUTH_CLIENT_ID=
|
||||
MAIL_MICROSOFT_OAUTH_CLIENT_SECRET=
|
||||
MAIL_MICROSOFT_OAUTH_TENANT=common
|
||||
MAIL_OAUTH_REDIRECT_URL=
|
||||
MAIL_APP_URL=http://localhost:3000
|
||||
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.25"
|
||||
cache: true
|
||||
|
||||
- name: Run unit tests
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
@ -10,7 +10,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /ultid ./cmd/ultid
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
RUN apk add --no-cache ca-certificates tzdata wget \
|
||||
&& addgroup -S ulti && adduser -S ulti -G ulti
|
||||
|
||||
COPY --from=builder /ultid /usr/local/bin/ultid
|
||||
|
||||
34
README.md
34
README.md
@ -36,30 +36,40 @@ Backend monolithe Go orchestrant la Ulti Suite — alternative souveraine à Goo
|
||||
# 1. Copy environment file
|
||||
cp .env.example .env
|
||||
# Edit secrets once at the top of .env (POSTGRES_PASSWORD, RUSTFS_SECRET_KEY, etc.)
|
||||
# Other variables use {{VAR}} placeholders expanded at launch.
|
||||
# Toggle modules with flags:
|
||||
# NEXTCLOUD_ENABLED=true|false
|
||||
# JITSI_ENABLED=true|false
|
||||
# IMMICH_ENABLED=true|false
|
||||
# Defaults use changeme — must match Authentik blueprints (deploy/authentik/blueprints/).
|
||||
|
||||
# 2. Start stack (core + modules enabled by flags)
|
||||
./deploy/compose-up.sh up -d
|
||||
./deploy/compose-up.sh up -d --build
|
||||
```
|
||||
|
||||
**Auto-configured on first start:**
|
||||
|
||||
- SQL migrations (`ULTID_AUTO_MIGRATE=true`, embedded in `ultid`)
|
||||
- Authentik OAuth apps **Ultimail** (`ulti-backend`) and **Nextcloud** via blueprints in `deploy/authentik/blueprints/`
|
||||
- OIDC issuer for `ultid` via internal nginx: `ULTID_OIDC_ISSUER=http://nginx/auth/application/o/ulti/`
|
||||
|
||||
**Frontend** (`gmail-interface-clone`): copy `.env.example` → `.env.local`, then `pnpm dev` → http://localhost:3000 → login redirects to Authentik.
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| API / Auth | http://localhost |
|
||||
| Grafana | http://localhost:3002 |
|
||||
| Frontend | http://localhost:3000 (Next dev) |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run locally (needs PG, KeyDB, RustFS running; loads .env with {{VAR}} expansion)
|
||||
# Run locally (needs PG, KeyDB, RustFS, Authentik running; loads .env with {{VAR}} expansion)
|
||||
go run ./cmd/ultid
|
||||
|
||||
# Build
|
||||
go build -o ultid ./cmd/ultid
|
||||
|
||||
# Expand .env for external tools (docker, migrate)
|
||||
# Migrations run automatically on ultid start. To disable: ULTID_AUTO_MIGRATE=false
|
||||
|
||||
# Manual migrate (optional)
|
||||
go run ./cmd/envexpand -in .env -out .env.resolved
|
||||
source <(grep -v '^#' .env.resolved | sed 's/^/export /')
|
||||
|
||||
# Run migrations (use expanded ULTID_DB_URL; host may need localhost instead of postgres)
|
||||
migrate -path migrations -database "$ULTID_DB_URL" up
|
||||
```
|
||||
|
||||
@ -112,7 +122,7 @@ Start with the rest of the stack (`./deploy/compose-up.sh up -d`), then open:
|
||||
| Service | URL | Notes |
|
||||
|---------|-----|-------|
|
||||
| Prometheus | http://localhost:9090 | Targets: `ultid`, `prometheus` |
|
||||
| Grafana | http://localhost:3000 | Login from `.env` (`GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD`, default `admin` / `admin`); dashboard **Ultid Baseline** under folder **Ultid** |
|
||||
| Grafana | http://localhost:3002 | Login from `.env` (`GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD`, default `admin` / `admin`); dashboard **Ultid Baseline** under folder **Ultid** |
|
||||
|
||||
**Alertmanager** — not included in compose; route labels `service=ultid` and `severity` (`critical`, `warning`) to your on-call channels when you add it.
|
||||
|
||||
@ -120,7 +130,7 @@ Start with the rest of the stack (`./deploy/compose-up.sh up -d`), then open:
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Backend | Go 1.23+ (chi, pgx, go-imap, go-smtp) |
|
||||
| Backend | Go 1.25+ (chi, pgx, go-imap, go-smtp) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Cache | KeyDB (Redis-compatible, multi-threaded) |
|
||||
| Object Storage | RustFS (S3-compatible, Apache 2.0) |
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
@ -20,7 +19,6 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/admin"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||
@ -30,10 +28,13 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
"github.com/ultisuite/ulti-backend/internal/dbmigrate"
|
||||
"github.com/ultisuite/ulti-backend/internal/config"
|
||||
"github.com/ultisuite/ulti-backend/internal/envexpand"
|
||||
"github.com/ultisuite/ulti-backend/internal/httpcors"
|
||||
mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap"
|
||||
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/smtp"
|
||||
mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage"
|
||||
@ -59,6 +60,11 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := dbmigrate.Up(cfg.DatabaseURL); err != nil {
|
||||
slog.Error("database migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
@ -82,7 +88,7 @@ func main() {
|
||||
slog.Warn("mail attachments bucket check failed", "error", err)
|
||||
}
|
||||
|
||||
verifier, err := auth.NewVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCClientID)
|
||||
verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second)
|
||||
if err != nil {
|
||||
slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err)
|
||||
}
|
||||
@ -140,15 +146,31 @@ func main() {
|
||||
|
||||
rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool))
|
||||
|
||||
oauthRedirect := cfg.MailOAuthRedirectURL
|
||||
if oauthRedirect == "" {
|
||||
oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port)
|
||||
if cfg.Domain != "" && cfg.Domain != "localhost" {
|
||||
oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain)
|
||||
}
|
||||
}
|
||||
mailOAuthSvc := mailoauth.NewService(mailoauth.Config{
|
||||
GoogleClientID: cfg.MailGoogleOAuthClientID,
|
||||
GoogleClientSecret: cfg.MailGoogleOAuthClientSecret,
|
||||
MicrosoftClientID: cfg.MailMicrosoftOAuthClientID,
|
||||
MicrosoftSecret: cfg.MailMicrosoftOAuthSecret,
|
||||
MicrosoftTenant: cfg.MailMicrosoftOAuthTenant,
|
||||
RedirectURL: oauthRedirect,
|
||||
}, rdb)
|
||||
|
||||
// Start background workers
|
||||
go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, imapsync.SyncDeps{
|
||||
go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{
|
||||
Storage: attachmentStorage,
|
||||
AttachBucket: cfg.MailAttachmentsBucket,
|
||||
Rules: rulesEngine,
|
||||
Hub: hub,
|
||||
}).Start(ctx)
|
||||
|
||||
sender := smtp.NewSender(pool, credentialManager)
|
||||
sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc)
|
||||
smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown)
|
||||
guardedSender := smtp.NewGuardedSender(sender, smtpCircuit)
|
||||
go smtp.NewOutboxProcessor(
|
||||
@ -160,18 +182,12 @@ func main() {
|
||||
).Start(ctx)
|
||||
|
||||
sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst)
|
||||
mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL)
|
||||
|
||||
// Router
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "Idempotency-Key", apiresponse.TraceIDHeader},
|
||||
ExposedHeaders: []string{apiresponse.TraceIDHeader},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Use(httpcors.Middleware(cfg))
|
||||
r.Use(middleware.TraceID)
|
||||
r.Use(observability.HTTPMetrics)
|
||||
r.Use(middleware.Logging)
|
||||
@ -189,11 +205,12 @@ func main() {
|
||||
r.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
r.Get("/ws", hub.HandleWS)
|
||||
r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth(verifier, pool, auditLogger))
|
||||
|
||||
r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter).Routes())
|
||||
r.Mount("/api/v1/mail", mailHandler.Routes())
|
||||
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
||||
r.Get("/api/v1/search", search.NewHandler(pool, search.Options{
|
||||
Nextcloud: ncClient,
|
||||
@ -219,7 +236,7 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
_ = rdb
|
||||
slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
|
||||
85
deploy/authentik/README.md
Normal file
85
deploy/authentik/README.md
Normal 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 d’option officielle en open source).
|
||||
- Placeholder `%(theme)s` sur le **logo** : non remplacé sur certaines pages SSR ; fallback CSS `prefers-color-scheme`.
|
||||
- Favicon onglet : pas de `%(theme)s` (bug SSR Authentik → 400) ; mark transparent unique.
|
||||
- Avatar stocké en attribut utilisateur (data-URI), pas encore affiché dans Ultimail header.
|
||||
- Multi-comptes simultanés type Gmail : non implémenté côté Ultimail.
|
||||
166
deploy/authentik/blueprints/01-ulti-enrollment.yaml
Normal file
166
deploy/authentik/blueprints/01-ulti-enrollment.yaml
Normal 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
|
||||
64
deploy/authentik/blueprints/02-ulti-brand.yaml
Normal file
64
deploy/authentik/blueprints/02-ulti-brand.yaml
Normal 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]]
|
||||
40
deploy/authentik/blueprints/nextcloud-oidc.yaml
Normal file
40
deploy/authentik/blueprints/nextcloud-oidc.yaml
Normal 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
|
||||
47
deploy/authentik/blueprints/ulti-oidc.yaml
Normal file
47
deploy/authentik/blueprints/ulti-oidc.yaml
Normal 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
|
||||
23
deploy/authentik/branding/ulti-authentik.css
Normal file
23
deploy/authentik/branding/ulti-authentik.css
Normal 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;
|
||||
}
|
||||
BIN
deploy/authentik/branding/ultimail-favicon-dark.png
Normal file
BIN
deploy/authentik/branding/ultimail-favicon-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
deploy/authentik/branding/ultimail-favicon-light.png
Normal file
BIN
deploy/authentik/branding/ultimail-favicon-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
deploy/authentik/branding/ultimail-favicon.png
Normal file
BIN
deploy/authentik/branding/ultimail-favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
deploy/authentik/branding/ultimail-logo-dark.png
Normal file
BIN
deploy/authentik/branding/ultimail-logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
deploy/authentik/branding/ultimail-logo-light.png
Normal file
BIN
deploy/authentik/branding/ultimail-logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@ -26,10 +26,11 @@ services:
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@ -37,6 +38,8 @@ services:
|
||||
condition: service_healthy
|
||||
rustfs:
|
||||
condition: service_started
|
||||
authentik-server:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@ -96,9 +99,23 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME}
|
||||
AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST}
|
||||
AUTHENTIK_WEB__PATH: /auth/
|
||||
AUTHENTIK_HOST: http://${DOMAIN:-localhost}
|
||||
env_file: ../.env.resolved
|
||||
volumes:
|
||||
- ./authentik/blueprints:/blueprints/custom:ro
|
||||
- ./authentik/branding/ultimail-logo-light.png:/web/dist/assets/branding/ultimail-logo-light.png:ro
|
||||
- ./authentik/branding/ultimail-logo-dark.png:/web/dist/assets/branding/ultimail-logo-dark.png:ro
|
||||
- ./authentik/branding/ultimail-favicon.png:/web/dist/assets/branding/ultimail-favicon.png:ro
|
||||
- ./authentik/branding/ultimail-favicon-light.png:/web/dist/assets/branding/ultimail-favicon-light.png:ro
|
||||
- ./authentik/branding/ultimail-favicon-dark.png:/web/dist/assets/branding/ultimail-favicon-dark.png:ro
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD", "ak", "healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 90s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@ -117,7 +134,15 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME}
|
||||
AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST}
|
||||
AUTHENTIK_WEB__PATH: /auth/
|
||||
AUTHENTIK_HOST: http://${DOMAIN:-localhost}
|
||||
env_file: ../.env.resolved
|
||||
volumes:
|
||||
- ./authentik/blueprints:/blueprints/custom:ro
|
||||
- ./authentik/branding/ultimail-logo-light.png:/web/dist/assets/branding/ultimail-logo-light.png:ro
|
||||
- ./authentik/branding/ultimail-logo-dark.png:/web/dist/assets/branding/ultimail-logo-dark.png:ro
|
||||
- ./authentik/branding/ultimail-favicon.png:/web/dist/assets/branding/ultimail-favicon.png:ro
|
||||
- ./authentik/branding/ultimail-favicon-light.png:/web/dist/assets/branding/ultimail-favicon-light.png:ro
|
||||
- ./authentik/branding/ultimail-favicon-dark.png:/web/dist/assets/branding/ultimail-favicon-dark.png:ro
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
@ -125,6 +150,14 @@ services:
|
||||
condition: service_healthy
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
authentik-server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "ak", "healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.54.1
|
||||
@ -157,7 +190,7 @@ services:
|
||||
- ./observability/grafana/ultid-baseline.json:/etc/grafana/dashboards/ultid-baseline.json:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3002:3000"
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
|
||||
@ -29,6 +29,9 @@ services:
|
||||
- OVERWRITEWEBROOT=/cloud
|
||||
- OVERWRITECLIURL=${NC_PUBLIC_URL:-http://localhost/cloud}
|
||||
- TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||
- NC_OIDC_CLIENT_ID=${NC_OIDC_CLIENT_ID:-ulti-nextcloud}
|
||||
- NC_OIDC_CLIENT_SECRET=${NC_OIDC_CLIENT_SECRET:-changeme}
|
||||
- NC_OIDC_DISCOVERY_URL=${NC_OIDC_DISCOVERY_URL:-http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration}
|
||||
volumes:
|
||||
- nextcloud_data:/var/www/html
|
||||
- ./nextcloud/init.sh:/docker-entrypoint-hooks.d/post-installation/init.sh:ro
|
||||
|
||||
@ -23,9 +23,9 @@ $OCC app:enable user_oidc || true
|
||||
# Configure OIDC (Authentik)
|
||||
$OCC config:app:set user_oidc --value="1" allow_multiple_user_backends
|
||||
$OCC user_oidc:provider Authentik \
|
||||
--clientid="${OIDC_CLIENT_ID:-ulti-nextcloud}" \
|
||||
--clientsecret="${OIDC_CLIENT_SECRET:-changeme}" \
|
||||
--discoveryuri="${OIDC_DISCOVERY_URL:-http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration}" \
|
||||
--clientid="${NC_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-ulti-nextcloud}}" \
|
||||
--clientsecret="${NC_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-changeme}}" \
|
||||
--discoveryuri="${NC_OIDC_DISCOVERY_URL:-${OIDC_DISCOVERY_URL:-http://nginx/auth/application/o/nextcloud/.well-known/openid-configuration}}" \
|
||||
--unique-uid=1 \
|
||||
--check-bearer=1 \
|
||||
--mapping-uid=preferred_username \
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
# Edge reverse proxy — single entry point (replaces Caddy).
|
||||
# Optional upstreams use Docker DNS resolver so nginx starts even if a module is disabled.
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Reflect browser Origin for cross-origin API calls (web app on :3000, API on :80).
|
||||
map $http_origin $cors_allow_origin {
|
||||
default $http_origin;
|
||||
'' '*';
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
@ -8,7 +19,25 @@ server {
|
||||
client_max_body_size 10G;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://ultid:8080;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $ultid_upstream ultid:8080;
|
||||
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
proxy_hide_header Access-Control-Max-Age;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Vary;
|
||||
|
||||
add_header Access-Control-Allow-Origin $cors_allow_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Accept, Authorization, Content-Type, Idempotency-Key, Origin, X-Requested-With, X-Trace-Id" always;
|
||||
add_header Access-Control-Expose-Headers "X-Trace-Id" always;
|
||||
add_header Access-Control-Max-Age 300 always;
|
||||
add_header Vary Origin always;
|
||||
|
||||
proxy_pass http://$ultid_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@ -17,7 +46,15 @@ server {
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://ultid:8080;
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $ultid_ws_upstream ultid:8080;
|
||||
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
|
||||
add_header Access-Control-Allow-Origin $cors_allow_origin always;
|
||||
add_header Vary Origin always;
|
||||
|
||||
proxy_pass http://$ultid_ws_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
@ -34,6 +71,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location /meet/ {
|
||||
|
||||
5
go.mod
5
go.mod
@ -29,19 +29,24 @@ require (
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@ -38,12 +38,19 @@ github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -63,6 +70,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
@ -85,6 +94,7 @@ github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@ -93,6 +103,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
|
||||
44
internal/api/mail/account_bootstrap.go
Normal file
44
internal/api/mail/account_bootstrap.go
Normal 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
|
||||
}
|
||||
18
internal/api/mail/account_bootstrap_test.go
Normal file
18
internal/api/mail/account_bootstrap_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
230
internal/api/mail/accounts.go
Normal file
230
internal/api/mail/accounts.go
Normal 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)
|
||||
}
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
||||
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/storage"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
@ -23,6 +24,8 @@ type Handler struct {
|
||||
svc ServiceAPI
|
||||
logger *slog.Logger
|
||||
sendLimiter *sendguard.RateLimiter
|
||||
oauth *mailoauth.Service
|
||||
appURL string
|
||||
}
|
||||
|
||||
func NewHandlerWithService(svc ServiceAPI) *Handler {
|
||||
@ -39,18 +42,37 @@ func NewHandler(
|
||||
objectStorage *storage.Client,
|
||||
attachmentsBucket string,
|
||||
sendLimiter *sendguard.RateLimiter,
|
||||
oauthSvc *mailoauth.Service,
|
||||
appURL string,
|
||||
) *Handler {
|
||||
h := NewHandlerWithService(NewService(db, audit, credentialManager, objectStorage, attachmentsBucket))
|
||||
h.sendLimiter = sendLimiter
|
||||
h.oauth = oauthSvc
|
||||
h.appURL = appURL
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Get("/settings", h.GetMailSettings)
|
||||
r.Patch("/settings", h.UpdateMailSettings)
|
||||
|
||||
r.Get("/unified-folders", h.ListUnifiedFolders)
|
||||
r.Post("/unified-folders/reorder", h.ReorderUnifiedFolders)
|
||||
r.Post("/unified-folders", h.CreateUnifiedFolder)
|
||||
r.Put("/unified-folders/{folderID}", h.UpdateUnifiedFolder)
|
||||
r.Delete("/unified-folders/{folderID}", h.DeleteUnifiedFolder)
|
||||
|
||||
r.Get("/accounts", h.ListAccounts)
|
||||
r.Post("/accounts", h.CreateAccount)
|
||||
r.Get("/accounts/discover", h.DiscoverAccountConfig)
|
||||
r.Post("/accounts/test", h.TestAccountConnection)
|
||||
r.Post("/accounts/{accountID}/test", h.TestStoredAccountConnection)
|
||||
r.Get("/accounts/oauth/providers", h.ListOAuthProviders)
|
||||
r.Post("/accounts/oauth/start", h.StartOAuthAccount)
|
||||
r.Get("/accounts/{accountID}", h.GetAccount)
|
||||
r.Put("/accounts/{accountID}", h.UpdateAccount)
|
||||
r.Delete("/accounts/{accountID}", h.DeleteAccount)
|
||||
r.Get("/accounts/{accountID}/identities", h.ListIdentities)
|
||||
r.Post("/accounts/{accountID}/identities", h.CreateIdentity)
|
||||
@ -59,6 +81,12 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.Put("/identities/{identityID}", h.UpdateIdentity)
|
||||
r.Delete("/identities/{identityID}", h.DeleteIdentity)
|
||||
|
||||
r.Get("/signatures", h.ListSignatures)
|
||||
r.Post("/signatures", h.CreateSignature)
|
||||
r.Get("/signatures/{signatureID}", h.GetSignature)
|
||||
r.Put("/signatures/{signatureID}", h.UpdateSignature)
|
||||
r.Delete("/signatures/{signatureID}", h.DeleteSignature)
|
||||
|
||||
r.Mount("/", h.FolderLabelRoutes())
|
||||
|
||||
r.Get("/search", h.SearchMessages)
|
||||
@ -151,7 +179,12 @@ func (h *Handler) CreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
account, err := h.svc.GetAccount(r.Context(), claims.Sub, chi.URLParam(r, "accountID"))
|
||||
accountID := chi.URLParam(r, "accountID")
|
||||
if d := validateAccountUUID(accountID); d != nil {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
account, err := h.svc.GetAccount(r.Context(), claims.Sub, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
@ -164,9 +197,59 @@ func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
apiresponse.WriteJSON(w, http.StatusOK, account)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
accountID := chi.URLParam(r, "accountID")
|
||||
if d := validateAccountUUID(accountID); d != nil {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAccountRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateUpdateAccount(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateAccount(r.Context(), claims.Sub, accountID, &req); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrUserNotProvisioned) {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrCredentialsUnavailable) {
|
||||
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrOAuthPasswordNotAllowed) {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "oauth accounts cannot use password credentials", nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrInvalidAccountCredentials) {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "username is required for password authentication", nil)
|
||||
return
|
||||
}
|
||||
h.logger.Error("update account", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if err := h.svc.DeleteAccount(r.Context(), claims.Sub, chi.URLParam(r, "accountID")); err != nil {
|
||||
accountID := chi.URLParam(r, "accountID")
|
||||
if d := validateAccountUUID(accountID); d != nil {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteAccount(r.Context(), claims.Sub, accountID); err != nil {
|
||||
if errors.Is(err, ErrUserNotProvisioned) {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil)
|
||||
return
|
||||
|
||||
34
internal/api/mail/handlers_account_discover.go
Normal file
34
internal/api/mail/handlers_account_discover.go
Normal 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)
|
||||
}
|
||||
196
internal/api/mail/handlers_account_oauth.go
Normal file
196
internal/api/mail/handlers_account_oauth.go
Normal 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
|
||||
}
|
||||
24
internal/api/mail/handlers_account_test_route_test.go
Normal file
24
internal/api/mail/handlers_account_test_route_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ func (h *Handler) FolderLabelRoutes() chi.Router {
|
||||
r.Delete("/folders/{folderID}", h.DeleteFolder)
|
||||
|
||||
r.Get("/labels", h.ListUserLabels)
|
||||
r.Post("/labels/reorder", h.ReorderUserLabels)
|
||||
r.Post("/labels", h.CreateUserLabel)
|
||||
r.Put("/labels/{labelID}", h.UpdateUserLabel)
|
||||
r.Delete("/labels/{labelID}", h.DeleteUserLabel)
|
||||
|
||||
@ -14,13 +14,18 @@ import (
|
||||
|
||||
func (h *Handler) ListIdentities(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
accountID := chi.URLParam(r, "accountID")
|
||||
if d := validateAccountUUID(accountID); d != nil {
|
||||
apivalidate.WriteNotFound(w, r, "account not found")
|
||||
return
|
||||
}
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListIdentities(r.Context(), claims.Sub, chi.URLParam(r, "accountID"), params)
|
||||
result, err := h.svc.ListIdentities(r.Context(), claims.Sub, accountID, params)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "account not found")
|
||||
@ -60,7 +65,13 @@ func (h *Handler) CreateIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.svc.CreateIdentity(r.Context(), claims.Sub, chi.URLParam(r, "accountID"), &req)
|
||||
accountID := chi.URLParam(r, "accountID")
|
||||
if d := validateAccountUUID(accountID); d != nil {
|
||||
apivalidate.WriteNotFound(w, r, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.svc.CreateIdentity(r.Context(), claims.Sub, accountID, &req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "account not found")
|
||||
|
||||
90
internal/api/mail/handlers_reorder.go
Normal file
90
internal/api/mail/handlers_reorder.go
Normal 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)
|
||||
}
|
||||
42
internal/api/mail/handlers_settings.go
Normal file
42
internal/api/mail/handlers_settings.go
Normal 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)
|
||||
}
|
||||
125
internal/api/mail/handlers_signatures.go
Normal file
125
internal/api/mail/handlers_signatures.go
Normal 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)
|
||||
}
|
||||
@ -16,13 +16,15 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||
)
|
||||
|
||||
const (
|
||||
testExternalID = "ext-user-1"
|
||||
testExternalID2 = "ext-user-2"
|
||||
testUserID = "user-uuid-1"
|
||||
testExternalID = "ext-user-1"
|
||||
testExternalID2 = "ext-user-2"
|
||||
testUserID = "user-uuid-1"
|
||||
testMailAccountID = "550e8400-e29b-41d4-a716-446655440000"
|
||||
)
|
||||
|
||||
type fakeMailService struct {
|
||||
@ -50,6 +52,65 @@ func (f *fakeMailService) ResolveUserID(_ context.Context, externalID string) (s
|
||||
return testUserID, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) GetMailSettings(_ context.Context, externalID string) (MailSettings, error) {
|
||||
if externalID != testExternalID {
|
||||
return MailSettings{}, ErrUserNotProvisioned
|
||||
}
|
||||
return defaultMailSettings(), nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) UpdateMailSettings(_ context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) {
|
||||
if externalID != testExternalID {
|
||||
return MailSettings{}, ErrUserNotProvisioned
|
||||
}
|
||||
current := defaultMailSettings()
|
||||
if req.Density != nil {
|
||||
current.Density = *req.Density
|
||||
}
|
||||
if req.ThemeMode != nil {
|
||||
current.ThemeMode = *req.ThemeMode
|
||||
}
|
||||
if req.BackgroundID != nil {
|
||||
current.BackgroundID = *req.BackgroundID
|
||||
}
|
||||
if req.InboxSort != nil {
|
||||
current.InboxSort = *req.InboxSort
|
||||
}
|
||||
if req.ReadingPane != nil {
|
||||
current.ReadingPane = *req.ReadingPane
|
||||
}
|
||||
if req.ConversationMode != nil {
|
||||
current.ConversationMode = *req.ConversationMode
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) ListUnifiedFolders(_ context.Context, externalID, _ string, params query.ListParams) (UnifiedFoldersList, error) {
|
||||
if externalID != testExternalID {
|
||||
return UnifiedFoldersList{}, ErrUserNotProvisioned
|
||||
}
|
||||
total := int64(0)
|
||||
return UnifiedFoldersList{Pagination: params.Meta(&total)}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) CreateUnifiedFolder(_ context.Context, _ string, _ *createUnifiedFolderRequest) (string, error) {
|
||||
return "uf-1", nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) UpdateUnifiedFolder(_ context.Context, externalID, _ string, _ *updateUnifiedFolderRequest) error {
|
||||
if externalID != testExternalID {
|
||||
return ErrUserNotProvisioned
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) DeleteUnifiedFolder(_ context.Context, externalID, _ string) error {
|
||||
if externalID != testExternalID {
|
||||
return ErrUserNotProvisioned
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) ListMessages(_ context.Context, externalID string, _ MessageListFilter, params query.ListParams) (MessagesList, error) {
|
||||
if externalID != testExternalID {
|
||||
return MessagesList{}, ErrUserNotProvisioned
|
||||
@ -226,9 +287,18 @@ func (f *fakeMailService) ListAccounts(context.Context, string, query.ListParams
|
||||
func (f *fakeMailService) CreateAccount(context.Context, string, *createAccountRequest) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeMailService) CreateAccountWithCredential(context.Context, string, *createAccountRequest, credentials.Credential) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeMailService) GetAccount(context.Context, string, string) (map[string]any, error) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeMailService) UpdateAccount(context.Context, string, string, *updateAccountRequest) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMailService) CredentialForConnectionTest(context.Context, string, *testAccountRequest) (credentials.Credential, error) {
|
||||
return credentials.Credential{AuthType: credentials.AuthPassword, Username: "u", Password: "p"}, nil
|
||||
}
|
||||
func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { return nil }
|
||||
func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) {
|
||||
return map[string]any{"messages": []any{}}, nil
|
||||
@ -315,13 +385,13 @@ func (f *fakeMailService) ListIdentities(_ context.Context, externalID, accountI
|
||||
if externalID != testExternalID {
|
||||
return IdentitiesList{}, ErrAccountNotFound
|
||||
}
|
||||
if accountID != "acc-1" {
|
||||
if accountID != testMailAccountID {
|
||||
return IdentitiesList{}, ErrAccountNotFound
|
||||
}
|
||||
total := int64(1)
|
||||
return IdentitiesList{
|
||||
Identities: []map[string]any{{
|
||||
"id": "id-1", "account_id": "acc-1", "email": "sender@example.com",
|
||||
"id": "id-1", "account_id": testMailAccountID, "email": "sender@example.com",
|
||||
"name": "Sender", "is_default": true, "signature_html": "",
|
||||
"reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil,
|
||||
}},
|
||||
@ -334,14 +404,14 @@ func (f *fakeMailService) GetIdentity(_ context.Context, externalID, identityID
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return map[string]any{
|
||||
"id": "id-1", "account_id": "acc-1", "email": "sender@example.com",
|
||||
"id": "id-1", "account_id": testMailAccountID, "email": "sender@example.com",
|
||||
"name": "Sender", "is_default": true, "signature_html": "",
|
||||
"reply_to_addrs": []string{}, "created_at": nil, "updated_at": nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) CreateIdentity(_ context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) {
|
||||
if externalID != testExternalID || accountID != "acc-1" {
|
||||
if externalID != testExternalID || accountID != testMailAccountID {
|
||||
return "", ErrAccountNotFound
|
||||
}
|
||||
if req.Email == "" {
|
||||
@ -364,6 +434,42 @@ func (f *fakeMailService) DeleteIdentity(_ context.Context, externalID, identity
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) ListSignatures(_ context.Context, externalID string, params query.ListParams) (SignaturesList, error) {
|
||||
if externalID != testExternalID {
|
||||
return SignaturesList{}, ErrNotFound
|
||||
}
|
||||
total := int64(0)
|
||||
return SignaturesList{Signatures: []map[string]any{}, Pagination: params.Meta(&total)}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) GetSignature(_ context.Context, externalID, signatureID string) (map[string]any, error) {
|
||||
if externalID != testExternalID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return map[string]any{"id": signatureID, "name": "Sig", "html": "<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) {
|
||||
if externalID != testExternalID {
|
||||
return FoldersList{}, ErrAccountNotFound
|
||||
@ -429,6 +535,20 @@ func (f *fakeMailService) DeleteUserLabel(_ context.Context, externalID, labelID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) ReorderUserLabels(_ context.Context, externalID string, _ *reorderLabelsRequest) error {
|
||||
if externalID != testExternalID {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) ReorderUnifiedFolders(_ context.Context, externalID string, _ *reorderUnifiedFoldersRequest) error {
|
||||
if externalID != testExternalID {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailService) SearchMessages(_ context.Context, externalID string, _ MessageSearchFilter, params query.ListParams) (MessageSearchResult, error) {
|
||||
if externalID != testExternalID {
|
||||
return MessageSearchResult{}, ErrUserNotProvisioned
|
||||
@ -557,7 +677,7 @@ func TestSendMessage(t *testing.T) {
|
||||
router := newTestMailRouter(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"account_id": "acc-1",
|
||||
"account_id": testMailAccountID,
|
||||
"to": []string{"recipient@example.com"},
|
||||
"subject": "Test subject",
|
||||
"body_text": "Hello world",
|
||||
@ -973,3 +1093,66 @@ func TestSimulateRule(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailSettingsHandlers(t *testing.T) {
|
||||
svc := newFakeMailService()
|
||||
router := newTestMailRouter(svc)
|
||||
|
||||
t.Run("get defaults", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var body MailSettings
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Density != "default" || body.ThemeMode != "system" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("patch density", func(t *testing.T) {
|
||||
payload, err := json.Marshal(map[string]string{"density": "compact"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var body MailSettings
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Density != "compact" {
|
||||
t.Fatalf("density = %q, want compact", body.Density)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("patch invalid", func(t *testing.T) {
|
||||
payload, err := json.Marshal(map[string]string{"density": "invalid"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
113
internal/api/mail/handlers_unified_folders.go
Normal file
113
internal/api/mail/handlers_unified_folders.go
Normal 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)
|
||||
}
|
||||
@ -17,7 +17,7 @@ type IdentitiesList struct {
|
||||
}
|
||||
|
||||
const identitySelectColumns = `
|
||||
mi.id, mi.account_id, mi.email, mi.name, mi.is_default, mi.signature_html, mi.reply_to_addrs, mi.created_at, mi.updated_at
|
||||
mi.id, mi.account_id, mi.email, mi.name, mi.is_default, mi.signature_html, mi.default_signature_id, mi.reply_to_addrs, mi.created_at, mi.updated_at
|
||||
`
|
||||
|
||||
func (s *Service) verifyAccountOwnership(ctx context.Context, externalID, accountID string) error {
|
||||
@ -46,19 +46,30 @@ func identityOwnershipJoin() string {
|
||||
`
|
||||
}
|
||||
|
||||
func scanIdentity(id, accountID, email, name, signatureHTML string, isDefault bool, replyToJSON []byte, createdAt, updatedAt any) map[string]any {
|
||||
func scanIdentity(id, accountID, email, name, signatureHTML string, isDefault bool, defaultSignatureID *string, replyToJSON []byte, createdAt, updatedAt any) map[string]any {
|
||||
replyTo := parseReplyToAddrs(replyToJSON)
|
||||
return map[string]any{
|
||||
"id": id,
|
||||
"account_id": accountID,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"is_default": isDefault,
|
||||
"signature_html": signatureHTML,
|
||||
"reply_to_addrs": replyTo,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
out := map[string]any{
|
||||
"id": id,
|
||||
"account_id": accountID,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"is_default": isDefault,
|
||||
"signature_html": signatureHTML,
|
||||
"reply_to_addrs": replyTo,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
if defaultSignatureID != nil && *defaultSignatureID != "" {
|
||||
out["default_signature_id"] = *defaultSignatureID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nullableUUID(id string) any {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func parseReplyToAddrs(raw []byte) []string {
|
||||
@ -104,12 +115,13 @@ func (s *Service) ListIdentities(ctx context.Context, externalID, accountID stri
|
||||
for rows.Next() {
|
||||
var id, acctID, email, name, signatureHTML string
|
||||
var isDefault bool
|
||||
var defaultSignatureID *string
|
||||
var replyToJSON []byte
|
||||
var createdAt, updatedAt any
|
||||
if err := rows.Scan(&id, &acctID, &email, &name, &isDefault, &signatureHTML, &replyToJSON, &createdAt, &updatedAt); err != nil {
|
||||
if err := rows.Scan(&id, &acctID, &email, &name, &isDefault, &signatureHTML, &defaultSignatureID, &replyToJSON, &createdAt, &updatedAt); err != nil {
|
||||
return IdentitiesList{}, err
|
||||
}
|
||||
identities = append(identities, scanIdentity(id, acctID, email, name, signatureHTML, isDefault, replyToJSON, createdAt, updatedAt))
|
||||
identities = append(identities, scanIdentity(id, acctID, email, name, signatureHTML, isDefault, defaultSignatureID, replyToJSON, createdAt, updatedAt))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return IdentitiesList{}, err
|
||||
@ -126,10 +138,11 @@ func (s *Service) GetIdentity(ctx context.Context, externalID, identityID string
|
||||
|
||||
var id, accountID, email, name, signatureHTML string
|
||||
var isDefault bool
|
||||
var defaultSignatureID *string
|
||||
var replyToJSON []byte
|
||||
var createdAt, updatedAt any
|
||||
err := s.db.QueryRow(ctx, query, identityID, externalID).Scan(
|
||||
&id, &accountID, &email, &name, &isDefault, &signatureHTML, &replyToJSON, &createdAt, &updatedAt,
|
||||
&id, &accountID, &email, &name, &isDefault, &signatureHTML, &defaultSignatureID, &replyToJSON, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@ -137,7 +150,7 @@ func (s *Service) GetIdentity(ctx context.Context, externalID, identityID string
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return scanIdentity(id, accountID, email, name, signatureHTML, isDefault, replyToJSON, createdAt, updatedAt), nil
|
||||
return scanIdentity(id, accountID, email, name, signatureHTML, isDefault, defaultSignatureID, replyToJSON, createdAt, updatedAt), nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateIdentity(ctx context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) {
|
||||
@ -151,14 +164,25 @@ func (s *Service) CreateIdentity(ctx context.Context, externalID, accountID stri
|
||||
}
|
||||
}
|
||||
|
||||
if req.DefaultSignatureID == "" {
|
||||
sigID, err := s.ensureIdentityDefaultSignature(ctx, externalID, req.Email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.DefaultSignatureID = sigID
|
||||
} else if err := s.verifySignatureOwnership(ctx, externalID, req.DefaultSignatureID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
|
||||
defaultSigID := nullableUUID(req.DefaultSignatureID)
|
||||
|
||||
var id string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, reply_to_addrs)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, default_signature_id, reply_to_addrs)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`, accountID, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON).Scan(&id)
|
||||
`, accountID, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, replyToJSON).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -178,15 +202,23 @@ func (s *Service) UpdateIdentity(ctx context.Context, externalID, identityID str
|
||||
}
|
||||
}
|
||||
|
||||
if req.DefaultSignatureID != "" {
|
||||
if err := s.verifySignatureOwnership(ctx, externalID, req.DefaultSignatureID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
|
||||
defaultSigID := nullableUUID(req.DefaultSignatureID)
|
||||
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE mail_identities mi SET
|
||||
email = $1, name = $2, is_default = $3, signature_html = $4, reply_to_addrs = $5, updated_at = NOW()
|
||||
email = $1, name = $2, is_default = $3, signature_html = $4,
|
||||
default_signature_id = $5, reply_to_addrs = $6, updated_at = NOW()
|
||||
FROM mail_accounts ma
|
||||
JOIN users u ON ma.user_id = u.id
|
||||
WHERE mi.id = $6 AND mi.account_id = ma.id AND u.external_id = $7
|
||||
`, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON, identityID, externalID)
|
||||
WHERE mi.id = $7 AND mi.account_id = ma.id AND u.external_id = $8
|
||||
`, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, replyToJSON, identityID, externalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ func TestListIdentities(t *testing.T) {
|
||||
svc := newFakeMailService()
|
||||
router := newTestIdentityRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts/acc-1/identities", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts/"+testMailAccountID+"/identities", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
@ -65,7 +65,7 @@ func TestCreateIdentity(t *testing.T) {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/acc-1/identities", bytes.NewReader(body))
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/"+testMailAccountID+"/identities", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
@ -23,10 +23,10 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, name, color, created_at
|
||||
SELECT id, name, color, sort_order, created_at
|
||||
FROM mail_user_labels
|
||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
ORDER BY name ASC
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, externalID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
@ -37,12 +37,13 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params
|
||||
labels := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, name, color string
|
||||
var sortOrder int
|
||||
var createdAt any
|
||||
if err := rows.Scan(&id, &name, &color, &createdAt); err != nil {
|
||||
if err := rows.Scan(&id, &name, &color, &sortOrder, &createdAt); err != nil {
|
||||
return UserLabelsList{}, err
|
||||
}
|
||||
labels = append(labels, map[string]any{
|
||||
"id": id, "name": name, "color": color, "created_at": createdAt,
|
||||
"id": id, "name": name, "color": color, "sort_order": sortOrder, "created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@ -58,8 +59,14 @@ func (s *Service) ListUserLabels(ctx context.Context, externalID string, params
|
||||
func (s *Service) CreateUserLabel(ctx context.Context, externalID string, req *createUserLabelRequest) (string, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO mail_user_labels (user_id, name, color)
|
||||
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3)
|
||||
WITH next_order AS (
|
||||
SELECT COALESCE(MAX(sort_order), -10) + 10 AS sort_order
|
||||
FROM mail_user_labels
|
||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
)
|
||||
INSERT INTO mail_user_labels (user_id, name, color, sort_order)
|
||||
SELECT (SELECT id FROM users WHERE external_id = $1), $2, $3, next_order.sort_order
|
||||
FROM next_order
|
||||
RETURNING id
|
||||
`, externalID, req.Name, req.Color).Scan(&id)
|
||||
if err != nil {
|
||||
|
||||
131
internal/api/mail/reorder.go
Normal file
131
internal/api/mail/reorder.go
Normal 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)
|
||||
}
|
||||
@ -19,10 +19,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUserNotProvisioned = errors.New("user not provisioned")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrCredentialsUnavailable = errors.New("credentials encryption unavailable")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUserNotProvisioned = errors.New("user not provisioned")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrCredentialsUnavailable = errors.New("credentials encryption unavailable")
|
||||
ErrOAuthPasswordNotAllowed = errors.New("password cannot be set on oauth account")
|
||||
ErrInvalidAccountCredentials = errors.New("account credentials invalid")
|
||||
ErrInvalidFolderScope = errors.New("invalid folder scope")
|
||||
ErrFolderHasChildren = errors.New("folder has children")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@ -108,10 +112,18 @@ func (s *Service) ListAccounts(ctx context.Context, externalID string, params qu
|
||||
}
|
||||
|
||||
func (s *Service) CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error) {
|
||||
return s.CreateAccountWithCredential(ctx, externalID, req, credentials.Credential{
|
||||
AuthType: credentials.AuthPassword,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) CreateAccountWithCredential(ctx context.Context, externalID string, req *createAccountRequest, cred credentials.Credential) (string, error) {
|
||||
if s.credentials == nil {
|
||||
return "", ErrCredentialsUnavailable
|
||||
}
|
||||
creds, err := s.credentials.Encrypt(req.Username, req.Password)
|
||||
encrypted, err := s.credentials.EncryptCredential(cred)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -121,26 +133,15 @@ func (s *Service) CreateAccount(ctx context.Context, externalID string, req *cre
|
||||
INSERT INTO mail_accounts (user_id, name, email, provider, imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls, credentials)
|
||||
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, externalID, req.Name, req.Email, req.Provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, creds).Scan(&id)
|
||||
`, externalID, req.Name, req.Email, req.Provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, encrypted).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) {
|
||||
var id, name, email, provider string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, name, email, provider FROM mail_accounts
|
||||
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||
`, accountID, externalID).Scan(&id, &name, &email, &provider)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
if err := s.bootstrapAccountDefaults(ctx, externalID, id, req.Email, req.Name); err != nil {
|
||||
_, _ = s.db.Exec(ctx, `DELETE FROM mail_accounts WHERE id = $1`, id)
|
||||
return "", fmt.Errorf("bootstrap account defaults: %w", err)
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "email": email, "provider": provider}, nil
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID string) error {
|
||||
|
||||
@ -6,15 +6,25 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||
)
|
||||
|
||||
// ServiceAPI is the mail handler service boundary. *Service implements it in production.
|
||||
type ServiceAPI interface {
|
||||
ResolveUserID(ctx context.Context, externalID string) (string, error)
|
||||
GetMailSettings(ctx context.Context, externalID string) (MailSettings, error)
|
||||
UpdateMailSettings(ctx context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error)
|
||||
ListUnifiedFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (UnifiedFoldersList, error)
|
||||
CreateUnifiedFolder(ctx context.Context, userID string, req *createUnifiedFolderRequest) (string, error)
|
||||
UpdateUnifiedFolder(ctx context.Context, externalID, folderID string, req *updateUnifiedFolderRequest) error
|
||||
DeleteUnifiedFolder(ctx context.Context, externalID, folderID string) error
|
||||
ListAccounts(ctx context.Context, externalID string, params query.ListParams) (AccountsList, error)
|
||||
CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error)
|
||||
CreateAccountWithCredential(ctx context.Context, externalID string, req *createAccountRequest, cred credentials.Credential) (string, error)
|
||||
GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error)
|
||||
UpdateAccount(ctx context.Context, externalID, accountID string, req *updateAccountRequest) error
|
||||
CredentialForConnectionTest(ctx context.Context, externalID string, req *testAccountRequest) (credentials.Credential, error)
|
||||
DeleteAccount(ctx context.Context, externalID, accountID string) error
|
||||
ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error)
|
||||
GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error)
|
||||
@ -46,6 +56,11 @@ type ServiceAPI interface {
|
||||
CreateIdentity(ctx context.Context, externalID, accountID string, req *createIdentityRequest) (string, error)
|
||||
UpdateIdentity(ctx context.Context, externalID, identityID string, req *updateIdentityRequest) error
|
||||
DeleteIdentity(ctx context.Context, externalID, identityID string) error
|
||||
ListSignatures(ctx context.Context, externalID string, params query.ListParams) (SignaturesList, error)
|
||||
GetSignature(ctx context.Context, externalID, signatureID string) (map[string]any, error)
|
||||
CreateSignature(ctx context.Context, externalID string, req *createSignatureRequest) (string, error)
|
||||
UpdateSignature(ctx context.Context, externalID, signatureID string, req *updateSignatureRequest) error
|
||||
DeleteSignature(ctx context.Context, externalID, signatureID string) error
|
||||
ListFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error)
|
||||
GetFolder(ctx context.Context, externalID, folderID string) (map[string]any, error)
|
||||
CreateFolder(ctx context.Context, userID string, req *createFolderRequest) (string, error)
|
||||
@ -55,6 +70,8 @@ type ServiceAPI interface {
|
||||
CreateUserLabel(ctx context.Context, externalID string, req *createUserLabelRequest) (string, error)
|
||||
UpdateUserLabel(ctx context.Context, externalID, labelID string, req *updateUserLabelRequest) error
|
||||
DeleteUserLabel(ctx context.Context, externalID, labelID string) error
|
||||
ReorderUserLabels(ctx context.Context, externalID string, req *reorderLabelsRequest) error
|
||||
ReorderUnifiedFolders(ctx context.Context, externalID string, req *reorderUnifiedFoldersRequest) error
|
||||
SearchMessages(ctx context.Context, externalID string, filter MessageSearchFilter, params query.ListParams) (MessageSearchResult, error)
|
||||
ListMessageAttachments(ctx context.Context, externalID, messageID string) ([]map[string]any, error)
|
||||
MessageAttachmentCIDMap(ctx context.Context, externalID, messageID string) (map[string]string, error)
|
||||
|
||||
142
internal/api/mail/settings.go
Normal file
142
internal/api/mail/settings.go
Normal 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, ¬ificationsJSON, &updatedAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return defaults, nil
|
||||
}
|
||||
return MailSettings{}, err
|
||||
}
|
||||
|
||||
if density != nil && *density != "" {
|
||||
defaults.Density = *density
|
||||
}
|
||||
if themeMode != nil && *themeMode != "" {
|
||||
defaults.ThemeMode = *themeMode
|
||||
}
|
||||
if backgroundID != nil && *backgroundID != "" {
|
||||
defaults.BackgroundID = *backgroundID
|
||||
}
|
||||
if inboxSort != nil && *inboxSort != "" {
|
||||
defaults.InboxSort = *inboxSort
|
||||
}
|
||||
if readingPane != nil && *readingPane != "" {
|
||||
defaults.ReadingPane = *readingPane
|
||||
}
|
||||
if conversationMode != nil {
|
||||
defaults.ConversationMode = *conversationMode
|
||||
}
|
||||
if len(notificationsJSON) > 0 && string(notificationsJSON) != "null" {
|
||||
var n MailNotificationSettings
|
||||
if err := json.Unmarshal(notificationsJSON, &n); err == nil {
|
||||
defaults.Notifications = n
|
||||
}
|
||||
}
|
||||
if updatedAt != nil {
|
||||
defaults.UpdatedAt = updatedAt
|
||||
}
|
||||
return defaults, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateMailSettings(ctx context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) {
|
||||
patch := map[string]any{}
|
||||
if req.Density != nil {
|
||||
patch["density"] = strings.ToLower(strings.TrimSpace(*req.Density))
|
||||
}
|
||||
if req.ThemeMode != nil {
|
||||
patch["theme_mode"] = strings.ToLower(strings.TrimSpace(*req.ThemeMode))
|
||||
}
|
||||
if req.BackgroundID != nil {
|
||||
patch["background_id"] = strings.ToLower(strings.TrimSpace(*req.BackgroundID))
|
||||
}
|
||||
if req.InboxSort != nil {
|
||||
patch["inbox_sort"] = strings.ToLower(strings.TrimSpace(*req.InboxSort))
|
||||
}
|
||||
if req.ReadingPane != nil {
|
||||
patch["reading_pane"] = strings.ToLower(strings.TrimSpace(*req.ReadingPane))
|
||||
}
|
||||
if req.ConversationMode != nil {
|
||||
patch["conversation_mode"] = *req.ConversationMode
|
||||
}
|
||||
if req.Notifications != nil {
|
||||
current, err := s.GetMailSettings(ctx, externalID)
|
||||
if err != nil {
|
||||
return MailSettings{}, err
|
||||
}
|
||||
merged := current.Notifications
|
||||
if req.Notifications.DesktopNewMail != nil {
|
||||
merged.DesktopNewMail = *req.Notifications.DesktopNewMail
|
||||
}
|
||||
if req.Notifications.DesktopMentions != nil {
|
||||
merged.DesktopMentions = *req.Notifications.DesktopMentions
|
||||
}
|
||||
if req.Notifications.EmailDigest != nil {
|
||||
merged.EmailDigest = *req.Notifications.EmailDigest
|
||||
}
|
||||
if req.Notifications.SoundEnabled != nil {
|
||||
merged.SoundEnabled = *req.Notifications.SoundEnabled
|
||||
}
|
||||
patch["notifications"] = merged
|
||||
}
|
||||
|
||||
patchJSON, err := json.Marshal(patch)
|
||||
if err != nil {
|
||||
return MailSettings{}, err
|
||||
}
|
||||
|
||||
var updatedAt any
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO settings (user_id, preferences)
|
||||
VALUES (
|
||||
(SELECT id FROM users WHERE external_id = $1),
|
||||
jsonb_build_object('mail', $2::jsonb)
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
preferences = jsonb_set(
|
||||
COALESCE(settings.preferences, '{}'::jsonb),
|
||||
'{mail}',
|
||||
COALESCE(settings.preferences->'mail', '{}'::jsonb) || $2::jsonb
|
||||
),
|
||||
updated_at = NOW()
|
||||
RETURNING updated_at
|
||||
`, externalID, patchJSON).Scan(&updatedAt)
|
||||
if err != nil {
|
||||
return MailSettings{}, err
|
||||
}
|
||||
|
||||
result, err := s.GetMailSettings(ctx, externalID)
|
||||
if err != nil {
|
||||
return MailSettings{}, err
|
||||
}
|
||||
result.UpdatedAt = updatedAt
|
||||
return result, nil
|
||||
}
|
||||
161
internal/api/mail/signatures.go
Normal file
161
internal/api/mail/signatures.go
Normal 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
|
||||
}
|
||||
284
internal/api/mail/unified_folders.go
Normal file
284
internal/api/mail/unified_folders.go
Normal 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
|
||||
}
|
||||
@ -11,10 +11,19 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
||||
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
|
||||
)
|
||||
|
||||
func validateAccountUUID(accountID string) *apivalidate.FieldDetail {
|
||||
if _, err := uuid.Parse(accountID); err != nil {
|
||||
return &apivalidate.FieldDetail{Field: "account_id", Message: "invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
maxAccountRequestBody = 32 << 10 // 32 KiB
|
||||
maxWebhookRequestBody = 128 << 10 // 128 KiB
|
||||
@ -152,6 +161,56 @@ type createAccountRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type updateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Provider string `json:"provider"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
IMAPTLS bool `json:"imap_tls"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPTLS bool `json:"smtp_tls"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func validateUpdateAccount(req *updateAccountRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if req.Name != "" && len(req.Name) > maxAccountName {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
|
||||
}
|
||||
if d := validateEmailField("email", req.Email); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateHostField("imap_host", req.IMAPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateHostField("smtp_host", req.SMTPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("imap_port", req.IMAPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("smtp_port", req.SMTPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if u := strings.TrimSpace(req.Username); u != "" {
|
||||
if d := validateCredentialField("username", u, maxUsernameLen); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
if req.Password != "" {
|
||||
if d := validateCredentialField("password", req.Password, maxPasswordLen); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if req.Name != "" && len(req.Name) > maxAccountName {
|
||||
@ -184,6 +243,112 @@ func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationErr
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type testAccountRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
IMAPTLS bool `json:"imap_tls"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPTLS bool `json:"smtp_tls"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AuthType string `json:"auth_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
OAuthProvider string `json:"oauth_provider"`
|
||||
}
|
||||
|
||||
func validateTestAccount(req *testAccountRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
useStoredCreds := strings.TrimSpace(req.AccountID) != ""
|
||||
|
||||
if useStoredCreds {
|
||||
if d := validateAccountUUID(req.AccountID); d != nil {
|
||||
d.Field = "account_id"
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
|
||||
if d := validateHostField("imap_host", req.IMAPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateHostField("smtp_host", req.SMTPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("imap_port", req.IMAPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("smtp_port", req.SMTPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if u := strings.TrimSpace(req.Username); u != "" {
|
||||
if d := validateCredentialField("username", u, maxUsernameLen); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
} else if !useStoredCreds {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "username", Message: "required"})
|
||||
}
|
||||
|
||||
if req.AuthType == "oauth2" {
|
||||
if strings.TrimSpace(req.AccessToken) == "" && !useStoredCreds {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "access_token", Message: "required"})
|
||||
}
|
||||
} else if req.Password != "" {
|
||||
if d := validateCredentialField("password", req.Password, maxPasswordLen); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
} else if !useStoredCreds {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "password", Message: "required"})
|
||||
}
|
||||
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type oauthStartRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
IMAPTLS bool `json:"imap_tls"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPTLS bool `json:"smtp_tls"`
|
||||
}
|
||||
|
||||
func validateOAuthStart(req *oauthStartRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if req.Provider != string(mailoauth.ProviderGoogle) && req.Provider != string(mailoauth.ProviderMicrosoft) {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "provider", Message: "invalid"})
|
||||
}
|
||||
if d := validateEmailField("email", req.Email); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if req.Name != "" && len(req.Name) > maxAccountName {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
|
||||
}
|
||||
if d := validateHostField("imap_host", req.IMAPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateHostField("smtp_host", req.SMTPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("imap_port", req.IMAPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("smtp_port", req.SMTPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To []string `json:"to"`
|
||||
|
||||
55
internal/api/mail/validate_accounts_test.go
Normal file
55
internal/api/mail/validate_accounts_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -15,19 +15,21 @@ const (
|
||||
)
|
||||
|
||||
type createIdentityRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
SignatureHTML string `json:"signature_html"`
|
||||
ReplyToAddrs []string `json:"reply_to_addrs"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
SignatureHTML string `json:"signature_html"`
|
||||
DefaultSignatureID string `json:"default_signature_id"`
|
||||
ReplyToAddrs []string `json:"reply_to_addrs"`
|
||||
}
|
||||
|
||||
type updateIdentityRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
SignatureHTML string `json:"signature_html"`
|
||||
ReplyToAddrs []string `json:"reply_to_addrs"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
SignatureHTML string `json:"signature_html"`
|
||||
DefaultSignatureID string `json:"default_signature_id"`
|
||||
ReplyToAddrs []string `json:"reply_to_addrs"`
|
||||
}
|
||||
|
||||
func validateReplyToAddrs(field string, addrs []string) []apivalidate.FieldDetail {
|
||||
@ -57,6 +59,9 @@ func validateCreateIdentity(req *createIdentityRequest) *apivalidate.ValidationE
|
||||
if len(req.SignatureHTML) > maxSignatureHTML {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "signature_html", Message: "too long"})
|
||||
}
|
||||
if d := validateOptionalUUID("default_signature_id", req.DefaultSignatureID); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if req.ReplyToAddrs == nil {
|
||||
req.ReplyToAddrs = []string{}
|
||||
}
|
||||
@ -80,6 +85,9 @@ func validateUpdateIdentity(req *updateIdentityRequest) *apivalidate.ValidationE
|
||||
if len(req.SignatureHTML) > maxSignatureHTML {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "signature_html", Message: "too long"})
|
||||
}
|
||||
if d := validateOptionalUUID("default_signature_id", req.DefaultSignatureID); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if req.ReplyToAddrs == nil {
|
||||
req.ReplyToAddrs = []string{}
|
||||
}
|
||||
@ -89,3 +97,10 @@ func validateUpdateIdentity(req *updateIdentityRequest) *apivalidate.ValidationE
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
func validateOptionalUUID(field, value string) *apivalidate.FieldDetail {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return validateAccountUUID(value)
|
||||
}
|
||||
|
||||
159
internal/api/mail/validate_settings.go
Normal file
159
internal/api/mail/validate_settings.go
Normal 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...)
|
||||
}
|
||||
43
internal/api/mail/validate_settings_test.go
Normal file
43
internal/api/mail/validate_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
internal/api/mail/validate_signatures.go
Normal file
59
internal/api/mail/validate_signatures.go
Normal 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...)
|
||||
}
|
||||
47
internal/api/mail/validate_unified_folders.go
Normal file
47
internal/api/mail/validate_unified_folders.go
Normal 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
|
||||
}
|
||||
@ -2,6 +2,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
@ -17,17 +22,70 @@ type Verifier struct {
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewVerifier(ctx context.Context, issuerURL, clientID string) (*Verifier, error) {
|
||||
provider, err := oidc.NewProvider(ctx, issuerURL)
|
||||
// NewVerifier builds an ID token verifier. issuerURL is the URL ultid uses to reach
|
||||
// the provider (e.g. http://nginx/auth/application/o/ulti/ in Docker).
|
||||
// discoveryHost is sent as the HTTP Host header (e.g. localhost) so Authentik returns
|
||||
// the same issuer claim as browser-issued tokens; JWKS is fetched via issuerURL.
|
||||
func NewVerifier(ctx context.Context, issuerURL, clientID, discoveryHost string) (*Verifier, error) {
|
||||
issuerURL = strings.TrimSuffix(strings.TrimSpace(issuerURL), "/")
|
||||
if issuerURL == "" {
|
||||
return nil, fmt.Errorf("empty issuer URL")
|
||||
}
|
||||
|
||||
discovery, err := fetchDiscovery(ctx, issuerURL, discoveryHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
|
||||
return &Verifier{verifier: verifier}, nil
|
||||
keySet := oidc.NewRemoteKeySet(ctx, issuerURL+"/jwks/")
|
||||
idVerifier := oidc.NewVerifier(discovery.Issuer, keySet, &oidc.Config{ClientID: clientID})
|
||||
return &Verifier{verifier: idVerifier}, nil
|
||||
}
|
||||
|
||||
type discoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
func fetchDiscovery(ctx context.Context, issuerURL, discoveryHost string) (*discoveryDocument, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
issuerURL+"/.well-known/openid-configuration",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if discoveryHost != "" {
|
||||
req.Host = discoveryHost
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("oidc discovery %s: %s: %s", issuerURL, resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var doc discoveryDocument
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.Issuer == "" {
|
||||
return nil, fmt.Errorf("oidc discovery %s: missing issuer", issuerURL)
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) Verify(ctx context.Context, rawToken string) (*Claims, error) {
|
||||
if v == nil || v.verifier == nil {
|
||||
return nil, fmt.Errorf("verifier unavailable")
|
||||
}
|
||||
|
||||
token, err := v.verifier.Verify(ctx, rawToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
44
internal/auth/oidc_test.go
Normal file
44
internal/auth/oidc_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
43
internal/auth/verifier_retry.go
Normal file
43
internal/auth/verifier_retry.go
Normal 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
|
||||
}
|
||||
@ -15,6 +15,9 @@ type Config struct {
|
||||
Domain string
|
||||
AppEnv string
|
||||
|
||||
// Browser clients (web app on another origin than the API gateway).
|
||||
CORSAllowedOrigins []string
|
||||
|
||||
// PostgreSQL
|
||||
DatabaseURL string
|
||||
|
||||
@ -65,6 +68,14 @@ type Config struct {
|
||||
MailActiveCredentialKeyID string
|
||||
MailWebhookSharedSecret string
|
||||
|
||||
MailGoogleOAuthClientID string
|
||||
MailGoogleOAuthClientSecret string
|
||||
MailMicrosoftOAuthClientID string
|
||||
MailMicrosoftOAuthSecret string
|
||||
MailMicrosoftOAuthTenant string
|
||||
MailOAuthRedirectURL string
|
||||
MailAppURL string
|
||||
|
||||
// Secret rotation policy
|
||||
SecretRotationMaxAge time.Duration
|
||||
OIDCSecretRotatedAt time.Time
|
||||
@ -99,6 +110,7 @@ func Load() (*Config, error) {
|
||||
Port: port,
|
||||
Domain: envOrDefault("DOMAIN", "localhost"),
|
||||
AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))),
|
||||
CORSAllowedOrigins: parseCSVEnv("ULTID_CORS_ALLOWED_ORIGINS"),
|
||||
|
||||
DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"),
|
||||
KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"),
|
||||
@ -141,6 +153,14 @@ func Load() (*Config, error) {
|
||||
MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""),
|
||||
MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"),
|
||||
|
||||
MailGoogleOAuthClientID: os.Getenv("MAIL_GOOGLE_OAUTH_CLIENT_ID"),
|
||||
MailGoogleOAuthClientSecret: secrets.Env("MAIL_GOOGLE_OAUTH_CLIENT_SECRET"),
|
||||
MailMicrosoftOAuthClientID: os.Getenv("MAIL_MICROSOFT_OAUTH_CLIENT_ID"),
|
||||
MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"),
|
||||
MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"),
|
||||
MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"),
|
||||
MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")),
|
||||
|
||||
SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour),
|
||||
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),
|
||||
SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"),
|
||||
@ -205,6 +225,22 @@ func envOrDefault(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseCSVEnv(key string) []string {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envOrDefaultSecret(key, fallback string) string {
|
||||
if v := secrets.Env(key); v != "" {
|
||||
return v
|
||||
|
||||
64
internal/dbmigrate/migrate.go
Normal file
64
internal/dbmigrate/migrate.go
Normal 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
|
||||
}
|
||||
95
internal/httpcors/httpcors.go
Normal file
95
internal/httpcors/httpcors.go
Normal 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()
|
||||
}
|
||||
95
internal/httpcors/httpcors_test.go
Normal file
95
internal/httpcors/httpcors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
69
internal/mail/autoconfig/discover.go
Normal file
69
internal/mail/autoconfig/discover.go
Normal 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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
76
internal/mail/autoconfig/discover_test.go
Normal file
76
internal/mail/autoconfig/discover_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
5
internal/mail/autoconfig/errors.go
Normal file
5
internal/mail/autoconfig/errors.go
Normal file
@ -0,0 +1,5 @@
|
||||
package autoconfig
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrInvalidEmail = errors.New("invalid email address")
|
||||
140
internal/mail/autoconfig/mozilla.go
Normal file
140
internal/mail/autoconfig/mozilla.go
Normal 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"
|
||||
}
|
||||
42
internal/mail/autoconfig/mx.go
Normal file
42
internal/mail/autoconfig/mx.go
Normal 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
|
||||
}
|
||||
280
internal/mail/autoconfig/providers.go
Normal file
280
internal/mail/autoconfig/providers.go
Normal 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...),
|
||||
}
|
||||
}
|
||||
20
internal/mail/autoconfig/types.go
Normal file
20
internal/mail/autoconfig/types.go
Normal 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"`
|
||||
}
|
||||
16
internal/mail/connect/auth.go
Normal file
16
internal/mail/connect/auth.go
Normal 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)
|
||||
}
|
||||
157
internal/mail/connect/test.go
Normal file
157
internal/mail/connect/test.go
Normal 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
|
||||
}
|
||||
101
internal/mail/credentials/credential.go
Normal file
101
internal/mail/credentials/credential.go
Normal 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
|
||||
}
|
||||
8
internal/mail/credentials/errors.go
Normal file
8
internal/mail/credentials/errors.go
Normal file
@ -0,0 +1,8 @@
|
||||
package credentials
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errIncomplete = errors.New("decrypted credentials are incomplete")
|
||||
errUnsupportedAuth = errors.New("unsupported credential auth type")
|
||||
)
|
||||
@ -19,11 +19,6 @@ type Manager struct {
|
||||
keys map[string][]byte
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func NewManager(keysSpec, activeKeyID string) (*Manager, error) {
|
||||
keys, err := parseKeys(keysSpec)
|
||||
if err != nil {
|
||||
@ -50,6 +45,48 @@ func NewManager(keysSpec, activeKeyID string) (*Manager, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) Encrypt(username, password string) ([]byte, error) {
|
||||
return m.EncryptCredential(Credential{
|
||||
AuthType: AuthPassword,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) EncryptCredential(c Credential) ([]byte, error) {
|
||||
if c.AuthType == "" {
|
||||
c.AuthType = AuthPassword
|
||||
}
|
||||
rawPayload, err := json.Marshal(c.toStored())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
return m.encryptRaw(rawPayload)
|
||||
}
|
||||
|
||||
func (m *Manager) Decrypt(blob []byte) (string, string, error) {
|
||||
c, err := m.DecryptCredential(blob)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if c.IsOAuth() {
|
||||
return c.Username, c.AccessToken, nil
|
||||
}
|
||||
return c.Username, c.Password, nil
|
||||
}
|
||||
|
||||
func (m *Manager) DecryptCredential(blob []byte) (Credential, error) {
|
||||
plaintext, err := m.decryptRaw(blob)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
var p storedPayload
|
||||
if err := json.Unmarshal(plaintext, &p); err != nil {
|
||||
return Credential{}, fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
return storedToCredential(p)
|
||||
}
|
||||
|
||||
func (m *Manager) encryptRaw(rawPayload []byte) ([]byte, error) {
|
||||
key := m.keys[m.activeKeyID]
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@ -66,11 +103,6 @@ func (m *Manager) Encrypt(username, password string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("nonce: %w", err)
|
||||
}
|
||||
|
||||
rawPayload, err := json.Marshal(payload{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, rawPayload, nil)
|
||||
|
||||
serialized := strings.Join([]string{
|
||||
@ -82,49 +114,41 @@ func (m *Manager) Encrypt(username, password string) ([]byte, error) {
|
||||
return []byte(serialized), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Decrypt(blob []byte) (string, string, error) {
|
||||
func (m *Manager) decryptRaw(blob []byte) ([]byte, error) {
|
||||
parts := strings.Split(string(blob), "|")
|
||||
if len(parts) != 4 || parts[0] != strings.TrimSuffix(prefix, "|") {
|
||||
return "", "", errors.New("credentials payload is not encrypted with supported format")
|
||||
return nil, errors.New("credentials payload is not encrypted with supported format")
|
||||
}
|
||||
|
||||
keyID := parts[1]
|
||||
key, ok := m.keys[keyID]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown credential key id %q", keyID)
|
||||
return nil, fmt.Errorf("unknown credential key id %q", keyID)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode nonce: %w", err)
|
||||
return nil, fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode ciphertext: %w", err)
|
||||
return nil, fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("new cipher: %w", err)
|
||||
return nil, fmt.Errorf("new cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("new gcm: %w", err)
|
||||
return nil, fmt.Errorf("new gcm: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decrypt: %w", err)
|
||||
return nil, fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
var p payload
|
||||
if err := json.Unmarshal(plaintext, &p); err != nil {
|
||||
return "", "", fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if p.Username == "" || p.Password == "" {
|
||||
return "", "", errors.New("decrypted credentials are incomplete")
|
||||
}
|
||||
return p.Username, p.Password, nil
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func IsEncrypted(blob []byte) bool {
|
||||
|
||||
@ -5,6 +5,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptOAuth(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
manager, err := NewManager("v1:"+key, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("new manager: %v", err)
|
||||
}
|
||||
|
||||
blob, err := manager.EncryptCredential(Credential{
|
||||
AuthType: AuthOAuth2,
|
||||
Username: "user@gmail.com",
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
OAuthProvider: "google",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt oauth: %v", err)
|
||||
}
|
||||
|
||||
cred, err := manager.DecryptCredential(blob)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt oauth: %v", err)
|
||||
}
|
||||
if !cred.IsOAuth() || cred.AccessToken != "access-token" {
|
||||
t.Fatalf("unexpected oauth cred: %+v", cred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
manager, err := NewManager("v1:"+key, "v1")
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -8,30 +9,30 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
)
|
||||
|
||||
func TestParseCredentials_missing(t *testing.T) {
|
||||
func TestResolveCredential_missing(t *testing.T) {
|
||||
w := &SyncWorker{credentials: &credentials.Manager{}}
|
||||
|
||||
_, _, err := w.parseCredentials(nil)
|
||||
_, err := w.resolveCredential(context.Background(), "acc-1", nil)
|
||||
if err == nil || err.Error() != "missing credentials" {
|
||||
t.Fatalf("parseCredentials(nil) error = %v, want missing credentials", err)
|
||||
t.Fatalf("resolveCredential(nil) error = %v, want missing credentials", err)
|
||||
}
|
||||
|
||||
_, _, err = w.parseCredentials([]byte{})
|
||||
_, err = w.resolveCredential(context.Background(), "acc-1", []byte{})
|
||||
if err == nil || err.Error() != "missing credentials" {
|
||||
t.Fatalf("parseCredentials([]) error = %v, want missing credentials", err)
|
||||
t.Fatalf("resolveCredential([]) error = %v, want missing credentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentials_plaintextForbidden(t *testing.T) {
|
||||
func TestResolveCredential_plaintextForbidden(t *testing.T) {
|
||||
w := &SyncWorker{credentials: &credentials.Manager{}}
|
||||
|
||||
_, _, err := w.parseCredentials([]byte(`{"username":"alice","password":"secret"}`))
|
||||
_, err := w.resolveCredential(context.Background(), "acc-1", []byte(`{"username":"alice","password":"secret"}`))
|
||||
if err == nil || err.Error() != "plaintext credentials forbidden" {
|
||||
t.Fatalf("parseCredentials(plaintext) error = %v, want plaintext credentials forbidden", err)
|
||||
t.Fatalf("resolveCredential(plaintext) error = %v, want plaintext credentials forbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentials_missingManager(t *testing.T) {
|
||||
func TestResolveCredential_missingManager(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
manager, err := credentials.NewManager("v1:"+key, "v1")
|
||||
if err != nil {
|
||||
@ -43,13 +44,13 @@ func TestParseCredentials_missingManager(t *testing.T) {
|
||||
}
|
||||
|
||||
w := &SyncWorker{credentials: nil}
|
||||
_, _, err = w.parseCredentials(blob)
|
||||
_, err = w.resolveCredential(context.Background(), "acc-1", blob)
|
||||
if err == nil || err.Error() != "credential manager not configured" {
|
||||
t.Fatalf("parseCredentials(no manager) error = %v, want credential manager not configured", err)
|
||||
t.Fatalf("resolveCredential(no manager) error = %v, want credential manager not configured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentials_encryptedSuccess(t *testing.T) {
|
||||
func TestResolveCredential_encryptedSuccess(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
manager, err := credentials.NewManager("v1:"+key, "v1")
|
||||
if err != nil {
|
||||
@ -61,16 +62,16 @@ func TestParseCredentials_encryptedSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
w := &SyncWorker{credentials: manager}
|
||||
username, password, err := w.parseCredentials(blob)
|
||||
cred, err := w.resolveCredential(context.Background(), "acc-1", blob)
|
||||
if err != nil {
|
||||
t.Fatalf("parseCredentials(encrypted) error = %v", err)
|
||||
t.Fatalf("resolveCredential(encrypted) error = %v", err)
|
||||
}
|
||||
if username != "alice@example.com" || password != "secret" {
|
||||
t.Fatalf("got %q/%q, want alice@example.com/secret", username, password)
|
||||
if cred.Username != "alice@example.com" || cred.Password != "secret" {
|
||||
t.Fatalf("got %+v, want alice@example.com/secret", cred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentials_decryptFailure(t *testing.T) {
|
||||
func TestResolveCredential_decryptFailure(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
manager, err := credentials.NewManager("v1:"+key, "v1")
|
||||
if err != nil {
|
||||
@ -78,7 +79,7 @@ func TestParseCredentials_decryptFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
w := &SyncWorker{credentials: manager}
|
||||
_, _, err = w.parseCredentials([]byte("UMC1|v1|invalid|payload"))
|
||||
_, err = w.resolveCredential(context.Background(), "acc-1", []byte("UMC1|v1|invalid|payload"))
|
||||
if err == nil {
|
||||
t.Fatal("expected decrypt error")
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/connect"
|
||||
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/storage"
|
||||
@ -35,17 +37,19 @@ type SyncWorker struct {
|
||||
logger *slog.Logger
|
||||
interval time.Duration
|
||||
credentials *credentials.Manager
|
||||
oauth *mailoauth.Service
|
||||
storage *storage.Client
|
||||
attachBucket string
|
||||
pipeline *syncPipeline
|
||||
}
|
||||
|
||||
func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager, deps SyncDeps) *SyncWorker {
|
||||
func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager, oauthSvc *mailoauth.Service, deps SyncDeps) *SyncWorker {
|
||||
return &SyncWorker{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "imap-sync"),
|
||||
interval: interval,
|
||||
credentials: credManager,
|
||||
oauth: oauthSvc,
|
||||
storage: deps.Storage,
|
||||
attachBucket: deps.AttachBucket,
|
||||
pipeline: newSyncPipeline(db, deps.Rules, deps.Hub),
|
||||
@ -138,11 +142,11 @@ func (w *SyncWorker) syncAccount(ctx context.Context, accountID, host string, po
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
username, password, err := w.parseCredentials(creds)
|
||||
cred, err := w.resolveCredential(ctx, accountID, creds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt credentials: %w", err)
|
||||
}
|
||||
if err := client.Login(username, password).Wait(); err != nil {
|
||||
if err := connect.AuthenticateIMAP(client, cred); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
@ -494,17 +498,24 @@ func flagsToStrings(flags []imap.Flag) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *SyncWorker) parseCredentials(creds []byte) (string, string, error) {
|
||||
func (w *SyncWorker) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) {
|
||||
if len(creds) == 0 {
|
||||
return "", "", errors.New("missing credentials")
|
||||
return credentials.Credential{}, errors.New("missing credentials")
|
||||
}
|
||||
if !credentials.IsEncrypted(creds) {
|
||||
return "", "", errors.New("plaintext credentials forbidden")
|
||||
return credentials.Credential{}, errors.New("plaintext credentials forbidden")
|
||||
}
|
||||
if w.credentials == nil {
|
||||
return "", "", errors.New("credential manager not configured")
|
||||
return credentials.Credential{}, errors.New("credential manager not configured")
|
||||
}
|
||||
return w.credentials.Decrypt(creds)
|
||||
cred, err := w.credentials.DecryptCredential(creds)
|
||||
if err != nil {
|
||||
return credentials.Credential{}, err
|
||||
}
|
||||
if w.oauth != nil && cred.IsOAuth() {
|
||||
return mailoauth.RefreshAccountCredential(ctx, w.db, w.credentials, w.oauth, accountID, cred)
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
|
||||
15
internal/mail/oauth/pkce.go
Normal file
15
internal/mail/oauth/pkce.go
Normal 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
|
||||
}
|
||||
64
internal/mail/oauth/refresh.go
Normal file
64
internal/mail/oauth/refresh.go
Normal 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
|
||||
}
|
||||
224
internal/mail/oauth/service.go
Normal file
224
internal/mail/oauth/service.go
Normal 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
|
||||
}
|
||||
@ -10,23 +10,27 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/connect"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
db *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
credentials *credentials.Manager
|
||||
oauth *mailoauth.Service
|
||||
}
|
||||
|
||||
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager) *Sender {
|
||||
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager, oauthSvc *mailoauth.Service) *Sender {
|
||||
return &Sender{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "smtp-sender"),
|
||||
credentials: credManager,
|
||||
oauth: oauthSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,7 +62,7 @@ func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
|
||||
return fmt.Errorf("query account: %w", err)
|
||||
}
|
||||
|
||||
username, password, err := s.parseCredentials(creds)
|
||||
cred, err := s.resolveCredential(ctx, req.AccountID, creds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt credentials: %w", err)
|
||||
}
|
||||
@ -71,7 +75,10 @@ func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
|
||||
allRecipients = append(allRecipients, req.Cc...)
|
||||
allRecipients = append(allRecipients, req.Bcc...)
|
||||
|
||||
auth := sasl.NewPlainClient("", username, password)
|
||||
auth, err := connect.SMTPClient(cred)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
|
||||
var sendErr error
|
||||
if useTLS {
|
||||
@ -100,13 +107,28 @@ func generateMessageID(from string) string {
|
||||
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(token), domain)
|
||||
}
|
||||
|
||||
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
|
||||
func (s *Sender) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) {
|
||||
if len(creds) == 0 {
|
||||
return "", "", errors.New("missing credentials")
|
||||
return credentials.Credential{}, errors.New("missing credentials")
|
||||
}
|
||||
if !credentials.IsEncrypted(creds) {
|
||||
return "", "", errors.New("plaintext credentials forbidden")
|
||||
return credentials.Credential{}, errors.New("plaintext credentials forbidden")
|
||||
}
|
||||
if s.credentials == nil {
|
||||
return credentials.Credential{}, errors.New("credential manager not configured")
|
||||
}
|
||||
cred, err := s.credentials.DecryptCredential(creds)
|
||||
if err != nil {
|
||||
return cred, err
|
||||
}
|
||||
if s.oauth != nil && cred.IsOAuth() {
|
||||
return mailoauth.RefreshAccountCredential(ctx, s.db, s.credentials, s.oauth, accountID, cred)
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// kept for tests that referenced parseCredentials
|
||||
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
|
||||
if s.credentials == nil {
|
||||
return "", "", errors.New("credential manager not configured")
|
||||
}
|
||||
|
||||
1
migrations/000013_unified_folders.down.sql
Normal file
1
migrations/000013_unified_folders.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS mail_unified_folders;
|
||||
17
migrations/000013_unified_folders.up.sql
Normal file
17
migrations/000013_unified_folders.up.sql
Normal 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);
|
||||
2
migrations/000014_label_sort_order.down.sql
Normal file
2
migrations/000014_label_sort_order.down.sql
Normal 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;
|
||||
4
migrations/000014_label_sort_order.up.sql
Normal file
4
migrations/000014_label_sort_order.up.sql
Normal 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);
|
||||
8
migrations/000015_mail_signatures.down.sql
Normal file
8
migrations/000015_mail_signatures.down.sql
Normal 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;
|
||||
16
migrations/000015_mail_signatures.up.sql
Normal file
16
migrations/000015_mail_signatures.up.sql
Normal 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);
|
||||
1
migrations/000016_bootstrap_identities.down.sql
Normal file
1
migrations/000016_bootstrap_identities.down.sql
Normal file
@ -0,0 +1 @@
|
||||
-- Irreversible data backfill; no-op on rollback.
|
||||
29
migrations/000016_bootstrap_identities.up.sql
Normal file
29
migrations/000016_bootstrap_identities.up.sql
Normal 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
8
migrations/embed.go
Normal file
@ -0,0 +1,8 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains SQL migrations embedded for ultid startup.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var Files embed.FS
|
||||
@ -180,7 +180,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
||||
|
||||
## 3) Frontend web (`gmail-interface-clone`)
|
||||
|
||||
> **Avancement (2026-05-23)** — migration offline-first API dans `gmail-interface-clone` : **13/22** items section 3 cochés. Reste : settings mail backend, cleanup fichiers mock, recherche sans fallback offline, sync par compte, modules suite (§3.4), a11y/i18n/perf (§3.6).
|
||||
> **Avancement (2026-05-23)** — migration offline-first API dans `gmail-interface-clone` : **14/22** items section 3 cochés. Reste : cleanup fichiers mock, recherche sans fallback offline, sync par compte, modules suite (§3.4), a11y/i18n/perf (§3.6).
|
||||
|
||||
### 3.1 Fondation data/API
|
||||
|
||||
@ -196,7 +196,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
||||
- [x] Brancher compose/send/scheduled sur outbox backend. *(frontend: `use-compose-mutations`, `scheduled-store` outbox)*
|
||||
- [ ] Brancher recherche UI sur backend search (fallback local supprimé). *(frontend: `useMailSearch` → `/mail/search` + fallback cache offline; autocomplete `search-engine` locale conservée)*
|
||||
- [x] Brancher multi-comptes réels (accounts API + switch account effectif). *(frontend: `useMailAccounts`, `account-store` sans `MOCK_USER_ACCOUNTS`)*
|
||||
- [ ] Brancher settings (pas page placeholder) avec persistance backend. *(frontend: `mail-settings-store` local — pas d'endpoint settings backend)*
|
||||
- [x] Brancher settings (pas page placeholder) avec persistance backend. *(frontend: `mail-settings-store` + sync `MailSettingsSync`, page `/mail/settings`, hooks `use-mail-settings`; backend: `GET/PATCH /api/v1/mail/settings` → `settings.preferences.mail`)*
|
||||
|
||||
### 3.3 Contacts UI
|
||||
|
||||
@ -247,20 +247,6 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
||||
- [ ] Mettre scan vulnérabilités dépendances/images en CI.
|
||||
- [ ] Ajouter CSP/headers sécurité côté frontend + proxy.
|
||||
|
||||
## 5) Plateformes futures
|
||||
|
||||
### Desktop (Tauri)
|
||||
|
||||
- [ ] Initialiser wrapper Tauri.
|
||||
- [ ] Gérer auth desktop sécurisée (token storage natif).
|
||||
- [ ] Ajouter notifications système.
|
||||
- [ ] Ajouter intégration filesystem native (uploads drag/drop large files).
|
||||
|
||||
### Mobile
|
||||
|
||||
- [ ] Définir stratégie mobile (PWA avancée vs app native).
|
||||
- [ ] Implémenter MVP mobile mail (liste/lecture/compose simple).
|
||||
- [ ] Ajouter sync background + notifications push.
|
||||
|
||||
## 6) Validation "ça fonctionne"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user