Refactor and enhance unified frontend and API features

- Updated environment configuration to unify frontend for mail and drive under a single service.
- Revised README to reflect changes in frontend setup and routing for the unified application.
- Introduced new API documentation endpoints for better accessibility of API specifications.
- Enhanced drive and mail services with improved handling of file uploads and metadata enrichment.
- Implemented new API token management features, including creation, listing, and revocation of tokens.
- Added tests for new functionalities in drive and mail services to ensure reliability and correctness.
This commit is contained in:
R3D347HR4Y 2026-06-07 15:44:30 +02:00
parent 556d5f416d
commit bd7534658a
48 changed files with 2765 additions and 185 deletions

View File

@ -236,10 +236,8 @@ MAIL_MICROSOFT_OAUTH_CLIENT_SECRET=
MAIL_MICROSOFT_OAUTH_TENANT=common
MAIL_OAUTH_REDIRECT_URL=
MAIL_APP_URL=http://localhost/mail
# Cible nginx → frontend mail (dev: Next sur l'hôte ; prod: ultimail:3000 si container)
# Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte ; prod: suite-frontend:3000)
MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000
# Dev: pnpm dev drive-suite sur :3001 | Prod sans dev local: drive-suite:3000
DRIVE_FRONTEND_UPSTREAM=host.docker.internal:3001
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
# -----------------------------------------------------------------------------

View File

@ -48,10 +48,7 @@ cp .env.example .env
- 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/`
**Frontends** (stack + `pnpm dev` sur lhôte, nginx route tout sur le port 80) :
- **Ultimail** (`gmail-interface-clone`) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost`, puis `pnpm dev` → http://localhost/mail/
- **UltiDrive** (`drive-suite`) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost/drive` et `NEXT_PUBLIC_BASE_PATH=/drive`, puis `pnpm dev` → http://localhost/drive/
**Frontend suite unifié** (`gmail-interface-clone` — mail + drive + contacts) : `.env.local` avec `NEXT_PUBLIC_APP_URL=http://localhost`, puis `pnpm dev` → http://localhost/mail/ et http://localhost/drive/
| Service | URL |
|---------|-----|
@ -88,8 +85,7 @@ Un seul **nginx** expose lentrée HTTP (`:80`) et route :
| `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
| `/mail/*` | Ultimail (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) |
| `/drive/*` | UltiDrive (`drive-suite`) |
| `/mail/*`, `/drive/*`, `/contacts` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) |
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).

View File

@ -19,11 +19,13 @@ import (
"github.com/redis/go-redis/v9"
"github.com/ultisuite/ulti-backend/internal/api/admin"
"github.com/ultisuite/ulti-backend/internal/api/docs"
"github.com/ultisuite/ulti-backend/internal/api/calendar"
"github.com/ultisuite/ulti-backend/internal/api/contacts"
"github.com/ultisuite/ulti-backend/internal/api/drive"
"github.com/ultisuite/ulti-backend/internal/api/office"
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
"github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge"
"github.com/ultisuite/ulti-backend/internal/api/mail/sendguard"
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
@ -207,6 +209,8 @@ func main() {
r.Use(observability.HTTPMetrics)
r.Use(middleware.Logging)
r.Mount("/api/docs", docs.NewHandler().Routes())
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
report := healthChecker.Check(r.Context())
statusCode := http.StatusOK
@ -225,8 +229,9 @@ func main() {
var driveHandler *drive.Handler
var driveSvc *drive.Service
if ncClient != nil {
driveSvc = drive.NewService(ncClient, hub)
driveHandler = drive.NewHandler(ncClient, hub)
driveSvc = drive.NewService(ncClient, hub, pool)
driveHandler = drive.NewHandlerWithService(driveSvc)
mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc})
}
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
officeSvc := office.NewService(ncClient, office.Config{
@ -246,6 +251,7 @@ func main() {
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
r.Use(middleware.EnforceApiTokenPolicy())
r.Mount("/api/v1/mail", mailHandler.Routes())
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())

View File

@ -29,18 +29,10 @@ entries:
url: http://localhost/api/auth/callback
- matching_mode: strict
url: http://127.0.0.1/api/auth/callback
- matching_mode: strict
url: http://localhost/drive/api/auth/callback
- matching_mode: strict
url: http://127.0.0.1/drive/api/auth/callback
- 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

View File

@ -9,7 +9,6 @@ services:
environment:
DOMAIN: ${DOMAIN:-localhost}
MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3000}
DRIVE_FRONTEND_UPSTREAM: ${DRIVE_FRONTEND_UPSTREAM:-host.docker.internal:3001}
env_file: ../.env.resolved
extra_hosts:
- "host.docker.internal:host-gateway"
@ -202,15 +201,15 @@ services:
prometheus:
condition: service_started
drive-suite:
suite-frontend:
build:
context: ../../drive-suite
context: ../../gmail-interface-clone
dockerfile: Dockerfile
restart: unless-stopped
environment:
- ULTI_PROXY_ORIGIN=http://nginx
- NEXT_PUBLIC_API_URL=/api/v1
- NEXT_PUBLIC_APP_URL=http://${DOMAIN:-localhost}/drive
- NEXT_PUBLIC_APP_URL=http://${DOMAIN:-localhost}
- OIDC_CLIENT_SECRET=${ULTID_OIDC_CLIENT_SECRET:-changeme}
- NEXT_PUBLIC_OIDC_ISSUER=http://${DOMAIN:-localhost}/auth/application/o/ulti/
- NEXT_PUBLIC_OIDC_CLIENT_ID=${ULTID_OIDC_CLIENT_ID:-ulti-backend}

View File

@ -157,92 +157,11 @@ server {
return 301 /office/;
}
# UltiDrive — variable proxy_pass must not include a URI path (passes client URI as-is).
location ^~ /drive/api/auth/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
rewrite ^/drive/api/auth/(.*)$ /api/auth/$1 break;
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
location ^~ /drive/_next/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
# Next.js dev-only assets (no /drive prefix in generated URLs with assetPrefix-only setup)
location ^~ /__nextjs_font/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /__nextjs_original-stack-frames {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /__nextjs_source-map {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /drive/login {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
rewrite ^/drive/login(.*)$ /login$1 break;
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
location ^~ /drive/auth/complete {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
rewrite ^/drive/auth/complete(.*)$ /auth/complete$1 break;
proxy_pass http://$drive_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
}
# UltiDrive — same suite frontend as mail (unified Next.js app)
location /drive/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
proxy_pass http://$drive_upstream;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -254,8 +173,8 @@ server {
location = /drive {
resolver 127.0.0.11 valid=10s ipv6=off;
set $drive_upstream ${DRIVE_FRONTEND_UPSTREAM};
proxy_pass http://$drive_upstream;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -265,8 +184,8 @@ server {
proxy_set_header Connection $connection_upgrade;
}
# Ultimail frontend — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000)
# Prod: set MAIL_FRONTEND_UPSTREAM=ultimail:3000 when the container exists.
# Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000)
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
location ^~ /api/auth/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};

View File

@ -0,0 +1,50 @@
package docs
import (
_ "embed"
"net/http"
"github.com/go-chi/chi/v5"
)
//go:embed openapi.yaml
var openAPISpec []byte
func NewHandler() *Handler {
return &Handler{}
}
type Handler struct{}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/openapi.yaml", h.serveSpec)
r.Get("/", h.serveUI)
r.Get("/*", h.serveUI)
return r
}
func (h *Handler) serveSpec(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write(openAPISpec)
}
func (h *Handler) serveUI(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(scalarHTML))
}
const scalarHTML = `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ulti API Documentation</title>
<style>body { margin: 0; }</style>
</head>
<body>
<script id="api-reference" data-url="/api/docs/openapi.yaml"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`

View File

@ -0,0 +1,552 @@
openapi: 3.1.0
info:
title: Ulti Suite API
version: 1.0.0
description: |
API REST Ultimail, UltiDrive et Contacts exposée par **ultid** sous `/api/v1`.
## Authentification
Deux modes :
| Mode | Header | Usage |
|------|--------|-------|
| **Session utilisateur** | `Authorization: Bearer <jwt_oidc>` | Interface web, apps avec login OIDC (Authentik) |
| **Token API** | `Authorization: Bearer ulti_<secret>` | Agents IA, scripts, intégrations programmatiques |
Les tokens API portent des permissions **fine-grained** (lecture/écriture par ressource) et des **scopes** optionnels (comptes mail, dossiers Drive).
## Permissions tokens API
Ressources principales :
- **Mail** : `mail.mailboxes`, `mail.labels`, `mail.messages`, `mail.search`, `mail.send`, `mail.attachments`, `mail.settings`, `mail.identities`, `mail.automation`
- **Drive** : `drive.folders`, `drive.files`, `drive.thumbnails`, `drive.download`, `drive.share`, `drive.upload`, `drive.rename`, `drive.move`, `drive.copy`
- **Contacts** : `contacts.read`, `contacts.search`, `contacts.write`, `contacts.delete`, `contacts.labels`
- **Automatisations** : `automation.rules`, `automation.webhooks`, `automation.llm`, `automation.search`, `automation.api_tokens` (super admin)
Chaque ressource accepte `read` et/ou `write` selon le cas.
## Scopes
- **mail_scope** : `{ "all_accounts": true }` ou `{ "all_accounts": false, "account_ids": ["uuid", ...] }`
- **drive_scope** : `{ "all_folders": true }` ou `{ "all_folders": false, "folder_paths": ["/Projects", ...] }`
servers:
- url: /api/v1
description: API ultid (proxifiée par nginx)
tags:
- name: Tokens API
description: Gestion des jetons programmatiques
- name: Mail
description: Messages, boîtes, envoi
- name: Drive
description: Fichiers et dossiers Nextcloud
- name: Contacts
description: Carnet d'adresses
- name: Automatisations
description: Règles, webhooks, fournisseurs
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT or ulti_token
description: JWT OIDC (session) ou token API `ulti_…`
schemas:
ApiTokenPermissionGrant:
type: object
required: [resource, read, write]
properties:
resource:
type: string
example: mail.messages
read:
type: boolean
write:
type: boolean
ApiTokenMailScope:
type: object
properties:
all_accounts:
type: boolean
account_ids:
type: array
items:
type: string
format: uuid
ApiTokenDriveScope:
type: object
properties:
all_folders:
type: boolean
folder_paths:
type: array
items:
type: string
example: /Projects
ApiToken:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
token_prefix:
type: string
example: ulti_a3f9b2c1
permissions:
type: array
items:
$ref: '#/components/schemas/ApiTokenPermissionGrant'
mail_scope:
$ref: '#/components/schemas/ApiTokenMailScope'
drive_scope:
$ref: '#/components/schemas/ApiTokenDriveScope'
created_at:
type: string
format: date-time
last_used_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
ApiTokenCreated:
allOf:
- $ref: '#/components/schemas/ApiToken'
- type: object
required: [token]
properties:
token:
type: string
description: Secret complet — affiché une seule fois à la création
CreateApiTokenRequest:
type: object
required: [name, permissions, mail_scope, drive_scope]
properties:
name:
type: string
permissions:
type: array
items:
$ref: '#/components/schemas/ApiTokenPermissionGrant'
mail_scope:
$ref: '#/components/schemas/ApiTokenMailScope'
drive_scope:
$ref: '#/components/schemas/ApiTokenDriveScope'
expires_at:
type: string
format: date-time
MessageSummary:
type: object
properties:
id:
type: string
format: uuid
account_id:
type: string
format: uuid
subject:
type: string
snippet:
type: string
date:
type: string
format: date-time
DriveFile:
type: object
properties:
path:
type: string
name:
type: string
type:
type: string
enum: [file, directory]
size:
type: integer
mime_type:
type: string
last_modified:
type: string
format: date-time
Error:
type: object
properties:
error:
type: object
properties:
code:
type: string
message:
type: string
security:
- bearerAuth: []
paths:
/mail/api-tokens:
get:
tags: [Tokens API]
summary: Lister les tokens API
description: Nécessite une session OIDC ou un token avec `automation.api_tokens` (écriture).
responses:
'200':
description: Liste des tokens actifs (sans secret)
content:
application/json:
schema:
type: object
properties:
tokens:
type: array
items:
$ref: '#/components/schemas/ApiToken'
post:
tags: [Tokens API]
summary: Créer un token API
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateApiTokenRequest'
example:
name: Agent tri support
permissions:
- resource: mail.messages
read: true
write: false
- resource: mail.labels
read: true
write: true
mail_scope:
all_accounts: false
account_ids: ["550e8400-e29b-41d4-a716-446655440000"]
drive_scope:
all_folders: true
folder_paths: []
responses:
'201':
description: Token créé — copier le champ `token` immédiatement
content:
application/json:
schema:
$ref: '#/components/schemas/ApiTokenCreated'
/mail/api-tokens/{tokenID}:
delete:
tags: [Tokens API]
summary: Révoquer un token API
parameters:
- name: tokenID
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: Token révoqué
/mail/messages:
get:
tags: [Mail]
summary: Lister les messages
description: |
Permission requise : `mail.messages` (lecture).
Scope mail appliqué automatiquement si le token est restreint à certains comptes.
parameters:
- name: account_id
in: query
schema:
type: string
format: uuid
- name: folder
in: query
schema:
type: string
example: inbox
- name: page
in: query
schema:
type: integer
- name: page_size
in: query
schema:
type: integer
responses:
'200':
description: Page de messages
content:
application/json:
schema:
type: object
properties:
messages:
type: array
items:
$ref: '#/components/schemas/MessageSummary'
/mail/messages/{messageID}:
get:
tags: [Mail]
summary: Lire un message
description: Vérifie que le message appartient à un compte autorisé par le token.
parameters:
- name: messageID
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Message complet
'403':
description: Compte hors scope du token
/mail/search:
get:
tags: [Mail]
summary: Rechercher des messages
description: Permission `mail.search` (lecture).
parameters:
- name: q
in: query
schema:
type: string
- name: account_id
in: query
schema:
type: string
format: uuid
- name: from
in: query
schema:
type: string
responses:
'200':
description: Résultats de recherche
/mail/send:
post:
tags: [Mail]
summary: Envoyer un message
description: Permission `mail.send` (écriture). `account_id` doit être dans le scope.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [account_id, to, subject]
properties:
account_id:
type: string
format: uuid
to:
type: array
items:
type: string
subject:
type: string
body_html:
type: string
responses:
'200':
description: Message envoyé ou mis en file
/mail/rules:
get:
tags: [Automatisations]
summary: Lister les règles de tri
description: Permission `automation.rules` (lecture).
responses:
'200':
description: Règles
post:
tags: [Automatisations]
summary: Créer une règle
description: Permission `automation.rules` (écriture).
responses:
'201':
description: Règle créée
/mail/webhooks:
get:
tags: [Automatisations]
summary: Lister les webhooks
description: Permission `automation.webhooks` (lecture).
responses:
'200':
description: Webhooks
post:
tags: [Automatisations]
summary: Créer un webhook
responses:
'201':
description: Webhook créé
/drive/files/{path}:
get:
tags: [Drive]
summary: Lister un dossier
description: Permission `drive.folders` ou `drive.files` (lecture). Path relatif au Drive.
parameters:
- name: path
in: path
required: true
schema:
type: string
example: Projects/docs
responses:
'200':
description: Contenu du dossier
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
$ref: '#/components/schemas/DriveFile'
post:
tags: [Drive]
summary: Uploader un fichier
description: Permission `drive.upload` (écriture). Path = dossier cible.
parameters:
- name: path
in: path
required: true
schema:
type: string
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'201':
description: Fichier uploadé
/drive/download/{path}:
get:
tags: [Drive]
summary: Télécharger un fichier
description: Permission `drive.download` (lecture).
parameters:
- name: path
in: path
required: true
schema:
type: string
responses:
'200':
description: Contenu binaire
/drive/preview/{path}:
get:
tags: [Drive]
summary: Miniature / aperçu
description: Permission `drive.thumbnails` (lecture).
parameters:
- name: path
in: path
required: true
schema:
type: string
responses:
'200':
description: Image ou flux de prévisualisation
/contacts/search:
get:
tags: [Contacts]
summary: Rechercher des contacts
description: Permission `contacts.search` (lecture).
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
'200':
description: Contacts correspondants
/contacts/books/{bookID}:
get:
tags: [Contacts]
summary: Lister les contacts d'un carnet
description: Permission `contacts.read` (lecture).
parameters:
- name: bookID
in: path
required: true
schema:
type: string
responses:
'200':
description: Contacts du carnet
post:
tags: [Contacts]
summary: Créer un contact
description: Permission `contacts.write` (écriture).
responses:
'201':
description: Contact créé
/contacts/discovery/llm-settings:
get:
tags: [Automatisations]
summary: Lire les fournisseurs LLM
description: Permission `automation.llm` (lecture).
responses:
'200':
description: Configuration LLM
put:
tags: [Automatisations]
summary: Mettre à jour les fournisseurs LLM
description: Permission `automation.llm` (écriture).
responses:
'200':
description: Configuration mise à jour
/search:
get:
tags: [Mail, Drive, Contacts]
summary: Recherche cross-suite
description: |
Vérifie les permissions selon `types` :
- `mail` → `mail.search`
- `drive` → `drive.files`
- `contacts` → `contacts.search`
parameters:
- name: q
in: query
required: true
schema:
type: string
- name: types
in: query
schema:
type: string
example: mail,contacts,drive
- name: account_id
in: query
schema:
type: string
format: uuid
responses:
'200':
description: Résultats agrégés

View File

@ -9,6 +9,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
@ -26,9 +27,13 @@ type Handler struct {
logger *slog.Logger
}
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub) *Handler {
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler {
return NewHandlerWithService(NewService(nc, hub, db))
}
func NewHandlerWithService(svc *Service) *Handler {
return &Handler{
svc: NewService(nc, hub),
svc: svc,
logger: slog.Default().With("component", "drive-api"),
}
}
@ -59,6 +64,8 @@ func (h *Handler) Routes() chi.Router {
r.With(read).Get("/starred/*", h.ListStarred)
r.With(read).Get("/shared", h.ListSharedWithMe)
r.With(read).Get("/search", h.Search)
r.With(read).Get("/filter-corpus", h.ListFilterCorpus)
r.With(read).Get("/filter-corpus/*", h.ListFilterCorpus)
r.With(read).Get("/shares", h.ListShares)
r.With(read).Get("/shares/recipients/lookup", h.LookupShareRecipient)
r.With(read).Get("/download/*", h.Download)
@ -95,13 +102,32 @@ func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) {
return
}
path := chi.URLParam(r, "*")
path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
result, err := h.svc.ListFiles(r.Context(), ncUser, path, params)
if err != nil {
h.logger.Error("list files", "error", err)
apivalidate.WriteInternal(w, r)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListFilterCorpus(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
result, err := h.svc.ListFilterCorpus(r.Context(), ncUser, path)
if err != nil {
h.logger.Error("list filter corpus", "error", err, "path", path)
apivalidate.WriteInternal(w, r)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}
@ -258,6 +284,9 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
if middleware.DenyIfDrivePathOutOfScope(w, r, req.Source, req.Destination) {
return
}
if err := h.svc.Move(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("move", "error", err)
@ -283,6 +312,9 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
if middleware.DenyIfDrivePathOutOfScope(w, r, req.Source, req.Destination) {
return
}
if err := h.svc.Copy(r.Context(), ncUser, req.Source, req.Destination); err != nil {
h.logger.Error("copy", "error", err)
@ -307,6 +339,9 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
if middleware.DenyIfDrivePathOutOfScope(w, r, req.Path) {
return
}
if err := h.svc.Rename(r.Context(), ncUser, req.Path, req.NewName); err != nil {
h.logger.Error("rename", "error", err)
@ -333,6 +368,7 @@ func (h *Handler) ListTrash(w http.ResponseWriter, r *http.Request) {
writeDriveError(w, r, err)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}
@ -353,6 +389,7 @@ func (h *Handler) ListRecent(w http.ResponseWriter, r *http.Request) {
writeDriveError(w, r, err)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}
@ -374,6 +411,7 @@ func (h *Handler) ListStarred(w http.ResponseWriter, r *http.Request) {
writeDriveError(w, r, err)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}
@ -394,6 +432,7 @@ func (h *Handler) ListSharedWithMe(w http.ResponseWriter, r *http.Request) {
writeDriveError(w, r, err)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}
@ -465,6 +504,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
writeDriveError(w, r, err)
return
}
h.svc.EnrichSources(r.Context(), claims.Sub, result.Files)
apiresponse.WriteJSON(w, http.StatusOK, result)
}

View File

@ -12,6 +12,8 @@ import (
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/paginate"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth"
@ -30,14 +32,16 @@ var (
type Service struct {
nc *nextcloud.Client
hub *realtime.Hub
db *pgxpool.Pool
maxUploadBytes int64
quotaReserveByte int64
}
func NewService(nc *nextcloud.Client, hub *realtime.Hub) *Service {
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
return &Service{
nc: nc,
hub: hub,
db: db,
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
}
@ -57,6 +61,11 @@ func (s *Service) notifyFileChanged(platformUserID, path string) {
s.hub.Broadcast(platformUserID, realtime.NewDriveFileChangedEvent(path))
}
// PublishFileChanged notifies connected clients that a drive path changed.
func (s *Service) PublishFileChanged(platformUserID, filePath string) {
s.notifyFileChanged(platformUserID, nextcloud.NormalizeClientPath(filePath))
}
func (s *Service) notifyShareUpdated(platformUserID, path string) {
if s.hub == nil || platformUserID == "" {
return
@ -69,6 +78,21 @@ type FilesList struct {
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (FilesList, error) {
if path == "" {
path = "/"
}
files, err := s.nc.ListFilesRecursive(ctx, userID, path, 0)
if err != nil {
return FilesList{}, mapDriveError(err)
}
total := int64(len(files))
return FilesList{
Files: files,
Pagination: query.ListParams{Page: 1, PageSize: len(files)}.Meta(&total),
}, nil
}
func (s *Service) ListFiles(ctx context.Context, userID, path string, params query.ListParams) (FilesList, error) {
if path == "" {
path = "/"
@ -517,18 +541,26 @@ func (s *Service) ensureQuota(ctx context.Context, userID string, incomingBytes
if err != nil {
return mapDriveError(err)
}
if incomingBytes <= 0 {
return nil
}
if quota.Free <= 0 {
return ErrQuotaExceeded
}
if incomingBytes+s.quotaReserveByte > quota.Free {
if !quotaAllowsUpload(quota.Free, incomingBytes, s.quotaReserveByte) {
return ErrQuotaExceeded
}
return nil
}
// quotaAllowsUpload mirrors Nextcloud quota semantics: negative free means unknown or unlimited.
func quotaAllowsUpload(free, incomingBytes, reserve int64) bool {
if incomingBytes <= 0 {
return true
}
if free < 0 {
return true
}
if free == 0 {
return false
}
return incomingBytes+reserve <= free
}
func mapDriveError(err error) error {
if err == nil {
return nil

View File

@ -0,0 +1,24 @@
package drive
import "testing"
func TestQuotaAllowsUploadUnlimited(t *testing.T) {
if !quotaAllowsUpload(-3, 1<<30, 0) {
t.Fatal("negative free (unlimited) should allow upload")
}
}
func TestQuotaAllowsUploadZeroFree(t *testing.T) {
if quotaAllowsUpload(0, 1, 0) {
t.Fatal("zero free should block upload")
}
}
func TestQuotaAllowsUploadInsufficient(t *testing.T) {
if quotaAllowsUpload(100, 95, 10) {
t.Fatal("upload exceeding free+reserve should be blocked")
}
if !quotaAllowsUpload(100, 90, 10) {
t.Fatal("upload within free+reserve should be allowed")
}
}

View File

@ -0,0 +1,60 @@
package drive
import (
"context"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func normalizeDriveSourcePath(filePath string) string {
return nextcloud.NormalizeClientPath(filePath)
}
// EnrichSources attaches suite source metadata (ultimail, ultimeet, …) from drive_file_sources.
func (s *Service) EnrichSources(ctx context.Context, externalUserID string, files []nextcloud.FileInfo) {
if s.db == nil || externalUserID == "" || len(files) == 0 {
return
}
var userID string
if err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalUserID).Scan(&userID); err != nil {
return
}
paths := make([]string, 0, len(files))
indexes := make([]int, 0, len(files))
for i, f := range files {
if f.Type != "file" || f.Path == "" {
continue
}
paths = append(paths, normalizeDriveSourcePath(f.Path))
indexes = append(indexes, i)
}
if len(paths) == 0 {
return
}
rows, err := s.db.Query(ctx, `
SELECT file_path, source FROM drive_file_sources
WHERE user_id = $1 AND file_path = ANY($2)
`, userID, paths)
if err != nil {
return
}
defer rows.Close()
byPath := make(map[string]string, len(paths))
for rows.Next() {
var filePath, source string
if err := rows.Scan(&filePath, &source); err != nil {
continue
}
byPath[filePath] = source
}
for i, filePath := range paths {
if source, ok := byPath[filePath]; ok {
files[indexes[i]].Source = source
}
}
}

View File

@ -0,0 +1,26 @@
package mail
import "fmt"
func appendMessageAccountScope(
baseQuery string,
args []any,
argIdx int,
accountID string,
scopedAccountIDs []string,
) (string, []any, int) {
if accountID != "" {
baseQuery += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
args = append(args, accountID)
return baseQuery, args, argIdx + 1
}
if scopedAccountIDs == nil {
return baseQuery, args, argIdx
}
if len(scopedAccountIDs) == 0 {
return baseQuery + " AND FALSE", args, argIdx
}
baseQuery += fmt.Sprintf(" AND m.account_id = ANY($%d)", argIdx)
args = append(args, scopedAccountIDs)
return baseQuery, args, argIdx + 1
}

View File

@ -0,0 +1,19 @@
package mail
import "testing"
func TestAppendMessageAccountScopeRestricted(t *testing.T) {
base, args, idx := appendMessageAccountScope(
" FROM messages m WHERE 1=1",
[]any{"user"},
2,
"",
[]string{"acc-1", "acc-2"},
)
if idx != 3 || len(args) != 2 {
t.Fatalf("idx=%d len(args)=%d", idx, len(args))
}
if base == "" || args[1] == nil {
t.Fatal("expected scoped clause")
}
}

View File

@ -39,7 +39,7 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag
}
rows, err := s.db.Query(ctx, `
SELECT id, filename, content_type, size, content_id, is_inline
SELECT id, filename, content_type, size, content_id, is_inline, COALESCE(drive_path, '')
FROM attachments WHERE message_id = $1
ORDER BY created_at ASC
`, messageID)
@ -50,10 +50,10 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag
out := make([]map[string]any, 0)
for rows.Next() {
var id, filename, contentType, contentID string
var id, filename, contentType, contentID, drivePath string
var size int64
var isInline bool
if err := rows.Scan(&id, &filename, &contentType, &size, &contentID, &isInline); err != nil {
if err := rows.Scan(&id, &filename, &contentType, &size, &contentID, &isInline, &drivePath); err != nil {
return nil, err
}
entry := map[string]any{
@ -63,6 +63,9 @@ func (s *Service) ListMessageAttachments(ctx context.Context, externalID, messag
if contentID != "" {
entry["content_id"] = contentID
}
if drivePath != "" {
entry["drive_path"] = drivePath
}
out = append(out, entry)
}
return out, rows.Err()

View File

@ -0,0 +1,133 @@
package mail
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
const driveSourceUltimail = "ultimail"
func (s *Service) SetDriveUploader(uploader DriveUploader) {
s.driveUploader = uploader
}
func (s *Service) SaveAttachmentToDrive(
ctx context.Context,
externalID, email, sub, displayName, messageID, attachmentID, folderPath string,
) (string, error) {
if s.driveUploader == nil {
return "", ErrDriveUnavailable
}
if s.storage == nil {
return "", errors.New("object storage unavailable")
}
folderPath = normalizeDriveFolder(folderPath)
userID, err := s.ensureMessageOwned(ctx, externalID, messageID)
if err != nil {
return "", err
}
var (
filename string
contentType string
size int64
s3Key string
existing string
)
err = s.db.QueryRow(ctx, `
SELECT a.filename, a.content_type, a.size, a.s3_key, COALESCE(a.drive_path, '')
FROM attachments a
WHERE a.id = $1 AND a.message_id = $2
`, attachmentID, messageID).Scan(&filename, &contentType, &size, &s3Key, &existing)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrAttachmentNotFound
}
return "", err
}
if existing != "" {
return existing, nil
}
if folderPath != "/" {
if err := s.driveUploader.EnsureNextcloudFolder(ctx, email, sub, displayName, folderPath); err != nil {
return "", err
}
}
destPath, err := uniqueDriveFilePath(ctx, s.driveUploader, email, sub, displayName, folderPath, filename)
if err != nil {
return "", err
}
obj, err := s.storage.Get(ctx, s3Key)
if err != nil {
return "", err
}
defer obj.Close()
destPath = nextcloud.NormalizeClientPath(destPath)
if err := s.driveUploader.UploadFile(ctx, email, sub, displayName, destPath, obj, contentType, size); err != nil {
return "", err
}
s.driveUploader.NotifyFileChanged(externalID, destPath)
if err := s.recordDriveFileSource(ctx, userID, destPath, driveSourceUltimail); err != nil {
return "", err
}
_, err = s.db.Exec(ctx, `
UPDATE attachments SET drive_path = $1 WHERE id = $2 AND message_id = $3
`, destPath, attachmentID, messageID)
if err != nil {
return "", err
}
return destPath, nil
}
func (s *Service) SaveMessageAttachmentsToDrive(
ctx context.Context,
externalID, email, sub, displayName, messageID, folderPath string,
) ([]map[string]any, error) {
list, err := s.ListMessageAttachments(ctx, externalID, messageID)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(list))
for _, att := range list {
if att["is_inline"] == true {
continue
}
id, _ := att["id"].(string)
if id == "" {
continue
}
drivePath, err := s.SaveAttachmentToDrive(ctx, externalID, email, sub, displayName, messageID, id, folderPath)
if err != nil {
return nil, err
}
entry := make(map[string]any, len(att)+1)
for k, v := range att {
entry[k] = v
}
entry["drive_path"] = drivePath
out = append(out, entry)
}
return out, nil
}
func (s *Service) recordDriveFileSource(ctx context.Context, userID, filePath, source string) error {
filePath = nextcloud.NormalizeClientPath(filePath)
_, err := s.db.Exec(ctx, `
INSERT INTO drive_file_sources (user_id, file_path, source)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, file_path) DO UPDATE SET source = EXCLUDED.source
`, userID, filePath, source)
return err
}

View File

@ -0,0 +1,65 @@
package mail
import (
"context"
"errors"
"io"
"path"
"strconv"
"strings"
)
// DriveUploader copies mail attachment bytes into a user's Nextcloud drive.
type DriveUploader interface {
EnsureNextcloudFolder(ctx context.Context, email, sub, displayName, folderPath string) error
UploadFile(ctx context.Context, email, sub, displayName, destPath string, body io.Reader, contentType string, size int64) error
FileExists(ctx context.Context, email, sub, displayName, filePath string) (bool, error)
NotifyFileChanged(platformUserID, filePath string)
}
var ErrDriveUnavailable = errors.New("drive unavailable")
func normalizeDriveFolder(folderPath string) string {
p := strings.TrimSpace(folderPath)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
p = path.Clean(p)
if p == "." {
return "/"
}
return p
}
func uniqueDriveFilePath(ctx context.Context, uploader DriveUploader, email, sub, displayName, folderPath, filename string) (string, error) {
base := strings.TrimSpace(filename)
if base == "" {
base = "piece-jointe"
}
ext := path.Ext(base)
stem := strings.TrimSuffix(base, ext)
if stem == "" {
stem = base
ext = ""
}
candidate := path.Join(folderPath, base)
for i := 0; i < 100; i++ {
exists, err := uploader.FileExists(ctx, email, sub, displayName, candidate)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
if i == 0 {
candidate = path.Join(folderPath, stem+" (1)"+ext)
continue
}
candidate = path.Join(folderPath, stem+" ("+strconv.Itoa(i+1)+")"+ext)
}
return "", errors.New("could not allocate unique drive path")
}

View File

@ -0,0 +1,80 @@
package drivebridge
import (
"context"
"errors"
"io"
"net/http"
"path"
"github.com/ultisuite/ulti-backend/internal/api/drive"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
// Bridge adapts drive.Service for mail attachment exports.
type Bridge struct {
Svc *drive.Service
}
func (b *Bridge) claims(email, sub, displayName string) *auth.Claims {
return &auth.Claims{Email: email, Sub: sub, Name: displayName}
}
func (b *Bridge) ncUser(ctx context.Context, email, sub, displayName string) (string, error) {
if b.Svc == nil {
return "", errors.New("drive unavailable")
}
return b.Svc.EnsureNextcloudUser(ctx, b.claims(email, sub, displayName))
}
func (b *Bridge) EnsureNextcloudFolder(ctx context.Context, email, sub, displayName, folderPath string) error {
userID, err := b.ncUser(ctx, email, sub, displayName)
if err != nil {
return err
}
err = b.Svc.CreateFolder(ctx, userID, folderPath)
if err == nil || errors.Is(err, drive.ErrConflict) {
return nil
}
var statusErr *nextcloud.HTTPStatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusMethodNotAllowed {
return nil
}
return err
}
func (b *Bridge) UploadFile(ctx context.Context, email, sub, displayName, destPath string, body io.Reader, contentType string, size int64) error {
userID, err := b.ncUser(ctx, email, sub, displayName)
if err != nil {
return err
}
return b.Svc.Upload(ctx, userID, destPath, body, contentType, size)
}
func (b *Bridge) NotifyFileChanged(platformUserID, filePath string) {
if b.Svc == nil {
return
}
b.Svc.PublishFileChanged(platformUserID, filePath)
}
func (b *Bridge) FileExists(ctx context.Context, email, sub, displayName, filePath string) (bool, error) {
userID, err := b.ncUser(ctx, email, sub, displayName)
if err != nil {
return false, err
}
parent := path.Dir(filePath)
name := path.Base(filePath)
files, err := b.Svc.ListFiles(ctx, userID, parent, query.ListParams{Page: 1, PageSize: 10_000})
if err != nil {
return false, err
}
for _, f := range files.Files {
if f.Name == name {
return true, nil
}
}
return false, nil
}

View File

@ -35,6 +35,13 @@ func (h *Handler) SetAccountSync(trigger AccountSyncTrigger) {
h.accountSync = trigger
}
// SetDriveUploader wires Nextcloud export for mail attachments.
func (h *Handler) SetDriveUploader(uploader DriveUploader) {
if s, ok := h.svc.(*Service); ok {
s.SetDriveUploader(uploader)
}
}
func NewHandlerWithService(svc ServiceAPI) *Handler {
return &Handler{
svc: svc,
@ -116,6 +123,8 @@ func (h *Handler) Routes() chi.Router {
r.Get("/messages/{messageID}/attachments/cid-map", h.MessageAttachmentCIDMap)
r.Post("/messages/{messageID}/attachments/reindex", h.ReindexMessageAttachments)
r.Post("/messages/{messageID}/attachments", h.UploadMessageAttachment)
r.Post("/messages/{messageID}/attachments/save-to-drive", h.SaveMessageAttachmentsToDrive)
r.Post("/messages/{messageID}/attachments/{attachmentID}/save-to-drive", h.SaveAttachmentToDrive)
r.Post("/messages/{messageID}/list-unsubscribe-mailto", h.SendListUnsubscribeMailto)
r.Get("/messages/{messageID}", h.GetMessage)
r.Put("/messages/{messageID}/labels", h.UpdateLabels)
@ -145,6 +154,10 @@ func (h *Handler) Routes() chi.Router {
r.Put("/webhooks/{webhookID}", h.UpdateWebhook)
r.Delete("/webhooks/{webhookID}", h.DeleteWebhook)
r.Get("/api-tokens", h.ListApiTokens)
r.Post("/api-tokens", h.CreateApiToken)
r.Delete("/api-tokens/{tokenID}", h.RevokeApiToken)
return r
}
@ -290,6 +303,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
Folder: r.URL.Query().Get("folder"),
AccountID: r.URL.Query().Get("account_id"),
}
h.applyMailListScope(&filter, r)
result, err := h.svc.ListMessages(r.Context(), claims.Sub, filter, params)
if err != nil {
@ -302,7 +316,11 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetMessage(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
msg, err := h.svc.GetMessage(r.Context(), claims.Sub, chi.URLParam(r, "messageID"))
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
msg, err := h.svc.GetMessage(r.Context(), claims.Sub, messageID)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
@ -326,8 +344,12 @@ func (h *Handler) UpdateLabels(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
if err := h.svc.UpdateLabels(r.Context(), claims.Sub, chi.URLParam(r, "messageID"), req.Labels); err != nil {
if err := h.svc.UpdateLabels(r.Context(), claims.Sub, messageID, req.Labels); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
@ -350,8 +372,12 @@ func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
if err := h.svc.UpdateFlags(r.Context(), claims.Sub, chi.URLParam(r, "messageID"), req.Flags); err != nil {
if err := h.svc.UpdateFlags(r.Context(), claims.Sub, messageID, req.Flags); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
@ -365,8 +391,12 @@ func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
if err := h.svc.DeleteMessage(r.Context(), claims.Sub, chi.URLParam(r, "messageID")); err != nil {
if err := h.svc.DeleteMessage(r.Context(), claims.Sub, messageID); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
@ -381,6 +411,9 @@ func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
if h.mailSender == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail send unavailable", nil)
@ -412,7 +445,11 @@ func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Reque
func (h *Handler) GetThread(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
result, err := h.svc.GetThread(r.Context(), claims.Sub, chi.URLParam(r, "threadID"))
threadID := chi.URLParam(r, "threadID")
if h.denyUnlessThreadInScope(w, r, threadID) {
return
}
result, err := h.svc.GetThread(r.Context(), claims.Sub, threadID, middleware.MailScopeAccountIDs(r.Context()))
if err != nil {
h.logger.Error("get thread", "error", err)
apivalidate.WriteInternal(w, r)
@ -453,6 +490,9 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
if middleware.DenyIfMailAccountOutOfScope(w, r, req.AccountID) {
return
}
id, status, err := h.svc.SendMessage(r.Context(), userID, &req)
if err != nil {

View File

@ -0,0 +1,137 @@
package mail
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"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/apitokens"
)
type createApiTokenRequest struct {
Name string `json:"name"`
Permissions []apitokens.PermissionGrant `json:"permissions"`
MailScope apitokens.MailScope `json:"mail_scope"`
DriveScope apitokens.DriveScope `json:"drive_scope"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func (h *Handler) ListApiTokens(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
db := h.db()
if db == nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "database unavailable", nil)
return
}
tokens, err := apitokens.List(r.Context(), db, claims.Sub)
if err != nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to list api tokens", nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
}
func (h *Handler) CreateApiToken(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req createApiTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid json", nil)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "name", Message: "name is required",
}))
return
}
if !hasAnyPermission(req.Permissions) {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "permissions", Message: "at least one permission is required",
}))
return
}
db := h.db()
if db == nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "database unavailable", nil)
return
}
created, err := apitokens.Create(
r.Context(),
db,
claims.Sub,
name,
req.Permissions,
normalizeMailScope(req.MailScope),
normalizeDriveScope(req.DriveScope),
req.ExpiresAt,
)
if err != nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to create api token", nil)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, created)
}
func (h *Handler) RevokeApiToken(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
tokenID := chi.URLParam(r, "tokenID")
db := h.db()
if db == nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "database unavailable", nil)
return
}
if err := apitokens.Revoke(r.Context(), db, claims.Sub, tokenID); err != nil {
if err == apitokens.ErrNotFound {
apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "api token not found", nil)
return
}
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to revoke api token", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func hasAnyPermission(grants []apitokens.PermissionGrant) bool {
for _, g := range grants {
if g.Read || g.Write {
return true
}
}
return false
}
func normalizeMailScope(scope apitokens.MailScope) apitokens.MailScope {
if scope.AllAccounts || len(scope.AccountIDs) == 0 {
return apitokens.MailScope{AllAccounts: true, AccountIDs: nil}
}
return scope
}
func normalizeDriveScope(scope apitokens.DriveScope) apitokens.DriveScope {
if scope.AllFolders || len(scope.FolderPaths) == 0 {
return apitokens.DriveScope{AllFolders: true, FolderPaths: nil}
}
return scope
}
func (h *Handler) db() *pgxpool.Pool {
if s, ok := h.svc.(*Service); ok {
return s.DB()
}
return nil
}

View File

@ -13,13 +13,19 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
driveapi "github.com/ultisuite/ulti-backend/internal/api/drive"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/mail/limits"
)
const maxJSONRequestBody = 32 << 10
func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
list, err := h.svc.ListMessageAttachments(r.Context(), claims.Sub, messageID)
if err != nil {
@ -37,6 +43,9 @@ func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request)
func (h *Handler) MessageAttachmentCIDMap(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
mapping, err := h.svc.MessageAttachmentCIDMap(r.Context(), claims.Sub, messageID)
if err != nil {
@ -54,6 +63,9 @@ func (h *Handler) MessageAttachmentCIDMap(w http.ResponseWriter, r *http.Request
func (h *Handler) ReindexMessageAttachments(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
if h.accountSync == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "sync_unavailable", "mail sync is not configured", nil)
return
@ -82,6 +94,9 @@ func (h *Handler) ReindexMessageAttachments(w http.ResponseWriter, r *http.Reque
func (h *Handler) UploadMessageAttachment(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
if err := r.ParseMultipartForm(limits.MaxMultipartUploadBytes); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid multipart form", nil)
@ -129,6 +144,9 @@ func (h *Handler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
inline := strings.HasSuffix(r.URL.Path, "/inline") || r.URL.Query().Get("inline") == "true"
attachmentID := chi.URLParam(r, "attachmentID")
if h.denyUnlessAttachmentInScope(w, r, attachmentID) {
return
}
filename, contentType, size, isInline, body, err := h.svc.OpenAttachment(r.Context(), claims.Sub, attachmentID)
if err != nil {
@ -227,6 +245,95 @@ func (h *Handler) DownloadDraftAttachment(w http.ResponseWriter, r *http.Request
_, _ = io.Copy(w, body)
}
type saveToDriveRequest struct {
FolderPath string `json:"folder_path"`
}
func (h *Handler) SaveAttachmentToDrive(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
attachmentID := chi.URLParam(r, "attachmentID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
var req saveToDriveRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
drivePath, err := h.svc.SaveAttachmentToDrive(
r.Context(),
claims.Sub,
claims.Email,
claims.Sub,
claims.Name,
messageID,
attachmentID,
req.FolderPath,
)
if err != nil {
if writeSaveToDriveError(w, r, h, err) {
return
}
h.logger.Error("save attachment to drive", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"drive_path": drivePath})
}
func (h *Handler) SaveMessageAttachmentsToDrive(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if h.denyUnlessMessageInScope(w, r, messageID) {
return
}
var req saveToDriveRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
list, err := h.svc.SaveMessageAttachmentsToDrive(
r.Context(),
claims.Sub,
claims.Email,
claims.Sub,
claims.Name,
messageID,
req.FolderPath,
)
if err != nil {
if writeSaveToDriveError(w, r, h, err) {
return
}
h.logger.Error("save message attachments to drive", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"attachments": list})
}
func writeSaveToDriveError(w http.ResponseWriter, r *http.Request, h *Handler, err error) bool {
switch {
case errors.Is(err, ErrNotFound), errors.Is(err, ErrAttachmentNotFound):
apivalidate.WriteNotFound(w, r, "not found")
return true
case errors.Is(err, ErrDriveUnavailable):
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "drive_unavailable", "drive is not available", nil)
return true
case errors.Is(err, driveapi.ErrQuotaExceeded):
apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "drive.quota_exceeded", "drive quota exceeded", nil)
return true
case errors.Is(err, driveapi.ErrForbidden):
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive access denied", nil)
return true
default:
return false
}
}
func writeAttachmentUploadError(w http.ResponseWriter, r *http.Request, err error) bool {
switch {
case errors.Is(err, limits.ErrAttachmentTooLarge), errors.Is(err, limits.ErrAttachmentsTotalTooLarge):

View File

@ -0,0 +1,79 @@
package mail
import (
"errors"
"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) applyMailListScope(filter *MessageListFilter, r *http.Request) {
filter.ScopedAccountIDs = middleware.MailScopeAccountIDs(r.Context())
}
func (h *Handler) applyMailSearchScope(filter *MessageSearchFilter, r *http.Request) {
filter.ScopedAccountIDs = middleware.MailScopeAccountIDs(r.Context())
}
func (h *Handler) denyUnlessMessageInScope(w http.ResponseWriter, r *http.Request, messageID string) bool {
if middleware.MailScopeAccountIDs(r.Context()) == nil {
return false
}
claims := middleware.ClaimsFromContext(r.Context())
accountID, err := h.svc.MessageAccountID(r.Context(), claims.Sub, messageID)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return true
}
h.logger.Error("resolve message account", "message_id", messageID, "error", err)
apivalidate.WriteInternal(w, r)
return true
}
if middleware.DenyIfMailAccountOutOfScope(w, r, accountID) {
return true
}
return false
}
func (h *Handler) denyUnlessAttachmentInScope(w http.ResponseWriter, r *http.Request, attachmentID string) bool {
if middleware.MailScopeAccountIDs(r.Context()) == nil {
return false
}
claims := middleware.ClaimsFromContext(r.Context())
accountID, err := h.svc.AttachmentAccountID(r.Context(), claims.Sub, attachmentID)
if err != nil {
if errors.Is(err, ErrAttachmentNotFound) || errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return true
}
h.logger.Error("resolve attachment account", "attachment_id", attachmentID, "error", err)
apivalidate.WriteInternal(w, r)
return true
}
if middleware.DenyIfMailAccountOutOfScope(w, r, accountID) {
return true
}
return false
}
func (h *Handler) denyUnlessThreadInScope(w http.ResponseWriter, r *http.Request, threadID string) bool {
scoped := middleware.MailScopeAccountIDs(r.Context())
if scoped == nil {
return false
}
claims := middleware.ClaimsFromContext(r.Context())
ok, err := h.svc.ThreadAccessible(r.Context(), claims.Sub, threadID, scoped)
if err != nil {
h.logger.Error("resolve thread scope", "thread_id", threadID, "error", err)
apivalidate.WriteInternal(w, r)
return true
}
if !ok {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
return true
}
return false
}

View File

@ -25,6 +25,7 @@ func (h *Handler) SearchMessages(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr)
return
}
h.applyMailSearchScope(&filter, r)
result, err := h.svc.SearchMessages(r.Context(), claims.Sub, filter, params)
if err != nil {

View File

@ -308,7 +308,22 @@ func (f *fakeMailService) DeleteAccount(context.Context, string, string) error {
func (f *fakeMailService) ResanitizeAccountBodies(context.Context, string, string) (ResanitizeBodiesResult, error) {
return ResanitizeBodiesResult{}, nil
}
func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) {
func (f *fakeMailService) MessageAccountID(_ context.Context, _, messageID string) (string, error) {
if f.deleted[messageID] {
return "", ErrNotFound
}
if _, ok := f.messages[messageID]; !ok {
return "", ErrNotFound
}
return testMailAccountID, nil
}
func (f *fakeMailService) AttachmentAccountID(context.Context, string, string) (string, error) {
return testMailAccountID, nil
}
func (f *fakeMailService) ThreadAccessible(context.Context, string, string, []string) (bool, error) {
return true, nil
}
func (f *fakeMailService) GetThread(context.Context, string, string, []string) (map[string]any, error) {
return map[string]any{"messages": []any{}}, nil
}
func (f *fakeMailService) ListRules(context.Context, string, query.ListParams) (RulesList, error) {
@ -608,6 +623,14 @@ func (f *fakeMailService) OpenDraftAttachment(context.Context, string, string, s
return "", "", nil, ErrAttachmentNotFound
}
func (f *fakeMailService) SaveAttachmentToDrive(context.Context, string, string, string, string, string, string, string) (string, error) {
return "/Ultimail/test.pdf", nil
}
func (f *fakeMailService) SaveMessageAttachmentsToDrive(context.Context, string, string, string, string, string, string) ([]map[string]any, error) {
return []map[string]any{}, nil
}
func newTestMailRouter(svc ServiceAPI) http.Handler {
return newTestMailRouterWithClaims(svc, &auth.Claims{
Sub: testExternalID,

View File

@ -12,13 +12,14 @@ import (
)
type MessageSearchFilter struct {
Query string
Sender string
DateFrom *time.Time
DateTo *time.Time
HasAttachments *bool
Label string
AccountID string
Query string
Sender string
DateFrom *time.Time
DateTo *time.Time
HasAttachments *bool
Label string
AccountID string
ScopedAccountIDs []string
}
type MessageSearchResult struct {
@ -35,11 +36,7 @@ func (s *Service) SearchMessages(ctx context.Context, externalID string, filter
args := []any{externalID}
argIdx := 2
if filter.AccountID != "" {
base += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
args = append(args, filter.AccountID)
argIdx++
}
base, args, argIdx = appendMessageAccountScope(base, args, argIdx, filter.AccountID, filter.ScopedAccountIDs)
if filter.Sender != "" {
base += fmt.Sprintf(" AND m.from_addr::text ILIKE '%%' || $%d || '%%'", argIdx)
args = append(args, filter.Sender)

View File

@ -31,11 +31,12 @@ var (
)
type Service struct {
db *pgxpool.Pool
db *pgxpool.Pool // exported via DB() for api token handlers
credentials *credentials.Manager
audit *securityaudit.Logger
storage *storage.Client
attachmentsBucket string
driveUploader DriveUploader
logger *slog.Logger
}
@ -50,6 +51,10 @@ func NewService(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager
}
}
func (s *Service) DB() *pgxpool.Pool {
return s.db
}
func (s *Service) ResolveUserID(ctx context.Context, externalID string) (string, error) {
var userID string
err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
@ -166,8 +171,9 @@ func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID strin
}
type MessageListFilter struct {
Folder string
AccountID string
Folder string
AccountID string
ScopedAccountIDs []string
}
type MessagesList struct {
@ -185,11 +191,7 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me
args := []any{externalID}
argIdx := 2
if filter.AccountID != "" {
baseQuery += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
args = append(args, filter.AccountID)
argIdx++
}
baseQuery, args, argIdx = appendMessageAccountScope(baseQuery, args, argIdx, filter.AccountID, filter.ScopedAccountIDs)
if clause, arg, ok := folderFilterClause(filter.Folder, argIdx); ok {
baseQuery += clause
args = append(args, arg)
@ -256,9 +258,63 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me
}, nil
}
func (s *Service) MessageAccountID(ctx context.Context, externalID, messageID string) (string, error) {
var accountID string
err := s.db.QueryRow(ctx, `
SELECT m.account_id::text
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, messageID, externalID).Scan(&accountID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return accountID, nil
}
func (s *Service) AttachmentAccountID(ctx context.Context, externalID, attachmentID string) (string, error) {
var accountID string
err := s.db.QueryRow(ctx, `
SELECT m.account_id::text
FROM attachments a
JOIN messages m ON a.message_id = m.id
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE a.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, attachmentID, externalID).Scan(&accountID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrAttachmentNotFound
}
return "", err
}
return accountID, nil
}
func (s *Service) ThreadAccessible(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (bool, error) {
if len(scopedAccountIDs) == 0 {
return false, nil
}
var ok bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.thread_id = $1
AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
AND m.account_id = ANY($3)
)
`, threadID, externalID, scopedAccountIDs).Scan(&ok)
return ok, err
}
func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) {
var msg struct {
ID string
AccountID string
MessageID string
ThreadID *string
InReplyTo string
@ -276,13 +332,13 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string)
Labels []string
}
err := s.db.QueryRow(ctx, `
SELECT m.id, m.message_id, m.thread_id, m.in_reply_to, m.references_header,
SELECT m.id, m.account_id::text, m.message_id, m.thread_id, m.in_reply_to, m.references_header,
m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.reply_to, m.auth_info, m.date,
m.body_text, m.body_html, m.flags, m.labels
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, messageID, externalID).Scan(
&msg.ID, &msg.MessageID, &msg.ThreadID, &msg.InReplyTo, &msg.References,
&msg.ID, &msg.AccountID, &msg.MessageID, &msg.ThreadID, &msg.InReplyTo, &msg.References,
&msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.ReplyTo, &msg.AuthInfo, &msg.Date,
&msg.Text, &msg.HTML, &msg.Flags, &msg.Labels,
)
@ -302,7 +358,7 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string)
`, bodyText, bodyHTML, repairedSnippet, subject, msg.ID)
}
out := map[string]any{
"id": msg.ID, "message_id": msg.MessageID, "subject": subject,
"id": msg.ID, "account_id": msg.AccountID, "message_id": msg.MessageID, "subject": subject,
"from": json.RawMessage(msg.From), "to": json.RawMessage(msg.To), "cc": json.RawMessage(msg.Cc),
"reply_to": json.RawMessage(msg.ReplyTo), "auth_info": json.RawMessage(msg.AuthInfo),
"date": msg.Date, "body_text": bodyText, "body_html": sanitize.SanitizeHTML(bodyHTML),
@ -373,13 +429,22 @@ func (s *Service) DeleteMessage(ctx context.Context, externalID, messageID strin
return nil
}
func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) {
rows, err := s.db.Query(ctx, `
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels
func (s *Service) GetThread(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (map[string]any, error) {
base := `
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.thread_id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
ORDER BY m.date ASC
`, threadID, externalID)
`
args := []any{threadID, externalID}
if scopedAccountIDs != nil {
if len(scopedAccountIDs) == 0 {
return map[string]any{"thread_id": threadID, "messages": []map[string]any{}}, nil
}
base += " AND m.account_id = ANY($3)"
args = append(args, scopedAccountIDs)
}
base += " ORDER BY m.date ASC"
rows, err := s.db.Query(ctx, base, args...)
if err != nil {
return nil, err
}
@ -391,13 +456,15 @@ func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (m
var from, toAddrs, ccAddrs []byte
var date any
var flags, labels []string
if err := rows.Scan(&id, &subject, &from, &toAddrs, &ccAddrs, &date, &snippet, &flags, &labels); err != nil {
var hasAttachments bool
if err := rows.Scan(&id, &subject, &from, &toAddrs, &ccAddrs, &date, &snippet, &flags, &labels, &hasAttachments); err != nil {
return nil, err
}
messages = append(messages, map[string]any{
"id": id, "subject": subject, "from": json.RawMessage(from),
"to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs),
"date": date, "snippet": snippet, "flags": flags, "labels": labels,
"has_attachments": hasAttachments,
})
}
if err := rows.Err(); err != nil {

View File

@ -28,12 +28,15 @@ type ServiceAPI interface {
DeleteAccount(ctx context.Context, externalID, accountID string) error
ResanitizeAccountBodies(ctx context.Context, externalID, accountID string) (ResanitizeBodiesResult, error)
ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error)
MessageAccountID(ctx context.Context, externalID, messageID string) (string, error)
AttachmentAccountID(ctx context.Context, externalID, attachmentID string) (string, error)
ThreadAccessible(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (bool, error)
GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error)
SendMailtoListUnsubscribe(ctx context.Context, externalID, messageID string, sender MailSender) (*listunsubscribe.Mailto, error)
UpdateLabels(ctx context.Context, externalID, messageID string, labels []string) error
UpdateFlags(ctx context.Context, externalID, messageID string, flags []string) error
DeleteMessage(ctx context.Context, externalID, messageID string) error
GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error)
GetThread(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (map[string]any, error)
SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error)
SendOutboxNow(ctx context.Context, userID, outboxID string) (status string, err error)
RescheduleOutbox(ctx context.Context, userID, outboxID string, scheduledAt time.Time) (status string, err error)
@ -81,6 +84,8 @@ type ServiceAPI interface {
OpenAttachment(ctx context.Context, externalID, attachmentID string) (filename, contentType string, size int64, isInline bool, body io.ReadCloser, err error)
UploadDraftAttachment(ctx context.Context, externalID, draftID, filename, contentType, contentID string, isInline bool, reader io.Reader, size int64) (string, error)
OpenDraftAttachment(ctx context.Context, externalID, draftID, attachmentID string) (filename, contentType string, body io.ReadCloser, err error)
SaveAttachmentToDrive(ctx context.Context, externalID, email, sub, displayName, messageID, attachmentID, folderPath string) (string, error)
SaveMessageAttachmentsToDrive(ctx context.Context, externalID, email, sub, displayName, messageID, folderPath string) ([]map[string]any, error)
}
var _ ServiceAPI = (*Service)(nil)

View File

@ -0,0 +1,65 @@
package middleware
import (
"net/http"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/apitokens"
)
func EnforceApiTokenPolicy() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := ApiTokenFromContext(r.Context())
if auth == nil {
next.ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/api/v1/search") {
reqs := apitokens.SearchRequirements(r.URL.Query().Get("types"))
for _, req := range reqs {
if !apitokens.AllowsRequirement(auth, req) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "insufficient api token permission", nil)
return
}
}
accountID := apitokens.ExtractMailAccountID(r.URL.Path, r.URL.Query().Get("account_id"))
if accountID != "" && !apitokens.AllowsMailAccount(auth, accountID) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
return
}
next.ServeHTTP(w, r)
return
}
req, ok := apitokens.RequirementForRequest(r.Method, r.URL.Path, r.URL.Query().Get("types"))
if !ok {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "route not allowed for api token", nil)
return
}
if !apitokens.AllowsRequirement(auth, req) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "insufficient api token permission", nil)
return
}
switch req.ScopeHint {
case apitokens.ScopeMailAccountQuery, apitokens.ScopeMailAccountPath:
accountID := apitokens.ExtractMailAccountID(r.URL.Path, r.URL.Query().Get("account_id"))
if accountID != "" && !apitokens.AllowsMailAccount(auth, accountID) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
return
}
case apitokens.ScopeDrivePathFromURL:
drivePath := apitokens.ExtractDrivePathFromURL(r.URL.Path)
if drivePath != "" && !apitokens.AllowsDrivePath(auth, drivePath) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive path out of token scope", nil)
return
}
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,61 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/ultisuite/ulti-backend/internal/apitokens"
"github.com/ultisuite/ulti-backend/internal/auth"
)
func TestEnforceApiTokenPolicyAllowsMailRead(t *testing.T) {
authCtx := &apitokens.AuthContext{
ExternalID: "user-1",
Permissions: []apitokens.PermissionGrant{
{Resource: "mail.messages", Read: true},
},
MailScope: apitokens.MailScope{AllAccounts: true},
DriveScope: apitokens.DriveScope{AllFolders: true},
}
called := false
handler := EnforceApiTokenPolicy()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/messages", nil)
ctx := context.WithValue(context.Background(), claimsKey, &auth.Claims{Sub: "user-1"})
ctx = context.WithValue(ctx, apiTokenKey, authCtx)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req.WithContext(ctx))
if rec.Code != http.StatusOK || !called {
t.Fatalf("status=%d called=%v", rec.Code, called)
}
}
func TestEnforceApiTokenPolicyDeniesMissingPermission(t *testing.T) {
authCtx := &apitokens.AuthContext{
ExternalID: "user-1",
Permissions: []apitokens.PermissionGrant{
{Resource: "mail.messages", Read: true},
},
MailScope: apitokens.MailScope{AllAccounts: true},
DriveScope: apitokens.DriveScope{AllFolders: true},
}
handler := EnforceApiTokenPolicy()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run")
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/mail/send", nil)
ctx := context.WithValue(context.Background(), claimsKey, &auth.Claims{Sub: "user-1"})
ctx = context.WithValue(ctx, apiTokenKey, authCtx)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req.WithContext(ctx))
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d", rec.Code)
}
}

View File

@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/apitokens"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/permission"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
@ -17,23 +18,14 @@ import (
type ctxKey string
const claimsKey ctxKey = "claims"
const (
claimsKey ctxKey = "claims"
apiTokenKey ctxKey = "api_token"
)
func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if verifier == nil || !verifier.Ready() {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
if audit != nil {
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
"reason": "verifier_unavailable",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
header := r.Header.Get("Authorization")
if header == "" {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthMissingAuthorization, "missing authorization header", nil)
@ -59,6 +51,60 @@ func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger)
}
return
}
token = strings.TrimSpace(token)
if strings.HasPrefix(token, apitokens.TokenPrefix()) {
if db == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
return
}
apiAuth, err := apitokens.Authenticate(r.Context(), db, token)
if err != nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidToken, "invalid api token", nil)
if audit != nil {
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
"reason": "api_token_verification_failed",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
if isApiTokenManagementRoute(r.URL.Path) && !apitokens.HasPermission(apiAuth, "automation.api_tokens", true) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "api token management requires super admin permission", nil)
return
}
claims := &auth.Claims{
Sub: apiAuth.ExternalID,
Email: apiAuth.Email,
Name: apiAuth.Name,
}
if audit != nil {
audit.Log(r.Context(), claims.Sub, securityaudit.ActionLogin, map[string]any{
"email": claims.Email,
"path": r.URL.Path,
"method": r.Method,
"api_token": apiAuth.TokenID,
"auth_mode": "api_token",
})
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
ctx = context.WithValue(ctx, apiTokenKey, apiAuth)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if verifier == nil || !verifier.Ready() {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
if audit != nil {
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
"reason": "verifier_unavailable",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
claims, err := verifier.Verify(r.Context(), token)
if err != nil {
@ -119,3 +165,12 @@ func ClaimsFromContext(ctx context.Context) *auth.Claims {
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
return claims
}
func ApiTokenFromContext(ctx context.Context) *apitokens.AuthContext {
authCtx, _ := ctx.Value(apiTokenKey).(*apitokens.AuthContext)
return authCtx
}
func isApiTokenManagementRoute(path string) bool {
return strings.Contains(path, "/api-tokens")
}

View File

@ -27,6 +27,10 @@ func RequireRole(roles ...permission.Role) func(http.Handler) http.Handler {
func RequirePermission(resource permission.Resource, level permission.Level) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ApiTokenFromContext(r.Context()) != nil {
next.ServeHTTP(w, r)
return
}
claims := ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)

View File

@ -0,0 +1,48 @@
package middleware
import (
"context"
"net/http"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/apitokens"
)
// MailScopeAccountIDs returns nil when all mail accounts are allowed (session or token),
// otherwise the explicit account IDs authorized by the API token.
func MailScopeAccountIDs(ctx context.Context) []string {
auth := ApiTokenFromContext(ctx)
if auth == nil || auth.MailScope.AllAccounts {
return nil
}
return auth.MailScope.AccountIDs
}
func DenyIfMailAccountOutOfScope(w http.ResponseWriter, r *http.Request, accountID string) bool {
auth := ApiTokenFromContext(r.Context())
if auth == nil || accountID == "" {
return false
}
if apitokens.AllowsMailAccount(auth, accountID) {
return false
}
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "mail account out of token scope", nil)
return true
}
func DenyIfDrivePathOutOfScope(w http.ResponseWriter, r *http.Request, paths ...string) bool {
auth := ApiTokenFromContext(r.Context())
if auth == nil {
return false
}
for _, p := range paths {
if p == "" {
continue
}
if !apitokens.AllowsDrivePath(auth, p) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive path out of token scope", nil)
return true
}
}
return false
}

View File

@ -0,0 +1,283 @@
package apitokens
import (
"net/http"
"strings"
)
type ScopeHint int
const (
ScopeNone ScopeHint = iota
ScopeMailAccountQuery
ScopeMailAccountPath
ScopeDrivePathFromURL
)
type Requirement struct {
Resource string
Alternatives []string
Write bool
ScopeHint ScopeHint
}
func RequirementForRequest(method, fullPath, typesQuery string) (Requirement, bool) {
method = strings.ToUpper(strings.TrimSpace(method))
path := strings.TrimSuffix(strings.TrimSpace(fullPath), "/")
if path == "" {
path = "/"
}
write := method != http.MethodGet && method != http.MethodHead
switch {
case strings.HasPrefix(path, "/api/v1/mail/api-tokens"):
return Requirement{Resource: "automation.api_tokens", Write: write || method == http.MethodDelete}, true
case strings.HasPrefix(path, "/api/v1/mail/webhooks"):
return Requirement{Resource: "automation.webhooks", Write: write}, true
case strings.HasPrefix(path, "/api/v1/mail/rules"):
return Requirement{Resource: "automation.rules", Write: write}, true
case strings.HasPrefix(path, "/api/v1/contacts/discovery/llm-settings"),
strings.HasPrefix(path, "/api/v1/contacts/discovery/llm-models/"):
return Requirement{Resource: "automation.llm", Write: write}, true
case strings.HasPrefix(path, "/api/v1/contacts/discovery/search-settings"):
return Requirement{Resource: "automation.search", Write: write}, true
case strings.HasPrefix(path, "/api/v1/contacts/discovery/"):
return Requirement{Resource: "contacts.write", Write: write}, true
case strings.HasPrefix(path, "/api/v1/contacts/search"):
return Requirement{Resource: "contacts.search", Write: false}, true
case strings.HasPrefix(path, "/api/v1/contacts/"):
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
if strings.Contains(path, "/merge-duplicates") || strings.Contains(path, "/improve") {
return Requirement{Resource: "contacts.write", Write: true}, true
}
if strings.Contains(path, "/books/") {
return Requirement{Resource: "contacts.write", Write: true}, true
}
return Requirement{Resource: "contacts.write", Write: true}, true
case http.MethodDelete:
return Requirement{Resource: "contacts.delete", Write: true}, true
default:
return Requirement{Resource: "contacts.read", Write: false}, true
}
case strings.HasPrefix(path, "/api/v1/drive/"):
return driveRequirement(method, path)
case strings.HasPrefix(path, "/api/v1/search"):
return searchRequirement(typesQuery)
case strings.HasPrefix(path, "/api/v1/mail/"):
return mailRequirement(method, path)
}
return Requirement{}, false
}
func mailRequirement(method, path string) (Requirement, bool) {
write := method != http.MethodGet && method != http.MethodHead
switch {
case strings.HasPrefix(path, "/api/v1/mail/settings"):
return Requirement{Resource: "mail.settings", Write: write}, true
case strings.HasPrefix(path, "/api/v1/mail/search"):
return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true
case strings.HasPrefix(path, "/api/v1/mail/send"),
strings.HasPrefix(path, "/api/v1/mail/outbox/"):
return Requirement{Resource: "mail.send", Write: true}, true
case strings.HasPrefix(path, "/api/v1/mail/signatures"):
return Requirement{Resource: "mail.settings", Write: write}, true
case strings.HasPrefix(path, "/api/v1/mail/identities/"):
return Requirement{Resource: "mail.identities", Write: write}, true
case strings.Contains(path, "/accounts/") && strings.Contains(path, "/identities"):
return Requirement{Resource: "mail.identities", Write: write, ScopeHint: ScopeMailAccountPath}, true
case strings.HasPrefix(path, "/api/v1/mail/accounts"):
if write {
return Requirement{Resource: "mail.settings", Write: true, ScopeHint: ScopeMailAccountPath}, true
}
return Requirement{Resource: "mail.mailboxes", Write: false, ScopeHint: ScopeMailAccountPath}, true
case strings.HasPrefix(path, "/api/v1/mail/unified-folders"),
strings.HasPrefix(path, "/api/v1/mail/folders"):
return Requirement{Resource: "mail.mailboxes", Write: write, ScopeHint: ScopeMailAccountQuery}, true
case strings.HasPrefix(path, "/api/v1/mail/labels"):
return Requirement{Resource: "mail.labels", Write: write}, true
case strings.HasPrefix(path, "/api/v1/mail/attachments/"),
strings.Contains(path, "/attachments"):
if write {
return Requirement{Resource: "mail.attachments", Write: true}, true
}
return Requirement{Resource: "mail.attachments", Write: false}, true
case strings.HasPrefix(path, "/api/v1/mail/messages"):
if strings.HasSuffix(path, "/labels") || strings.HasSuffix(path, "/flags") {
return Requirement{Resource: "mail.labels", Write: true}, true
}
if write {
return Requirement{Resource: "mail.labels", Write: true}, true
}
return Requirement{Resource: "mail.messages", Write: false, ScopeHint: ScopeMailAccountQuery}, true
case strings.HasPrefix(path, "/api/v1/mail/threads"):
return Requirement{Resource: "mail.messages", Write: false}, true
case strings.HasPrefix(path, "/api/v1/mail/drafts"):
if write {
return Requirement{Resource: "mail.send", Write: true}, true
}
return Requirement{Resource: "mail.messages", Write: false}, true
default:
return Requirement{}, false
}
}
func driveRequirement(method, path string) (Requirement, bool) {
write := method != http.MethodGet && method != http.MethodHead
switch {
case strings.Contains(path, "/preview/"):
return Requirement{Resource: "drive.thumbnails", Write: false, ScopeHint: ScopeDrivePathFromURL}, true
case strings.Contains(path, "/download/"):
return Requirement{Resource: "drive.download", Write: false, ScopeHint: ScopeDrivePathFromURL}, true
case strings.Contains(path, "/shares"):
return Requirement{Resource: "drive.share", Write: write}, true
case strings.Contains(path, "/move"):
return Requirement{Resource: "drive.move", Write: true}, true
case strings.Contains(path, "/copy"):
return Requirement{Resource: "drive.copy", Write: true}, true
case strings.Contains(path, "/rename"):
return Requirement{Resource: "drive.rename", Write: true}, true
case strings.Contains(path, "/files/") || strings.Contains(path, "/folders/"):
if write {
return Requirement{Resource: "drive.upload", Write: true, ScopeHint: ScopeDrivePathFromURL}, true
}
return Requirement{
Resource: "drive.folders",
Alternatives: []string{"drive.files"},
Write: false,
ScopeHint: ScopeDrivePathFromURL,
}, true
case strings.Contains(path, "/search"),
strings.Contains(path, "/recent"),
strings.Contains(path, "/starred"),
strings.Contains(path, "/shared"),
strings.Contains(path, "/filter-corpus"),
strings.Contains(path, "/quota"),
strings.Contains(path, "/trash"):
if write {
return Requirement{Resource: "drive.upload", Write: true}, true
}
return Requirement{Resource: "drive.files", Write: false}, true
default:
return Requirement{}, false
}
}
func searchRequirement(typesQuery string) (Requirement, bool) {
types := parseSearchTypes(typesQuery)
if len(types) == 0 {
return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true
}
req := Requirement{Write: false, ScopeHint: ScopeMailAccountQuery}
for _, t := range types {
switch t {
case "mail":
req.Resource = "mail.search"
case "contacts":
req.Resource = "contacts.search"
case "drive":
req.Resource = "drive.files"
default:
continue
}
return req, true
}
return Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}, true
}
func parseSearchTypes(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ToLower(part))
if part != "" {
out = append(out, part)
}
}
return out
}
func AllowsRequirement(auth *AuthContext, req Requirement) bool {
if auth == nil {
return true
}
if req.Resource == "automation.api_tokens" && !req.Write {
return HasPermission(auth, req.Resource, true)
}
if HasPermission(auth, req.Resource, req.Write) {
return true
}
for _, alt := range req.Alternatives {
if HasPermission(auth, alt, req.Write) {
return true
}
}
return false
}
func SearchRequirements(typesQuery string) []Requirement {
types := parseSearchTypes(typesQuery)
if len(types) == 0 {
return []Requirement{{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}}
}
reqs := make([]Requirement, 0, len(types))
for _, t := range types {
switch t {
case "mail":
reqs = append(reqs, Requirement{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery})
case "contacts":
reqs = append(reqs, Requirement{Resource: "contacts.search", Write: false})
case "drive":
reqs = append(reqs, Requirement{Resource: "drive.files", Write: false})
}
}
if len(reqs) == 0 {
return []Requirement{{Resource: "mail.search", Write: false, ScopeHint: ScopeMailAccountQuery}}
}
return reqs
}
func ExtractMailAccountID(path, queryAccountID string) string {
if id := strings.TrimSpace(queryAccountID); id != "" {
return id
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "accounts" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
func ExtractDrivePathFromURL(fullPath string) string {
markers := []string{
"/api/v1/drive/files/",
"/api/v1/drive/download/",
"/api/v1/drive/preview/",
"/api/v1/drive/folders/",
}
for _, marker := range markers {
if idx := strings.Index(fullPath, marker); idx >= 0 {
rest := fullPath[idx+len(marker):]
if rest == "" {
return "/"
}
return NormalizeDriveScopePath("/" + rest)
}
}
return ""
}

View File

@ -0,0 +1,65 @@
package apitokens
import "testing"
func TestRequirementForMailMessages(t *testing.T) {
req, ok := RequirementForRequest("GET", "/api/v1/mail/messages", "")
if !ok || req.Resource != "mail.messages" || req.Write {
t.Fatalf("got %+v ok=%v", req, ok)
}
}
func TestRequirementForMailSend(t *testing.T) {
req, ok := RequirementForRequest("POST", "/api/v1/mail/send", "")
if !ok || req.Resource != "mail.send" || !req.Write {
t.Fatalf("got %+v ok=%v", req, ok)
}
}
func TestRequirementForDriveUpload(t *testing.T) {
req, ok := RequirementForRequest("POST", "/api/v1/drive/files/Projects", "")
if !ok || req.Resource != "drive.upload" || !req.Write || req.ScopeHint != ScopeDrivePathFromURL {
t.Fatalf("got %+v ok=%v", req, ok)
}
}
func TestRequirementForAutomationWebhooks(t *testing.T) {
req, ok := RequirementForRequest("DELETE", "/api/v1/mail/webhooks/abc", "")
if !ok || req.Resource != "automation.webhooks" || !req.Write {
t.Fatalf("got %+v ok=%v", req, ok)
}
}
func TestSearchRequirementsMultipleTypes(t *testing.T) {
reqs := SearchRequirements("mail,contacts,drive")
if len(reqs) != 3 {
t.Fatalf("len = %d", len(reqs))
}
}
func TestAllowsRequirementAlternatives(t *testing.T) {
auth := &AuthContext{
Permissions: []PermissionGrant{
{Resource: "drive.files", Read: true},
},
}
req := Requirement{Resource: "drive.folders", Alternatives: []string{"drive.files"}, Write: false}
if !AllowsRequirement(auth, req) {
t.Fatal("expected drive.files alternative to satisfy folders read")
}
}
func TestExtractMailAccountIDFromPath(t *testing.T) {
got := ExtractMailAccountID("/api/v1/mail/accounts/550e8400-e29b-41d4-a716-446655440000/sync", "")
if got != "550e8400-e29b-41d4-a716-446655440000" {
t.Fatalf("got %q", got)
}
}
func TestExtractDrivePathFromURL(t *testing.T) {
got := ExtractDrivePathFromURL("/api/v1/drive/files/Projects/docs")
want := "/Projects/docs"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

View File

@ -0,0 +1,69 @@
package apitokens
import (
"path"
"strings"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func AllowsMailAccount(auth *AuthContext, accountID string) bool {
if auth == nil || accountID == "" {
return true
}
if auth.MailScope.AllAccounts {
return true
}
for _, id := range auth.MailScope.AccountIDs {
if id == accountID {
return true
}
}
return false
}
func AllowsDrivePath(auth *AuthContext, rawPath string) bool {
if auth == nil {
return true
}
if auth.DriveScope.AllFolders {
return true
}
target := NormalizeDriveScopePath(rawPath)
if target == "" {
return true
}
for _, allowed := range auth.DriveScope.FolderPaths {
if drivePathWithinScope(target, allowed) {
return true
}
}
return false
}
func NormalizeDriveScopePath(rawPath string) string {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return ""
}
normalized := nextcloud.NormalizeClientPath(rawPath)
if normalized == "" {
return "/"
}
if !strings.HasPrefix(normalized, "/") {
normalized = "/" + normalized
}
return path.Clean(normalized)
}
func drivePathWithinScope(target, allowed string) bool {
target = NormalizeDriveScopePath(target)
allowed = NormalizeDriveScopePath(allowed)
if allowed == "/" {
return true
}
if target == allowed {
return true
}
return strings.HasPrefix(target, allowed+"/")
}

View File

@ -0,0 +1,33 @@
package apitokens
import "testing"
func TestDrivePathWithinScope(t *testing.T) {
auth := &AuthContext{
DriveScope: DriveScope{
AllFolders: false,
FolderPaths: []string{"/Projects"},
},
}
if !AllowsDrivePath(auth, "/Projects/docs/report.pdf") {
t.Fatal("expected nested path within /Projects")
}
if AllowsDrivePath(auth, "/Personal/notes.txt") {
t.Fatal("did not expect /Personal to be allowed")
}
}
func TestAllowsMailAccountScoped(t *testing.T) {
auth := &AuthContext{
MailScope: MailScope{
AllAccounts: false,
AccountIDs: []string{"acc-1"},
},
}
if !AllowsMailAccount(auth, "acc-1") {
t.Fatal("expected acc-1")
}
if AllowsMailAccount(auth, "acc-2") {
t.Fatal("did not expect acc-2")
}
}

View File

@ -0,0 +1,300 @@
package apitokens
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const tokenPrefix = "ulti_"
func TokenPrefix() string {
return tokenPrefix
}
var (
ErrNotFound = errors.New("api token not found")
ErrRevoked = errors.New("api token revoked")
ErrExpired = errors.New("api token expired")
)
type PermissionGrant struct {
Resource string `json:"resource"`
Read bool `json:"read"`
Write bool `json:"write"`
}
type MailScope struct {
AllAccounts bool `json:"all_accounts"`
AccountIDs []string `json:"account_ids"`
}
type DriveScope struct {
AllFolders bool `json:"all_folders"`
FolderPaths []string `json:"folder_paths"`
}
type Token struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"token_prefix"`
Permissions []PermissionGrant `json:"permissions"`
MailScope MailScope `json:"mail_scope"`
DriveScope DriveScope `json:"drive_scope"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type CreatedToken struct {
Token
TokenSecret string `json:"token"`
}
type AuthContext struct {
TokenID string
UserID string
ExternalID string
Email string
Name string
Permissions []PermissionGrant
MailScope MailScope
DriveScope DriveScope
}
func HashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
func generateSecret() (string, string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
encoded := base64.RawURLEncoding.EncodeToString(raw)
full := tokenPrefix + encoded
visible := tokenPrefix + encoded[:8]
return full, visible, nil
}
func List(ctx context.Context, db *pgxpool.Pool, externalID string) ([]Token, error) {
rows, err := db.Query(ctx, `
SELECT t.id, t.name, t.token_prefix, t.permissions, t.mail_scope, t.drive_scope,
t.created_at, t.last_used_at, t.expires_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE u.external_id = $1 AND t.revoked_at IS NULL
ORDER BY t.created_at DESC
`, externalID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Token, 0)
for rows.Next() {
item, err := scanToken(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func Create(ctx context.Context, db *pgxpool.Pool, externalID string, name string, permissions []PermissionGrant, mailScope MailScope, driveScope DriveScope, expiresAt *time.Time) (CreatedToken, error) {
secret, prefix, err := generateSecret()
if err != nil {
return CreatedToken{}, err
}
permJSON, err := json.Marshal(permissions)
if err != nil {
return CreatedToken{}, err
}
mailJSON, err := json.Marshal(mailScope)
if err != nil {
return CreatedToken{}, err
}
driveJSON, err := json.Marshal(driveScope)
if err != nil {
return CreatedToken{}, err
}
var item Token
err = db.QueryRow(ctx, `
INSERT INTO api_tokens (
user_id, name, token_prefix, secret_hash, permissions, mail_scope, drive_scope, expires_at
)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
$2, $3, $4, $5, $6, $7, $8
)
RETURNING id, name, token_prefix, permissions, mail_scope, drive_scope, created_at, last_used_at, expires_at
`, externalID, name, prefix, HashSecret(secret), permJSON, mailJSON, driveJSON, expiresAt).Scan(
&item.ID,
&item.Name,
&item.TokenPrefix,
&permJSON,
&mailJSON,
&driveJSON,
&item.CreatedAt,
&item.LastUsedAt,
&item.ExpiresAt,
)
if err != nil {
return CreatedToken{}, err
}
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
return CreatedToken{}, err
}
return CreatedToken{Token: item, TokenSecret: secret}, nil
}
func Revoke(ctx context.Context, db *pgxpool.Pool, externalID, tokenID string) error {
result, err := db.Exec(ctx, `
UPDATE api_tokens
SET revoked_at = now(), updated_at = now()
WHERE id = $1
AND user_id = (SELECT id FROM users WHERE external_id = $2)
AND revoked_at IS NULL
`, tokenID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func Authenticate(ctx context.Context, db *pgxpool.Pool, secret string) (*AuthContext, error) {
secret = strings.TrimSpace(secret)
if !strings.HasPrefix(secret, tokenPrefix) {
return nil, fmt.Errorf("not an api token")
}
hash := HashSecret(secret)
row := db.QueryRow(ctx, `
SELECT t.id, u.id::text, u.external_id, u.email, COALESCE(u.name, ''),
t.permissions, t.mail_scope, t.drive_scope, t.expires_at, t.revoked_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.secret_hash = $1
LIMIT 1
`, hash)
var auth AuthContext
var permJSON, mailJSON, driveJSON []byte
var expiresAt *time.Time
var revokedAt *time.Time
if err := row.Scan(
&auth.TokenID,
&auth.UserID,
&auth.ExternalID,
&auth.Email,
&auth.Name,
&permJSON,
&mailJSON,
&driveJSON,
&expiresAt,
&revokedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if revokedAt != nil {
return nil, ErrRevoked
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return nil, ErrExpired
}
if err := json.Unmarshal(permJSON, &auth.Permissions); err != nil {
return nil, err
}
if err := json.Unmarshal(mailJSON, &auth.MailScope); err != nil {
return nil, err
}
if err := json.Unmarshal(driveJSON, &auth.DriveScope); err != nil {
return nil, err
}
_, _ = db.Exec(ctx, `
UPDATE api_tokens SET last_used_at = now(), updated_at = now() WHERE id = $1
`, auth.TokenID)
return &auth, nil
}
func HasPermission(auth *AuthContext, resource string, write bool) bool {
if auth == nil {
return false
}
for _, grant := range auth.Permissions {
if grant.Resource != resource {
continue
}
if write {
return grant.Write
}
return grant.Read || grant.Write
}
return false
}
func ConstantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanToken(rows rowScanner) (Token, error) {
var item Token
var permJSON, mailJSON, driveJSON []byte
if err := rows.Scan(
&item.ID,
&item.Name,
&item.TokenPrefix,
&permJSON,
&mailJSON,
&driveJSON,
&item.CreatedAt,
&item.LastUsedAt,
&item.ExpiresAt,
); err != nil {
return Token{}, err
}
if err := decodeTokenJSON(permJSON, mailJSON, driveJSON, &item); err != nil {
return Token{}, err
}
return item, nil
}
func decodeTokenJSON(permJSON, mailJSON, driveJSON []byte, item *Token) error {
if err := json.Unmarshal(permJSON, &item.Permissions); err != nil {
return err
}
if err := json.Unmarshal(mailJSON, &item.MailScope); err != nil {
return err
}
if err := json.Unmarshal(driveJSON, &item.DriveScope); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,37 @@
package apitokens
import "testing"
func TestHashSecretDeterministic(t *testing.T) {
a := HashSecret("ulti_test_secret")
b := HashSecret("ulti_test_secret")
if len(a) != 32 {
t.Fatalf("hash length = %d, want 32", len(a))
}
for i := range a {
if a[i] != b[i] {
t.Fatal("hash not deterministic")
}
}
}
func TestHasPermission(t *testing.T) {
auth := &AuthContext{
Permissions: []PermissionGrant{
{Resource: "mail.messages", Read: true, Write: false},
{Resource: "mail.send", Read: false, Write: true},
},
}
if !HasPermission(auth, "mail.messages", false) {
t.Fatal("expected read on mail.messages")
}
if HasPermission(auth, "mail.messages", true) {
t.Fatal("did not expect write on mail.messages")
}
if !HasPermission(auth, "mail.send", true) {
t.Fatal("expected write on mail.send")
}
if HasPermission(auth, "drive.files", false) {
t.Fatal("did not expect drive.files")
}
}

View File

@ -101,14 +101,10 @@ func Catalog(cfg *config.Config) []AppSpec {
func ultimailRedirectURIs(cfg *config.Config) []string {
base := baseURL(cfg)
mail := strings.TrimRight(cfg.MailAppURL, "/")
drive := strings.TrimRight(base+"/drive", "/")
return uniqueURIs(
mail+"/api/auth/callback",
"http://localhost:3000/api/auth/callback",
"http://127.0.0.1:3000/api/auth/callback",
drive+"/api/auth/callback",
"http://localhost:3001/api/auth/callback",
"http://127.0.0.1:3001/api/auth/callback",
base+"/api/auth/callback",
)
}
@ -145,11 +141,10 @@ func immichRedirectURIs(cfg *config.Config) []string {
func driveRedirectURIs(cfg *config.Config) []string {
base := baseURL(cfg)
drive := strings.TrimRight(base+"/drive", "/")
return uniqueURIs(
drive+"/api/auth/callback",
"http://localhost:3001/api/auth/callback",
"http://127.0.0.1:3001/api/auth/callback",
base+"/api/auth/callback",
"http://localhost:3000/api/auth/callback",
"http://127.0.0.1:3000/api/auth/callback",
)
}

View File

@ -141,6 +141,19 @@ func NormalizeClientFilePath(userID, path string) string {
return path
}
// FileNameFromClientPath returns the storage basename for a logical client path.
func FileNameFromClientPath(path string) string {
return pathBaseName(NormalizeClientPath(path))
}
// SyncFileDisplayName aligns display name with the storage path basename when present.
func SyncFileDisplayName(path, name string) string {
if bn := FileNameFromClientPath(path); bn != "" {
return bn
}
return strings.TrimSpace(name)
}
// EnsureClientFilePath joins name when path is a parent directory (Nextcloud recent API).
func EnsureClientFilePath(path, name string) string {
path = NormalizeClientPath(path)

View File

@ -88,6 +88,14 @@ func TestNormalizeClientFilePathStripsOCSPrefix(t *testing.T) {
}
}
func TestSyncFileDisplayNamePrefersPathBasename(t *testing.T) {
got := SyncFileDisplayName("/Documents/actual.jpg", "Display Name.jpg")
want := "actual.jpg"
if got != want {
t.Fatalf("SyncFileDisplayName() = %q, want %q", got, want)
}
}
func TestEnsureClientFilePathJoinsName(t *testing.T) {
got := EnsureClientFilePath("/Documents", "report.pdf")
want := "/Documents/report.pdf"

View File

@ -26,6 +26,7 @@ type FileInfo struct {
FileID int64 `json:"file_id,omitempty"`
IsFavorite bool `json:"is_favorite"`
IsShared bool `json:"is_shared"`
Source string `json:"source,omitempty"`
}
type ShareInfo struct {
@ -357,9 +358,10 @@ func (c *Client) listRecentOCS(ctx context.Context, userID string, limit int) ([
NormalizeClientFilePath(userID, item.Path),
item.Name,
)
name := SyncFileDisplayName(logicalPath, item.Name)
files = append(files, FileInfo{
Path: logicalPath,
Name: item.Name,
Name: name,
Type: fileType,
Size: item.Size,
MimeType: item.MimeType,
@ -443,6 +445,7 @@ func (c *Client) ListSharedWithMe(ctx context.Context, userID string) ([]FileInf
NormalizeClientFilePath(userID, item.Path),
name,
)
name = SyncFileDisplayName(logicalPath, name)
files = append(files, FileInfo{
Path: logicalPath,
Name: name,
@ -600,8 +603,10 @@ func (c *Client) EmptyTrash(ctx context.Context, userID string) error {
}
const (
favoritesMaxDirs = 2000
favoritesMaxCollect = 500
favoritesMaxDirs = 2000
favoritesMaxCollect = 500
filterCorpusMaxDirs = 2000
filterCorpusMaxFiles = 10000
)
func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, maxCollect int) ([]FileInfo, error) {
@ -651,6 +656,52 @@ func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, max
return results, nil
}
// ListFilesRecursive collects file entries (not directories) under basePath for client-side filtering.
func (c *Client) ListFilesRecursive(ctx context.Context, userID, basePath string, maxFiles int) ([]FileInfo, error) {
if maxFiles <= 0 {
maxFiles = filterCorpusMaxFiles
}
basePath = normalizeSearchPath(basePath)
if basePath == "" {
basePath = "/"
}
queue := []string{basePath}
seen := map[string]struct{}{basePath: {}}
results := make([]FileInfo, 0, min(maxFiles, 256))
visited := 0
for len(queue) > 0 && visited < filterCorpusMaxDirs && len(results) < maxFiles {
dir := queue[0]
queue = queue[1:]
visited++
files, err := c.ListFiles(ctx, userID, dir)
if err != nil {
continue
}
for _, f := range files {
if isDirectoryEntry(f) {
child := normalizeSearchPath(f.Path)
if child == "" || child == "/" {
continue
}
if _, ok := seen[child]; ok {
continue
}
seen[child] = struct{}{}
queue = append(queue, child)
continue
}
results = append(results, f)
if len(results) >= maxFiles {
break
}
}
}
return results, nil
}
func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
filePath = normalizeOperationPath(userID, filePath)
davPath := c.WebDAVPath(userID, filePath)
@ -971,6 +1022,7 @@ func parsePropfindResponse(body io.Reader, listDir string) ([]FileInfo, error) {
name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href)
clientPath := ResolvePropfindClientPath(listDir, r.Href, name)
name = SyncFileDisplayName(clientPath, name)
fileType := "file"
if r.Propstat.Prop.ResourceType.Collection != nil {

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_attachments_drive_path;
ALTER TABLE attachments
DROP COLUMN IF EXISTS drive_path;

View File

@ -0,0 +1,6 @@
ALTER TABLE attachments
ADD COLUMN IF NOT EXISTS drive_path TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_attachments_drive_path
ON attachments (drive_path)
WHERE drive_path <> '';

View File

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

View File

@ -0,0 +1,11 @@
CREATE TABLE drive_file_sources (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
source TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, file_path),
CONSTRAINT drive_file_sources_source_chk CHECK (source IN ('ultimail', 'ultimeet'))
);
CREATE INDEX idx_drive_file_sources_user_source
ON drive_file_sources (user_id, source);

View File

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

View File

@ -0,0 +1,19 @@
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_prefix TEXT NOT NULL,
secret_hash BYTEA NOT NULL,
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
mail_scope JSONB NOT NULL DEFAULT '{"all_accounts": true, "account_ids": []}'::jsonb,
drive_scope JSONB NOT NULL DEFAULT '{"all_folders": true, "folder_paths": []}'::jsonb,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_api_tokens_prefix ON api_tokens(token_prefix) WHERE revoked_at IS NULL;
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_secret_hash ON api_tokens(secret_hash) WHERE revoked_at IS NULL;