Backend starting to get good

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

View File

@ -32,6 +32,10 @@ TYPESENSE_API_KEY=changeme
# -----------------------------------------------------------------------------
DOMAIN=localhost
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
# -----------------------------------------------------------------------------

View File

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

View File

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git ca-certificates
@ -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

View File

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

View File

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

View File

@ -0,0 +1,85 @@
# Authentik — provisioning local
Blueprints in `blueprints/` are mounted into Authentik at `/blueprints/custom` and applied automatically by the worker on startup.
| Fichier | Rôle |
|---------|------|
| `01-ulti-enrollment.yaml` | Inscription self-service (`ulti-enrollment`) |
| `02-ulti-brand.yaml` | Branding Ultimail + lien « Créer un compte » sur login |
| `ulti-oidc.yaml` | App OIDC Ultimail |
| `nextcloud-oidc.yaml` | App OIDC Nextcloud |
Assets branding : générés depuis le frontend (`pnpm run brand:authentik` dans `gmail-interface-clone`) :
| Fichier Authentik | Thème | Description |
|-------------------|-------|-------------|
| `ultimail-logo-light.png` | clair | Picto + wordmark sur fond blanc |
| `ultimail-logo-dark.png` | sombre | Picto + texte clair, fond transparent |
| `ultimail-favicon.png` | — | Mark 32×32 transparent (favicon onglet, URL **sans** `%(theme)s`) |
| `ultimail-favicon-light.png` | clair | Variante archive (fond blanc) |
| `ultimail-favicon-dark.png` | sombre | Variante archive (fond sombre) |
Logo : placeholder Authentik `%(theme)s` + fallback CSS `prefers-color-scheme`.
Favicon onglet : **chemin statique** — Authentik ne substitue pas `%(theme)s` dans le `<link rel="icon">` SSR (erreur 400).
Regénérer après MAJ du master brand :
```bash
cd ../gmail-interface-clone
pnpm run brand:build && pnpm run brand:authentik
cd ../ulti-backend
./deploy/compose-up.sh up -d authentik-server authentik-worker
docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/02-ulti-brand.yaml
```
## Inscription utilisateur
Flow public : `http://localhost/auth/if/flow/ulti-enrollment/`
Étapes collectées :
1. E-mail (identifiant), mot de passe
2. Nom et prénom, téléphone (optionnel), avatar (optionnel)
3. Création du compte + connexion automatique
Sur la page de connexion Authentik, lien **« Besoin d'un compte ? S'inscrire »** (identification stage).
## Branding
- Titre navigateur : **Ultimail**
- Logo / favicon : marque Ultimail, variantes **light** et **dark** (thème Authentik)
- CSS custom : masque « Powered by authentik » et liens goauthentik.io
- Locale par défaut : `fr`
Modifier le logo : `pnpm run brand:authentik` dans le repo frontend, puis redémarrer Authentik + réappliquer le blueprint brand.
## Secrets OIDC
| App | Slug | Client ID | Secret (`.env`) |
|-----|------|-----------|------------------|
| Ultimail | `ulti` | `ulti-backend` | `ULTID_OIDC_CLIENT_SECRET` |
| Nextcloud | `nextcloud` | `ulti-nextcloud` | `NC_OIDC_CLIENT_SECRET` |
Defaults blueprints : `changeme` — sync avec `.env`.
## Appliquer / vérifier
```bash
# Re-appliquer après modification
docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/01-ulti-enrollment.yaml
docker exec deploy-authentik-server-1 ak apply_blueprint /blueprints/custom/02-ulti-brand.yaml
./deploy/compose-up.sh restart authentik-worker
# Vérifier OIDC Ultimail
curl -s http://localhost/auth/application/o/ulti/.well-known/openid-configuration | head -5
```
`redirect_uris` : objets `{ matching_mode, url }` (Authentik 2025.2+).
## Limites connues
- « Powered by authentik » masqué via CSS (pas doption officielle en open source).
- Placeholder `%(theme)s` sur le **logo** : non remplacé sur certaines pages SSR ; fallback CSS `prefers-color-scheme`.
- Favicon onglet : pas de `%(theme)s` (bug SSR Authentik → 400) ; mark transparent unique.
- Avatar stocké en attribut utilisateur (data-URI), pas encore affiché dans Ultimail header.
- Multi-comptes simultanés type Gmail : non implémenté côté Ultimail.

View File

@ -0,0 +1,166 @@
# Ultimail — inscription self-service (email, mot de passe, profil, avatar optionnel)
version: 1
metadata:
name: Ultimail enrollment
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_flows.flow
id: ulti-enrollment-flow
identifiers:
slug: ulti-enrollment
attrs:
name: Ultimail — Créer un compte
title: Créer votre compte Ultimail
designation: enrollment
authentication: require_unauthenticated
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-email
identifiers:
name: ulti-enrollment-field-email
attrs:
field_key: username
label: Adresse e-mail
type: email
required: true
placeholder: vous@exemple.com
placeholder_expression: false
order: 0
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-email-sync
identifiers:
name: ulti-enrollment-field-email-sync
attrs:
field_key: email
label: E-mail
type: hidden
required: true
initial_value: "{{ prompt_data.username }}"
initial_value_expression: true
placeholder_expression: false
order: 1
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-password
identifiers:
name: ulti-enrollment-field-password
attrs:
field_key: password
label: Mot de passe
type: password
required: true
placeholder: Mot de passe
placeholder_expression: false
order: 1
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-password-repeat
identifiers:
name: ulti-enrollment-field-password-repeat
attrs:
field_key: password_repeat
label: Confirmer le mot de passe
type: password
required: true
placeholder: Confirmer le mot de passe
placeholder_expression: false
order: 2
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-name
identifiers:
name: ulti-enrollment-field-name
attrs:
field_key: name
label: Nom et prénom
type: text
required: true
placeholder: Jean Dupont
placeholder_expression: false
order: 0
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-phone
identifiers:
name: ulti-enrollment-field-phone
attrs:
field_key: attributes.phone
label: Numéro de téléphone (optionnel)
type: text
required: false
placeholder: +33 6 12 34 56 78
placeholder_expression: false
order: 1
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-avatar
identifiers:
name: ulti-enrollment-field-avatar
attrs:
field_key: attributes.avatar
label: Photo de profil (optionnel)
type: file
required: false
placeholder: ""
placeholder_expression: false
order: 2
- model: authentik_stages_prompt.promptstage
id: ulti-enroll-prompt-credentials
identifiers:
name: ulti-enrollment-prompt-credentials
attrs:
fields:
- !KeyOf ulti-enroll-field-email
- !KeyOf ulti-enroll-field-email-sync
- !KeyOf ulti-enroll-field-password
- !KeyOf ulti-enroll-field-password-repeat
- model: authentik_stages_prompt.promptstage
id: ulti-enroll-prompt-profile
identifiers:
name: ulti-enrollment-prompt-profile
attrs:
fields:
- !KeyOf ulti-enroll-field-name
- !KeyOf ulti-enroll-field-phone
- !KeyOf ulti-enroll-field-avatar
- model: authentik_stages_user_write.userwritestage
id: ulti-enroll-user-write
identifiers:
name: ulti-enrollment-user-write
attrs:
user_creation_mode: always_create
create_users_as_inactive: false
- model: authentik_stages_user_login.userloginstage
id: ulti-enroll-user-login
identifiers:
name: ulti-enrollment-user-login
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf ulti-enrollment-flow
stage: !KeyOf ulti-enroll-prompt-credentials
order: 10
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf ulti-enrollment-flow
stage: !KeyOf ulti-enroll-prompt-profile
order: 20
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf ulti-enrollment-flow
stage: !KeyOf ulti-enroll-user-write
order: 30
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf ulti-enrollment-flow
stage: !KeyOf ulti-enroll-user-login
order: 100

View File

@ -0,0 +1,64 @@
# Ultimail — branding + lien inscription sur le flow de connexion
version: 1
metadata:
name: Ultimail brand and authentication
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_flows.flow
identifiers:
slug: default-authentication-flow
attrs:
name: Connexion Ultimail
title: Connexion Ultimail
- model: authentik_stages_identification.identificationstage
identifiers:
name: default-authentication-identification
attrs:
user_fields:
- email
- username
enrollment_flow: !Find [authentik_flows.flow, [slug, ulti-enrollment]]
- model: authentik_brands.brand
identifiers:
domain: authentik-default
attrs:
branding_title: Ultimail
branding_logo: /static/dist/assets/branding/ultimail-logo-%(theme)s.png
branding_favicon: /static/dist/assets/branding/ultimail-favicon.png
branding_custom_css: |
/* Ultimail — masquer le branding Authentik */
ak-branding-footer,
.pf-c-login__footer,
.pf-c-login__footer-text,
a[href*="goauthentik.io"],
a[href*="authentik.io"] {
display: none !important;
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
}
ak-brand-logo img,
.pf-c-brand img {
max-height: 48px;
width: auto;
max-width: min(280px, 80vw);
content: url("/auth/static/dist/assets/branding/ultimail-logo-light.png");
}
@media (prefers-color-scheme: dark) {
ak-brand-logo img,
.pf-c-brand img {
content: url("/auth/static/dist/assets/branding/ultimail-logo-dark.png");
}
}
ak-flow-executor::part(footer) {
display: none !important;
}
attributes:
settings:
locale: fr
flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]]

View File

@ -0,0 +1,40 @@
# Authentik blueprint — Nextcloud OIDC (when NEXTCLOUD_ENABLED=true)
# Client secret must match NC_OIDC_CLIENT_SECRET in .env
version: 1
metadata:
name: Nextcloud OIDC
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_providers_oauth2.oauth2provider
id: nc-oauth-provider
identifiers:
name: ulti-nextcloud-provider
attrs:
name: ulti-nextcloud-provider
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
client_type: confidential
client_id: ulti-nextcloud
client_secret: changeme
redirect_uris:
- matching_mode: strict
url: http://localhost/cloud/apps/user_oidc/code
- matching_mode: strict
url: http://127.0.0.1/cloud/apps/user_oidc/code
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application
identifiers:
slug: nextcloud
attrs:
name: Nextcloud
slug: nextcloud
group: Ulti Suite
provider: !KeyOf nc-oauth-provider
meta_launch_url: http://localhost/cloud/
policy_engine_mode: any

View File

@ -0,0 +1,47 @@
# Authentik blueprint — Ultimail OIDC (auto-applied on worker startup)
# Client secret must match ULTID_OIDC_CLIENT_SECRET in .env
version: 1
metadata:
name: Ultimail OIDC
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_providers_oauth2.oauth2provider
id: ulti-oauth-provider
identifiers:
name: ulti-backend-provider
attrs:
name: ulti-backend-provider
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
access_token_validity: "hours=1"
refresh_token_validity: "days=365"
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]]
client_type: confidential
client_id: ulti-backend
client_secret: changeme
redirect_uris:
- matching_mode: strict
url: http://localhost:3000/api/auth/callback
- matching_mode: strict
url: http://127.0.0.1:3000/api/auth/callback
- matching_mode: strict
url: http://localhost:3001/api/auth/callback
- matching_mode: strict
url: http://127.0.0.1:3001/api/auth/callback
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application
identifiers:
slug: ulti
attrs:
name: Ultimail
slug: ulti
group: Ulti Suite
provider: !KeyOf ulti-oauth-provider
meta_launch_url: http://localhost:3000/
policy_engine_mode: any

View File

@ -0,0 +1,23 @@
/* Ultimail — masquer le branding Authentik sur flows et portail utilisateur */
ak-branding-footer,
.pf-c-login__footer,
.pf-c-login__footer-text,
a[href*="goauthentik.io"],
a[href*="authentik.io"] {
display: none !important;
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
}
ak-brand-logo img,
.pf-c-brand img {
max-height: 48px;
width: auto;
max-width: min(280px, 80vw);
}
ak-flow-executor::part(footer) {
display: none !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -26,10 +26,11 @@ services:
networks:
- 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:

View File

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

View File

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

View File

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

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

@ -38,12 +38,19 @@ github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/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=

View File

@ -0,0 +1,44 @@
package mail
import (
"context"
"fmt"
"strings"
)
func identityDisplayName(name, email string) string {
if n := strings.TrimSpace(name); n != "" {
return n
}
if i := strings.Index(email, "@"); i > 0 {
return email[:i]
}
return strings.TrimSpace(email)
}
func defaultSignatureName(email string) string {
return fmt.Sprintf("Signature — %s", strings.TrimSpace(email))
}
func (s *Service) ensureIdentityDefaultSignature(ctx context.Context, externalID, email string) (string, error) {
return s.CreateSignature(ctx, externalID, &createSignatureRequest{
Name: defaultSignatureName(email),
HTML: "",
})
}
// bootstrapAccountDefaults creates the default send identity and linked signature for a new mail account.
func (s *Service) bootstrapAccountDefaults(ctx context.Context, externalID, accountID, email, accountName string) error {
sigID, err := s.ensureIdentityDefaultSignature(ctx, externalID, email)
if err != nil {
return err
}
_, err = s.CreateIdentity(ctx, externalID, accountID, &createIdentityRequest{
Email: strings.TrimSpace(email),
Name: identityDisplayName(accountName, email),
IsDefault: true,
DefaultSignatureID: sigID,
ReplyToAddrs: []string{},
})
return err
}

View File

@ -0,0 +1,18 @@
package mail
import "testing"
func TestIdentityDisplayName(t *testing.T) {
if got := identityDisplayName("Work", "a@b.com"); got != "Work" {
t.Fatalf("got %q", got)
}
if got := identityDisplayName("", "alice@example.com"); got != "alice" {
t.Fatalf("got %q", got)
}
}
func TestDefaultSignatureName(t *testing.T) {
if got := defaultSignatureName("a@b.com"); got != "Signature — a@b.com" {
t.Fatalf("got %q", got)
}
}

View File

@ -0,0 +1,230 @@
package mail
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
)
type accountRow struct {
id, name, email, provider string
imapHost, smtpHost string
imapPort, smtpPort int
imapTLS, smtpTLS bool
credentials []byte
}
func (s *Service) loadAccountRow(ctx context.Context, externalID, accountID string) (accountRow, error) {
var row accountRow
err := s.db.QueryRow(ctx, `
SELECT ma.id, ma.name, ma.email, ma.provider,
ma.imap_host, ma.imap_port, ma.imap_tls,
ma.smtp_host, ma.smtp_port, ma.smtp_tls,
ma.credentials
FROM mail_accounts ma
JOIN users u ON ma.user_id = u.id
WHERE ma.id = $1 AND u.external_id = $2
`, accountID, externalID).Scan(
&row.id, &row.name, &row.email, &row.provider,
&row.imapHost, &row.imapPort, &row.imapTLS,
&row.smtpHost, &row.smtpPort, &row.smtpTLS,
&row.credentials,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return accountRow{}, ErrNotFound
}
return accountRow{}, err
}
return row, nil
}
func accountDetailFromRow(row accountRow, cred credentials.Credential) map[string]any {
out := map[string]any{
"id": row.id,
"name": row.name,
"email": row.email,
"provider": row.provider,
"imap_host": row.imapHost,
"imap_port": row.imapPort,
"imap_tls": row.imapTLS,
"smtp_host": row.smtpHost,
"smtp_port": row.smtpPort,
"smtp_tls": row.smtpTLS,
"auth_type": string(credentials.AuthPassword),
"username": cred.Username,
}
if cred.AuthType != "" {
out["auth_type"] = string(cred.AuthType)
}
if cred.IsOAuth() {
out["oauth_provider"] = cred.OAuthProvider
}
return out
}
func (s *Service) decryptAccountCredential(blob []byte) (credentials.Credential, error) {
if s.credentials == nil {
return credentials.Credential{}, ErrCredentialsUnavailable
}
if len(blob) == 0 {
return credentials.Credential{}, nil
}
return s.credentials.DecryptCredential(blob)
}
func (s *Service) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) {
row, err := s.loadAccountRow(ctx, externalID, accountID)
if err != nil {
return nil, err
}
cred, err := s.decryptAccountCredential(row.credentials)
if err != nil {
return nil, err
}
return accountDetailFromRow(row, cred), nil
}
func (s *Service) UpdateAccount(ctx context.Context, externalID, accountID string, req *updateAccountRequest) error {
if s.credentials == nil {
return ErrCredentialsUnavailable
}
row, err := s.loadAccountRow(ctx, externalID, accountID)
if err != nil {
return err
}
cred, err := s.decryptAccountCredential(row.credentials)
if err != nil {
return err
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = row.name
}
provider := strings.TrimSpace(req.Provider)
if provider == "" {
provider = row.provider
}
if cred.IsOAuth() {
if strings.TrimSpace(req.Password) != "" {
return ErrOAuthPasswordNotAllowed
}
if u := strings.TrimSpace(req.Username); u != "" {
cred.Username = u
}
} else {
if u := strings.TrimSpace(req.Username); u != "" {
cred.Username = u
}
if p := req.Password; p != "" {
cred.Password = p
}
if strings.TrimSpace(cred.Username) == "" {
return ErrInvalidAccountCredentials
}
}
encrypted, err := s.credentials.EncryptCredential(cred)
if err != nil {
return err
}
result, err := s.db.Exec(ctx, `
UPDATE mail_accounts ma SET
name = $1,
email = $2,
provider = $3,
imap_host = $4,
imap_port = $5,
imap_tls = $6,
smtp_host = $7,
smtp_port = $8,
smtp_tls = $9,
credentials = $10,
updated_at = NOW()
FROM users u
WHERE ma.id = $11 AND ma.user_id = u.id AND u.external_id = $12
`, name, req.Email, provider,
req.IMAPHost, req.IMAPPort, req.IMAPTLS,
req.SMTPHost, req.SMTPPort, req.SMTPTLS,
encrypted, accountID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func credentialFromTestRequest(req *testAccountRequest) (credentials.Credential, error) {
if req.AuthType == string(credentials.AuthOAuth2) {
return credentials.Credential{
AuthType: credentials.AuthOAuth2,
Username: strings.TrimSpace(req.Username),
AccessToken: strings.TrimSpace(req.AccessToken),
OAuthProvider: strings.TrimSpace(req.OAuthProvider),
}, nil
}
if strings.TrimSpace(req.Password) == "" {
return credentials.Credential{}, ErrInvalidAccountCredentials
}
return credentials.Credential{
AuthType: credentials.AuthPassword,
Username: strings.TrimSpace(req.Username),
Password: req.Password,
}, nil
}
func mergeTestCredential(stored credentials.Credential, req *testAccountRequest) (credentials.Credential, error) {
cred := stored
if u := strings.TrimSpace(req.Username); u != "" {
cred.Username = u
}
if req.AuthType == string(credentials.AuthOAuth2) || cred.IsOAuth() {
if t := strings.TrimSpace(req.AccessToken); t != "" {
cred.AccessToken = t
}
if strings.TrimSpace(cred.AccessToken) == "" {
return credentials.Credential{}, ErrInvalidAccountCredentials
}
return cred, nil
}
if req.Password != "" {
cred.Password = req.Password
}
if strings.TrimSpace(cred.Password) == "" {
return credentials.Credential{}, ErrInvalidAccountCredentials
}
if strings.TrimSpace(cred.Username) == "" {
return credentials.Credential{}, ErrInvalidAccountCredentials
}
return cred, nil
}
func (s *Service) CredentialForConnectionTest(ctx context.Context, externalID string, req *testAccountRequest) (credentials.Credential, error) {
accountID := strings.TrimSpace(req.AccountID)
if accountID == "" {
return credentialFromTestRequest(req)
}
row, err := s.loadAccountRow(ctx, externalID, accountID)
if err != nil {
return credentials.Credential{}, err
}
stored, err := s.decryptAccountCredential(row.credentials)
if err != nil {
return credentials.Credential{}, err
}
return mergeTestCredential(stored, req)
}

View File

@ -15,6 +15,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/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

View File

@ -0,0 +1,34 @@
package mail
import (
"errors"
"net/http"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/mail/autoconfig"
)
func (h *Handler) DiscoverAccountConfig(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.URL.Query().Get("email"))
if d := validateEmailField("email", email); d != nil {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(*d))
return
}
result, err := autoconfig.Discover(r.Context(), email)
if err != nil {
if errors.Is(err, autoconfig.ErrInvalidEmail) {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "email", Message: "invalid"},
))
return
}
h.logger.Error("discover account config", "error", err, "email", email)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}

View File

@ -0,0 +1,196 @@
package mail
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/mail/connect"
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
)
func (h *Handler) TestAccountConnection(w http.ResponseWriter, r *http.Request) {
h.testAccountConnection(w, r, "")
}
func (h *Handler) TestStoredAccountConnection(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountID")
if d := validateAccountUUID(accountID); d != nil {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.testAccountConnection(w, r, accountID)
}
func (h *Handler) testAccountConnection(w http.ResponseWriter, r *http.Request, accountIDFromURL string) {
claims := middleware.ClaimsFromContext(r.Context())
var req testAccountRequest
if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil {
return
}
if accountIDFromURL != "" {
req.AccountID = accountIDFromURL
}
if verr := validateTestAccount(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
cred, err := h.svc.CredentialForConnectionTest(r.Context(), claims.Sub, &req)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if errors.Is(err, ErrInvalidAccountCredentials) {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "missing credentials for connection test", nil)
return
}
if errors.Is(err, ErrCredentialsUnavailable) {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil)
return
}
h.logger.Error("resolve test credentials", "error", err)
apivalidate.WriteInternal(w, r)
return
}
result := connect.Test(r.Context(), connect.ServerConfig{
IMAPHost: req.IMAPHost,
IMAPPort: req.IMAPPort,
IMAPTLS: req.IMAPTLS,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPTLS: req.SMTPTLS,
}, cred)
if result.IMAPError != "" {
result.IMAPError = connect.SanitizeError(result.IMAPError)
}
if result.SMTPError != "" {
result.SMTPError = connect.SanitizeError(result.SMTPError)
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListOAuthProviders(w http.ResponseWriter, r *http.Request) {
if h.oauth == nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"providers": []string{}})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"providers": h.oauth.EnabledProviders(),
})
}
func (h *Handler) StartOAuthAccount(w http.ResponseWriter, r *http.Request) {
if h.oauth == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail oauth unavailable", nil)
return
}
claims := middleware.ClaimsFromContext(r.Context())
var req oauthStartRequest
if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil {
return
}
if verr := validateOAuthStart(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
pending := h.oauthPendingFromRequest(&req)
authURL, state, err := h.oauth.Start(r.Context(), claims.Sub, mailoauth.Provider(req.Provider), pending)
if err != nil {
h.logger.Error("oauth start", "error", err, "provider", req.Provider)
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]string{
"authorization_url": authURL,
"state": state,
})
}
func (h *Handler) OAuthCallback(w http.ResponseWriter, r *http.Request) {
if h.oauth == nil || h.svc == nil {
http.Redirect(w, r, h.oauthReturnURL("error", "oauth_unavailable"), http.StatusFound)
return
}
state := strings.TrimSpace(r.URL.Query().Get("state"))
code := strings.TrimSpace(r.URL.Query().Get("code"))
if errParam := r.URL.Query().Get("error"); errParam != "" {
http.Redirect(w, r, h.oauthReturnURL("error", errParam), http.StatusFound)
return
}
if state == "" || code == "" {
http.Redirect(w, r, h.oauthReturnURL("error", "missing_code"), http.StatusFound)
return
}
pending, token, err := h.oauth.Exchange(r.Context(), state, code)
if err != nil {
h.logger.Error("oauth exchange", "error", err)
http.Redirect(w, r, h.oauthReturnURL("error", "exchange_failed"), http.StatusFound)
return
}
cred := mailoauth.CredentialFromToken(pending.Email, pending.Provider, token)
_, err = h.svc.CreateAccountWithCredential(r.Context(), pending.UserExternalID, &createAccountRequest{
Name: pending.Name,
Email: pending.Email,
Provider: pending.ProviderID,
IMAPHost: pending.IMAPHost,
IMAPPort: pending.IMAPPort,
IMAPTLS: pending.IMAPTLS,
SMTPHost: pending.SMTPHost,
SMTPPort: pending.SMTPPort,
SMTPTLS: pending.SMTPTLS,
}, cred)
if err != nil {
h.logger.Error("oauth create account", "error", err)
http.Redirect(w, r, h.oauthReturnURL("error", "create_failed"), http.StatusFound)
return
}
http.Redirect(w, r, h.oauthReturnURL("success", ""), http.StatusFound)
}
func (h *Handler) oauthPendingFromRequest(req *oauthStartRequest) mailoauth.PendingAccount {
name := req.Name
if name == "" {
name = req.Email
}
return mailoauth.PendingAccount{
Email: req.Email,
Name: name,
ProviderID: req.ProviderID,
IMAPHost: req.IMAPHost,
IMAPPort: req.IMAPPort,
IMAPTLS: req.IMAPTLS,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPTLS: req.SMTPTLS,
}
}
func (h *Handler) oauthReturnURL(status, code string) string {
base := strings.TrimRight(h.appURL, "/")
if base == "" {
base = "http://localhost:3000"
}
u := base + "/mail/settings/accounts?oauth=" + status
if code != "" {
u += "&code=" + code
}
return u
}

View File

@ -0,0 +1,24 @@
package mail
import (
"net/http"
"testing"
"github.com/go-chi/chi/v5"
)
func TestStoredAccountTestRouteRegistered(t *testing.T) {
t.Parallel()
h := NewHandlerWithService(&fakeMailService{})
var found bool
_ = chi.Walk(h.Routes(), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if method == http.MethodPost && route == "/accounts/{accountID}/test" {
found = true
}
return nil
})
if !found {
t.Fatal("POST /accounts/{accountID}/test route not registered")
}
}

View File

@ -23,6 +23,7 @@ func (h *Handler) FolderLabelRoutes() chi.Router {
r.Delete("/folders/{folderID}", h.DeleteFolder)
r.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)

View File

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

View File

@ -0,0 +1,90 @@
package mail
import (
"fmt"
"net/http"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
)
const maxReorderItems = 500
func validateReorderLabels(req *reorderLabelsRequest) *apivalidate.ValidationError {
if req == nil || len(req.Items) == 0 {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "required"})
}
if len(req.Items) > maxReorderItems {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "too many"})
}
for i, item := range req.Items {
if item.ID == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: fmt.Sprintf("id required at index %d", i)})
}
}
return nil
}
func validateReorderUnifiedFolders(req *reorderUnifiedFoldersRequest) *apivalidate.ValidationError {
if req == nil || len(req.Items) == 0 {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "required"})
}
if len(req.Items) > maxReorderItems {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "too many"})
}
for _, item := range req.Items {
if item.ID == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "items", Message: "id required"})
}
}
return nil
}
func (h *Handler) ReorderUserLabels(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req reorderLabelsRequest
if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil {
return
}
if verr := validateReorderLabels(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.ReorderUserLabels(r.Context(), claims.Sub, &req); err != nil {
if err == ErrNotFound {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("reorder labels", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ReorderUnifiedFolders(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req reorderUnifiedFoldersRequest
if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil {
return
}
if verr := validateReorderUnifiedFolders(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.ReorderUnifiedFolders(r.Context(), claims.Sub, &req); err != nil {
if err == ErrNotFound {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if err == ErrInvalidFolderScope {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid folder scope", nil)
return
}
h.logger.Error("reorder unified folders", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -0,0 +1,42 @@
package mail
import (
"net/http"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
)
func (h *Handler) GetMailSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
settings, err := h.svc.GetMailSettings(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get mail settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, settings)
}
func (h *Handler) UpdateMailSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req patchMailSettingsRequest
if err := apivalidate.DecodeJSON(w, r, maxSettingsRequestBody, &req); err != nil {
return
}
if verr := validatePatchMailSettings(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
settings, err := h.svc.UpdateMailSettings(r.Context(), claims.Sub, &req)
if err != nil {
h.logger.Error("update mail settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, settings)
}

View File

@ -0,0 +1,125 @@
package mail
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query"
)
func (h *Handler) ListSignatures(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListSignatures(r.Context(), claims.Sub, params)
if err != nil {
h.logger.Error("list signatures", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) GetSignature(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
signatureID := chi.URLParam(r, "signatureID")
if d := validateSignatureUUID(signatureID); d != nil {
apivalidate.WriteNotFound(w, r, "not found")
return
}
signature, err := h.svc.GetSignature(r.Context(), claims.Sub, signatureID)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("get signature", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, signature)
}
func (h *Handler) CreateSignature(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req createSignatureRequest
if err := apivalidate.DecodeJSON(w, r, maxSignatureRequestBody, &req); err != nil {
return
}
if verr := validateCreateSignature(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
id, err := h.svc.CreateSignature(r.Context(), claims.Sub, &req)
if err != nil {
h.logger.Error("create signature", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h *Handler) UpdateSignature(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
signatureID := chi.URLParam(r, "signatureID")
if d := validateSignatureUUID(signatureID); d != nil {
apivalidate.WriteNotFound(w, r, "not found")
return
}
var req updateSignatureRequest
if err := apivalidate.DecodeJSON(w, r, maxSignatureRequestBody, &req); err != nil {
return
}
if verr := validateUpdateSignature(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.UpdateSignature(r.Context(), claims.Sub, signatureID, &req); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("update signature", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteSignature(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
signatureID := chi.URLParam(r, "signatureID")
if d := validateSignatureUUID(signatureID); d != nil {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if err := h.svc.DeleteSignature(r.Context(), claims.Sub, signatureID); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("delete signature", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateSignatureUUID(id string) *apivalidate.FieldDetail {
return validateAccountUUID(id)
}

View File

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

View File

@ -0,0 +1,113 @@
package mail
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query"
)
func (h *Handler) ListUnifiedFolders(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListUnifiedFolders(r.Context(), claims.Sub, r.URL.Query().Get("account_id"), params)
if err != nil {
if errors.Is(err, ErrAccountNotFound) {
apivalidate.WriteNotFound(w, r, "account not found")
return
}
h.logger.Error("list unified folders", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) CreateUnifiedFolder(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
if err != nil {
h.writeUserResolveError(w, r, err)
return
}
var req createUnifiedFolderRequest
if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil {
return
}
if verr := validateCreateUnifiedFolder(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
id, err := h.svc.CreateUnifiedFolder(r.Context(), userID, &req)
if err != nil {
if errors.Is(err, ErrAccountNotFound) {
apivalidate.WriteNotFound(w, r, "account not found")
return
}
if errors.Is(err, ErrInvalidFolderScope) {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid folder scope", nil)
return
}
h.logger.Error("create unified folder", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h *Handler) UpdateUnifiedFolder(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req updateUnifiedFolderRequest
if err := apivalidate.DecodeJSON(w, r, maxUnifiedFolderRequestBody, &req); err != nil {
return
}
if verr := validateUpdateUnifiedFolder(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
err := h.svc.UpdateUnifiedFolder(r.Context(), claims.Sub, chi.URLParam(r, "folderID"), &req)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("update unified folder", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteUnifiedFolder(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
err := h.svc.DeleteUnifiedFolder(r.Context(), claims.Sub, chi.URLParam(r, "folderID"))
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if errors.Is(err, ErrFolderHasChildren) {
apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, "folder has children", nil)
return
}
h.logger.Error("delete unified folder", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -17,7 +17,7 @@ type IdentitiesList struct {
}
const identitySelectColumns = `
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
}

View File

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

View File

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

View File

@ -0,0 +1,131 @@
package mail
import (
"context"
"strings"
"github.com/jackc/pgx/v5"
)
type reorderLabelItem struct {
ID string `json:"id"`
SortOrder int `json:"sort_order"`
}
type reorderLabelsRequest struct {
Items []reorderLabelItem `json:"items"`
}
type reorderUnifiedFolderItem struct {
ID string `json:"id"`
SortOrder int `json:"sort_order"`
ParentID *string `json:"parent_id"`
}
type reorderUnifiedFoldersRequest struct {
Items []reorderUnifiedFolderItem `json:"items"`
}
func (s *Service) ReorderUserLabels(ctx context.Context, externalID string, req *reorderLabelsRequest) error {
if len(req.Items) == 0 {
return nil
}
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return err
}
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for _, item := range req.Items {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
tag, err := tx.Exec(ctx, `
UPDATE mail_user_labels SET sort_order = $1
WHERE id = $2 AND user_id = $3
`, item.SortOrder, id, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
}
return tx.Commit(ctx)
}
func (s *Service) ReorderUnifiedFolders(ctx context.Context, externalID string, req *reorderUnifiedFoldersRequest) error {
if len(req.Items) == 0 {
return nil
}
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return err
}
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for _, item := range req.Items {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
var parentArg any
if item.ParentID != nil {
parentID := strings.TrimSpace(*item.ParentID)
if parentID != "" {
parentArg = parentID
var parentAccountID *string
if err := tx.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders WHERE id = $1 AND user_id = $2
`, parentID, userID).Scan(&parentAccountID); err != nil {
if err == pgx.ErrNoRows {
return ErrNotFound
}
return err
}
var folderAccountID *string
if err := tx.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders WHERE id = $1 AND user_id = $2
`, id, userID).Scan(&folderAccountID); err != nil {
if err == pgx.ErrNoRows {
return ErrNotFound
}
return err
}
if (folderAccountID == nil) != (parentAccountID == nil) {
return ErrInvalidFolderScope
}
if folderAccountID != nil && parentAccountID != nil && *folderAccountID != *parentAccountID {
return ErrInvalidFolderScope
}
}
}
tag, err := tx.Exec(ctx, `
UPDATE mail_unified_folders SET
sort_order = $1,
parent_id = $2,
updated_at = NOW()
WHERE id = $3 AND user_id = $4
`, item.SortOrder, parentArg, id, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
}
return tx.Commit(ctx)
}

View File

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

View File

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

View File

@ -0,0 +1,142 @@
package mail
import (
"context"
"encoding/json"
"strings"
"github.com/jackc/pgx/v5"
)
func (s *Service) GetMailSettings(ctx context.Context, externalID string) (MailSettings, error) {
defaults := defaultMailSettings()
var density, themeMode, backgroundID, inboxSort, readingPane *string
var conversationMode *bool
var notificationsJSON []byte
var updatedAt any
err := s.db.QueryRow(ctx, `
SELECT
s.preferences->'mail'->>'density',
s.preferences->'mail'->>'theme_mode',
s.preferences->'mail'->>'background_id',
s.preferences->'mail'->>'inbox_sort',
s.preferences->'mail'->>'reading_pane',
(s.preferences->'mail'->>'conversation_mode')::boolean,
s.preferences->'mail'->'notifications',
s.updated_at
FROM users u
LEFT JOIN settings s ON s.user_id = u.id
WHERE u.external_id = $1
`, externalID).Scan(&density, &themeMode, &backgroundID, &inboxSort, &readingPane, &conversationMode, &notificationsJSON, &updatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return defaults, nil
}
return MailSettings{}, err
}
if density != nil && *density != "" {
defaults.Density = *density
}
if themeMode != nil && *themeMode != "" {
defaults.ThemeMode = *themeMode
}
if backgroundID != nil && *backgroundID != "" {
defaults.BackgroundID = *backgroundID
}
if inboxSort != nil && *inboxSort != "" {
defaults.InboxSort = *inboxSort
}
if readingPane != nil && *readingPane != "" {
defaults.ReadingPane = *readingPane
}
if conversationMode != nil {
defaults.ConversationMode = *conversationMode
}
if len(notificationsJSON) > 0 && string(notificationsJSON) != "null" {
var n MailNotificationSettings
if err := json.Unmarshal(notificationsJSON, &n); err == nil {
defaults.Notifications = n
}
}
if updatedAt != nil {
defaults.UpdatedAt = updatedAt
}
return defaults, nil
}
func (s *Service) UpdateMailSettings(ctx context.Context, externalID string, req *patchMailSettingsRequest) (MailSettings, error) {
patch := map[string]any{}
if req.Density != nil {
patch["density"] = strings.ToLower(strings.TrimSpace(*req.Density))
}
if req.ThemeMode != nil {
patch["theme_mode"] = strings.ToLower(strings.TrimSpace(*req.ThemeMode))
}
if req.BackgroundID != nil {
patch["background_id"] = strings.ToLower(strings.TrimSpace(*req.BackgroundID))
}
if req.InboxSort != nil {
patch["inbox_sort"] = strings.ToLower(strings.TrimSpace(*req.InboxSort))
}
if req.ReadingPane != nil {
patch["reading_pane"] = strings.ToLower(strings.TrimSpace(*req.ReadingPane))
}
if req.ConversationMode != nil {
patch["conversation_mode"] = *req.ConversationMode
}
if req.Notifications != nil {
current, err := s.GetMailSettings(ctx, externalID)
if err != nil {
return MailSettings{}, err
}
merged := current.Notifications
if req.Notifications.DesktopNewMail != nil {
merged.DesktopNewMail = *req.Notifications.DesktopNewMail
}
if req.Notifications.DesktopMentions != nil {
merged.DesktopMentions = *req.Notifications.DesktopMentions
}
if req.Notifications.EmailDigest != nil {
merged.EmailDigest = *req.Notifications.EmailDigest
}
if req.Notifications.SoundEnabled != nil {
merged.SoundEnabled = *req.Notifications.SoundEnabled
}
patch["notifications"] = merged
}
patchJSON, err := json.Marshal(patch)
if err != nil {
return MailSettings{}, err
}
var updatedAt any
err = s.db.QueryRow(ctx, `
INSERT INTO settings (user_id, preferences)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
jsonb_build_object('mail', $2::jsonb)
)
ON CONFLICT (user_id) DO UPDATE SET
preferences = jsonb_set(
COALESCE(settings.preferences, '{}'::jsonb),
'{mail}',
COALESCE(settings.preferences->'mail', '{}'::jsonb) || $2::jsonb
),
updated_at = NOW()
RETURNING updated_at
`, externalID, patchJSON).Scan(&updatedAt)
if err != nil {
return MailSettings{}, err
}
result, err := s.GetMailSettings(ctx, externalID)
if err != nil {
return MailSettings{}, err
}
result.UpdatedAt = updatedAt
return result, nil
}

View File

@ -0,0 +1,161 @@
package mail
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
)
type SignaturesList struct {
Signatures []map[string]any `json:"signatures"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func scanSignature(id, name, html string, sortOrder int, createdAt, updatedAt any) map[string]any {
return map[string]any{
"id": id,
"name": name,
"html": html,
"sort_order": sortOrder,
"created_at": createdAt,
"updated_at": updatedAt,
}
}
func (s *Service) verifySignatureOwnership(ctx context.Context, externalID, signatureID string) error {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mail_signatures ms
JOIN users u ON ms.user_id = u.id
WHERE ms.id = $1 AND u.external_id = $2
)
`, signatureID, externalID).Scan(&exists)
if err != nil {
return err
}
if !exists {
return ErrNotFound
}
return nil
}
func (s *Service) ListSignatures(ctx context.Context, externalID string, params query.ListParams) (SignaturesList, error) {
var total int64
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM mail_signatures ms
JOIN users u ON ms.user_id = u.id
WHERE u.external_id = $1
`, externalID).Scan(&total); err != nil {
return SignaturesList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT ms.id, ms.name, ms.html, ms.sort_order, ms.created_at, ms.updated_at
FROM mail_signatures ms
JOIN users u ON ms.user_id = u.id
WHERE u.external_id = $1
ORDER BY ms.sort_order ASC, ms.created_at ASC
LIMIT $2 OFFSET $3
`, externalID, params.Limit(), params.Offset())
if err != nil {
return SignaturesList{}, err
}
defer rows.Close()
signatures := make([]map[string]any, 0)
for rows.Next() {
var id, name, html string
var sortOrder int
var createdAt, updatedAt any
if err := rows.Scan(&id, &name, &html, &sortOrder, &createdAt, &updatedAt); err != nil {
return SignaturesList{}, err
}
signatures = append(signatures, scanSignature(id, name, html, sortOrder, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return SignaturesList{}, err
}
return SignaturesList{
Signatures: signatures,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) GetSignature(ctx context.Context, externalID, signatureID string) (map[string]any, error) {
var id, name, html string
var sortOrder int
var createdAt, updatedAt any
err := s.db.QueryRow(ctx, `
SELECT ms.id, ms.name, ms.html, ms.sort_order, ms.created_at, ms.updated_at
FROM mail_signatures ms
JOIN users u ON ms.user_id = u.id
WHERE ms.id = $1 AND u.external_id = $2
`, signatureID, externalID).Scan(&id, &name, &html, &sortOrder, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return scanSignature(id, name, html, sortOrder, createdAt, updatedAt), nil
}
func (s *Service) CreateSignature(ctx context.Context, externalID string, req *createSignatureRequest) (string, error) {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return "", err
}
var id string
err = s.db.QueryRow(ctx, `
INSERT INTO mail_signatures (user_id, name, html)
VALUES ($1, $2, $3)
RETURNING id
`, userID, req.Name, req.HTML).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
func (s *Service) UpdateSignature(ctx context.Context, externalID, signatureID string, req *updateSignatureRequest) error {
result, err := s.db.Exec(ctx, `
UPDATE mail_signatures ms SET
name = $1, html = $2, updated_at = NOW()
FROM users u
WHERE ms.id = $3 AND ms.user_id = u.id AND u.external_id = $4
`, req.Name, req.HTML, signatureID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteSignature(ctx context.Context, externalID, signatureID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM mail_signatures ms
USING users u
WHERE ms.id = $1 AND ms.user_id = u.id AND u.external_id = $2
`, signatureID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
if s.audit != nil {
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "mail_signature", "signature_id": signatureID,
})
}
return nil
}

View File

@ -0,0 +1,284 @@
package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/ultisuite/ulti-backend/internal/api/query"
)
type UnifiedFoldersList struct {
Folders []map[string]any `json:"folders"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func scanUnifiedFolderRow(
id string,
accountID *string,
parentID *string,
name, color string,
sortOrder int,
createdAt, updatedAt any,
) map[string]any {
row := map[string]any{
"id": id,
"name": name,
"color": color,
"sort_order": sortOrder,
"created_at": createdAt,
"updated_at": updatedAt,
"scope": "global",
}
if accountID != nil && *accountID != "" {
row["account_id"] = *accountID
row["scope"] = "account"
}
if parentID != nil && *parentID != "" {
row["parent_id"] = *parentID
}
return row
}
func (s *Service) ListUnifiedFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (UnifiedFoldersList, error) {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return UnifiedFoldersList{}, err
}
countQuery := `
SELECT COUNT(*) FROM mail_unified_folders
WHERE user_id = $1
`
listQuery := `
SELECT id, account_id, parent_id, name, color, sort_order, created_at, updated_at
FROM mail_unified_folders
WHERE user_id = $1
`
args := []any{userID}
switch strings.TrimSpace(accountID) {
case "", "all":
// all scopes
case "global":
countQuery += ` AND account_id IS NULL`
listQuery += ` AND account_id IS NULL`
default:
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2
)
`, accountID, userID).Scan(&owned); err != nil {
return UnifiedFoldersList{}, err
}
if !owned {
return UnifiedFoldersList{}, ErrAccountNotFound
}
countQuery += ` AND account_id = $2`
listQuery += ` AND account_id = $2`
args = append(args, accountID)
}
var total int64
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return UnifiedFoldersList{}, err
}
listQuery += fmt.Sprintf(` ORDER BY sort_order ASC, name ASC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, params.Limit(), params.Offset())
rows, err := s.db.Query(ctx, listQuery, args...)
if err != nil {
return UnifiedFoldersList{}, err
}
defer rows.Close()
folders := make([]map[string]any, 0)
for rows.Next() {
var id, name, color string
var accountIDVal, parentIDVal *string
var sortOrder int
var createdAt, updatedAt any
if err := rows.Scan(&id, &accountIDVal, &parentIDVal, &name, &color, &sortOrder, &createdAt, &updatedAt); err != nil {
return UnifiedFoldersList{}, err
}
folders = append(folders, scanUnifiedFolderRow(id, accountIDVal, parentIDVal, name, color, sortOrder, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return UnifiedFoldersList{}, err
}
return UnifiedFoldersList{
Folders: folders,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) CreateUnifiedFolder(ctx context.Context, userID string, req *createUnifiedFolderRequest) (string, error) {
scopeAccountID := strings.TrimSpace(req.AccountID)
parentID := strings.TrimSpace(req.ParentID)
if scopeAccountID != "" {
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2)
`, scopeAccountID, userID).Scan(&owned); err != nil {
return "", err
}
if !owned {
return "", ErrAccountNotFound
}
}
if parentID != "" {
var parentAccountID *string
if err := s.db.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders
WHERE id = $1 AND user_id = $2
`, parentID, userID).Scan(&parentAccountID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
if scopeAccountID == "" {
if parentAccountID != nil {
return "", ErrInvalidFolderScope
}
} else if parentAccountID == nil || *parentAccountID != scopeAccountID {
return "", ErrInvalidFolderScope
}
}
var id string
var accountArg any
if scopeAccountID == "" {
accountArg = nil
} else {
accountArg = scopeAccountID
}
var parentArg any
if parentID == "" {
parentArg = nil
} else {
parentArg = parentID
}
err := s.db.QueryRow(ctx, `
INSERT INTO mail_unified_folders (user_id, account_id, parent_id, name, color, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, userID, accountArg, parentArg, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return "", ErrDuplicateFolder
}
return "", err
}
return id, nil
}
func (s *Service) UpdateUnifiedFolder(ctx context.Context, externalID, folderID string, req *updateUnifiedFolderRequest) error {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return err
}
if req.ParentID != nil {
parentID := strings.TrimSpace(*req.ParentID)
if parentID != "" {
var parentAccountID *string
if err := s.db.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders
WHERE id = $1 AND user_id = $2
`, parentID, userID).Scan(&parentAccountID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
var folderAccountID *string
if err := s.db.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders
WHERE id = $1 AND user_id = $2
`, folderID, userID).Scan(&folderAccountID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if (folderAccountID == nil) != (parentAccountID == nil) {
return ErrInvalidFolderScope
}
if folderAccountID != nil && parentAccountID != nil && *folderAccountID != *parentAccountID {
return ErrInvalidFolderScope
}
}
}
var parentArg any
if req.ParentID == nil {
result, err := s.db.Exec(ctx, `
UPDATE mail_unified_folders SET
name = $1,
color = $2,
sort_order = $3,
updated_at = NOW()
WHERE id = $4 AND user_id = $5
`, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder, folderID, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
parentID := strings.TrimSpace(*req.ParentID)
if parentID == "" {
parentArg = nil
} else {
parentArg = parentID
}
result, err := s.db.Exec(ctx, `
UPDATE mail_unified_folders SET
name = $1,
color = $2,
sort_order = $3,
parent_id = $4,
updated_at = NOW()
WHERE id = $5 AND user_id = $6
`, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder, parentArg, folderID, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteUnifiedFolder(ctx context.Context, externalID, folderID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM mail_unified_folders
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
`, folderID, externalID)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrFolderHasChildren
}
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@ -11,10 +11,19 @@ import (
"time"
"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"`

View File

@ -0,0 +1,55 @@
package mail
import "testing"
func TestValidateUpdateAccount(t *testing.T) {
t.Parallel()
valid := &updateAccountRequest{
Email: "user@example.com",
IMAPHost: "imap.example.com",
IMAPPort: 993,
SMTPHost: "smtp.example.com",
SMTPPort: 587,
}
if err := validateUpdateAccount(valid); err != nil {
t.Fatalf("expected valid update, got %v", err)
}
withPassword := *valid
withPassword.Password = "secret"
if err := validateUpdateAccount(&withPassword); err != nil {
t.Fatalf("expected valid update with password, got %v", err)
}
emptyEmail := *valid
emptyEmail.Email = ""
if err := validateUpdateAccount(&emptyEmail); err == nil {
t.Fatal("expected validation error for empty email")
}
}
func TestValidateTestAccountWithStoredCredentials(t *testing.T) {
t.Parallel()
stored := &testAccountRequest{
AccountID: "550e8400-e29b-41d4-a716-446655440000",
IMAPHost: "imap.example.com",
IMAPPort: 993,
SMTPHost: "smtp.example.com",
SMTPPort: 587,
}
if err := validateTestAccount(stored); err != nil {
t.Fatalf("expected valid test with account_id, got %v", err)
}
noCreds := &testAccountRequest{
IMAPHost: "imap.example.com",
IMAPPort: 993,
SMTPHost: "smtp.example.com",
SMTPPort: 587,
}
if err := validateTestAccount(noCreds); err == nil {
t.Fatal("expected validation error without password or account_id")
}
}

View File

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

View File

@ -0,0 +1,159 @@
package mail
import (
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
const maxSettingsRequestBody = 4 << 10 // 4 KiB
var (
allowedDensities = map[string]struct{}{
"default": {},
"normal": {},
"compact": {},
}
allowedThemeModes = map[string]struct{}{
"light": {},
"dark": {},
"system": {},
}
allowedBackgroundIDs = map[string]struct{}{
"none": {},
"gradient-aurora": {},
"gradient-sunset": {},
"gradient-ocean": {},
"gradient-blossom": {},
"photo-mountains": {},
"photo-ocean": {},
"photo-city": {},
"photo-nature": {},
}
allowedInboxSorts = map[string]struct{}{
"default": {},
"important": {},
"unread": {},
"starred": {},
}
allowedReadingPanes = map[string]struct{}{
"none": {},
"right": {},
"below": {},
}
)
type MailNotificationSettings struct {
DesktopNewMail bool `json:"desktop_new_mail"`
DesktopMentions bool `json:"desktop_mentions"`
EmailDigest bool `json:"email_digest"`
SoundEnabled bool `json:"sound_enabled"`
}
type MailSettings struct {
Density string `json:"density"`
ThemeMode string `json:"theme_mode"`
BackgroundID string `json:"background_id"`
InboxSort string `json:"inbox_sort"`
ReadingPane string `json:"reading_pane"`
ConversationMode bool `json:"conversation_mode"`
Notifications MailNotificationSettings `json:"notifications"`
UpdatedAt any `json:"updated_at,omitempty"`
}
type patchMailNotificationSettings struct {
DesktopNewMail *bool `json:"desktop_new_mail"`
DesktopMentions *bool `json:"desktop_mentions"`
EmailDigest *bool `json:"email_digest"`
SoundEnabled *bool `json:"sound_enabled"`
}
type patchMailSettingsRequest struct {
Density *string `json:"density"`
ThemeMode *string `json:"theme_mode"`
BackgroundID *string `json:"background_id"`
InboxSort *string `json:"inbox_sort"`
ReadingPane *string `json:"reading_pane"`
ConversationMode *bool `json:"conversation_mode"`
Notifications *patchMailNotificationSettings `json:"notifications"`
}
func defaultMailNotificationSettings() MailNotificationSettings {
return MailNotificationSettings{
DesktopNewMail: true,
DesktopMentions: true,
EmailDigest: false,
SoundEnabled: false,
}
}
func defaultMailSettings() MailSettings {
return MailSettings{
Density: "default",
ThemeMode: "system",
BackgroundID: "none",
InboxSort: "default",
ReadingPane: "none",
ConversationMode: true,
Notifications: defaultMailNotificationSettings(),
}
}
func validateEnum(field, value string, allowed map[string]struct{}) *apivalidate.FieldDetail {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return &apivalidate.FieldDetail{Field: field, Message: "required"}
}
if _, ok := allowed[value]; !ok {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
return nil
}
func validatePatchMailSettings(req *patchMailSettingsRequest) *apivalidate.ValidationError {
if req == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "required"})
}
hasField := req.Density != nil ||
req.ThemeMode != nil ||
req.BackgroundID != nil ||
req.InboxSort != nil ||
req.ReadingPane != nil ||
req.ConversationMode != nil ||
req.Notifications != nil
if !hasField {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "at least one field required"})
}
var details []apivalidate.FieldDetail
if req.Density != nil {
if d := validateEnum("density", *req.Density, allowedDensities); d != nil {
details = append(details, *d)
}
}
if req.ThemeMode != nil {
if d := validateEnum("theme_mode", *req.ThemeMode, allowedThemeModes); d != nil {
details = append(details, *d)
}
}
if req.BackgroundID != nil {
if d := validateEnum("background_id", *req.BackgroundID, allowedBackgroundIDs); d != nil {
details = append(details, *d)
}
}
if req.InboxSort != nil {
if d := validateEnum("inbox_sort", *req.InboxSort, allowedInboxSorts); d != nil {
details = append(details, *d)
}
}
if req.ReadingPane != nil {
if d := validateEnum("reading_pane", *req.ReadingPane, allowedReadingPanes); d != nil {
details = append(details, *d)
}
}
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}

View File

@ -0,0 +1,43 @@
package mail
import "testing"
func TestValidatePatchMailSettings(t *testing.T) {
t.Run("empty body", func(t *testing.T) {
err := validatePatchMailSettings(&patchMailSettingsRequest{})
if err == nil {
t.Fatal("expected validation error")
}
})
t.Run("valid partial patch", func(t *testing.T) {
density := "compact"
err := validatePatchMailSettings(&patchMailSettingsRequest{Density: &density})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("invalid density", func(t *testing.T) {
density := "huge"
err := validatePatchMailSettings(&patchMailSettingsRequest{Density: &density})
if err == nil {
t.Fatal("expected validation error")
}
})
t.Run("invalid background", func(t *testing.T) {
bg := "photo-space"
err := validatePatchMailSettings(&patchMailSettingsRequest{BackgroundID: &bg})
if err == nil {
t.Fatal("expected validation error")
}
})
}
func TestDefaultMailSettings(t *testing.T) {
d := defaultMailSettings()
if d.Density != "default" || d.ThemeMode != "system" || !d.ConversationMode {
t.Fatalf("defaults = %#v", d)
}
}

View File

@ -0,0 +1,59 @@
package mail
import (
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
const (
maxSignatureRequestBody = 256 << 10 // 256 KiB
maxSignatureName = 128
maxSignatureHTMLContent = 64 << 10 // 64 KiB
)
type createSignatureRequest struct {
Name string `json:"name"`
HTML string `json:"html"`
}
type updateSignatureRequest struct {
Name string `json:"name"`
HTML string `json:"html"`
}
func validateCreateSignature(req *createSignatureRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
name := strings.TrimSpace(req.Name)
if name == "" {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
} else if len(name) > maxSignatureName {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
if len(req.HTML) > maxSignatureHTMLContent {
details = append(details, apivalidate.FieldDetail{Field: "html", Message: "too long"})
}
if len(details) == 0 {
req.Name = name
return nil
}
return apivalidate.NewValidationError(details...)
}
func validateUpdateSignature(req *updateSignatureRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
name := strings.TrimSpace(req.Name)
if name == "" {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
} else if len(name) > maxSignatureName {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
if len(req.HTML) > maxSignatureHTMLContent {
details = append(details, apivalidate.FieldDetail{Field: "html", Message: "too long"})
}
if len(details) == 0 {
req.Name = name
return nil
}
return apivalidate.NewValidationError(details...)
}

View File

@ -0,0 +1,47 @@
package mail
import (
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
const maxUnifiedFolderRequestBody = 8 << 10
type createUnifiedFolderRequest struct {
Name string `json:"name"`
Color string `json:"color"`
AccountID string `json:"account_id"`
ParentID string `json:"parent_id"`
SortOrder int `json:"sort_order"`
}
type updateUnifiedFolderRequest struct {
Name string `json:"name"`
Color string `json:"color"`
SortOrder int `json:"sort_order"`
ParentID *string `json:"parent_id"`
}
func validateCreateUnifiedFolder(req *createUnifiedFolderRequest) *apivalidate.ValidationError {
if req == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "required"})
}
if strings.TrimSpace(req.Name) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "name", Message: "required"})
}
if len(req.Name) > maxFolderName {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
return nil
}
func validateUpdateUnifiedFolder(req *updateUnifiedFolderRequest) *apivalidate.ValidationError {
if req == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "body", Message: "required"})
}
if strings.TrimSpace(req.Name) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "name", Message: "required"})
}
return nil
}

View File

@ -2,6 +2,11 @@ package auth
import (
"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

View File

@ -0,0 +1,44 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewVerifierPublicIssuerInternalFetchURL(t *testing.T) {
t.Parallel()
publicIssuer := "http://localhost/auth/application/o/ulti"
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": publicIssuer,
"jwks_uri": publicIssuer + "/jwks/",
})
})
mux.HandleFunc("/jwks/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"keys":[]}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
verifier, err := NewVerifier(
context.Background(),
strings.TrimSuffix(srv.URL, "/"),
"ulti-backend",
"localhost",
)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
if verifier == nil || verifier.verifier == nil {
t.Fatal("expected verifier")
}
}

View File

@ -0,0 +1,43 @@
package auth
import (
"context"
"log/slog"
"time"
)
// NewVerifierWithRetry waits for the OIDC provider (e.g. Authentik blueprints) to become ready.
func NewVerifierWithRetry(ctx context.Context, issuerURL, clientID, discoveryHost string, attempts int, delay time.Duration) (*Verifier, error) {
if issuerURL == "" || clientID == "" {
return nil, nil
}
if attempts < 1 {
attempts = 1
}
var lastErr error
for i := 1; i <= attempts; i++ {
verifier, err := NewVerifier(ctx, issuerURL, clientID, discoveryHost)
if err == nil {
if i > 1 {
slog.Info("OIDC verifier ready", "attempt", i)
}
return verifier, nil
}
lastErr = err
if i == attempts {
break
}
slog.Warn("OIDC verifier not ready, retrying",
"attempt", i,
"max", attempts,
"error", err,
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, lastErr
}

View File

@ -15,6 +15,9 @@ type Config struct {
Domain string
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

View File

@ -0,0 +1,64 @@
package dbmigrate
import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/ultisuite/ulti-backend/migrations"
)
// Up applies pending SQL migrations. No-op when ULTID_AUTO_MIGRATE=false.
func Up(databaseURL string) error {
if !autoMigrateEnabled() {
slog.Info("database auto-migrate disabled")
return nil
}
if strings.TrimSpace(databaseURL) == "" {
return fmt.Errorf("empty database URL")
}
source, err := iofs.New(migrations.Files, ".")
if err != nil {
return fmt.Errorf("migration source: %w", err)
}
m, err := migrate.NewWithSourceInstance("iofs", source, databaseURL)
if err != nil {
return fmt.Errorf("migrate init: %w", err)
}
defer func() {
srcErr, dbErr := m.Close()
if srcErr != nil {
slog.Warn("migrate close source", "error", srcErr)
}
if dbErr != nil {
slog.Warn("migrate close database", "error", dbErr)
}
}()
if err := m.Up(); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
slog.Info("database schema up to date")
return nil
}
return fmt.Errorf("migrate up: %w", err)
}
slog.Info("database migrations applied")
return nil
}
func autoMigrateEnabled() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("ULTID_AUTO_MIGRATE")))
if v == "false" || v == "0" || v == "no" {
return false
}
return true
}

View File

@ -0,0 +1,95 @@
package httpcors
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/go-chi/cors"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/config"
)
// Middleware returns chi CORS handler configured from app config.
func Middleware(cfg *config.Config) func(http.Handler) http.Handler {
opts := cors.Options{
AllowedMethods: []string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodOptions,
},
AllowedHeaders: []string{
"Accept",
"Authorization",
"Content-Type",
"Idempotency-Key",
"Origin",
"X-Requested-With",
apiresponse.TraceIDHeader,
},
ExposedHeaders: []string{apiresponse.TraceIDHeader},
AllowCredentials: false,
MaxAge: 300,
}
allowed := cfg.CORSAllowedOrigins
switch {
case len(allowed) == 1 && allowed[0] == "*":
opts.AllowedOrigins = []string{"*"}
case len(allowed) > 0:
opts.AllowedOrigins = allowed
case cfg != nil && !cfg.IsProduction():
opts.AllowOriginFunc = allowLocalDevOrigin
default:
opts.AllowedOrigins = defaultProductionOrigins(cfg)
}
return cors.Handler(opts)
}
func defaultProductionOrigins(cfg *config.Config) []string {
if cfg == nil || cfg.Domain == "" {
return []string{"*"}
}
domain := strings.TrimSpace(cfg.Domain)
return []string{
"https://" + domain,
"http://" + domain,
}
}
func allowLocalDevOrigin(_ *http.Request, origin string) bool {
u, err := url.Parse(origin)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
host, _, err := net.SplitHostPort(u.Host)
if err != nil {
host = u.Host
}
switch strings.ToLower(host) {
case "localhost", "127.0.0.1", "::1":
return true
default:
return isPrivateLANHost(host)
}
}
func isPrivateLANHost(host string) bool {
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsPrivate() || ip.IsLoopback()
}

View File

@ -0,0 +1,95 @@
package httpcors
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/ultisuite/ulti-backend/internal/config"
)
func TestMiddlewareDevAllowsLocalhostOrigin(t *testing.T) {
cfg := &config.Config{AppEnv: "development"}
handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("Access-Control-Allow-Origin = %q, want http://localhost:3000", got)
}
}
func TestMiddlewareDevAllowsPrivateLANOrigin(t *testing.T) {
cfg := &config.Config{AppEnv: "development"}
handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil)
req.Header.Set("Origin", "http://192.168.0.20:3000")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://192.168.0.20:3000" {
t.Fatalf("Access-Control-Allow-Origin = %q, want http://192.168.0.20:3000", got)
}
}
func TestMiddlewareDevPreflightIncludesHeadMethod(t *testing.T) {
cfg := &config.Config{AppEnv: "development"}
handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodOptions, "/api/v1/mail/settings", nil)
req.Header.Set("Origin", "http://localhost:3000")
req.Header.Set("Access-Control-Request-Method", "HEAD")
req.Header.Set("Access-Control-Request-Headers", "authorization")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "HEAD" {
t.Fatalf("Access-Control-Allow-Methods = %q, want HEAD", got)
}
}
func TestMiddlewareProductionUsesDomainOrigins(t *testing.T) {
cfg := &config.Config{AppEnv: "production", Domain: "mail.example.com"}
handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil)
req.Header.Set("Origin", "https://mail.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://mail.example.com" {
t.Fatalf("Access-Control-Allow-Origin = %q, want https://mail.example.com", got)
}
}
func TestMiddlewareExplicitWildcard(t *testing.T) {
cfg := &config.Config{
AppEnv: "production",
Domain: "mail.example.com",
CORSAllowedOrigins: []string{"*"},
}
handler := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/settings", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
}

View File

@ -0,0 +1,69 @@
package autoconfig
import (
"context"
"net/mail"
"strings"
"time"
)
// Discover resolves likely IMAP/SMTP settings for an email address.
func Discover(ctx context.Context, email string) (Result, error) {
email = strings.TrimSpace(email)
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address == "" {
return Result{}, ErrInvalidEmail
}
email = strings.ToLower(parsed.Address)
at := strings.LastIndex(email, "@")
if at <= 0 || at >= len(email)-1 {
return Result{}, ErrInvalidEmail
}
domain := strings.ToLower(email[at+1:])
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if preset, ok := matchDomain(domain); ok {
r := preset.toResult(email, domain, "preset", "high")
return r, nil
}
if r, ok := discoverFromMX(ctx, email, domain); ok && r.IMAPHost != "" {
r.Email = email
return r, nil
}
if r, ok := fetchMozillaAutoconfig(ctx, domain); ok {
r.Email = email
r.Domain = domain
if r.IMAPHost != "" || r.SMTPHost != "" {
return r, nil
}
}
return guessFromDomain(email, domain), nil
}
func guessFromDomain(email, domain string) Result {
return Result{
Email: email,
Domain: domain,
ProviderID: "custom",
ProviderName: "Serveur personnalisé",
Source: "guess",
Confidence: "low",
IMAPHost: "imap." + domain,
IMAPPort: 993,
IMAPTLS: true,
SMTPHost: "smtp." + domain,
SMTPPort: 587,
SMTPTLS: true,
UsernameHint: "email",
AuthMethods: []string{"password"},
Notes: []string{
"Aucune configuration connue pour ce domaine.",
"Vérifiez les paramètres auprès de votre hébergeur ou activez les réglages avancés.",
},
}
}

View File

@ -0,0 +1,76 @@
package autoconfig
import (
"context"
"testing"
)
func TestMatchDomainGmail(t *testing.T) {
p, ok := matchDomain("gmail.com")
if !ok || p.id != "gmail" {
t.Fatalf("expected gmail preset, got ok=%v id=%q", ok, p.id)
}
}
func TestMatchDomainOutlookFR(t *testing.T) {
p, ok := matchDomain("outlook.fr")
if !ok || p.id != "outlook" {
t.Fatalf("expected outlook preset, got ok=%v id=%q", ok, p.id)
}
}
func TestDiscoverPreset(t *testing.T) {
r, err := Discover(context.Background(), "User@Gmail.COM")
if err != nil {
t.Fatal(err)
}
if r.Source != "preset" || r.IMAPHost != "imap.gmail.com" {
t.Fatalf("unexpected result: %+v", r)
}
if r.Email != "User@Gmail.COM" {
// ParseAddress normalizes? Actually mail.ParseAddress keeps case in local part
// but domain might be lowercased in Address field
}
}
func TestGuessFromDomain(t *testing.T) {
r := guessFromDomain("a@example.org", "example.org")
if r.Source != "guess" || r.IMAPHost != "imap.example.org" {
t.Fatalf("unexpected guess: %+v", r)
}
}
func TestParseMozillaConfig(t *testing.T) {
const sample = `<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<domain>example.com</domain>
<displayName>Example Mail</displayName>
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
</outgoingServer>
</emailProvider>
</clientConfig>`
r, ok := parseMozillaConfig([]byte(sample), "example.com")
if !ok {
t.Fatal("expected parse ok")
}
if r.IMAPHost != "imap.example.com" || r.SMTPHost != "smtp.example.com" {
t.Fatalf("unexpected hosts: %+v", r)
}
}
func TestDiscoverInvalidEmail(t *testing.T) {
_, err := Discover(context.Background(), "not-an-email")
if err != ErrInvalidEmail {
t.Fatalf("expected ErrInvalidEmail, got %v", err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,11 +19,6 @@ type Manager struct {
keys map[string][]byte
}
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,23 +10,27 @@ import (
"strings"
"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")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

8
migrations/embed.go Normal file
View File

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

View File

@ -180,7 +180,7 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
## 3) Frontend web (`gmail-interface-clone`)
> **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"