Enhance API and configuration for contact discovery and public sharing

- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts.
- Implemented retry logic for handling missing DAV credentials during contact operations.
- Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations.
- Updated Nextcloud configuration to support public share links and improved error handling for public share permissions.
- Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback.
- Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
This commit is contained in:
R3D347HR4Y 2026-06-06 20:27:02 +02:00
parent 69bde44b94
commit 556d5f416d
82 changed files with 8225 additions and 246 deletions

View File

@ -118,7 +118,7 @@ AUTHENTIK_API_URL=http://authentik-server:9000
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# URL interne (ultid → nginx Nextcloud, racine WebDAV) # URL interne (ultid → nginx Nextcloud, racine WebDAV)
NEXTCLOUD_URL=http://nextcloud:80 NEXTCLOUD_URL=http://nextcloud:80
# URL publique UI (edge nginx /cloud/) # URL publique UI (edge nginx /cloud/) — aussi base Destination WebDAV MOVE/COPY
NC_PUBLIC_URL=http://{{DOMAIN}}/cloud NC_PUBLIC_URL=http://{{DOMAIN}}/cloud
NC_OVERWRITE_PROTOCOL=http NC_OVERWRITE_PROTOCOL=http
NC_ADMIN_USER=admin NC_ADMIN_USER=admin
@ -157,6 +157,8 @@ ONLYOFFICE_JWT_SECRET=changeme-onlyoffice-jwt
ONLYOFFICE_OIDC_CLIENT_ID=ulti-onlyoffice ONLYOFFICE_OIDC_CLIENT_ID=ulti-onlyoffice
# ONLYOFFICE_OIDC_CLIENT_SECRET — defini dans la section Secrets # ONLYOFFICE_OIDC_CLIENT_SECRET — defini dans la section Secrets
ULTID_PUBLIC_URL=http://{{DOMAIN}} ULTID_PUBLIC_URL=http://{{DOMAIN}}
# Base URL for public share links (default: {ULTID_PUBLIC_URL}/drive → /drive/s/{token})
# DRIVE_PUBLIC_URL=http://{{DOMAIN}}/drive
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Jitsi Meet (Visioconference) # Jitsi Meet (Visioconference)

View File

@ -94,7 +94,13 @@ func main() {
verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second) verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second)
if err != nil { if err != nil {
slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err) slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", err)
}
verifierHolder := auth.NewHolder(verifier)
if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain)
verifierHolder = pending
verifierHolder.StartBackgroundRetry(ctx, 5*time.Second)
} }
if cfg.IsProduction() { if cfg.IsProduction() {
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
@ -103,7 +109,7 @@ func main() {
"ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "") "ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "")
os.Exit(1) os.Exit(1)
} }
if verifier == nil { if !verifierHolder.Ready() {
slog.Error("OIDC verifier initialization failed in production") slog.Error("OIDC verifier initialization failed in production")
os.Exit(1) os.Exit(1)
} }
@ -127,8 +133,10 @@ func main() {
var ncClient *nextcloud.Client var ncClient *nextcloud.Client
if cfg.NextcloudEnabled { if cfg.NextcloudEnabled {
ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass). ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass).
WithPublicURL(cfg.NextcloudPublicURL).
WithDrivePublicURL(cfg.DrivePublicURL).
WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager)) WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager))
slog.Info("nextcloud enabled", "url", cfg.NextcloudURL) slog.Info("nextcloud enabled", "url", cfg.NextcloudURL, "public_url", cfg.NextcloudPublicURL, "drive_public_url", cfg.DrivePublicURL)
} }
// Meet config (nil if disabled) // Meet config (nil if disabled)
@ -146,7 +154,7 @@ func main() {
} }
// WebSocket hub // WebSocket hub
hub := realtime.NewHub(verifier, pool) hub := realtime.NewHub(verifierHolder, pool)
healthChecker := observability.NewHealthChecker(cfg, pool, rdb) healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool)) rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool))
@ -214,9 +222,11 @@ func main() {
r.Get("/ws", hub.HandleWS) r.Get("/ws", hub.HandleWS)
r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback) r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback)
var driveHandler *drive.Handler
var driveSvc *drive.Service var driveSvc *drive.Service
if ncClient != nil { if ncClient != nil {
driveSvc = drive.NewService(ncClient, hub) driveSvc = drive.NewService(ncClient, hub)
driveHandler = drive.NewHandler(ncClient, hub)
} }
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil { if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
officeSvc := office.NewService(ncClient, office.Config{ officeSvc := office.NewService(ncClient, office.Config{
@ -227,11 +237,15 @@ func main() {
JWTSecret: cfg.OnlyOfficeJWTSecret, JWTSecret: cfg.OnlyOfficeJWTSecret,
}) })
officeHandler := office.NewHandler(officeSvc, driveSvc) officeHandler := office.NewHandler(officeSvc, driveSvc)
r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifier, pool, auditLogger))) r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger)))
driveHandler.SetPublicOffice(officeHandler)
}
if driveHandler != nil {
r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes())
} }
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.Auth(verifier, pool, auditLogger)) r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
r.Mount("/api/v1/mail", mailHandler.Routes()) r.Mount("/api/v1/mail", mailHandler.Routes())
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
@ -246,8 +260,8 @@ func main() {
TypesenseCollection: cfg.TypesenseCollection, TypesenseCollection: cfg.TypesenseCollection,
}).Search) }).Search)
if ncClient != nil { if driveHandler != nil {
r.Mount("/api/v1/drive", drive.NewHandler(ncClient, hub).Routes()) r.Mount("/api/v1/drive", driveHandler.Routes())
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient, pool).Routes()) r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient, pool).Routes())
} }

View File

@ -19,7 +19,7 @@ services:
- REDIS_HOST_PORT=6379 - REDIS_HOST_PORT=6379
- NEXTCLOUD_ADMIN_USER=${NC_ADMIN_USER:-admin} - NEXTCLOUD_ADMIN_USER=${NC_ADMIN_USER:-admin}
- NEXTCLOUD_ADMIN_PASSWORD=${NC_ADMIN_PASSWORD:-changeme} - NEXTCLOUD_ADMIN_PASSWORD=${NC_ADMIN_PASSWORD:-changeme}
- NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN:-localhost} - NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN:-localhost} nextcloud
- OBJECTSTORE_S3_BUCKET=nextcloud - OBJECTSTORE_S3_BUCKET=nextcloud
- OBJECTSTORE_S3_HOST=rustfs - OBJECTSTORE_S3_HOST=rustfs
- OBJECTSTORE_S3_PORT=9000 - OBJECTSTORE_S3_PORT=9000

View File

@ -103,6 +103,11 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Public Nextcloud share links → UltiDrive viewer
location ~ ^/cloud/index\.php/s/([^/]+)/?(.*)$ {
return 301 /drive/s/$1$is_args$args;
}
location /cloud/ { location /cloud/ {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
set $nc_upstream nextcloud; set $nc_upstream nextcloud;
@ -317,6 +322,19 @@ server {
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
} }
location ^~ /contacts {
resolver 127.0.0.11 valid=10s ipv6=off;
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;
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 ^~ /_next/ { location ^~ /_next/ {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};

View File

@ -0,0 +1,52 @@
#!/bin/sh
# Merge autoAssembly into the running OnlyOffice container's local.json.
# Do NOT bind-mount deploy/onlyoffice/local.json — it replaces the auto-generated
# config (postgres, JWT, rabbitmq) and prevents Document Server from starting.
set -e
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
INTERVAL="${ONLYOFFICE_AUTO_ASSEMBLY_INTERVAL:-1m}"
STEP="${ONLYOFFICE_AUTO_ASSEMBLY_STEP:-1m}"
if ! docker compose --env-file .env.resolved \
-f deploy/docker-compose.yml \
-f deploy/onlyoffice/docker-compose.onlyoffice.yml \
ps onlyoffice 2>/dev/null | grep -q onlyoffice; then
echo "OnlyOffice container not found. Start the stack first:" >&2
echo " ./deploy/compose-up.sh up -d onlyoffice" >&2
exit 1
fi
echo "Enabling OnlyOffice autoAssembly (interval=${INTERVAL}, step=${STEP})…"
docker compose --env-file .env.resolved \
-f deploy/docker-compose.yml \
-f deploy/onlyoffice/docker-compose.onlyoffice.yml \
exec -T onlyoffice python3 - "$INTERVAL" "$STEP" <<'PY'
import json
import sys
interval, step = sys.argv[1], sys.argv[2]
path = "/etc/onlyoffice/documentserver/local.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
co = data.setdefault("services", {}).setdefault("CoAuthoring", {})
co["autoAssembly"] = {"enable": True, "interval": interval, "step": step}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
print("Updated", path)
PY
docker compose --env-file .env.resolved \
-f deploy/docker-compose.yml \
-f deploy/onlyoffice/docker-compose.onlyoffice.yml \
exec -T onlyoffice supervisorctl restart ds:docservice ds:converter
echo "OnlyOffice autoAssembly enabled."

View File

@ -0,0 +1,12 @@
{
"_comment": "Fragment only — never bind-mount this file. It replaces auto-generated local.json and breaks startup. Run deploy/onlyoffice/configure-auto-assembly.sh after the container is healthy.",
"services": {
"CoAuthoring": {
"autoAssembly": {
"enable": true,
"interval": "1m",
"step": "1m"
}
}
}
}

View File

@ -0,0 +1,430 @@
package contacts
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
"github.com/ultisuite/ulti-backend/internal/llm"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/websearch"
"github.com/ultisuite/ulti-backend/internal/permission"
)
func (h *Handler) discoveryRoutes() chi.Router {
r := chi.NewRouter()
read := middleware.RequirePermission(permission.ResourceContacts, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceContacts, permission.LevelWrite)
r.With(write).Post("/scan", h.StartDiscoveryScan)
r.With(write).Post("/scan/cancel", h.CancelDiscoveryScan)
r.With(read).Get("/scan/active", h.GetActiveDiscoveryScan)
r.With(read).Get("/scan/{scanID}", h.GetDiscoveryScan)
r.With(read).Get("/other", h.ListOtherDiscoveredContacts)
r.With(read).Get("/ignored", h.ListIgnoredDiscoveredContacts)
r.With(read).Get("/blocked", h.ListBlockedDiscoveredContacts)
r.With(read).Get("/suggestions", h.ListEnrichmentSuggestions)
r.With(read).Get("/counts", h.GetDiscoveryCounts)
r.With(write).Post("/profiles/{profileID}/add-to-book", h.AddDiscoveredProfileToBook)
r.With(write).Post("/profiles/{profileID}/accept", h.AcceptDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/reject", h.RejectDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/ignore", h.IgnoreDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/block", h.BlockDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/enrich", h.EnrichDiscoveredProfile)
r.With(write).Post("/suggestions/{suggestionID}/accept", h.AcceptEnrichmentSuggestion)
r.With(write).Post("/suggestions/{suggestionID}/reject", h.RejectEnrichmentSuggestion)
r.With(read).Get("/llm-settings", h.GetLLMSettings)
r.With(write).Put("/llm-settings", h.UpdateLLMSettings)
r.With(read).Post("/llm-models/discover", h.DiscoverLLMModels)
r.With(read).Get("/search-settings", h.GetSearchSettings)
r.With(write).Put("/search-settings", h.UpdateSearchSettings)
return r
}
func (h *Handler) StartDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
bookID := r.URL.Query().Get("book_id")
if bookID == "" {
bookID = "contacts"
}
scan, err := h.discovery.StartScan(r.Context(), claims.Sub, ncUser, bookID)
if err != nil {
h.logger.Error("start discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusAccepted, scan)
}
func (h *Handler) CancelDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
if err := h.discovery.CancelActiveScan(r.Context(), claims.Sub); err != nil {
h.logger.Error("cancel discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) GetActiveDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
scan, err := h.discovery.GetActiveScan(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get active discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if scan == nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"scan": nil})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"scan": scan})
}
func (h *Handler) GetDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
scan, err := h.discovery.GetScan(r.Context(), claims.Sub, chi.URLParam(r, "scanID"))
if err != nil {
h.logger.Error("get discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, scan)
}
func (h *Handler) ListOtherDiscoveredContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
limit := discovery.DefaultOtherGroupsPageSize
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
page, err := h.discovery.ListOtherProfileGroupsPage(r.Context(), claims.Sub, limit, offset, strings.TrimSpace(r.URL.Query().Get("q")))
if err != nil {
h.logger.Error("list other discovered contacts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if page.Groups == nil {
page.Groups = []discovery.ProfileGroup{}
}
apiresponse.WriteJSON(w, http.StatusOK, page)
}
func (h *Handler) ListIgnoredDiscoveredContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
profiles, err := h.discovery.ListProfilesByStatus(r.Context(), claims.Sub, discovery.ProfileIgnored)
if err != nil {
h.logger.Error("list ignored discovered contacts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if profiles == nil {
profiles = []discovery.Profile{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"profiles": profiles})
}
func (h *Handler) ListBlockedDiscoveredContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
profiles, err := h.discovery.ListProfilesByStatus(r.Context(), claims.Sub, discovery.ProfileBlocked)
if err != nil {
h.logger.Error("list blocked discovered contacts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if profiles == nil {
profiles = []discovery.Profile{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"profiles": profiles})
}
func (h *Handler) ListEnrichmentSuggestions(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
enrichOnly := r.URL.Query().Get("type") == "enrich"
suggestions, err := h.discovery.ListSuggestions(r.Context(), claims.Sub, enrichOnly)
if err != nil {
h.logger.Error("list enrichment suggestions", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if suggestions == nil {
suggestions = []discovery.Suggestion{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"suggestions": suggestions})
}
func (h *Handler) GetDiscoveryCounts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
other, suggestions, ignored, blocked, err := h.discovery.PendingCounts(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("discovery counts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{
"other_contacts": other,
"suggestions": suggestions,
"ignored": ignored,
"blocked": blocked,
})
}
type acceptProfileRequest struct {
ContactUID string `json:"contact_uid"`
}
type addDiscoveredToBookRequest struct {
BookID string `json:"book_id"`
Contact nextcloud.Contact `json:"contact"`
}
func (h *Handler) AddDiscoveredProfileToBook(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req addDiscoveredToBookRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
if strings.TrimSpace(req.BookID) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "book_id", Message: "required",
}))
return
}
if verr := validateCreateContact(&req.Contact); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
created, err := h.svc.CreateContact(r.Context(), ncUser, req.BookID, &req.Contact)
if err != nil {
h.writeContactServiceError(w, r, "create contact", err)
return
}
uid := strings.TrimSpace(created.UID)
if err := h.discovery.AcceptProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"), uid); err != nil {
h.logger.Error("accept discovered profile after create", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, created)
}
func (h *Handler) AcceptDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req acceptProfileRequest
if r.ContentLength > 0 {
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
}
if err := h.discovery.AcceptProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"), req.ContactUID); err != nil {
h.logger.Error("accept discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) RejectDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if err := h.discovery.RejectProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID")); err != nil {
h.logger.Error("reject discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) IgnoreDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
emails, err := h.discovery.IgnoreProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"))
if err != nil {
h.logger.Error("ignore discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"emails": emails})
}
func (h *Handler) EnrichDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
result, err := h.discovery.StartProfileEnrichment(r.Context(), claims.Sub, chi.URLParam(r, "profileID"))
if err != nil {
switch {
case errors.Is(err, discovery.ErrProfileNotFound):
apiresponse.WriteError(w, r, http.StatusNotFound, "profile_not_found", err.Error(), nil)
case errors.Is(err, discovery.ErrNoSignatures):
apiresponse.WriteError(w, r, http.StatusBadRequest, "no_signatures", err.Error(), nil)
case errors.Is(err, discovery.ErrAlreadyEnriched):
apiresponse.WriteError(w, r, http.StatusConflict, "already_enriched", err.Error(), nil)
case errors.Is(err, discovery.ErrLLMNotConfigured):
apiresponse.WriteError(w, r, http.StatusBadRequest, "llm_not_configured", err.Error(), nil)
case errors.Is(err, discovery.ErrProfileNotSuggested):
apiresponse.WriteError(w, r, http.StatusConflict, "profile_not_suggested", err.Error(), nil)
default:
h.logger.Error("start profile enrichment", "error", err)
apivalidate.WriteInternal(w, r)
}
return
}
apiresponse.WriteJSON(w, http.StatusAccepted, result)
}
func (h *Handler) BlockDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
emails, err := h.discovery.BlockProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"))
if err != nil {
h.logger.Error("block discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"emails": emails})
}
func (h *Handler) AcceptEnrichmentSuggestion(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
sug, err := h.discovery.AcceptSuggestion(r.Context(), claims.Sub, chi.URLParam(r, "suggestionID"))
if err != nil {
h.logger.Error("accept enrichment suggestion", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, sug)
}
func (h *Handler) RejectEnrichmentSuggestion(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if err := h.discovery.RejectSuggestion(r.Context(), claims.Sub, chi.URLParam(r, "suggestionID")); err != nil {
h.logger.Error("reject enrichment suggestion", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) GetLLMSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
settings, err := h.discovery.GetLLMSettings(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get llm settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, settings)
}
func (h *Handler) UpdateLLMSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var settings llm.Settings
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &settings); err != nil {
return
}
updated, err := h.discovery.UpdateLLMSettings(r.Context(), claims.Sub, settings)
if err != nil {
h.logger.Error("update llm settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, updated)
}
type discoverLLMModelsRequest struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key,omitempty"`
}
func (h *Handler) DiscoverLLMModels(w http.ResponseWriter, r *http.Request) {
var req discoverLLMModelsRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
if strings.TrimSpace(req.BaseURL) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "base_url", Message: "is required",
}))
return
}
client := llm.NewClient()
models, err := client.ListModels(r.Context(), llm.Provider{
BaseURL: req.BaseURL,
APIKey: req.APIKey,
})
if err != nil {
h.logger.Error("discover llm models", "error", err)
apiresponse.WriteError(w, r, http.StatusBadGateway, "llm_models_unavailable", err.Error(), nil)
return
}
if models == nil {
models = []string{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"models": models})
}
func (h *Handler) GetSearchSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
settings, err := h.discovery.GetSearchSettings(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get search settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, settings)
}
func (h *Handler) UpdateSearchSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var settings websearch.Settings
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &settings); err != nil {
return
}
updated, err := h.discovery.UpdateSearchSettings(r.Context(), claims.Sub, settings)
if err != nil {
h.logger.Error("update search settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, updated)
}

View File

@ -1,6 +1,7 @@
package contacts package contacts
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@ -15,18 +16,28 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/permission"
) )
type Handler struct { type Handler struct {
svc *Service svc *Service
discovery *discovery.Service
logger *slog.Logger logger *slog.Logger
} }
func NewHandler(nc *nextcloud.Client, db *pgxpool.Pool) *Handler { func NewHandler(nc *nextcloud.Client, db *pgxpool.Pool) *Handler {
var disc *discovery.Service
if db != nil {
disc = discovery.NewService(db)
if nc != nil {
disc.SetNextcloud(discovery.NewNCAdapter(nc))
}
}
return &Handler{ return &Handler{
svc: NewService(nc, db), svc: NewService(nc, db),
discovery: disc,
logger: slog.Default().With("component", "contacts-api"), logger: slog.Default().With("component", "contacts-api"),
} }
} }
@ -42,10 +53,13 @@ func (h *Handler) Routes() chi.Router {
r.With(read).Get("/books/{bookID}/interactions/*", h.GetContactInteractions) r.With(read).Get("/books/{bookID}/interactions/*", h.GetContactInteractions)
r.With(read).Get("/search", h.SearchContacts) r.With(read).Get("/search", h.SearchContacts)
r.With(read).Get("/interactions", h.GetInteractionsByEmail) r.With(read).Get("/interactions", h.GetInteractionsByEmail)
r.With(read).Get("/*", h.GetContact)
r.With(write).Post("/books/{bookID}", h.CreateContact) r.With(write).Post("/books/{bookID}", h.CreateContact)
r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts) r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts)
r.With(write).Post("/improve", h.ImproveContact)
r.With(write).Put("/*", h.UpdateContact) r.With(write).Put("/*", h.UpdateContact)
r.With(write).Delete("/*", h.DeleteContact) r.With(write).Delete("/*", h.DeleteContact)
r.Mount("/discovery", h.discoveryRoutes())
return r return r
} }
@ -72,13 +86,36 @@ func (h *Handler) writeContactServiceError(w http.ResponseWriter, r *http.Reques
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
} }
func (h *Handler) retryOnDAVMissing(
ctx context.Context,
claims *auth.Claims,
ncUser string,
op func(userID string) error,
) error {
err := op(ncUser)
if !errors.Is(err, nextcloud.ErrDAVCredentialsMissing) {
return err
}
refreshed, reproErr := h.svc.ReprovisionPrincipal(ctx, claims)
if reproErr != nil {
h.logger.Error("reprovision nextcloud principal", "error", reproErr, "user", ncUser)
return err
}
return op(refreshed)
}
func (h *Handler) ListAddressBooks(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListAddressBooks(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims) ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok { if !ok {
return return
} }
books, err := h.svc.ListAddressBooks(r.Context(), ncUser) var books []nextcloud.AddressBook
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
var listErr error
books, listErr = h.svc.ListAddressBooks(r.Context(), userID)
return listErr
})
if err != nil { if err != nil {
h.writeContactServiceError(w, r, "list address books", err) h.writeContactServiceError(w, r, "list address books", err)
return return
@ -123,7 +160,13 @@ func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := h.svc.ListContacts(r.Context(), ncUser, chi.URLParam(r, "bookID"), params) bookID := chi.URLParam(r, "bookID")
var result ContactsList
err = h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
var listErr error
result, listErr = h.svc.ListContacts(r.Context(), userID, bookID, params)
return listErr
})
if err != nil { if err != nil {
h.writeContactServiceError(w, r, "list contacts", err) h.writeContactServiceError(w, r, "list contacts", err)
return return
@ -173,11 +216,37 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.svc.CreateContact(r.Context(), ncUser, chi.URLParam(r, "bookID"), &contact); err != nil { created, err := h.svc.CreateContact(r.Context(), ncUser, chi.URLParam(r, "bookID"), &contact)
if err != nil {
h.writeContactServiceError(w, r, "create contact", err) h.writeContactServiceError(w, r, "create contact", err)
return return
} }
w.WriteHeader(http.StatusCreated) apiresponse.WriteJSON(w, http.StatusCreated, created)
}
func (h *Handler) GetContact(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
contactPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/")
if verr := validateDeletePath(contactPath); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
var contact *nextcloud.Contact
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
var getErr error
contact, getErr = h.svc.GetContact(r.Context(), userID, contactPath)
return getErr
})
if err != nil {
h.writeContactServiceError(w, r, "get contact", err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, contact)
} }
func (h *Handler) UpdateContact(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateContact(w http.ResponseWriter, r *http.Request) {
@ -313,3 +382,27 @@ func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) ImproveContact(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
var input discovery.ImproveContactInput
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &input); err != nil {
return
}
data, err := h.discovery.ImproveContact(r.Context(), claims.Sub, input)
if err != nil {
switch {
case errors.Is(err, discovery.ErrLLMNotConfigured):
apiresponse.WriteError(w, r, http.StatusBadRequest, "llm_not_configured", err.Error(), nil)
default:
h.logger.Error("improve contact", "error", err)
apivalidate.WriteInternal(w, r)
}
return
}
apiresponse.WriteJSON(w, http.StatusOK, data)
}

View File

@ -0,0 +1,120 @@
package contacts
import (
"sort"
"strings"
"unicode"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func normalizeContactSearchText(value string) string {
var b strings.Builder
b.Grow(len(value))
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
if unicode.IsMark(r) {
continue
}
b.WriteRune(r)
}
return b.String()
}
func queryTokens(query string) []string {
n := normalizeContactSearchText(query)
if n == "" {
return nil
}
return strings.Fields(n)
}
func fieldMatchScore(haystack, needle string) float64 {
h := normalizeContactSearchText(haystack)
n := normalizeContactSearchText(needle)
if n == "" || !strings.Contains(h, n) {
return 0
}
if h == n {
return 1
}
if strings.HasPrefix(h, n) {
return 0.95 + 0.05*float64(len(n))/float64(max(len(h), 1))
}
for _, word := range strings.FieldsFunc(h, func(r rune) bool {
return r == ' ' || r == '@' || r == '.' || r == '_' || r == '+' || r == '-'
}) {
if word == "" {
continue
}
if strings.HasPrefix(word, n) {
return 0.88 + 0.07*float64(len(n))/float64(max(len(word), 1))
}
}
idx := strings.Index(h, n)
positionBonus := 1 - float64(idx)/float64(max(len(h), 1))*0.35
lengthBonus := float64(len(n)) / float64(max(len(h), 1))
return 0.42 + 0.28*positionBonus + 0.22*lengthBonus
}
func contactSearchFields(c nextcloud.Contact) []string {
out := make([]string, 0, 4)
for _, v := range []string{c.FullName, c.Email, c.Phone, c.Org} {
if strings.TrimSpace(v) != "" {
out = append(out, v)
}
}
return out
}
func contactQueryMatchScore(c nextcloud.Contact, query string) float64 {
fields := contactSearchFields(c)
tokens := queryTokens(query)
if len(tokens) == 0 {
return 0
}
var total float64
for _, token := range tokens {
best := 0.0
for _, field := range fields {
best = max(best, fieldMatchScore(field, token))
best = max(best, fieldMatchScore(field, query))
}
if best == 0 {
return 0
}
total += best
}
return total / float64(len(tokens))
}
func rankContactsByQuery(contacts []nextcloud.Contact, query string) []nextcloud.Contact {
query = strings.TrimSpace(query)
if query == "" {
return contacts
}
type scored struct {
contact nextcloud.Contact
score float64
}
matches := make([]scored, 0, len(contacts))
for _, c := range contacts {
if score := contactQueryMatchScore(c, query); score > 0 {
matches = append(matches, scored{contact: c, score: score})
}
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return strings.ToLower(matches[i].contact.FullName) < strings.ToLower(matches[j].contact.FullName)
})
out := make([]nextcloud.Contact, len(matches))
for i, m := range matches {
out[i] = m.contact
}
return out
}

View File

@ -0,0 +1,42 @@
package contacts
import (
"testing"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func TestFieldMatchScoreExactAndPrefix(t *testing.T) {
if got := fieldMatchScore("Alice Martin", "alice martin"); got != 1 {
t.Fatalf("exact match: got %v want 1", got)
}
prefix := fieldMatchScore("alice@example.com", "alice")
if prefix < 0.95 {
t.Fatalf("email prefix too low: %v", prefix)
}
}
func TestRankContactsByQueryOrder(t *testing.T) {
contacts := []nextcloud.Contact{
{FullName: "Jonathan Smith", Email: "jonathan@corp.com"},
{FullName: "Jon Smith", Email: "jon@corp.com"},
{FullName: "Bob Builder", Email: "bob@corp.com"},
}
ranked := rankContactsByQuery(contacts, "jon")
if len(ranked) != 2 {
t.Fatalf("expected 2 matches, got %d", len(ranked))
}
if ranked[0].FullName != "Jon Smith" {
t.Fatalf("expected Jon Smith first, got %q", ranked[0].FullName)
}
}
func TestRankContactsByQueryNoFuzzy(t *testing.T) {
contacts := []nextcloud.Contact{
{FullName: "Jonathan", Email: "jonathan@corp.com"},
}
ranked := rankContactsByQuery(contacts, "jhn")
if len(ranked) != 0 {
t.Fatalf("expected no fuzzy match, got %d", len(ranked))
}
}

View File

@ -32,6 +32,18 @@ func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims)
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
} }
// ReprovisionPrincipal drops cached CardDAV credentials and provisions fresh app password.
func (s *Service) ReprovisionPrincipal(ctx context.Context, claims *auth.Claims) (string, error) {
if s.nc == nil {
return "", fmt.Errorf("nextcloud unavailable")
}
userID := nextcloud.UserIDFromClaims(claims.Email, claims.Sub)
if err := s.nc.InvalidatePrincipalCredentials(ctx, userID); err != nil {
return "", err
}
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
}
func bookPath(userID, bookID string) string { func bookPath(userID, bookID string) string {
return nextcloud.AddressBookPath(userID, bookID) return nextcloud.AddressBookPath(userID, bookID)
} }
@ -63,21 +75,34 @@ func (s *Service) SearchContacts(ctx context.Context, userID, bookID, q string,
if searchQ == "" { if searchQ == "" {
searchQ = strings.TrimSpace(params.Q) searchQ = strings.TrimSpace(params.Q)
} }
contacts, err := s.nc.SearchContacts(ctx, userID, bookPath(userID, bookID), searchQ) if searchQ == "" {
var zero int64
return ContactsList{
Contacts: []nextcloud.Contact{},
Pagination: params.Meta(&zero),
}, nil
}
contacts, err := s.nc.ListContacts(ctx, userID, bookPath(userID, bookID))
if err != nil { if err != nil {
return ContactsList{}, err return ContactsList{}, err
} }
page, total := paginate.Slice(contacts, params.Offset(), params.Limit()) ranked := rankContactsByQuery(contacts, searchQ)
page, total := paginate.Slice(ranked, params.Offset(), params.Limit())
return ContactsList{ return ContactsList{
Contacts: page, Contacts: page,
Pagination: params.Meta(&total), Pagination: params.Meta(&total),
}, nil }, nil
} }
func (s *Service) CreateContact(ctx context.Context, userID, bookID string, contact *nextcloud.Contact) error { func (s *Service) CreateContact(ctx context.Context, userID, bookID string, contact *nextcloud.Contact) (*nextcloud.Contact, error) {
return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact) return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact)
} }
func (s *Service) GetContact(ctx context.Context, userID, contactPath string) (*nextcloud.Contact, error) {
return s.nc.GetContact(ctx, userID, contactPath)
}
func (s *Service) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *nextcloud.Contact) (string, error) { func (s *Service) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *nextcloud.Contact) (string, error) {
return s.nc.UpdateContact(ctx, userID, contactPath, ifMatch, contact) return s.nc.UpdateContact(ctx, userID, contactPath, ifMatch, contact)
} }
@ -438,18 +463,5 @@ func extractAddresses(raw []byte) []string {
} }
func filterContacts(contacts []nextcloud.Contact, q string) []nextcloud.Contact { func filterContacts(contacts []nextcloud.Contact, q string) []nextcloud.Contact {
q = strings.ToLower(strings.TrimSpace(q)) return rankContactsByQuery(contacts, q)
if q == "" {
return contacts
}
out := make([]nextcloud.Contact, 0, len(contacts))
for _, c := range contacts {
if strings.Contains(strings.ToLower(c.FullName), q) ||
strings.Contains(strings.ToLower(c.Email), q) ||
strings.Contains(strings.ToLower(c.Phone), q) ||
strings.Contains(strings.ToLower(c.Org), q) {
out = append(out, c)
}
}
return out
} }

View File

@ -22,6 +22,7 @@ import (
type Handler struct { type Handler struct {
svc *Service svc *Service
publicOffice PublicOfficeAPI
logger *slog.Logger logger *slog.Logger
} }
@ -32,6 +33,10 @@ func NewHandler(nc *nextcloud.Client, hub *realtime.Hub) *Handler {
} }
} }
func (h *Handler) SetPublicOffice(api PublicOfficeAPI) {
h.publicOffice = api
}
func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) { func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) {
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims) userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
if err != nil { if err != nil {
@ -46,7 +51,6 @@ func (h *Handler) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead) read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite) write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite)
admin := middleware.RequirePermission(permission.ResourceDrive, permission.LevelAdmin)
r.With(read).Get("/quota", h.GetQuota) r.With(read).Get("/quota", h.GetQuota)
r.With(read).Get("/trash", h.ListTrash) r.With(read).Get("/trash", h.ListTrash)
@ -68,11 +72,13 @@ func (h *Handler) Routes() chi.Router {
r.With(write).Post("/copy", h.Copy) r.With(write).Post("/copy", h.Copy)
r.With(write).Post("/rename", h.Rename) r.With(write).Post("/rename", h.Rename)
r.With(write).Post("/trash/restore", h.RestoreTrash) r.With(write).Post("/trash/restore", h.RestoreTrash)
r.With(write).Post("/trash/delete", h.DeleteTrash)
r.With(write).Delete("/trash", h.EmptyTrash)
r.With(write).Post("/favorite", h.SetFavorite) r.With(write).Post("/favorite", h.SetFavorite)
r.With(admin).Post("/shares", h.CreateShare) r.With(write).Post("/shares", h.CreateShare)
r.With(admin).Post("/shares/{shareID}/send-email", h.SendShareEmail) r.With(write).Post("/shares/{shareID}/send-email", h.SendShareEmail)
r.With(admin).Put("/shares/{shareID}", h.UpdateShare) r.With(write).Put("/shares/{shareID}", h.UpdateShare)
r.With(admin).Delete("/shares/{shareID}", h.DeleteShare) r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
return r return r
} }
@ -450,8 +456,11 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteQueryError(w, r, err) apivalidate.WriteQueryError(w, r, err)
return return
} }
basePath := r.URL.Query().Get("path") result, err := h.svc.Search(r.Context(), ncUser, SearchOptions{
result, err := h.svc.Search(r.Context(), ncUser, basePath, params) Scope: r.URL.Query().Get("scope"),
BasePath: r.URL.Query().Get("path"),
Suggest: r.URL.Query().Get("suggest") == "1",
}, params)
if err != nil { if err != nil {
writeDriveError(w, r, err) writeDriveError(w, r, err)
return return
@ -590,6 +599,40 @@ func (h *Handler) RestoreTrash(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) DeleteTrash(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req deleteTrashRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateDeleteTrashRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.DeleteTrash(r.Context(), ncUser, req.Name); err != nil {
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) EmptyTrash(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
if err := h.svc.EmptyTrash(r.Context(), ncUser); err != nil {
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) { func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
ncUser, ok := h.nextcloudUser(w, r, claims) ncUser, ok := h.nextcloudUser(w, r, claims)

View File

@ -0,0 +1,196 @@
package drive
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
func (h *Handler) PublicRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/shares/{token}", h.GetPublicShare)
r.Get("/shares/{token}/preview", h.PreviewPublicShare)
r.Get("/shares/{token}/download/*", h.DownloadPublicShare)
r.Put("/shares/{token}/files/*", h.UploadPublicShare)
r.Post("/shares/{token}/folders/*", h.CreatePublicShareFolder)
r.Delete("/shares/{token}/files/*", h.DeletePublicShareItem)
r.Post("/shares/{token}/rename", h.RenamePublicShareItem)
if h.publicOffice != nil {
h.publicOffice.RegisterPublicShareRoutes(r)
}
return r
}
func publicSharePassword(r *http.Request) string {
return strings.TrimSpace(r.URL.Query().Get("password"))
}
func (h *Handler) GetPublicShare(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
if token == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "token", Message: "required"},
))
return
}
path := strings.TrimSpace(r.URL.Query().Get("path"))
if path == "" {
path = "/"
}
view, err := h.svc.GetPublicShare(r.Context(), token, path, publicSharePassword(r))
if err != nil {
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, view)
}
func (h *Handler) DownloadPublicShare(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
if token == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "token", Message: "required"},
))
return
}
filePath := strings.TrimSpace(chi.URLParam(r, "*"))
if filePath == "" {
filePath = "/"
}
body, contentType, err := h.svc.DownloadPublicShare(r.Context(), token, filePath, publicSharePassword(r))
if err != nil {
writeDriveError(w, r, err)
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
_, _ = io.Copy(w, body)
}
func (h *Handler) PreviewPublicShare(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
if token == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "token", Message: "required"},
))
return
}
filePath := strings.TrimSpace(r.URL.Query().Get("path"))
if filePath == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
width, _ := strconv.Atoi(r.URL.Query().Get("w"))
height, _ := strconv.Atoi(r.URL.Query().Get("h"))
body, contentType, err := h.svc.PreviewPublicShare(r.Context(), token, filePath, publicSharePassword(r), width, height)
if err != nil {
writeDriveError(w, r, err)
return
}
defer body.Close()
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
_, _ = io.Copy(w, body)
}
func (h *Handler) UploadPublicShare(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
filePath := strings.TrimSpace(chi.URLParam(r, "*"))
if token == "" || filePath == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
if !strings.HasPrefix(filePath, "/") {
filePath = "/" + filePath
}
if err := h.svc.UploadPublicShare(r.Context(), token, filePath, publicSharePassword(r), r.Body, r.Header.Get("Content-Type")); err != nil {
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusCreated)
}
func (h *Handler) CreatePublicShareFolder(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
folderPath := strings.TrimSpace(chi.URLParam(r, "*"))
if token == "" || folderPath == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
if !strings.HasPrefix(folderPath, "/") {
folderPath = "/" + folderPath
}
if err := h.svc.CreatePublicShareFolder(r.Context(), token, folderPath, publicSharePassword(r)); err != nil {
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusCreated)
}
func (h *Handler) DeletePublicShareItem(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
filePath := strings.TrimSpace(chi.URLParam(r, "*"))
if token == "" || filePath == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
if !strings.HasPrefix(filePath, "/") {
filePath = "/" + filePath
}
if err := h.svc.DeletePublicShareItem(r.Context(), token, filePath, publicSharePassword(r)); err != nil {
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type publicRenameRequest struct {
Path string `json:"path"`
NewName string `json:"new_name"`
}
func (h *Handler) RenamePublicShareItem(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
if token == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "token", Message: "required"},
))
return
}
var req publicRenameRequest
if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
return
}
if strings.TrimSpace(req.Path) == "" || strings.TrimSpace(req.NewName) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
apivalidate.FieldDetail{Field: "new_name", Message: "required"},
))
return
}
if err := h.svc.RenamePublicShareItem(r.Context(), token, req.Path, req.NewName, publicSharePassword(r)); err != nil {
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -0,0 +1,8 @@
package drive
import "github.com/go-chi/chi/v5"
// PublicOfficeAPI registers OnlyOffice routes on the public drive router.
type PublicOfficeAPI interface {
RegisterPublicShareRoutes(r chi.Router)
}

View File

@ -0,0 +1,86 @@
package drive
import (
"context"
"io"
"path"
"strings"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func (s *Service) UploadPublicShare(ctx context.Context, token, filePath, password string, body io.Reader, contentType string) error {
perms, err := s.GetPublicSharePermissions(ctx, token, password)
if err != nil {
return err
}
if !nextcloud.PublicShareCanCreate(perms) && !nextcloud.PublicShareCanUpdate(perms) {
return ErrForbidden
}
return mapPublicShareError(s.nc.UploadPublicShare(ctx, token, filePath, password, body, contentType))
}
func (s *Service) CreatePublicShareFolder(ctx context.Context, token, folderPath, password string) error {
if err := s.requirePublicSharePerm(ctx, token, password, nextcloud.PublicShareCanCreate); err != nil {
return err
}
return mapPublicShareError(s.nc.CreatePublicShareFolder(ctx, token, folderPath, password))
}
func (s *Service) DeletePublicShareItem(ctx context.Context, token, filePath, password string) error {
if err := s.requirePublicSharePerm(ctx, token, password, nextcloud.PublicShareCanDelete); err != nil {
return err
}
return mapPublicShareError(s.nc.DeletePublicShare(ctx, token, filePath, password))
}
func (s *Service) RenamePublicShareItem(ctx context.Context, token, filePath, newName, password string) error {
if strings.Contains(newName, "/") {
return ErrInvalid
}
if err := s.requirePublicSharePathPerm(ctx, token, filePath, password, nextcloud.PublicShareCanUpdate); err != nil {
return err
}
filePath = nextcloud.NormalizeClientPath(filePath)
dir := path.Dir("/" + strings.TrimPrefix(filePath, "/"))
destination := path.Join(dir, newName)
return mapPublicShareError(s.nc.MovePublicShare(ctx, token, filePath, destination, password))
}
func (s *Service) GetPublicSharePermissions(ctx context.Context, token, password string) (int, error) {
perms, err := s.nc.GetPublicSharePermissions(ctx, token, password)
if err != nil {
return 0, mapPublicShareError(err)
}
return perms, nil
}
func (s *Service) GetPublicSharePathPermissions(ctx context.Context, token, path, password string) (int, error) {
perms, err := s.nc.GetPublicSharePathPermissions(ctx, token, path, password)
if err != nil {
return 0, mapPublicShareError(err)
}
return perms, nil
}
func (s *Service) requirePublicSharePerm(ctx context.Context, token, password string, check func(int) bool) error {
perms, err := s.GetPublicSharePermissions(ctx, token, password)
if err != nil {
return err
}
if !check(perms) {
return ErrForbidden
}
return nil
}
func (s *Service) requirePublicSharePathPerm(ctx context.Context, token, path, password string, check func(int) bool) error {
perms, err := s.GetPublicSharePathPermissions(ctx, token, path, password)
if err != nil {
return err
}
if !check(perms) {
return ErrForbidden
}
return nil
}

View File

@ -128,18 +128,20 @@ func (s *Service) ListStarred(ctx context.Context, userID, basePath string, para
if basePath == "" { if basePath == "" {
basePath = "/" basePath = "/"
} }
files, err := s.nc.ListFiles(ctx, userID, basePath) limit := params.Limit()
if limit <= 0 {
limit = 50
}
collect := limit + params.Offset()
if collect <= 0 || collect > 500 {
collect = 500
}
starred, err := s.nc.ListFavorites(ctx, userID, basePath, int(collect))
if err != nil { if err != nil {
return FilesList{}, mapDriveError(err) return FilesList{}, mapDriveError(err)
} }
starred := make([]nextcloud.FileInfo, 0, len(files))
for _, f := range files {
if f.IsFavorite {
starred = append(starred, f)
}
}
filtered := filterFiles(starred, params.Q) filtered := filterFiles(starred, params.Q)
page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) page, total := paginate.Slice(filtered, params.Offset(), limit)
return FilesList{ return FilesList{
Files: page, Files: page,
Pagination: params.Meta(&total), Pagination: params.Meta(&total),
@ -201,10 +203,14 @@ func (s *Service) CreateFolder(ctx context.Context, userID, path string) error {
} }
func (s *Service) Move(ctx context.Context, userID, source, destination string) error { func (s *Service) Move(ctx context.Context, userID, source, destination string) error {
source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source))
destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination))
return mapDriveError(s.nc.Move(ctx, userID, source, destination)) return mapDriveError(s.nc.Move(ctx, userID, source, destination))
} }
func (s *Service) Copy(ctx context.Context, userID, source, destination string) error { func (s *Service) Copy(ctx context.Context, userID, source, destination string) error {
source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source))
destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination))
return mapDriveError(s.nc.Copy(ctx, userID, source, destination)) return mapDriveError(s.nc.Copy(ctx, userID, source, destination))
} }
@ -212,12 +218,14 @@ func (s *Service) Rename(ctx context.Context, userID, filePath, newName string)
if strings.Contains(newName, "/") { if strings.Contains(newName, "/") {
return ErrInvalid return ErrInvalid
} }
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
dir := path.Dir("/" + strings.TrimPrefix(filePath, "/")) dir := path.Dir("/" + strings.TrimPrefix(filePath, "/"))
destination := path.Join(dir, newName) destination := path.Join(dir, newName)
return mapDriveError(s.nc.Move(ctx, userID, filePath, destination)) return mapDriveError(s.nc.Move(ctx, userID, filePath, destination))
} }
func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) { func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) {
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
opts, err := s.buildCreateShareOptions(ctx, req, permissions) opts, err := s.buildCreateShareOptions(ctx, req, permissions)
if err != nil { if err != nil {
return nil, err return nil, err
@ -226,6 +234,7 @@ func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req
if err != nil { if err != nil {
return nil, mapDriveError(err) return nil, mapDriveError(err)
} }
s.rewriteShareURL(share)
if shouldSendShareEmail(opts, req) { if shouldSendShareEmail(opts, req) {
if sendErr := s.nc.SendShareEmail(ctx, userID, share.ID, ""); sendErr != nil { if sendErr := s.nc.SendShareEmail(ctx, userID, share.ID, ""); sendErr != nil {
return share, nil return share, nil
@ -308,13 +317,69 @@ func (s *Service) GetQuota(ctx context.Context, userID string) (nextcloud.UserQu
} }
func (s *Service) ListShares(ctx context.Context, userID, filePath string) ([]nextcloud.ShareInfo, error) { func (s *Service) ListShares(ctx context.Context, userID, filePath string) ([]nextcloud.ShareInfo, error) {
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
shares, err := s.nc.ListShares(ctx, userID, filePath) shares, err := s.nc.ListShares(ctx, userID, filePath)
if err != nil { if err != nil {
return nil, mapDriveError(err) return nil, mapDriveError(err)
} }
s.rewriteShareURLs(shares)
return shares, nil return shares, nil
} }
func (s *Service) GetPublicShare(ctx context.Context, token, path, password string) (*nextcloud.PublicShareView, error) {
view, err := s.nc.GetPublicShare(ctx, token, path, password)
if err != nil {
return nil, mapPublicShareError(err)
}
return view, nil
}
func (s *Service) DownloadPublicShare(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) {
body, contentType, err := s.nc.DownloadPublicShare(ctx, token, filePath, password)
if err != nil {
return nil, "", mapPublicShareError(err)
}
return body, contentType, nil
}
func (s *Service) PreviewPublicShare(ctx context.Context, token, filePath, password string, width, height int) (io.ReadCloser, string, error) {
body, contentType, err := s.nc.PreviewPublicShare(ctx, token, filePath, password, width, height)
if err != nil {
return nil, "", mapPublicShareError(err)
}
return body, contentType, nil
}
func (s *Service) rewriteShareURL(share *nextcloud.ShareInfo) {
if share == nil || s.nc == nil {
return
}
if share.ShareType == 3 && strings.TrimSpace(share.Token) != "" {
if u := s.nc.PublicShareURL(share.Token); u != "" {
share.URL = u
}
}
}
func (s *Service) rewriteShareURLs(shares []nextcloud.ShareInfo) {
for i := range shares {
s.rewriteShareURL(&shares[i])
}
}
func mapPublicShareError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, nextcloud.ErrPublicSharePasswordRequired) {
return ErrForbidden
}
if errors.Is(err, nextcloud.ErrInvalidPublicShare) {
return ErrInvalid
}
return mapDriveError(err)
}
func (s *Service) UpdateShare(ctx context.Context, userID, shareID string, permissions int, expireDate, password string) (*nextcloud.ShareInfo, error) { func (s *Service) UpdateShare(ctx context.Context, userID, shareID string, permissions int, expireDate, password string) (*nextcloud.ShareInfo, error) {
share, err := s.nc.UpdateShare(ctx, userID, shareID, permissions, expireDate, password) share, err := s.nc.UpdateShare(ctx, userID, shareID, permissions, expireDate, password)
if err != nil { if err != nil {
@ -331,26 +396,70 @@ func (s *Service) RestoreTrash(ctx context.Context, userID, trashName string) er
return mapDriveError(s.nc.RestoreFromTrash(ctx, userID, trashName)) return mapDriveError(s.nc.RestoreFromTrash(ctx, userID, trashName))
} }
func (s *Service) DeleteTrash(ctx context.Context, userID, trashName string) error {
return mapDriveError(s.nc.DeleteFromTrash(ctx, userID, trashName))
}
func (s *Service) EmptyTrash(ctx context.Context, userID string) error {
return mapDriveError(s.nc.EmptyTrash(ctx, userID))
}
func (s *Service) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error { func (s *Service) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
return mapDriveError(s.nc.SetFavorite(ctx, userID, filePath, favorite)) return mapDriveError(s.nc.SetFavorite(ctx, userID, filePath, favorite))
} }
func (s *Service) Search(ctx context.Context, userID, basePath string, params query.ListParams) (FilesList, error) { func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions, params query.ListParams) (FilesList, error) {
if basePath == "" { q := strings.TrimSpace(params.Q)
basePath = "/" if q == "" {
return FilesList{
Files: []nextcloud.FileInfo{},
Pagination: params.Meta(ptrInt64(0)),
}, nil
} }
files, err := s.nc.ListFiles(ctx, userID, basePath)
limit := params.Limit()
if limit <= 0 {
if opts.Suggest {
limit = 8
} else {
limit = 100
}
}
scope := nextcloud.SearchScope(opts.Scope)
if scope == "" {
scope = nextcloud.SearchScopeAll
}
files, err := s.nc.SearchFiles(ctx, userID, nextcloud.SearchOptions{
Query: q,
Scope: scope,
BasePath: opts.BasePath,
Suggest: opts.Suggest,
Limit: limit + params.Offset(),
})
if err != nil { if err != nil {
return FilesList{}, mapDriveError(err) return FilesList{}, mapDriveError(err)
} }
filtered := filterFiles(files, params.Q)
page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) page, total := paginate.Slice(files, params.Offset(), limit)
return FilesList{ return FilesList{
Files: page, Files: page,
Pagination: params.Meta(&total), Pagination: params.Meta(&total),
}, nil }, nil
} }
type SearchOptions struct {
Scope string
BasePath string
Suggest bool
}
func ptrInt64(v int64) *int64 {
return &v
}
type NewFileKind string type NewFileKind string
const ( const (
@ -434,7 +543,7 @@ func mapDriveError(err error) error {
switch statusErr.StatusCode { switch statusErr.StatusCode {
case http.StatusNotFound: case http.StatusNotFound:
return ErrNotFound return ErrNotFound
case http.StatusConflict: case http.StatusConflict, http.StatusPreconditionFailed:
return ErrConflict return ErrConflict
case http.StatusForbidden, http.StatusUnauthorized: case http.StatusForbidden, http.StatusUnauthorized:
return ErrForbidden return ErrForbidden

View File

@ -131,6 +131,19 @@ func validateRestoreTrashRequest(req *restoreTrashRequest) *apivalidate.Validati
return nil return nil
} }
type deleteTrashRequest struct {
Name string `json:"name"`
}
func validateDeleteTrashRequest(req *deleteTrashRequest) *apivalidate.ValidationError {
if strings.TrimSpace(req.Name) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "name", Message: "required",
})
}
return nil
}
type favoriteRequest struct { type favoriteRequest struct {
Path string `json:"path"` Path string `json:"path"`
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`

View File

@ -19,10 +19,10 @@ type ctxKey string
const claimsKey ctxKey = "claims" const claimsKey ctxKey = "claims"
func Auth(verifier *auth.Verifier, db *pgxpool.Pool, audit *securityaudit.Logger) func(http.Handler) http.Handler { func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if verifier == nil { if verifier == nil || !verifier.Ready() {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil) apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
if audit != nil { if audit != nil {
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{ audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{

View File

@ -90,14 +90,20 @@ func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) {
mode = "edit" mode = "edit"
} }
cfg, err := h.svc.EditorConfig(r.Context(), ncUser, req.Path, mode, claims.Name) cfg, err := h.svc.EditorConfig(r.Context(), ncUser, req.Path, mode, claims.Sub, claims.Name)
if err != nil { if err != nil {
h.logger.Error("editor config", "error", err) h.logger.Error("editor config", "error", err)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
return return
} }
wrapped, err := h.svc.wrapEditorConfig(cfg)
if err != nil {
h.logger.Error("editor config jwt", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"config": cfg, "config": wrapped,
"serverUrl": h.svc.PublicURL(), "serverUrl": h.svc.PublicURL(),
}) })
} }
@ -162,14 +168,23 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
return return
} }
// status 2 = ready for saving, 6 = must force save // status 2 = last editor closed, save final version; 6 = force save while editing
if payload.Status == 2 || payload.Status == 6 { if payload.Status == 2 || payload.Status == 6 {
if payload.URL != "" { if payload.URL != "" {
resp, err := http.Get(payload.URL) resp, err := http.Get(payload.URL)
if err == nil && resp.StatusCode == http.StatusOK { if err != nil {
h.logger.Error("office callback fetch", "error", err, "path", filePath, "status", payload.Status)
} else if resp.StatusCode != http.StatusOK {
h.logger.Error("office callback fetch status", "status", resp.StatusCode, "path", filePath, "oo_status", payload.Status)
resp.Body.Close()
} else {
defer resp.Body.Close() defer resp.Body.Close()
ct := resp.Header.Get("Content-Type") ct := resp.Header.Get("Content-Type")
_ = h.svc.SaveDocument(r.Context(), ncUser, filePath, resp.Body, ct) if err := h.svc.SaveDocument(r.Context(), ncUser, filePath, resp.Body, ct); err != nil {
h.logger.Error("office callback save", "error", err, "path", filePath, "status", payload.Status)
} else if payload.Status == 2 {
h.svc.RotateDocumentKeyAfterSave(r.Context(), ncUser, filePath)
}
} }
} }
} }

View File

@ -0,0 +1,46 @@
package office
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
)
// documentKeyStore tracks OnlyOffice session keys per Nextcloud file id.
// The key stays stable while editors are active (refresh, co-editing) and rotates
// only after a successful save when the last editor closes (callback status 2).
type documentKeyStore struct {
mu sync.Mutex
vers map[int64]uint64
}
func newDocumentKeyStore() *documentKeyStore {
return &documentKeyStore{vers: make(map[int64]uint64)}
}
func (k *documentKeyStore) current(fileID int64) string {
k.mu.Lock()
defer k.mu.Unlock()
ver := k.vers[fileID]
if ver == 0 {
ver = 1
k.vers[fileID] = ver
}
return documentSessionKey(fileID, ver)
}
func (k *documentKeyStore) rotateAfterSave(fileID int64) {
k.mu.Lock()
defer k.mu.Unlock()
ver := k.vers[fileID]
if ver == 0 {
ver = 1
}
k.vers[fileID] = ver + 1
}
func documentSessionKey(fileID int64, version uint64) string {
h := sha256.Sum256([]byte(fmt.Sprintf("nc:%d:v%d", fileID, version)))
return hex.EncodeToString(h[:16])
}

View File

@ -0,0 +1,30 @@
package office
import "testing"
func TestDocumentKeyStoreStableWhileActive(t *testing.T) {
store := newDocumentKeyStore()
k1 := store.current(99)
k2 := store.current(99)
if k1 != k2 {
t.Fatalf("expected stable key during session, got %q vs %q", k1, k2)
}
}
func TestDocumentKeyStoreRotatesAfterSave(t *testing.T) {
store := newDocumentKeyStore()
before := store.current(7)
store.rotateAfterSave(7)
after := store.current(7)
if before == after {
t.Fatalf("expected new key after save rotation")
}
}
func TestDocumentSessionKeyDiffersByFile(t *testing.T) {
a := documentSessionKey(1, 1)
b := documentSessionKey(2, 1)
if a == b {
t.Fatal("expected different keys for different files")
}
}

View File

@ -0,0 +1,150 @@
package office
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func (h *Handler) PublicShareRoutes() chi.Router {
r := chi.NewRouter()
h.RegisterPublicShareRoutes(r)
return r
}
func (h *Handler) RegisterPublicShareRoutes(r chi.Router) {
r.Post("/shares/{token}/office/session", h.PublicShareSession)
r.Get("/shares/{token}/office/document", h.PublicShareDocument)
r.Post("/shares/{token}/office/callback", h.PublicShareCallback)
}
type publicOfficeSessionRequest struct {
Path string `json:"path"`
Mode string `json:"mode"`
Password string `json:"password"`
GuestID string `json:"guest_id"`
}
func publicSharePassword(r *http.Request) string {
return strings.TrimSpace(r.URL.Query().Get("password"))
}
func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
if token == "" {
apivalidate.WriteNotFound(w, r, "not found")
return
}
var req publicOfficeSessionRequest
if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil {
return
}
if strings.TrimSpace(req.Path) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
))
return
}
password := strings.TrimSpace(req.Password)
if password == "" {
password = publicSharePassword(r)
}
perms, err := h.svc.nc.GetPublicSharePathPermissions(r.Context(), token, req.Path, password)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
if !nextcloud.PublicShareCanRead(perms) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mode := strings.TrimSpace(req.Mode)
if mode == "" {
mode = "edit"
}
if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) {
mode = "view"
}
cfg, err := h.svc.PublicEditorConfig(r.Context(), token, req.Path, mode, password, req.GuestID)
if err != nil {
h.logger.Error("public editor config", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"config": cfg,
"serverUrl": h.svc.PublicURL(),
})
}
func (h *Handler) PublicShareDocument(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
filePath := strings.TrimSpace(r.URL.Query().Get("path"))
password := publicSharePassword(r)
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
if h.svc.Cfg.JWTSecret != "" && !VerifyPublicDocAccess(token, filePath, password, sig, h.svc.Cfg.JWTSecret) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
body, contentType, err := h.svc.OpenPublicDocument(r.Context(), PublicShareAccess{
Token: token, FilePath: filePath, Password: password,
})
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer body.Close()
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
_, _ = io.Copy(w, body)
}
func (h *Handler) PublicShareCallback(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(chi.URLParam(r, "token"))
filePath := strings.TrimSpace(r.URL.Query().Get("path"))
password := publicSharePassword(r)
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
if h.svc.Cfg.JWTSecret != "" && !VerifyPublicDocAccess(token, filePath, password, sig, h.svc.Cfg.JWTSecret) {
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{"error": 1})
return
}
var payload struct {
Status int `json:"status"`
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{"error": 1})
return
}
if payload.Status == 2 || payload.Status == 6 {
if payload.URL != "" {
resp, err := http.Get(payload.URL)
if err != nil {
h.logger.Error("public office callback fetch", "error", err, "path", filePath, "status", payload.Status)
} else if resp.StatusCode != http.StatusOK {
h.logger.Error("public office callback fetch status", "status", resp.StatusCode, "path", filePath, "oo_status", payload.Status)
resp.Body.Close()
} else {
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if err := h.svc.SavePublicDocument(r.Context(), PublicShareAccess{
Token: token, FilePath: filePath, Password: password,
}, resp.Body, ct); err != nil {
h.logger.Error("public office callback save", "error", err, "path", filePath, "status", payload.Status)
} else if payload.Status == 2 {
h.svc.RotatePublicDocumentKeyAfterSave(r.Context(), token, filePath, password)
}
}
}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{"error": 0})
}

View File

@ -0,0 +1,111 @@
package office
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"time"
)
type PublicShareAccess struct {
Token string
FilePath string
Password string
}
func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode, password, guestID string) (map[string]any, error) {
token = strings.TrimSpace(token)
filePath = normalizePath(filePath)
if token == "" || filePath == "" {
return nil, fmt.Errorf("invalid public office session")
}
if mode == "" {
mode = "edit"
}
rev, err := s.nc.PublicShareFileRevision(ctx, token, filePath, password)
if err != nil {
return nil, fmt.Errorf("resolve public file revision: %w", err)
}
apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/")
sig, err := signPublicDocAccess(token, filePath, password, s.Cfg.JWTSecret)
if err != nil {
return nil, err
}
downloadURL := buildPublicOfficeEndpointURL(apiBase, token, "/office/document", filePath, password, sig)
callbackURL := buildPublicOfficeEndpointURL(apiBase, token, "/office/callback", filePath, password, sig)
editorUserID := strings.TrimSpace(guestID)
if editorUserID == "" {
editorUserID = "public-guest"
} else {
editorUserID = "public:" + editorUserID
}
config, err := buildEditorConfig(buildEditorConfigInput{
filePath: filePath,
mode: mode,
editorUserID: editorUserID,
userName: "Invité",
documentKey: s.keys.current(rev.FileID),
downloadURL: downloadURL,
callbackURL: callbackURL,
})
if err != nil {
return nil, err
}
return wrapConfig(config, s.Cfg.JWTSecret)
}
func (s *Service) OpenPublicDocument(ctx context.Context, access PublicShareAccess) (io.ReadCloser, string, error) {
return s.nc.DownloadPublicShare(ctx, access.Token, access.FilePath, access.Password)
}
func (s *Service) SavePublicDocument(ctx context.Context, access PublicShareAccess, body io.Reader, contentType string) error {
return s.nc.UploadPublicShare(ctx, access.Token, access.FilePath, access.Password, body, contentType)
}
func buildPublicOfficeEndpointURL(base, token, endpoint, filePath, password, sig string) string {
q := url.Values{}
q.Set("path", normalizePath(filePath))
if password != "" {
q.Set("password", password)
}
if sig != "" {
q.Set("sig", sig)
}
return strings.TrimRight(base, "/") + "/api/v1/drive/public/shares/" + url.PathEscape(token) + endpoint + "?" + q.Encode()
}
func signPublicDocAccess(token, filePath, password, secret string) (string, error) {
payload := map[string]any{
"token": strings.TrimSpace(token),
"path": normalizePath(filePath),
"password": password,
"exp": time.Now().Add(2 * time.Hour).Unix(),
}
return signJWT(payload, secret)
}
func VerifyPublicDocAccess(token, filePath, password, sig, secret string) bool {
if secret == "" {
return true
}
payload, err := verifyJWT(sig, secret)
if err != nil {
return false
}
if payload["token"] != strings.TrimSpace(token) || payload["path"] != normalizePath(filePath) {
return false
}
if pw, _ := payload["password"].(string); pw != password {
return false
}
if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() {
return false
}
return true
}

View File

@ -2,8 +2,7 @@ package office
import ( import (
"context" "context"
"crypto/sha256" "fmt"
"encoding/hex"
"io" "io"
"net/url" "net/url"
"path" "path"
@ -25,10 +24,15 @@ type Config struct {
type Service struct { type Service struct {
nc *nextcloud.Client nc *nextcloud.Client
Cfg Config Cfg Config
keys *documentKeyStore
} }
func NewService(nc *nextcloud.Client, cfg Config) *Service { func NewService(nc *nextcloud.Client, cfg Config) *Service {
return &Service{nc: nc, Cfg: cfg} return &Service{
nc: nc,
Cfg: cfg,
keys: newDocumentKeyStore(),
}
} }
func (s *Service) PublicURL() string { func (s *Service) PublicURL() string {
@ -39,15 +43,16 @@ func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims)
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
} }
func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, userName string) (map[string]any, error) { func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, editorUserID, userName string) (map[string]any, error) {
filePath = normalizePath(filePath) filePath = normalizePath(filePath)
docType := documentType(filePath) rev, err := s.nc.FileRevision(ctx, ncUser, filePath)
key := documentKey(ncUser, filePath) if err != nil {
return nil, fmt.Errorf("resolve file revision: %w", err)
}
apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/") apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/")
sig := "" sig := ""
if s.Cfg.JWTSecret != "" { if s.Cfg.JWTSecret != "" {
var err error
sig, err = signDocAccess(ncUser, filePath, s.Cfg.JWTSecret) sig, err = signDocAccess(ncUser, filePath, s.Cfg.JWTSecret)
if err != nil { if err != nil {
return nil, err return nil, err
@ -56,12 +61,63 @@ func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, user
downloadURL := buildOfficeEndpointURL(apiBase, "/api/v1/office/document", ncUser, filePath, sig) downloadURL := buildOfficeEndpointURL(apiBase, "/api/v1/office/document", ncUser, filePath, sig)
callbackURL := buildOfficeEndpointURL(apiBase, "/api/v1/office/callback", ncUser, filePath, "") callbackURL := buildOfficeEndpointURL(apiBase, "/api/v1/office/callback", ncUser, filePath, "")
edit := mode == "edit" return buildEditorConfig(buildEditorConfigInput{
filePath: filePath,
mode: mode,
editorUserID: editorUserID,
userName: userName,
documentKey: s.keys.current(rev.FileID),
downloadURL: downloadURL,
callbackURL: callbackURL,
})
}
func (s *Service) RotateDocumentKeyAfterSave(ctx context.Context, ncUser, filePath string) {
rev, err := s.nc.FileRevision(ctx, ncUser, filePath)
if err != nil {
return
}
s.keys.rotateAfterSave(rev.FileID)
}
func (s *Service) RotatePublicDocumentKeyAfterSave(ctx context.Context, token, filePath, password string) {
rev, err := s.nc.PublicShareFileRevision(ctx, token, filePath, password)
if err != nil {
return
}
s.keys.rotateAfterSave(rev.FileID)
}
func (s *Service) wrapEditorConfig(config map[string]any) (map[string]any, error) {
return wrapConfig(config, s.Cfg.JWTSecret)
}
func (s *Service) OpenDocument(ctx context.Context, ncUser, filePath string) (io.ReadCloser, string, error) {
return s.nc.Download(ctx, ncUser, normalizePath(filePath))
}
func (s *Service) SaveDocument(ctx context.Context, ncUser, filePath string, body io.Reader, contentType string) error {
return s.nc.Upload(ctx, ncUser, normalizePath(filePath), body, contentType)
}
type buildEditorConfigInput struct {
filePath string
mode string
editorUserID string
userName string
documentKey string
downloadURL string
callbackURL string
}
func buildEditorConfig(in buildEditorConfigInput) (map[string]any, error) {
docType := documentType(in.filePath)
edit := in.mode == "edit"
document := map[string]any{ document := map[string]any{
"fileType": fileExt(filePath), "fileType": fileExt(in.filePath),
"key": key, "key": in.documentKey,
"title": path.Base(filePath), "title": path.Base(in.filePath),
"url": downloadURL, "url": in.downloadURL,
"permissions": map[string]any{ "permissions": map[string]any{
"comment": true, "comment": true,
"copy": true, "copy": true,
@ -77,13 +133,18 @@ func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, user
}, },
} }
editorCfg := map[string]any{ editorCfg := map[string]any{
"mode": mode, "mode": in.mode,
"user": map[string]any{ "user": map[string]any{
"id": ncUser, "id": in.editorUserID,
"name": userName, "name": in.userName,
},
"callbackUrl": in.callbackURL,
"coEditing": map[string]any{
"mode": "fast",
"change": false,
}, },
"callbackUrl": callbackURL,
"customization": map[string]any{ "customization": map[string]any{
"autosave": true,
"forcesave": true, "forcesave": true,
}, },
} }
@ -92,20 +153,7 @@ func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, user
"document": document, "document": document,
"editorConfig": editorCfg, "editorConfig": editorCfg,
} }
return wrapConfig(config, s.Cfg.JWTSecret) return config, nil
}
func (s *Service) OpenDocument(ctx context.Context, ncUser, filePath string) (io.ReadCloser, string, error) {
return s.nc.Download(ctx, ncUser, normalizePath(filePath))
}
func (s *Service) SaveDocument(ctx context.Context, ncUser, filePath string, body io.Reader, contentType string) error {
return s.nc.Upload(ctx, ncUser, normalizePath(filePath), body, contentType)
}
func documentKey(ncUser, filePath string) string {
h := sha256.Sum256([]byte(ncUser + "|" + filePath + "|" + time.Now().Format("2006-01-02")))
return hex.EncodeToString(h[:16])
} }
func documentType(filePath string) string { func documentType(filePath string) string {

84
internal/auth/holder.go Normal file
View File

@ -0,0 +1,84 @@
package auth
import (
"context"
"errors"
"log/slog"
"sync/atomic"
"time"
)
var ErrVerifierUnavailable = errors.New("verifier unavailable")
// Holder keeps an OIDC verifier available, retrying in the background when Authentik
// is not ready at ultid startup (common in local Docker compose).
type Holder struct {
v atomic.Pointer[Verifier]
issuerURL string
clientID string
discoveryHost string
}
func NewHolder(initial *Verifier) *Holder {
h := &Holder{}
if initial != nil {
h.v.Store(initial)
}
return h
}
func NewHolderPending(issuerURL, clientID, discoveryHost string) *Holder {
return &Holder{
issuerURL: issuerURL,
clientID: clientID,
discoveryHost: discoveryHost,
}
}
func (h *Holder) Ready() bool {
return h != nil && h.v.Load() != nil
}
func (h *Holder) Get() *Verifier {
if h == nil {
return nil
}
return h.v.Load()
}
func (h *Holder) Verify(ctx context.Context, rawToken string) (*Claims, error) {
v := h.Get()
if v == nil {
return nil, ErrVerifierUnavailable
}
return v.Verify(ctx, rawToken)
}
// StartBackgroundRetry keeps trying until the verifier is ready or ctx is cancelled.
func (h *Holder) StartBackgroundRetry(ctx context.Context, interval time.Duration) {
if h == nil || h.Ready() || h.issuerURL == "" || h.clientID == "" {
return
}
if interval <= 0 {
interval = 5 * time.Second
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
v, err := NewVerifier(ctx, h.issuerURL, h.clientID, h.discoveryHost)
if err == nil {
h.v.Store(v)
slog.Info("OIDC verifier ready (background retry)")
return
}
slog.Warn("OIDC verifier background retry", "error", err)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}

View File

@ -56,6 +56,7 @@ type Config struct {
// Nextcloud // Nextcloud
NextcloudEnabled bool NextcloudEnabled bool
NextcloudURL string NextcloudURL string
NextcloudPublicURL string
NCAdminUser string NCAdminUser string
NCAdminPass string NCAdminPass string
@ -66,6 +67,7 @@ type Config struct {
OnlyOfficeAPIInternalURL string OnlyOfficeAPIInternalURL string
OnlyOfficeJWTSecret string OnlyOfficeJWTSecret string
UltidPublicURL string UltidPublicURL string
DrivePublicURL string
// Jitsi // Jitsi
JitsiEnabled bool JitsiEnabled bool
@ -165,6 +167,7 @@ func Load() (*Config, error) {
NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true), NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true),
NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"),
NextcloudPublicURL: nextcloudPublicURL(),
NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"), NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"),
NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"), NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"),
@ -174,6 +177,7 @@ func Load() (*Config, error) {
OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"), OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"),
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"), OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
DrivePublicURL: drivePublicURL(),
JitsiEnabled: envBool("JITSI_ENABLED", true), JitsiEnabled: envBool("JITSI_ENABLED", true),
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"), JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
@ -343,6 +347,24 @@ func joinURL(base, path string) string {
return strings.TrimRight(base, "/") + path return strings.TrimRight(base, "/") + path
} }
// nextcloudPublicURL is the external WebDAV base (OVERWRITECLIURL), used for MOVE/COPY Destination headers.
func nextcloudPublicURL() string {
if v := strings.TrimSpace(os.Getenv("NC_PUBLIC_URL")); v != "" {
return strings.TrimRight(v, "/")
}
domain := envOrDefault("DOMAIN", "localhost")
proto := envOrDefault("NC_OVERWRITE_PROTOCOL", "http")
return proto + "://" + domain + "/cloud"
}
// drivePublicURL is the browser-facing UltiDrive base used in public share links.
func drivePublicURL() string {
if v := strings.TrimSpace(os.Getenv("DRIVE_PUBLIC_URL")); v != "" {
return strings.TrimRight(v, "/")
}
return strings.TrimRight(envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), "/") + "/drive"
}
func defaultHealthJitsiURL(publicURL string) string { func defaultHealthJitsiURL(publicURL string) string {
trimmed := strings.TrimRight(publicURL, "/") trimmed := strings.TrimRight(publicURL, "/")
trimmed = strings.TrimSuffix(trimmed, "/meet") trimmed = strings.TrimSuffix(trimmed, "/meet")

View File

@ -0,0 +1,131 @@
package discovery
import (
"strings"
)
var mailingListDomains = map[string]struct{}{
"sendgrid.net": {},
"sendgrid.com": {},
"sendinblue.com": {},
"brevo.com": {},
"mailchimp.com": {},
"list-manage.com": {},
"mailgun.org": {},
"mailgun.net": {},
"amazonses.com": {},
"messagingengine.com": {},
"constantcontact.com": {},
"campaign-archive.com": {},
"hubspotemail.net": {},
"mailjet.com": {},
"sparkpostmail.com": {},
"postmarkapp.com": {},
"mandrillapp.com": {},
"emarsys.net": {},
"customeriomail.com": {},
"intercom-mail.com": {},
}
var disposableDomains = map[string]struct{}{
"libero.it": {},
"guerrillamail.com": {},
"mailinator.com": {},
"tempmail.com": {},
"10minutemail.com": {},
"throwaway.email": {},
"yopmail.com": {},
"sharklasers.com": {},
"trashmail.com": {},
"getnada.com": {},
"maildrop.cc": {},
"dispostable.com": {},
"fakeinbox.com": {},
"temp-mail.org": {},
"burnermail.io": {},
}
func emailDomain(email string) string {
email = strings.ToLower(strings.TrimSpace(email))
at := strings.LastIndex(email, "@")
if at < 0 || at == len(email)-1 {
return ""
}
return email[at+1:]
}
func isMailingListDomain(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
if _, ok := mailingListDomains[domain]; ok {
return true
}
for d := range mailingListDomains {
if strings.HasSuffix(domain, "."+d) {
return true
}
}
return false
}
func isNoReplyEmail(email string) bool {
email = strings.ToLower(strings.TrimSpace(email))
return strings.Contains(email, "noreply") ||
strings.Contains(email, "no-reply") ||
strings.Contains(email, "no_reply")
}
func isDisposableDomain(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
if _, ok := disposableDomains[domain]; ok {
return true
}
for d := range disposableDomains {
if strings.HasSuffix(domain, "."+d) {
return true
}
}
return false
}
func classifyAddress(agg *addressAgg) (isMailingList, isDisposable, isSpamHeavy bool, reason string) {
domain := emailDomain(agg.Email)
isDisposable = isDisposableDomain(domain)
isNoReply := isNoReplyEmail(agg.Email)
isMailingList = isMailingListDomain(domain) || agg.MailingList > 0 || agg.ListUnsub > 0 || isNoReply
if agg.MessageCount >= 3 {
mlSignals := agg.MailingList + agg.ListUnsub
if float64(mlSignals)/float64(agg.MessageCount) >= 0.5 {
isMailingList = true
}
}
if agg.MessageCount >= 3 {
spamRatio := float64(agg.SpamCount) / float64(agg.MessageCount)
isSpamHeavy = spamRatio >= 0.5
}
var parts []string
if isNoReply {
parts = append(parts, "no-reply")
}
if isMailingList && !isNoReply {
parts = append(parts, "liste de diffusion")
}
if isDisposable {
parts = append(parts, "email jetable")
}
if isSpamHeavy {
parts = append(parts, "majoritairement spam")
}
if len(parts) > 0 {
reason = strings.Join(parts, ", ")
}
return isMailingList, isDisposable, isSpamHeavy, reason
}
func shouldEnrich(isMailingList, isDisposable, isSpamHeavy bool, sigCount int) bool {
if isMailingList || isDisposable || isSpamHeavy {
return false
}
return sigCount > 0
}

View File

@ -0,0 +1,115 @@
package discovery
import "testing"
func TestIsNoReplyEmail(t *testing.T) {
cases := []struct {
email string
want bool
}{
{"noreply@uber.com", true},
{"no-reply@zoom.us", true},
{"user@users.noreply.github.com", true},
{"alice@corp.com", false},
{"john.doe@company.com", false},
}
for _, tc := range cases {
if got := isNoReplyEmail(tc.email); got != tc.want {
t.Fatalf("isNoReplyEmail(%q) = %v, want %v", tc.email, got, tc.want)
}
}
}
func TestClassifyAddressNoReply(t *testing.T) {
isML, _, _, reason := classifyAddress(&addressAgg{Email: "noreply@github.com"})
if !isML {
t.Fatal("expected noreply address to be classified as non-suggestable")
}
if !contains(reason, "no-reply") {
t.Fatalf("expected no-reply in reason, got %q", reason)
}
}
func TestIsMailingListDomain(t *testing.T) {
if !isMailingListDomain("sendgrid.net") {
t.Fatal("expected sendgrid.net to be mailing list")
}
if !isMailingListDomain("mail.example.sendgrid.net") {
t.Fatal("expected subdomain of sendgrid")
}
if isMailingListDomain("gmail.com") {
t.Fatal("gmail.com should not be mailing list")
}
}
func TestClassifyAddressMailingListRatio(t *testing.T) {
isML, _, _, _ := classifyAddress(&addressAgg{
Email: "news@company.com",
MessageCount: 10,
ListUnsub: 6,
})
if !isML {
t.Fatal("expected majority list-unsubscribe signals to flag mailing list")
}
}
func TestClassifyAddressSpamHeavyRatio(t *testing.T) {
_, _, isSpam, _ := classifyAddress(&addressAgg{
Email: "spammer@evil.com",
MessageCount: 4,
SpamCount: 2,
})
if !isSpam {
t.Fatal("expected 50% spam ratio with min 3 messages")
}
}
func TestCompanyNamesMatch(t *testing.T) {
if !companyNamesMatch("Acme Inc", "acme inc") {
t.Fatal("expected case-insensitive company match")
}
if companyNamesMatch("Acme", "Globex") {
t.Fatal("expected different companies not to match")
}
}
func TestIsDisposableDomain(t *testing.T) {
if !isDisposableDomain("yopmail.com") {
t.Fatal("expected yopmail to be disposable")
}
if isDisposableDomain("company.com") {
t.Fatal("company.com should not be disposable")
}
}
func TestExtractSignature(t *testing.T) {
body := "Hello there\n\n--\nJohn Doe\nCEO · Acme Inc\n+33 1 23 45 67 89"
text, conf := extractSignature(body, "", "john@acme.com", "John Doe")
if text == "" || conf < 0.5 {
t.Fatalf("expected signature, got %q conf=%v", text, conf)
}
if !contains(text, "John Doe") {
t.Fatalf("expected name in signature: %q", text)
}
}
func TestDetectForwardedAddresses(t *testing.T) {
body := "---------- Forwarded message ---------\nFrom: Alice <alice@example.com>\nSubject: Hi"
addrs := detectForwardedAddresses(body, "")
if len(addrs) != 1 || addrs[0] != "alice@example.com" {
t.Fatalf("unexpected forwarded addrs: %v", addrs)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@ -0,0 +1,150 @@
package discovery
import (
"context"
"encoding/json"
"strings"
)
func (s *Service) inferMissingCompanies(ctx context.Context, externalUserID, ncUserID, bookID string) {
domainCompanies := s.loadDomainCompanyHints(ctx, externalUserID, ncUserID, bookID)
rows, err := s.db.Query(ctx, `
SELECT p.id::text, p.primary_email, COALESCE(p.enriched_data, '{}'::jsonb)
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1
AND p.status = 'suggested'
AND NOT p.is_mailing_list
AND NOT p.is_disposable
AND NOT p.is_spam_heavy` + noReplyProfilesSQL + `
`, externalUserID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var id, email string
var enrichedJSON []byte
if err := rows.Scan(&id, &email, &enrichedJSON); err != nil {
continue
}
domain := emailDomain(email)
if isConsumerMailDomain(domain) {
continue
}
var data EnrichedContactData
_ = json.Unmarshal(enrichedJSON, &data)
if strings.TrimSpace(data.Company) != "" {
continue
}
company, ok := domainCompanies[domain]
if !ok || strings.TrimSpace(company) == "" {
continue
}
data.Company = company
payload, _ := json.Marshal(data)
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET enriched_data = $3::jsonb,
enrichment_status = CASE
WHEN enrichment_status IN ('skipped', 'failed', 'pending') THEN 'enriched'
ELSE enrichment_status
END,
updated_at = NOW()
WHERE id = $1::uuid AND user_id = (SELECT id FROM users WHERE external_id = $2)
`, id, externalUserID, string(payload))
}
}
func (s *Service) loadDomainCompanyHints(ctx context.Context, externalUserID, ncUserID, bookID string) map[string]string {
hints := map[string]int{}
domainBest := map[string]string{}
rows, err := s.db.Query(ctx, `
SELECT lower(split_part(p.primary_email, '@', 2)) AS domain,
trim(both '"' from (p.enriched_data->>'company')) AS company,
COUNT(*)::int AS cnt
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1
AND p.enriched_data->>'company' IS NOT NULL
AND trim(p.enriched_data->>'company') != ''
GROUP BY 1, 2
HAVING COUNT(*) >= 2
`, externalUserID)
if err == nil {
defer rows.Close()
for rows.Next() {
var domain, company string
var cnt int
if err := rows.Scan(&domain, &company, &cnt); err != nil {
continue
}
if isConsumerMailDomain(domain) {
continue
}
if cnt > hints[domain] {
hints[domain] = cnt
domainBest[domain] = company
}
}
}
if s.nc != nil && ncUserID != "" {
contacts := s.loadNCContacts(ctx, ncUserID, bookID)
orgByDomain := map[string]map[string]int{}
for _, c := range contacts {
org := strings.TrimSpace(c.Org)
if org == "" {
continue
}
domain := emailDomain(c.Email)
if isConsumerMailDomain(domain) {
continue
}
if orgByDomain[domain] == nil {
orgByDomain[domain] = map[string]int{}
}
orgByDomain[domain][org]++
}
for domain, orgs := range orgByDomain {
bestOrg := ""
bestCnt := 0
for org, cnt := range orgs {
if cnt > bestCnt {
bestCnt = cnt
bestOrg = org
}
}
if bestCnt < 2 {
continue
}
batchOrg := domainBest[domain]
batchCnt := hints[domain]
if batchCnt >= 2 && !companyNamesMatch(batchOrg, bestOrg) {
delete(domainBest, domain)
continue
}
if batchCnt == 0 || bestCnt >= batchCnt {
domainBest[domain] = bestOrg
}
}
}
return domainBest
}
func companyNamesMatch(a, b string) bool {
a = normalizeCompanyName(a)
b = normalizeCompanyName(b)
if a == "" || b == "" {
return false
}
return a == b
}
func normalizeCompanyName(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}

View File

@ -0,0 +1,196 @@
package discovery
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
)
func (s *Service) relatedProfileIDs(ctx context.Context, externalUserID, profileID string) ([]string, error) {
var groupID *string
err := s.db.QueryRow(ctx, `
SELECT person_group_id::text
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.id = $2::uuid
`, externalUserID, profileID).Scan(&groupID)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("profile not found")
}
return nil, err
}
if groupID != nil && strings.TrimSpace(*groupID) != "" {
rows, err := s.db.Query(ctx, `
SELECT p.id::text
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.person_group_id = $2::uuid
`, externalUserID, *groupID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if len(ids) > 0 {
return ids, rows.Err()
}
}
return []string{profileID}, nil
}
func (s *Service) profileEmails(ctx context.Context, externalUserID string, profileIDs []string) ([]string, error) {
rows, err := s.db.Query(ctx, `
SELECT primary_email
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.id = ANY($2::uuid[])
`, externalUserID, profileIDs)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]struct{}{}
var emails []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
low := strings.ToLower(strings.TrimSpace(email))
if low == "" {
continue
}
if _, ok := seen[low]; ok {
continue
}
seen[low] = struct{}{}
emails = append(emails, low)
}
return emails, rows.Err()
}
func (s *Service) persistEmailRejections(ctx context.Context, externalUserID string, emails []string, rejectionType string) {
for _, email := range emails {
_, _ = s.db.Exec(ctx, `
INSERT INTO contact_discovery_rejections (user_id, rejection_key, rejection_type)
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3)
ON CONFLICT DO NOTHING
`, externalUserID, "email:"+email, rejectionType)
}
}
func (s *Service) rejectPendingSuggestions(ctx context.Context, externalUserID string, profileIDs []string) {
_, _ = s.db.Exec(ctx, `
UPDATE contact_enrichment_suggestions
SET status = 'rejected', rejected_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
AND profile_id = ANY($2::uuid[])
AND status = 'pending'
`, externalUserID, profileIDs)
}
func (s *Service) IgnoreProfile(ctx context.Context, externalUserID, profileID string) ([]string, error) {
ids, err := s.relatedProfileIDs(ctx, externalUserID, profileID)
if err != nil {
return nil, err
}
_, err = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET status = 'ignored', ignored_at = NOW(), updated_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
AND id = ANY($2::uuid[])
`, externalUserID, ids)
if err != nil {
return nil, err
}
emails, err := s.profileEmails(ctx, externalUserID, ids)
if err != nil {
return nil, err
}
s.persistEmailRejections(ctx, externalUserID, emails, "ignore")
s.rejectPendingSuggestions(ctx, externalUserID, ids)
return emails, nil
}
func (s *Service) BlockProfile(ctx context.Context, externalUserID, profileID string) ([]string, error) {
ids, err := s.relatedProfileIDs(ctx, externalUserID, profileID)
if err != nil {
return nil, err
}
_, err = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET status = 'blocked',
blocked_at = NOW(),
user_blocked = true,
is_spam_heavy = true,
updated_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
AND id = ANY($2::uuid[])
`, externalUserID, ids)
if err != nil {
return nil, err
}
emails, err := s.profileEmails(ctx, externalUserID, ids)
if err != nil {
return nil, err
}
s.persistEmailRejections(ctx, externalUserID, emails, "block")
s.rejectPendingSuggestions(ctx, externalUserID, ids)
return emails, nil
}
func (s *Service) ListProfilesByStatus(ctx context.Context, externalUserID string, status ProfileStatus) ([]Profile, error) {
rows, err := s.db.Query(ctx, `
SELECT `+profileSelectColumns+`
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.status = $2
ORDER BY `+profileInteractionOrderBy+`
`, externalUserID, status)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []Profile
for rows.Next() {
p, err := scanProfileRow(rows)
if err != nil {
return nil, err
}
profiles = append(profiles, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
s.attachSignaturesToProfiles(ctx, profiles)
return profiles, nil
}
func (s *Service) ListOtherProfileGroups(ctx context.Context, externalUserID string) ([]ProfileGroup, error) {
page, err := s.ListOtherProfileGroupsPage(ctx, externalUserID, MaxOtherGroupsPageSize, 0, "")
if err != nil {
return nil, err
}
if page.Total <= page.Limit {
return page.Groups, nil
}
all := append([]ProfileGroup{}, page.Groups...)
for offset := page.Limit; offset < page.Total; offset += MaxOtherGroupsPageSize {
next, err := s.ListOtherProfileGroupsPage(ctx, externalUserID, MaxOtherGroupsPageSize, offset, "")
if err != nil {
return nil, err
}
all = append(all, next.Groups...)
}
return all, nil
}

View File

@ -0,0 +1,162 @@
package discovery
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/ultisuite/ulti-backend/internal/llm"
)
const enrichSystemPrompt = `Tu es un assistant qui extrait des informations de contact structurées à partir de signatures d'emails.
Réponds UNIQUEMENT en JSON valide, sans markdown ni texte autour.
Privilégie les informations des signatures les plus récentes (listées en premier).
Ne devine pas : omets les champs incertains.
Format attendu:
{
"first_name": "",
"last_name": "",
"company": "",
"department": "",
"job_title": "",
"emails": [{"value": "", "label": "work|home|other"}],
"phones": [{"value": "", "label": "work|mobile|home|other"}],
"addresses": [{"street": "", "city": "", "region": "", "postal_code": "", "country": "", "label": "work|home"}],
"website": "",
"social_profiles": [{"value": "https://...", "label": "linkedin|twitter|facebook|instagram|github|other"}],
"notes": ""
}`
func buildEnrichPrompt(email, displayName string, signatures []SignatureEntry) string {
var b strings.Builder
fmt.Fprintf(&b, "Email principal: %s\n", email)
if displayName != "" {
fmt.Fprintf(&b, "Nom affiché: %s\n", displayName)
}
b.WriteString("\nSignatures (du plus récent au plus ancien):\n")
for i, sig := range signatures {
text := sig.SignatureText
if len(text) > 1500 {
text = text[:1500]
}
fmt.Fprintf(&b, "\n--- Signature %d (%s) ---\n%s\n", i+1, sig.MessageDate.Format("2006-01-02"), text)
}
return b.String()
}
func parseEnrichedData(raw string) (*EnrichedContactData, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var data EnrichedContactData
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil, err
}
return &data, nil
}
func enrichWithLLMTimeout(ctx context.Context, client *llm.Client, settings llm.Settings, email, displayName string, signatures []SignatureEntry, timeout time.Duration) (*EnrichedContactData, error) {
enrichCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resultCh := make(chan struct {
data *EnrichedContactData
err error
}, 1)
go func() {
data, err := enrichWithLLM(enrichCtx, client, settings, email, displayName, signatures)
resultCh <- struct {
data *EnrichedContactData
err error
}{data, err}
}()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-enrichCtx.Done():
if err := enrichCtx.Err(); err != context.DeadlineExceeded {
return nil, err
}
return nil, fmt.Errorf("llm enrichment timed out after %s", timeout)
case res := <-resultCh:
return res.data, res.err
}
}
func enrichWithLLM(ctx context.Context, client *llm.Client, settings llm.Settings, email, displayName string, signatures []SignatureEntry) (*EnrichedContactData, error) {
if client == nil || len(signatures) == 0 {
return nil, fmt.Errorf("no signatures to enrich")
}
provider, model, err := llm.ResolveProvider(settings, "")
if err != nil {
return nil, err
}
prompt := buildEnrichPrompt(email, displayName, signatures)
raw, err := client.Complete(ctx, provider, model, enrichSystemPrompt, prompt)
if err != nil {
return nil, err
}
return parseEnrichedData(raw)
}
func enrichedDataToSuggestions(userID, profileID string, data *EnrichedContactData) []Suggestion {
if data == nil {
return nil
}
var out []Suggestion
addField := func(fieldPath, value, label, sugType string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
out = append(out, Suggestion{
ProfileID: profileID,
SuggestionType: sugType,
FieldPath: fieldPath,
SuggestedValue: value,
SuggestedLabel: label,
Confidence: 0.75,
Status: SuggestionPending,
})
}
addField("first_name", data.FirstName, "", "new_contact")
addField("last_name", data.LastName, "", "new_contact")
addField("company", data.Company, "", "new_contact")
addField("department", data.Department, "", "new_contact")
addField("job_title", data.JobTitle, "", "new_contact")
addField("website", data.Website, "", "new_contact")
addField("notes", data.Notes, "", "new_contact")
for _, sp := range data.SocialProfiles {
addField("social_profiles", sp.Value, sp.Label, "new_contact")
}
for _, e := range data.Emails {
addField("emails", e.Value, e.Label, "new_contact")
}
for _, p := range data.Phones {
addField("phones", p.Value, p.Label, "new_contact")
}
for _, a := range data.Addresses {
parts := []string{a.Street, a.City, a.Region, a.PostalCode, a.Country}
var nonEmpty []string
for _, p := range parts {
if strings.TrimSpace(p) != "" {
nonEmpty = append(nonEmpty, strings.TrimSpace(p))
}
}
if len(nonEmpty) > 0 {
addField("addresses", strings.Join(nonEmpty, ", "), a.Label, "new_contact")
}
}
_ = userID
return out
}

View File

@ -0,0 +1,205 @@
package discovery
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrProfileNotFound = errors.New("profile not found")
ErrNoSignatures = errors.New("no signatures available for enrichment")
ErrAlreadyEnriched = errors.New("profile already enriched")
ErrLLMNotConfigured = errors.New("llm provider not configured")
ErrProfileNotSuggested = errors.New("profile is not available for enrichment")
)
type ProfileEnrichResponse struct {
ProfileID string `json:"profile_id"`
EnrichmentStatus EnrichmentStatus `json:"enrichment_status"`
}
func (s *Service) StartProfileEnrichment(ctx context.Context, externalUserID, profileID string) (ProfileEnrichResponse, error) {
sigs, err := s.loadProfileSignatures(ctx, profileID)
if err != nil {
return ProfileEnrichResponse{}, err
}
if len(sigs) == 0 {
return ProfileEnrichResponse{}, ErrNoSignatures
}
llmSettings, _ := s.loadLLMSettings(ctx, externalUserID)
if !llmSettingsHasProvider(llmSettings) {
return ProfileEnrichResponse{}, ErrLLMNotConfigured
}
tag, err := s.db.Exec(ctx, `
UPDATE contact_discovered_profiles p
SET enrichment_status = 'enriching', updated_at = NOW()
FROM users u
WHERE p.user_id = u.id
AND u.external_id = $1
AND p.id = $2::uuid
AND p.status = 'suggested'
AND p.enrichment_status NOT IN ('enriched', 'enriching')
`, externalUserID, profileID)
if err != nil {
return ProfileEnrichResponse{}, err
}
if tag.RowsAffected() == 0 {
var status ProfileStatus
var enrichStatus EnrichmentStatus
err := s.db.QueryRow(ctx, `
SELECT p.status, p.enrichment_status
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.id = $2::uuid
`, externalUserID, profileID).Scan(&status, &enrichStatus)
if err != nil {
if err == pgx.ErrNoRows {
return ProfileEnrichResponse{}, ErrProfileNotFound
}
return ProfileEnrichResponse{}, err
}
if status != ProfileSuggested {
return ProfileEnrichResponse{}, ErrProfileNotSuggested
}
if enrichStatus == EnrichEnriched {
return ProfileEnrichResponse{}, ErrAlreadyEnriched
}
if enrichStatus == EnrichEnriching {
return ProfileEnrichResponse{
ProfileID: profileID,
EnrichmentStatus: EnrichEnriching,
}, nil
}
return ProfileEnrichResponse{}, fmt.Errorf("profile not enrichable")
}
ncUserID, bookID := s.resolveDiscoveryNCContext(ctx, externalUserID)
go s.runProfileEnrichment(externalUserID, profileID, ncUserID, bookID)
return ProfileEnrichResponse{
ProfileID: profileID,
EnrichmentStatus: EnrichEnriching,
}, nil
}
func (s *Service) runProfileEnrichment(externalUserID, profileID, ncUserID, bookID string) {
ctx := context.Background()
defer func() {
if r := recover(); r != nil {
s.logger.Error("profile enrichment panicked", "profile_id", profileID, "panic", r)
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET enrichment_status = 'failed', updated_at = NOW()
WHERE id = $1::uuid AND enrichment_status = 'enriching'
`, profileID)
}
}()
profile, err := s.getProfileByID(ctx, externalUserID, profileID)
if err != nil {
s.logger.Warn("profile enrichment load profile failed", "profile_id", profileID, "error", err)
s.markProfileEnrichmentFailed(ctx, profileID)
return
}
sigs, err := s.loadProfileSignatures(ctx, profileID)
if err != nil || len(sigs) == 0 {
s.markProfileEnrichmentFailed(ctx, profileID)
return
}
llmSettings, err := s.loadLLMSettings(ctx, externalUserID)
if err != nil || !llmSettingsHasProvider(llmSettings) {
s.markProfileEnrichmentFailed(ctx, profileID)
return
}
enriched, enrichErr := enrichWithLLMTimeout(
ctx, s.llm, llmSettings,
profile.PrimaryEmail, profile.DisplayName, sigs, llmEnrichTimeout,
)
if enrichErr != nil {
s.logger.Warn("profile enrichment llm failed", "profile_id", profileID, "error", enrichErr)
s.markProfileEnrichmentFailed(ctx, profileID)
return
}
rejections, _ := s.loadRejections(ctx, externalUserID)
if err := s.applyEnrichmentResults(ctx, externalUserID, profileID, profile.PrimaryEmail, enriched, ncUserID, bookID, rejections); err != nil {
s.logger.Warn("profile enrichment persist failed", "profile_id", profileID, "error", err)
s.markProfileEnrichmentFailed(ctx, profileID)
return
}
s.inferMissingCompanies(ctx, externalUserID, ncUserID, bookID)
}
func (s *Service) markProfileEnrichmentFailed(ctx context.Context, profileID string) {
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET enrichment_status = 'failed', updated_at = NOW()
WHERE id = $1::uuid AND enrichment_status = 'enriching'
`, profileID)
}
func (s *Service) applyEnrichmentResults(
ctx context.Context,
externalUserID, profileID, email string,
enriched *EnrichedContactData,
ncUserID, bookID string,
rejections map[string]bool,
) error {
enrichedJSON, _ := json.Marshal(enriched)
_, err := s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET enrichment_status = 'enriched', enriched_data = $2::jsonb, enriched_at = NOW(), updated_at = NOW()
WHERE id = $1::uuid
`, profileID, string(enrichedJSON))
if err != nil {
return err
}
var existingContacts []ncContact
if ncUserID != "" {
existingContacts = s.loadNCContacts(ctx, ncUserID, bookID)
}
match := findExistingContact(existingContacts, email)
var suggestions []Suggestion
if match != nil {
suggestions = enrichExistingContactSuggestions(profileID, match.UID, enriched, match)
} else {
suggestions = enrichedDataToSuggestions(externalUserID, profileID, enriched)
}
for _, sug := range suggestions {
if match != nil && suggestionAlreadyOnContact(match, sug) {
continue
}
rejKey := fmt.Sprintf("field:%s:%s:%s", profileID, sug.FieldPath, sug.SuggestedValue)
if rejections[rejKey] {
continue
}
_, _ = s.db.Exec(ctx, `
INSERT INTO contact_enrichment_suggestions (
user_id, profile_id, suggestion_type, field_path, suggested_value, suggested_label, confidence, status
)
VALUES (
(SELECT id FROM users WHERE external_id = $1), $2::uuid, $3, $4, $5, $6, $7, 'pending'
)
ON CONFLICT (user_id, profile_id, field_path, suggested_value) DO UPDATE SET
status = CASE
WHEN contact_enrichment_suggestions.status = 'rejected' THEN 'rejected'
ELSE 'pending'
END,
confidence = EXCLUDED.confidence
`, externalUserID, profileID, sug.SuggestionType, sug.FieldPath, sug.SuggestedValue, sug.SuggestedLabel, sug.Confidence)
}
return nil
}

View File

@ -0,0 +1,178 @@
package discovery
import (
"context"
"sort"
"strings"
"github.com/google/uuid"
)
var consumerMailDomains = map[string]struct{}{
"gmail.com": {}, "googlemail.com": {}, "outlook.com": {}, "hotmail.com": {},
"live.com": {}, "msn.com": {}, "yahoo.com": {}, "yahoo.fr": {},
"icloud.com": {}, "me.com": {}, "mac.com": {}, "proton.me": {}, "protonmail.com": {},
"aol.com": {}, "gmx.com": {}, "gmx.fr": {}, "mail.com": {}, "zoho.com": {},
"yandex.com": {}, "yandex.ru": {}, "fastmail.com": {},
}
func isConsumerMailDomain(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return true
}
if _, ok := consumerMailDomains[domain]; ok {
return true
}
return false
}
func personGroupKey(p Profile) string {
if p.EnrichedData != nil {
fn := strings.TrimSpace(p.EnrichedData.FirstName)
ln := strings.TrimSpace(p.EnrichedData.LastName)
if fn != "" || ln != "" {
return strings.ToLower(strings.TrimSpace(fn + " " + ln))
}
}
dn := strings.TrimSpace(p.DisplayName)
if dn != "" && !strings.Contains(dn, "@") {
return strings.ToLower(dn)
}
return ""
}
func groupProfiles(profiles []Profile) []ProfileGroup {
if len(profiles) == 0 {
return nil
}
buckets := map[string][]Profile{}
var solo []Profile
for _, p := range profiles {
key := personGroupKey(p)
if key == "" {
solo = append(solo, p)
continue
}
buckets[key] = append(buckets[key], p)
}
var groups []ProfileGroup
for key, list := range buckets {
if len(list) == 1 {
solo = append(solo, list[0])
continue
}
groups = append(groups, mergeProfileGroup(key, list))
}
for _, p := range solo {
groups = append(groups, mergeProfileGroup(p.ID, []Profile{p}))
}
sort.Slice(groups, func(i, j int) bool {
return compareProfileGroupsByInteraction(groups[i], groups[j])
})
return groups
}
func mergeProfileGroup(key string, profiles []Profile) ProfileGroup {
sort.Slice(profiles, func(i, j int) bool {
return compareProfilesByInteraction(profiles[i], profiles[j])
})
primary := profiles[0]
ids := make([]string, 0, len(profiles))
emailSeen := map[string]struct{}{}
var allEmails []EmailEntry
accountMap := map[string]*AccountDetection{}
totalMessages := 0
for _, p := range profiles {
ids = append(ids, p.ID)
totalMessages += p.MessageCount
for _, e := range p.AllEmails {
low := strings.ToLower(strings.TrimSpace(e.Email))
if low == "" {
continue
}
if _, ok := emailSeen[low]; ok {
continue
}
emailSeen[low] = struct{}{}
allEmails = append(allEmails, e)
}
if _, ok := emailSeen[strings.ToLower(primary.PrimaryEmail)]; !ok {
emailSeen[strings.ToLower(primary.PrimaryEmail)] = struct{}{}
}
for _, a := range p.DetectedInAccounts {
hit, ok := accountMap[a.AccountID]
if !ok {
cp := a
accountMap[a.AccountID] = &cp
continue
}
hit.MessageCount += a.MessageCount
}
}
var accounts []AccountDetection
for _, a := range accountMap {
accounts = append(accounts, *a)
}
sort.Slice(accounts, func(i, j int) bool {
return accounts[i].MessageCount > accounts[j].MessageCount
})
merged := primary
merged.AllEmails = allEmails
merged.DetectedInAccounts = accounts
merged.MessageCount = totalMessages
merged.AllEmails = allEmails
merged.DetectedInAccounts = accounts
merged.MessageCount = totalMessages
return ProfileGroup{
GroupKey: key,
ProfileIDs: ids,
Profile: merged,
Profiles: profiles,
DisplayName: profileDisplayName(merged),
PrimaryEmail: merged.PrimaryEmail,
MessageCount: totalMessages,
}
}
func profileDisplayName(p Profile) string {
if p.EnrichedData != nil {
fn := strings.TrimSpace(p.EnrichedData.FirstName)
ln := strings.TrimSpace(p.EnrichedData.LastName)
if fn != "" || ln != "" {
return strings.TrimSpace(fn + " " + ln)
}
}
if strings.TrimSpace(p.DisplayName) != "" {
return strings.TrimSpace(p.DisplayName)
}
return p.PrimaryEmail
}
func (s *Service) assignPersonGroups(ctx context.Context, externalUserID string) error {
profiles, err := s.ListProfilesByStatus(ctx, externalUserID, ProfileSuggested)
if err != nil {
return err
}
groups := groupProfiles(profiles)
for _, g := range groups {
if len(g.ProfileIDs) < 2 {
continue
}
groupID := uuid.NewString()
for _, id := range g.ProfileIDs {
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET person_group_id = $3::uuid, updated_at = NOW()
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalUserID, id, groupID)
}
}
return nil
}

View File

@ -0,0 +1,132 @@
package discovery
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/ultisuite/ulti-backend/internal/llm"
"github.com/ultisuite/ulti-backend/internal/websearch"
)
const improveContactSystemPrompt = `Tu es un assistant qui nettoie, structure et enrichit une fiche contact existante.
Réponds UNIQUEMENT en JSON valide, sans markdown ni texte autour.
Corrige les problèmes de formatage : URLs avec slash final, accents/casse des noms, téléphones, adresses mal remplies ou incomplètes, champs vides inutiles.
Réorganise les informations de façon cohérente. Ne supprime pas d'emails ou téléphones valides.
Tu peux compléter poste, entreprise, site web, réseaux sociaux ou notes à partir des résultats de recherche en ligne fournis, uniquement si tu es raisonnablement confiant qu'ils concernent cette personne (profils LinkedIn, pages entreprise, etc.). En cas de doute ou d'homonymie, n'ajoute rien de nouveau.
Quand des résultats de recherche mentionnent des profils LinkedIn ou d'autres réseaux sociaux pertinents, ajoute-les dans social_profiles avec l'URL complète. Ne mets pas LinkedIn dans website : website = site personnel ou entreprise, social_profiles = profils sociaux.
Format attendu:
{
"first_name": "",
"last_name": "",
"company": "",
"department": "",
"job_title": "",
"emails": [{"value": "", "label": "work|home|other"}],
"phones": [{"value": "", "label": "work|mobile|home|other"}],
"addresses": [{"street": "", "city": "", "region": "", "postal_code": "", "country": "", "label": "work|home"}],
"website": "",
"social_profiles": [{"value": "https://...", "label": "linkedin|twitter|facebook|instagram|github|other"}],
"notes": ""
}`
type ImproveContactInput struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
MiddleName string `json:"middle_name,omitempty"`
Company string `json:"company,omitempty"`
Department string `json:"department,omitempty"`
JobTitle string `json:"job_title,omitempty"`
Website string `json:"website,omitempty"`
Notes string `json:"notes,omitempty"`
Emails []FieldWithLabel `json:"emails,omitempty"`
Phones []FieldWithLabel `json:"phones,omitempty"`
Addresses []AddressField `json:"addresses,omitempty"`
SocialProfiles []FieldWithLabel `json:"social_profiles,omitempty"`
Birthday string `json:"birthday,omitempty"`
RawVCard string `json:"raw_vcard,omitempty"`
}
func contactCityFromAddresses(addresses []AddressField) string {
for _, a := range addresses {
if city := strings.TrimSpace(a.City); city != "" {
return city
}
}
return ""
}
func buildImproveContactPrompt(input ImproveContactInput, searchSection string) string {
var b strings.Builder
b.WriteString("Fiche contact actuelle:\n")
payload, _ := json.MarshalIndent(input, "", " ")
b.Write(payload)
if searchSection != "" {
b.WriteString("\n\n")
b.WriteString(searchSection)
b.WriteString("\n\nÀ partir de ces résultats, complète social_profiles (LinkedIn en priorité, puis X/Twitter, Facebook, Instagram, GitHub) lorsque l'URL correspond clairement à cette personne.")
}
b.WriteString("\n\nPropose une version nettoyée, mieux structurée et enrichie si les sources le permettent.")
return b.String()
}
func (s *Service) fetchContactSearchResults(ctx context.Context, externalUserID string, input ImproveContactInput) string {
if s.websearch == nil {
return ""
}
searchSettings, err := s.loadSearchSettings(ctx, externalUserID)
if err != nil || !searchSettingsConfigured(searchSettings) {
return ""
}
provider, err := websearch.ResolveProvider(searchSettings)
if err != nil {
return ""
}
query := websearch.BuildContactSearchQuery(
input.FirstName,
input.LastName,
input.MiddleName,
input.Company,
input.JobTitle,
contactCityFromAddresses(input.Addresses),
)
if query == "" {
return ""
}
results, err := s.websearch.Search(ctx, provider, query, 5)
if err != nil {
s.logger.Warn("contact improve web search failed", "error", err)
return ""
}
return websearch.FormatResultsForPrompt(results)
}
func (s *Service) ImproveContact(ctx context.Context, externalUserID string, input ImproveContactInput) (*EnrichedContactData, error) {
if s.llm == nil {
return nil, ErrLLMNotConfigured
}
llmSettings, err := s.loadLLMSettings(ctx, externalUserID)
if err != nil || !llmSettingsHasProvider(llmSettings) {
return nil, ErrLLMNotConfigured
}
provider, model, err := llm.ResolveProvider(llmSettings, "")
if err != nil {
return nil, err
}
improveCtx, cancel := context.WithTimeout(ctx, llmEnrichTimeout)
defer cancel()
searchSection := s.fetchContactSearchResults(improveCtx, externalUserID, input)
prompt := buildImproveContactPrompt(input, searchSection)
raw, err := s.llm.Complete(improveCtx, provider, model, improveContactSystemPrompt, prompt)
if err != nil {
return nil, err
}
data, err := parseEnrichedData(raw)
if err != nil {
return nil, fmt.Errorf("parse improved contact: %w", err)
}
return data, nil
}

View File

@ -0,0 +1,47 @@
package discovery
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/llm"
)
func (s *Service) GetLLMSettings(ctx context.Context, externalUserID string) (llm.Settings, error) {
return s.loadLLMSettings(ctx, externalUserID)
}
func (s *Service) UpdateLLMSettings(ctx context.Context, externalUserID string, settings llm.Settings) (llm.Settings, error) {
if s.db == nil {
return llm.Settings{}, fmt.Errorf("database unavailable")
}
raw, err := json.Marshal(settings)
if err != nil {
return llm.Settings{}, err
}
_, err = s.db.Exec(ctx, `
INSERT INTO settings (user_id, preferences)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
jsonb_build_object('llm', $2::jsonb)
)
ON CONFLICT (user_id) DO UPDATE SET
preferences = jsonb_set(
COALESCE(settings.preferences, '{}'::jsonb),
'{llm}',
$2::jsonb
),
updated_at = NOW()
`, externalUserID, string(raw))
if err != nil {
return llm.Settings{}, err
}
return s.loadLLMSettings(ctx, externalUserID)
}
func LoadLLMSettingsFromPool(ctx context.Context, db *pgxpool.Pool, externalUserID string) (llm.Settings, error) {
s := NewService(db)
return s.loadLLMSettings(ctx, externalUserID)
}

View File

@ -0,0 +1,160 @@
package discovery
import (
"context"
"strings"
"unicode"
)
func (s *Service) loadNCContacts(ctx context.Context, ncUserID, bookID string) []ncContact {
if s.nc == nil || ncUserID == "" {
return nil
}
if bookID == "" {
bookID = "contacts"
}
path := "/addressbooks/" + ncUserID + "/" + bookID + "/"
contacts, err := s.nc.ListContacts(ctx, ncUserID, path)
if err != nil {
s.logger.Warn("load nc contacts for enrichment", "error", err)
return nil
}
return contacts
}
func (s *Service) resolveDiscoveryNCContext(ctx context.Context, externalUserID string) (ncUserID, bookID string) {
bookID = "contacts"
_ = s.db.QueryRow(ctx, `
SELECT COALESCE(NULLIF(nc_user_id, ''), ''), COALESCE(NULLIF(book_id, ''), 'contacts')
FROM contact_discovery_scans
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
ORDER BY started_at DESC
LIMIT 1
`, externalUserID).Scan(&ncUserID, &bookID)
return
}
func findExistingContact(contacts []ncContact, email string) *ncContact {
email = strings.ToLower(strings.TrimSpace(email))
for i := range contacts {
if strings.EqualFold(strings.TrimSpace(contacts[i].Email), email) {
return &contacts[i]
}
}
return nil
}
func normalizePhone(s string) string {
var b strings.Builder
for _, r := range s {
if unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
func phonesEqual(a, b string) bool {
na, nb := normalizePhone(a), normalizePhone(b)
if na == "" || nb == "" {
return false
}
if len(na) >= 9 && len(nb) >= 9 {
return na[len(na)-9:] == nb[len(nb)-9:]
}
return na == nb
}
func stringsEqualFoldTrim(a, b string) bool {
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
}
func suggestionAlreadyOnContact(existing *ncContact, sug Suggestion) bool {
if existing == nil {
return false
}
val := strings.TrimSpace(sug.SuggestedValue)
if val == "" {
return true
}
switch sug.FieldPath {
case "full_name":
return stringsEqualFoldTrim(val, existing.FullName)
case "company":
return stringsEqualFoldTrim(val, existing.Org)
case "phones":
return phonesEqual(val, existing.Phone)
case "emails":
return strings.EqualFold(val, strings.TrimSpace(existing.Email))
default:
return false
}
}
func filterRedundantSuggestions(suggestions []Suggestion, contacts []ncContact) []Suggestion {
if len(suggestions) == 0 || len(contacts) == 0 {
return suggestions
}
byUID := make(map[string]*ncContact, len(contacts))
for i := range contacts {
byUID[contacts[i].UID] = &contacts[i]
}
filtered := suggestions[:0]
for _, sug := range suggestions {
if sug.SuggestionType != "enrich_contact" || sug.TargetContactUID == "" {
filtered = append(filtered, sug)
continue
}
if suggestionAlreadyOnContact(byUID[sug.TargetContactUID], sug) {
continue
}
filtered = append(filtered, sug)
}
return filtered
}
func enrichExistingContactSuggestions(profileID, contactUID string, data *EnrichedContactData, existing *ncContact) []Suggestion {
if data == nil || existing == nil {
return nil
}
var out []Suggestion
addIfMissing := func(fieldPath, value, label string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
sug := Suggestion{
ProfileID: profileID,
TargetContactUID: contactUID,
SuggestionType: "enrich_contact",
FieldPath: fieldPath,
SuggestedValue: value,
SuggestedLabel: label,
Confidence: 0.7,
Status: SuggestionPending,
}
if suggestionAlreadyOnContact(existing, sug) {
return
}
out = append(out, sug)
}
fullName := strings.TrimSpace(data.FirstName + " " + data.LastName)
addIfMissing("full_name", fullName, "")
addIfMissing("company", data.Company, "")
addIfMissing("job_title", data.JobTitle, "")
for _, p := range data.Phones {
addIfMissing("phones", p.Value, p.Label)
}
for _, e := range data.Emails {
if !strings.EqualFold(e.Value, existing.Email) {
addIfMissing("emails", e.Value, e.Label)
}
}
for _, sp := range data.SocialProfiles {
addIfMissing("social_profiles", sp.Value, sp.Label)
}
return out
}

View File

@ -0,0 +1,33 @@
package discovery
import (
"context"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
type ncAdapter struct {
nc *nextcloud.Client
}
func NewNCAdapter(nc *nextcloud.Client) *ncAdapter {
return &ncAdapter{nc: nc}
}
func (a *ncAdapter) ListContacts(ctx context.Context, userID, bookPath string) ([]ncContact, error) {
contacts, err := a.nc.ListContacts(ctx, userID, bookPath)
if err != nil {
return nil, err
}
out := make([]ncContact, 0, len(contacts))
for _, c := range contacts {
out = append(out, ncContact{
UID: c.UID,
FullName: c.FullName,
Email: c.Email,
Phone: c.Phone,
Org: c.Org,
})
}
return out, nil
}

View File

@ -0,0 +1,347 @@
package discovery
import (
"context"
"fmt"
"strings"
)
const (
DefaultOtherGroupsPageSize = 12
MaxOtherGroupsPageSize = 50
)
type OtherProfileGroupsPage struct {
Groups []ProfileGroup `json:"groups"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
}
const otherProfilesBaseWhere = `
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1
AND p.status = 'suggested'
AND NOT p.is_mailing_list
AND NOT p.is_disposable
AND NOT p.is_spam_heavy` + noReplyProfilesSQL + suggestableProfilesSQL
func otherProfileTokenMatchesParam(param string) string {
return fmt.Sprintf(`(
position(%[1]s in lower(p.display_name)) > 0
OR position(%[1]s in lower(p.primary_email)) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'first_name', ''))) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'last_name', ''))) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'company', ''))) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'department', ''))) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'job_title', ''))) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'website', ''))) > 0
OR position(%[1]s in lower(COALESCE(p.enriched_data->>'notes', ''))) > 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(p.all_emails, '[]'::jsonb)) AS e
WHERE position(%[1]s in lower(COALESCE(e->>'email', ''))) > 0
OR position(%[1]s in lower(COALESCE(e->>'display_name', ''))) > 0
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(p.enriched_data->'emails', '[]'::jsonb)) AS ee
WHERE position(%[1]s in lower(COALESCE(ee->>'value', ''))) > 0
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(p.enriched_data->'phones', '[]'::jsonb)) AS ph
WHERE position(%[1]s in lower(COALESCE(ph->>'value', ''))) > 0
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(p.detected_in_accounts, '[]'::jsonb)) AS a
WHERE position(%[1]s in lower(COALESCE(a->>'account_email', ''))) > 0
OR position(%[1]s in lower(COALESCE(a->>'account_name', ''))) > 0
)
)`, param)
}
func otherProfileSearchClause(search string, paramIndex int) (clause string, terms []string) {
tokens := discoveryQueryTokens(search)
if len(tokens) == 0 {
return "", nil
}
parts := make([]string, 0, len(tokens))
for i, token := range tokens {
parts = append(parts, otherProfileTokenMatchesParam(fmt.Sprintf("$%d", paramIndex+i)))
terms = append(terms, token)
}
return " AND " + strings.Join(parts, " AND "), terms
}
func (s *Service) countOtherProfileGroups(ctx context.Context, externalUserID string, search string) (int, error) {
searchClause, searchTerms := otherProfileSearchClause(search, 2)
args := []any{externalUserID}
for _, term := range searchTerms {
args = append(args, term)
}
var total int
err := s.db.QueryRow(ctx, `
SELECT COUNT(*)::int FROM (
SELECT DISTINCT COALESCE(p.person_group_id, p.id) AS grp_id
`+otherProfilesBaseWhere+searchClause+`
) g
`, args...).Scan(&total)
return total, err
}
func (s *Service) listOtherProfileGroupIDs(ctx context.Context, externalUserID string, limit, offset int, search string) ([]string, error) {
searchClause, searchTerms := otherProfileSearchClause(search, 2)
args := []any{externalUserID}
limitIdx := 2
offsetIdx := 3
for _, term := range searchTerms {
args = append(args, term)
limitIdx++
offsetIdx++
}
args = append(args, limit, offset)
rows, err := s.db.Query(ctx, fmt.Sprintf(`
SELECT grp_id::text FROM (
SELECT COALESCE(p.person_group_id, p.id) AS grp_id,
SUM(p.outbound_count) AS total_outbound,
SUM(p.inbound_from_cc_count) AS total_inbound,
SUM(p.copresence_cc_bcc_count) AS total_copresence,
SUM(p.message_count) AS total_messages,
MAX(p.last_message_at) AS last_message_at
`+otherProfilesBaseWhere+searchClause+`
GROUP BY COALESCE(p.person_group_id, p.id)
) ranked
ORDER BY `+groupInteractionOrderBy+`
LIMIT $%d OFFSET $%d
`, limitIdx, offsetIdx), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Service) listAllOtherProfileGroupIDs(ctx context.Context, externalUserID string, search string) ([]string, error) {
searchClause, searchTerms := otherProfileSearchClause(search, 2)
args := []any{externalUserID}
for _, term := range searchTerms {
args = append(args, term)
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT COALESCE(p.person_group_id, p.id)::text AS grp_id
`+otherProfilesBaseWhere+searchClause+`
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Service) listOtherProfilesForGroupIDs(ctx context.Context, externalUserID string, groupIDs []string) ([]Profile, error) {
if len(groupIDs) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT `+profileSelectColumns+`
`+otherProfilesBaseWhere+`
AND COALESCE(p.person_group_id, p.id) = ANY($2::uuid[])
ORDER BY `+profileInteractionOrderBy+`
`, externalUserID, groupIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []Profile
for rows.Next() {
p, err := scanProfileRow(rows)
if err != nil {
return nil, err
}
profiles = append(profiles, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
s.attachSignaturesToProfiles(ctx, profiles)
return profiles, nil
}
func (s *Service) attachSignaturesToProfiles(ctx context.Context, profiles []Profile) {
if len(profiles) == 0 {
return
}
ids := make([]string, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.ID)
}
batch, err := s.loadProfileSignaturesBatch(ctx, ids)
if err != nil {
return
}
for i := range profiles {
profiles[i].Signatures = batch[profiles[i].ID]
}
}
func (s *Service) loadProfileSignaturesBatch(ctx context.Context, profileIDs []string) (map[string][]SignatureEntry, error) {
out := make(map[string][]SignatureEntry, len(profileIDs))
if len(profileIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT profile_id::text, id::text, COALESCE(message_id::text, ''), signature_text, message_date, confidence
FROM contact_discovered_signatures
WHERE profile_id = ANY($1::uuid[])
ORDER BY profile_id, message_date DESC
`, profileIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var profileID string
var se SignatureEntry
if err := rows.Scan(&profileID, &se.ID, &se.MessageID, &se.SignatureText, &se.MessageDate, &se.Confidence); err != nil {
return nil, err
}
list := out[profileID]
if len(list) < 5 {
out[profileID] = append(list, se)
}
}
return out, rows.Err()
}
func (s *Service) ListOtherProfileGroupsPage(ctx context.Context, externalUserID string, limit, offset int, search string) (OtherProfileGroupsPage, error) {
if limit <= 0 {
limit = DefaultOtherGroupsPageSize
}
if limit > MaxOtherGroupsPageSize {
limit = MaxOtherGroupsPageSize
}
if offset < 0 {
offset = 0
}
search = strings.TrimSpace(search)
if search != "" {
return s.listOtherProfileGroupsSearchPage(ctx, externalUserID, limit, offset, search)
}
total, err := s.countOtherProfileGroups(ctx, externalUserID, search)
if err != nil {
return OtherProfileGroupsPage{}, err
}
if total == 0 || offset >= total {
return OtherProfileGroupsPage{
Groups: []ProfileGroup{},
Total: total,
Limit: limit,
Offset: offset,
HasMore: false,
}, nil
}
groupIDs, err := s.listOtherProfileGroupIDs(ctx, externalUserID, limit, offset, search)
if err != nil {
return OtherProfileGroupsPage{}, err
}
groups, err := s.loadOtherProfileGroupsByIDs(ctx, externalUserID, groupIDs)
if err != nil {
return OtherProfileGroupsPage{}, err
}
nextOffset := offset + len(groups)
hasMore := nextOffset < total
return OtherProfileGroupsPage{
Groups: groups,
Total: total,
Limit: limit,
Offset: offset,
HasMore: hasMore,
}, nil
}
func (s *Service) listOtherProfileGroupsSearchPage(ctx context.Context, externalUserID string, limit, offset int, search string) (OtherProfileGroupsPage, error) {
groupIDs, err := s.listAllOtherProfileGroupIDs(ctx, externalUserID, search)
if err != nil {
return OtherProfileGroupsPage{}, err
}
if len(groupIDs) == 0 {
return OtherProfileGroupsPage{
Groups: []ProfileGroup{},
Total: 0,
Limit: limit,
Offset: offset,
HasMore: false,
}, nil
}
groups, err := s.loadOtherProfileGroupsByIDs(ctx, externalUserID, groupIDs)
if err != nil {
return OtherProfileGroupsPage{}, err
}
ranked := rankProfileGroupsByQuery(groups, search)
total := len(ranked)
if offset >= total {
return OtherProfileGroupsPage{
Groups: []ProfileGroup{},
Total: total,
Limit: limit,
Offset: offset,
HasMore: false,
}, nil
}
end := offset + limit
if end > total {
end = total
}
page := ranked[offset:end]
return OtherProfileGroupsPage{
Groups: page,
Total: total,
Limit: limit,
Offset: offset,
HasMore: end < total,
}, nil
}
func (s *Service) loadOtherProfileGroupsByIDs(ctx context.Context, externalUserID string, groupIDs []string) ([]ProfileGroup, error) {
profiles, err := s.listOtherProfilesForGroupIDs(ctx, externalUserID, groupIDs)
if err != nil {
return nil, err
}
groups := filterSuggestableGroups(groupProfiles(profiles))
if groups == nil {
return []ProfileGroup{}, nil
}
return groups, nil
}

View File

@ -0,0 +1,77 @@
package discovery
const noReplyProfilesSQL = `
AND NOT (p.primary_email ILIKE '%noreply%' OR p.primary_email ILIKE '%no-reply%' OR p.primary_email ILIKE '%no_reply%')
AND NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(p.all_emails, '[]'::jsonb)) AS e
WHERE COALESCE(e->>'email', '') ILIKE '%noreply%'
OR COALESCE(e->>'email', '') ILIKE '%no-reply%'
OR COALESCE(e->>'email', '') ILIKE '%no_reply%'
)`
const profileSelectColumns = `
p.id::text, p.display_name, p.primary_email, p.all_emails,
p.message_count, p.sent_count, p.received_count, p.spam_count, p.forwarded_count,
p.outbound_count, p.inbound_from_cc_count, p.copresence_cc_bcc_count,
p.is_mailing_list, p.is_disposable, p.is_spam_heavy, COALESCE(p.classification_reason, ''),
COALESCE(p.linked_contact_uid, ''), p.enrichment_status, p.enriched_data,
p.status, p.last_message_at, p.detected_in_accounts`
const profileInteractionOrderBy = `
p.outbound_count DESC,
p.inbound_from_cc_count DESC,
p.copresence_cc_bcc_count DESC,
p.message_count DESC,
p.last_message_at DESC NULLS LAST`
const groupInteractionOrderBy = `
total_outbound DESC,
total_inbound DESC,
total_copresence DESC,
total_messages DESC,
last_message_at DESC NULLS LAST`
func compareProfilesByInteraction(a, b Profile) bool {
if a.OutboundCount != b.OutboundCount {
return a.OutboundCount > b.OutboundCount
}
if a.InboundFromCCCount != b.InboundFromCCCount {
return a.InboundFromCCCount > b.InboundFromCCCount
}
if a.CopresenceCCBCCCount != b.CopresenceCCBCCCount {
return a.CopresenceCCBCCCount > b.CopresenceCCBCCCount
}
if a.MessageCount != b.MessageCount {
return a.MessageCount > b.MessageCount
}
return a.DisplayName < b.DisplayName
}
func compareProfileGroupsByInteraction(a, b ProfileGroup) bool {
ao, ai, ac, am := groupInteractionTotals(a.Profiles)
bo, bi, bc, bm := groupInteractionTotals(b.Profiles)
if ao != bo {
return ao > bo
}
if ai != bi {
return ai > bi
}
if ac != bc {
return ac > bc
}
if am != bm {
return am > bm
}
return a.DisplayName < b.DisplayName
}
func groupInteractionTotals(profiles []Profile) (outbound, inbound, copresence, messages int) {
for _, p := range profiles {
outbound += p.OutboundCount
inbound += p.InboundFromCCCount
copresence += p.CopresenceCCBCCCount
messages += p.MessageCount
}
return outbound, inbound, copresence, messages
}

View File

@ -0,0 +1,276 @@
package discovery
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
func (s *Service) ListOtherProfiles(ctx context.Context, externalUserID string) ([]Profile, error) {
rows, err := s.db.Query(ctx, `
SELECT `+profileSelectColumns+`
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1
AND p.status = 'suggested'
AND NOT p.is_mailing_list
AND NOT p.is_disposable
AND NOT p.is_spam_heavy`+noReplyProfilesSQL+suggestableProfilesSQL+`
ORDER BY `+profileInteractionOrderBy+`
`, externalUserID)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []Profile
for rows.Next() {
p, err := scanProfileRow(rows)
if err != nil {
return nil, err
}
profiles = append(profiles, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
s.attachSignaturesToProfiles(ctx, profiles)
return profiles, nil
}
func (s *Service) ListSuggestions(ctx context.Context, externalUserID string, enrichOnly bool) ([]Suggestion, error) {
query := `
SELECT s.id::text, COALESCE(s.profile_id::text, ''), COALESCE(s.target_contact_uid, ''),
s.suggestion_type, s.field_path, s.suggested_value, COALESCE(s.suggested_label, ''),
s.confidence, s.status
FROM contact_enrichment_suggestions s
JOIN users u ON s.user_id = u.id
WHERE u.external_id = $1 AND s.status = 'pending'
`
if enrichOnly {
query += ` AND s.suggestion_type = 'enrich_contact'`
} else {
query += ` AND s.suggestion_type IN ('new_contact', 'enrich_contact')`
}
query += ` ORDER BY s.confidence DESC, s.created_at DESC`
rows, err := s.db.Query(ctx, query, externalUserID)
if err != nil {
return nil, err
}
defer rows.Close()
var suggestions []Suggestion
for rows.Next() {
var sug Suggestion
if err := rows.Scan(
&sug.ID, &sug.ProfileID, &sug.TargetContactUID,
&sug.SuggestionType, &sug.FieldPath, &sug.SuggestedValue, &sug.SuggestedLabel,
&sug.Confidence, &sug.Status,
); err != nil {
return nil, err
}
if sug.ProfileID != "" {
p, err := s.getProfileByID(ctx, externalUserID, sug.ProfileID)
if err == nil {
sug.Profile = &p
}
}
suggestions = append(suggestions, sug)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(suggestions) > 0 {
ncUserID, bookID := s.resolveDiscoveryNCContext(ctx, externalUserID)
contacts := s.loadNCContacts(ctx, ncUserID, bookID)
suggestions = filterRedundantSuggestions(suggestions, contacts)
}
return suggestions, nil
}
func (s *Service) AcceptProfile(ctx context.Context, externalUserID, profileID, contactUID string) error {
ids, err := s.relatedProfileIDs(ctx, externalUserID, profileID)
if err != nil {
return err
}
tag, err := s.db.Exec(ctx, `
UPDATE contact_discovered_profiles
SET status = 'accepted', linked_contact_uid = NULLIF($3, ''), accepted_at = NOW(), updated_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
AND id = ANY($2::uuid[])
AND status IN ('suggested', 'ignored', 'blocked')
`, externalUserID, ids, contactUID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("profile not found or already processed")
}
_, _ = s.db.Exec(ctx, `
UPDATE contact_enrichment_suggestions
SET status = 'accepted', accepted_at = NOW()
WHERE profile_id = ANY($2::uuid[])
AND user_id = (SELECT id FROM users WHERE external_id = $1)
AND status = 'pending'
`, externalUserID, ids)
return nil
}
func (s *Service) RejectProfile(ctx context.Context, externalUserID, profileID string) error {
var email string
err := s.db.QueryRow(ctx, `
UPDATE contact_discovered_profiles
SET status = 'rejected', rejected_at = NOW(), updated_at = NOW()
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1)
RETURNING primary_email
`, externalUserID, profileID).Scan(&email)
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("profile not found")
}
return err
}
_, _ = s.db.Exec(ctx, `
INSERT INTO contact_discovery_rejections (user_id, rejection_key, rejection_type)
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, 'profile')
ON CONFLICT DO NOTHING
`, externalUserID, "email:"+strings.ToLower(email))
_, _ = s.db.Exec(ctx, `
UPDATE contact_enrichment_suggestions
SET status = 'rejected', rejected_at = NOW()
WHERE profile_id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'pending'
`, externalUserID, profileID)
return nil
}
func (s *Service) AcceptSuggestion(ctx context.Context, externalUserID, suggestionID string) (Suggestion, error) {
var sug Suggestion
err := s.db.QueryRow(ctx, `
UPDATE contact_enrichment_suggestions
SET status = 'accepted', accepted_at = NOW()
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'pending'
RETURNING id::text, COALESCE(profile_id::text, ''), COALESCE(target_contact_uid, ''),
suggestion_type, field_path, suggested_value, COALESCE(suggested_label, ''), confidence, status
`, externalUserID, suggestionID).Scan(
&sug.ID, &sug.ProfileID, &sug.TargetContactUID,
&sug.SuggestionType, &sug.FieldPath, &sug.SuggestedValue, &sug.SuggestedLabel,
&sug.Confidence, &sug.Status,
)
if err != nil {
return Suggestion{}, err
}
return sug, nil
}
func (s *Service) RejectSuggestion(ctx context.Context, externalUserID, suggestionID string) error {
var profileID, fieldPath, value string
err := s.db.QueryRow(ctx, `
UPDATE contact_enrichment_suggestions
SET status = 'rejected', rejected_at = NOW()
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'pending'
RETURNING COALESCE(profile_id::text, ''), field_path, suggested_value
`, externalUserID, suggestionID).Scan(&profileID, &fieldPath, &value)
if err != nil {
return err
}
rejKey := fmt.Sprintf("field:%s:%s:%s", profileID, fieldPath, value)
_, _ = s.db.Exec(ctx, `
INSERT INTO contact_discovery_rejections (user_id, rejection_key, rejection_type)
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, 'field_suggestion')
ON CONFLICT DO NOTHING
`, externalUserID, rejKey)
return nil
}
func (s *Service) PendingCounts(ctx context.Context, externalUserID string) (otherCount, suggestionCount, ignoredCount, blockedCount int, err error) {
otherCount, err = s.countOtherProfileGroups(ctx, externalUserID, "")
if err != nil {
return
}
err = s.db.QueryRow(ctx, `
SELECT
(SELECT COUNT(*)::int FROM contact_enrichment_suggestions s
JOIN users u ON s.user_id = u.id
WHERE u.external_id = $1 AND s.status = 'pending'),
(SELECT COUNT(*)::int FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.status = 'ignored'),
(SELECT COUNT(*)::int FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.status = 'blocked')
`, externalUserID).Scan(&suggestionCount, &ignoredCount, &blockedCount)
return
}
func (s *Service) getProfileByID(ctx context.Context, externalUserID, profileID string) (Profile, error) {
row := s.db.QueryRow(ctx, `
SELECT `+profileSelectColumns+`
FROM contact_discovered_profiles p
JOIN users u ON p.user_id = u.id
WHERE u.external_id = $1 AND p.id = $2::uuid
`, externalUserID, profileID)
return scanProfileRow(row)
}
func (s *Service) loadProfileSignatures(ctx context.Context, profileID string) ([]SignatureEntry, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, COALESCE(message_id::text, ''), signature_text, message_date, confidence
FROM contact_discovered_signatures WHERE profile_id = $1::uuid
ORDER BY message_date DESC LIMIT 5
`, profileID)
if err != nil {
return nil, err
}
defer rows.Close()
var sigs []SignatureEntry
for rows.Next() {
var se SignatureEntry
if err := rows.Scan(&se.ID, &se.MessageID, &se.SignatureText, &se.MessageDate, &se.Confidence); err != nil {
return nil, err
}
sigs = append(sigs, se)
}
return sigs, rows.Err()
}
type profileScanner interface {
Scan(dest ...any) error
}
func scanProfileRow(row profileScanner) (Profile, error) {
var p Profile
var allEmailsJSON []byte
var enrichedJSON []byte
var accountsJSON []byte
var lastMsg *time.Time
err := row.Scan(
&p.ID, &p.DisplayName, &p.PrimaryEmail, &allEmailsJSON,
&p.MessageCount, &p.SentCount, &p.ReceivedCount, &p.SpamCount, &p.ForwardedCount,
&p.OutboundCount, &p.InboundFromCCCount, &p.CopresenceCCBCCCount,
&p.IsMailingList, &p.IsDisposable, &p.IsSpamHeavy, &p.ClassificationReason,
&p.LinkedContactUID, &p.EnrichmentStatus, &enrichedJSON,
&p.Status, &lastMsg, &accountsJSON,
)
if err != nil {
return Profile{}, err
}
_ = json.Unmarshal(allEmailsJSON, &p.AllEmails)
_ = json.Unmarshal(accountsJSON, &p.DetectedInAccounts)
if len(enrichedJSON) > 0 && string(enrichedJSON) != "null" {
var ed EnrichedContactData
if json.Unmarshal(enrichedJSON, &ed) == nil {
p.EnrichedData = &ed
}
}
p.LastMessageAt = lastMsg
return p, nil
}

View File

@ -0,0 +1,396 @@
package discovery
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
scanBatchSize = 500
scanProgressEvery = 100
maxBodyChars = 12_000
maxSignatureMsgsPerEmail = 3
maxLLMEnrichPerScan = 25
minMessagesForLLM = 2
profileProgressEvery = 25
llmEnrichDelay = 1500 * time.Millisecond
llmEnrichTimeout = 45 * time.Second
staleScanAfter = 2 * time.Hour
)
var ErrScanAlreadyRunning = fmt.Errorf("contact discovery scan already running")
func (s *Service) StartScan(ctx context.Context, externalUserID, ncUserID, bookID string) (Scan, error) {
if s.db == nil {
return Scan{}, fmt.Errorf("database unavailable")
}
if bookID == "" {
bookID = "contacts"
}
if err := s.failStaleScans(ctx, externalUserID); err != nil {
return Scan{}, err
}
if active, err := s.findActiveScan(ctx, externalUserID); err != nil {
return Scan{}, err
} else if active != nil {
return *active, nil
}
var totalMessages int
_ = s.db.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalUserID).Scan(&totalMessages)
var scanID string
err := s.db.QueryRow(ctx, `
INSERT INTO contact_discovery_scans (
user_id, status, phase, total_messages, book_id, nc_user_id
)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
'running', 'scanning_messages', $2, $3, $4
)
RETURNING id::text
`, externalUserID, totalMessages, bookID, ncUserID).Scan(&scanID)
if err != nil {
return Scan{}, err
}
go s.runScan(externalUserID, ncUserID, bookID, scanID)
scan, err := s.GetScan(ctx, externalUserID, scanID)
if err != nil {
return Scan{
ID: scanID,
Status: ScanRunning,
Phase: PhaseScanning,
TotalMessages: totalMessages,
StartedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}, nil
}
return scan, nil
}
func (s *Service) CancelActiveScan(ctx context.Context, externalUserID string) error {
active, err := s.findActiveScan(ctx, externalUserID)
if err != nil {
return err
}
if active != nil {
if cancel, ok := s.scanCancels.LoadAndDelete(active.ID); ok {
cancel.(context.CancelFunc)()
}
}
_, err = s.db.Exec(ctx, `
UPDATE contact_discovery_scans sc
SET status = 'failed',
phase = 'done',
error_message = 'Analyse annulée',
completed_at = NOW(),
updated_at = NOW()
FROM users u
WHERE sc.user_id = u.id
AND u.external_id = $1
AND sc.status IN ('pending', 'running')
`, externalUserID)
return err
}
func (s *Service) GetActiveScan(ctx context.Context, externalUserID string) (*Scan, error) {
if err := s.failStaleScans(ctx, externalUserID); err != nil {
return nil, err
}
return s.findActiveScan(ctx, externalUserID)
}
func (s *Service) findActiveScan(ctx context.Context, externalUserID string) (*Scan, error) {
var scanID string
err := s.db.QueryRow(ctx, `
SELECT sc.id::text
FROM contact_discovery_scans sc
JOIN users u ON sc.user_id = u.id
WHERE u.external_id = $1 AND sc.status IN ('pending', 'running')
ORDER BY sc.started_at DESC
LIMIT 1
`, externalUserID).Scan(&scanID)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
scan, err := s.GetScan(ctx, externalUserID, scanID)
if err != nil {
return nil, err
}
return &scan, nil
}
func (s *Service) failStaleScans(ctx context.Context, externalUserID string) error {
_, err := s.db.Exec(ctx, `
UPDATE contact_discovery_scans sc
SET status = 'failed',
phase = 'done',
error_message = 'scan timed out (interrupted or stale)',
completed_at = NOW(),
updated_at = NOW()
FROM users u
WHERE sc.user_id = u.id
AND u.external_id = $1
AND sc.status IN ('pending', 'running')
AND sc.updated_at < NOW() - INTERVAL '30 minutes'
`, externalUserID)
return err
}
func (s *Service) updateScanProgress(ctx context.Context, scanID, externalUserID string, phase ScanPhase, messagesScanned, profilesDone, totalMessages, profilesTotal int) error {
_, err := s.db.Exec(ctx, `
UPDATE contact_discovery_scans sc
SET phase = $3,
messages_scanned = $4,
profiles_found = $5,
total_messages = CASE WHEN $6 > 0 THEN $6 ELSE sc.total_messages END,
profiles_total = CASE WHEN $7 > 0 THEN $7 ELSE sc.profiles_total END,
updated_at = NOW()
FROM users u
WHERE sc.id = $1::uuid AND sc.user_id = u.id AND u.external_id = $2
`, scanID, externalUserID, phase, messagesScanned, profilesDone, totalMessages, profilesTotal)
return err
}
func scanProgressPercent(phase ScanPhase, messagesScanned, totalMessages, profilesDone, profilesTotal int) float64 {
switch phase {
case PhaseScanning:
if totalMessages <= 0 {
return 5
}
return 5 + float64(messagesScanned)/float64(totalMessages)*60
case PhaseProfiles:
if profilesTotal <= 0 {
return 68
}
return 65 + float64(profilesDone)/float64(profilesTotal)*5
case PhaseEnriching:
if profilesTotal <= 0 {
return 75
}
return 70 + float64(profilesDone)/float64(profilesTotal)*28
case PhaseDone:
return 100
default:
return 0
}
}
func accountHitsToJSON(accounts map[string]*AccountDetection) ([]byte, error) {
if len(accounts) == 0 {
return []byte("[]"), nil
}
list := make([]AccountDetection, 0, len(accounts))
for _, a := range accounts {
list = append(list, *a)
}
sort.Slice(list, func(i, j int) bool {
if list[i].MessageCount == list[j].MessageCount {
return list[i].AccountEmail < list[j].AccountEmail
}
return list[i].MessageCount > list[j].MessageCount
})
return json.Marshal(list)
}
func (s *Service) recordAccountHit(agg *addressAgg, row messageRow) {
if agg.Accounts == nil {
agg.Accounts = map[string]*AccountDetection{}
}
hit, ok := agg.Accounts[row.AccountID]
if !ok {
hit = &AccountDetection{
AccountID: row.AccountID,
AccountEmail: row.AccountEmail,
AccountName: row.AccountName,
}
agg.Accounts[row.AccountID] = hit
}
hit.MessageCount++
}
func (s *Service) scanMessageBatch(ctx context.Context, externalUserID, afterID string, limit int) ([]messageRow, error) {
var rows []messageRow
query := `
SELECT m.id::text, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.bcc_addrs,
m.reply_to, COALESCE(m.snippet, ''), ''::text, ''::text,
m.date, m.flags, m.labels, COALESCE(m.auth_info, '{}'::jsonb),
COALESCE(mf.folder_type, ''), ma.id::text,
COALESCE(ma.email, ''), COALESCE(ma.name, '')
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
LEFT JOIN mail_folders mf ON m.folder_id = mf.id
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1)
`
args := []any{externalUserID}
if afterID != "" {
query += ` AND m.id > $2::uuid`
args = append(args, afterID)
}
query += ` ORDER BY m.id ASC LIMIT `
if afterID != "" {
query += `$3`
args = append(args, limit)
} else {
query += `$2`
args = append(args, limit)
}
dbRows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer dbRows.Close()
for dbRows.Next() {
var row messageRow
var authJSON []byte
if err := dbRows.Scan(
&row.ID, &row.Subject, &row.FromAddr, &row.ToAddrs,
&row.CcAddrs, &row.BccAddrs, &row.ReplyTo,
&row.Snippet, &row.BodyText, &row.BodyHTML, &row.Date, &row.Flags, &row.Labels,
&authJSON, &row.FolderType, &row.AccountID, &row.AccountEmail, &row.AccountName,
); err != nil {
return nil, err
}
row.AuthInfo = authJSON
if row.AccountName == "" {
row.AccountName = row.AccountEmail
}
rows = append(rows, row)
}
return rows, dbRows.Err()
}
func (s *Service) processMessageRow(row messageRow, aggs map[string]*addressAgg, rejections, existingEmails map[string]bool) {
isSpam := isSpamMessage(row.Flags, row.Labels)
isSent := row.FolderType == "sent" || strings.EqualFold(row.FolderType, "sent")
auth := parseAuthInfo(row.AuthInfo)
hasListUnsub := strings.TrimSpace(auth.ListUnsubscribe) != ""
fromAddrs := parseAddresses(row.FromAddr)
toAddrs := parseAddresses(row.ToAddrs)
ccAddrs := parseAddresses(row.CcAddrs)
bccAddrs := parseAddresses(row.BccAddrs)
replyAddrs := parseAddresses(row.ReplyTo)
type addrRole struct {
email string
name string
role string
}
var all []addrRole
for _, a := range fromAddrs {
all = append(all, addrRole{a.Address, a.Name, "from"})
}
for _, a := range toAddrs {
all = append(all, addrRole{a.Address, a.Name, "to"})
}
for _, a := range ccAddrs {
all = append(all, addrRole{a.Address, a.Name, "cc"})
}
for _, a := range bccAddrs {
all = append(all, addrRole{a.Address, a.Name, "bcc"})
}
for _, a := range replyAddrs {
all = append(all, addrRole{a.Address, a.Name, "reply_to"})
}
for _, email := range detectForwardedAddresses(row.Snippet, "") {
all = append(all, addrRole{email, "", "forwarded"})
}
userIsRecipient := accountIsMessageRecipient(row.AccountEmail, toAddrs, ccAddrs, bccAddrs)
for _, ar := range all {
email := strings.ToLower(strings.TrimSpace(ar.email))
if email == "" || isOwnAddress(email, row.AccountEmail) {
continue
}
if rejections["email:"+email] || existingEmails[email] || isNoReplyEmail(email) {
continue
}
agg, ok := aggs[email]
if !ok {
agg = &addressAgg{Email: email, Roles: map[string]struct{}{}, Accounts: map[string]*AccountDetection{}}
aggs[email] = agg
}
agg.MessageCount++
agg.Roles[ar.role] = struct{}{}
s.recordAccountHit(agg, row)
if ar.name != "" && agg.DisplayName == "" {
agg.DisplayName = ar.name
}
if isSent {
agg.SentCount++
if ar.role == "to" || ar.role == "cc" || ar.role == "bcc" {
agg.OutboundCount++
}
} else {
agg.ReceivedCount++
if ar.role == "from" || ar.role == "cc" {
agg.InboundFromCCCount++
}
if userIsRecipient && (ar.role == "cc" || ar.role == "bcc") {
agg.CopresenceCCBCCCount++
}
}
if isSpam {
agg.SpamCount++
}
if ar.role == "forwarded" {
agg.ForwardedCount++
}
if hasListUnsub {
agg.ListUnsub++
}
if row.rowIsMailingList(auth) {
agg.MailingList++
}
if row.Date.After(agg.LastSeen) {
agg.LastSeen = row.Date
}
if ar.role == "from" {
agg.trackFromMessage(row.ID, row.Date)
}
}
}
func (agg *addressAgg) trackFromMessage(id string, date time.Time) {
if id == "" {
return
}
for i, ref := range agg.fromMessageRefs {
if ref.id == id {
if date.After(ref.date) {
agg.fromMessageRefs[i].date = date
}
return
}
}
agg.fromMessageRefs = append(agg.fromMessageRefs, fromMessageRef{id: id, date: date})
sort.Slice(agg.fromMessageRefs, func(i, j int) bool {
return agg.fromMessageRefs[i].date.After(agg.fromMessageRefs[j].date)
})
if len(agg.fromMessageRefs) > maxSignatureMsgsPerEmail {
agg.fromMessageRefs = agg.fromMessageRefs[:maxSignatureMsgsPerEmail]
}
}

View File

@ -0,0 +1,167 @@
package discovery
import (
"sort"
"strings"
"unicode"
)
func normalizeDiscoverySearchText(value string) string {
var b strings.Builder
b.Grow(len(value))
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
if unicode.IsMark(r) {
continue
}
b.WriteRune(r)
}
return b.String()
}
func discoveryQueryTokens(query string) []string {
n := normalizeDiscoverySearchText(query)
if n == "" {
return nil
}
return strings.Fields(n)
}
func discoveryFieldMatchScore(haystack, needle string) float64 {
h := normalizeDiscoverySearchText(haystack)
n := normalizeDiscoverySearchText(needle)
if n == "" || !strings.Contains(h, n) {
return 0
}
if h == n {
return 1
}
if strings.HasPrefix(h, n) {
return 0.95 + 0.05*float64(len(n))/float64(max(len(h), 1))
}
for _, word := range strings.FieldsFunc(h, func(r rune) bool {
return r == ' ' || r == '@' || r == '.' || r == '_' || r == '+' || r == '-'
}) {
if word == "" {
continue
}
if strings.HasPrefix(word, n) {
return 0.88 + 0.07*float64(len(n))/float64(max(len(word), 1))
}
}
idx := strings.Index(h, n)
positionBonus := 1 - float64(idx)/float64(max(len(h), 1))*0.35
lengthBonus := float64(len(n)) / float64(max(len(h), 1))
return 0.42 + 0.28*positionBonus + 0.22*lengthBonus
}
func appendNonEmptyFields(fields []string, values ...string) []string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
fields = append(fields, v)
}
}
return fields
}
func profileSearchFields(p Profile) []string {
fields := appendNonEmptyFields(nil, p.DisplayName, p.PrimaryEmail)
if p.EnrichedData != nil {
d := p.EnrichedData
fields = appendNonEmptyFields(fields,
d.FirstName, d.LastName, d.Company, d.Department, d.JobTitle, d.Website, d.Notes,
)
for _, e := range d.Emails {
fields = appendNonEmptyFields(fields, e.Value)
}
for _, ph := range d.Phones {
fields = appendNonEmptyFields(fields, ph.Value)
}
for _, a := range d.Addresses {
fields = appendNonEmptyFields(fields, a.Street, a.City, a.Region, a.PostalCode, a.Country)
}
}
for _, e := range p.AllEmails {
fields = appendNonEmptyFields(fields, e.Email, e.DisplayName)
}
for _, a := range p.DetectedInAccounts {
fields = appendNonEmptyFields(fields, a.AccountEmail, a.AccountName)
}
return fields
}
func profileGroupSearchFields(group ProfileGroup) []string {
fields := appendNonEmptyFields(nil, group.DisplayName, group.PrimaryEmail)
for _, p := range group.Profiles {
fields = append(fields, profileSearchFields(p)...)
}
if len(group.Profiles) == 0 {
fields = append(fields, profileSearchFields(group.Profile)...)
}
seen := make(map[string]struct{}, len(fields))
unique := make([]string, 0, len(fields))
for _, f := range fields {
key := normalizeDiscoverySearchText(f)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
unique = append(unique, f)
}
return unique
}
func profileGroupQueryMatchScore(group ProfileGroup, query string) float64 {
fields := profileGroupSearchFields(group)
tokens := discoveryQueryTokens(query)
if len(tokens) == 0 {
return 0
}
var total float64
for _, token := range tokens {
best := 0.0
for _, field := range fields {
best = max(best, discoveryFieldMatchScore(field, token))
best = max(best, discoveryFieldMatchScore(field, query))
}
if best == 0 {
return 0
}
total += best
}
return total / float64(len(tokens))
}
func rankProfileGroupsByQuery(groups []ProfileGroup, query string) []ProfileGroup {
query = strings.TrimSpace(query)
if query == "" {
return groups
}
type scored struct {
group ProfileGroup
score float64
}
matches := make([]scored, 0, len(groups))
for _, g := range groups {
if score := profileGroupQueryMatchScore(g, query); score > 0 {
matches = append(matches, scored{group: g, score: score})
}
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return strings.ToLower(matches[i].group.DisplayName) < strings.ToLower(matches[j].group.DisplayName)
})
out := make([]ProfileGroup, len(matches))
for i, m := range matches {
out[i] = m.group
}
return out
}

View File

@ -0,0 +1,98 @@
package discovery
import "testing"
func TestDiscoveryFieldMatchScoreExactAndPrefix(t *testing.T) {
if got := discoveryFieldMatchScore("Alice Martin", "alice martin"); got != 1 {
t.Fatalf("exact match: got %v want 1", got)
}
prefix := discoveryFieldMatchScore("alice@example.com", "alice")
if prefix < 0.95 {
t.Fatalf("email prefix too low: %v", prefix)
}
}
func TestRankProfileGroupsByQueryOrder(t *testing.T) {
groups := []ProfileGroup{
{
DisplayName: "Jonathan Smith",
Profile: Profile{DisplayName: "Jonathan Smith", PrimaryEmail: "jonathan@corp.com"},
Profiles: []Profile{{DisplayName: "Jonathan Smith", PrimaryEmail: "jonathan@corp.com"}},
},
{
DisplayName: "Jon Smith",
Profile: Profile{DisplayName: "Jon Smith", PrimaryEmail: "jon@corp.com"},
Profiles: []Profile{{DisplayName: "Jon Smith", PrimaryEmail: "jon@corp.com"}},
},
{
DisplayName: "Bob Builder",
Profile: Profile{DisplayName: "Bob Builder", PrimaryEmail: "bob@corp.com"},
Profiles: []Profile{{DisplayName: "Bob Builder", PrimaryEmail: "bob@corp.com"}},
},
}
ranked := rankProfileGroupsByQuery(groups, "jon")
if len(ranked) != 2 {
t.Fatalf("expected 2 matches, got %d", len(ranked))
}
if ranked[0].DisplayName != "Jon Smith" {
t.Fatalf("expected Jon Smith first, got %q", ranked[0].DisplayName)
}
}
func TestRankProfileGroupsByQueryRequiresAllTokens(t *testing.T) {
groups := []ProfileGroup{
{
DisplayName: "John Doe",
Profile: Profile{DisplayName: "John Doe", PrimaryEmail: "john@corp.com"},
Profiles: []Profile{{DisplayName: "John Doe", PrimaryEmail: "john@corp.com"}},
},
{
DisplayName: "John Smith",
Profile: Profile{DisplayName: "John Smith", PrimaryEmail: "john@other.com"},
Profiles: []Profile{{DisplayName: "John Smith", PrimaryEmail: "john@other.com"}},
},
}
ranked := rankProfileGroupsByQuery(groups, "john doe")
if len(ranked) != 1 {
t.Fatalf("expected 1 match, got %d", len(ranked))
}
if ranked[0].DisplayName != "John Doe" {
t.Fatalf("expected John Doe, got %q", ranked[0].DisplayName)
}
}
func TestRankProfileGroupsByQueryNoFuzzy(t *testing.T) {
groups := []ProfileGroup{
{
DisplayName: "Jonathan",
Profile: Profile{DisplayName: "Jonathan", PrimaryEmail: "jonathan@corp.com"},
Profiles: []Profile{{DisplayName: "Jonathan", PrimaryEmail: "jonathan@corp.com"}},
},
}
ranked := rankProfileGroupsByQuery(groups, "jhn")
if len(ranked) != 0 {
t.Fatalf("expected no fuzzy match, got %d", len(ranked))
}
}
func TestRankProfileGroupsByQuerySecondaryEmail(t *testing.T) {
groups := []ProfileGroup{
{
DisplayName: "Contact",
Profile: Profile{
DisplayName: "Contact",
PrimaryEmail: "main@corp.com",
AllEmails: []EmailEntry{{Email: "alias@corp.com", DisplayName: "Alias"}},
},
Profiles: []Profile{{
DisplayName: "Contact",
PrimaryEmail: "main@corp.com",
AllEmails: []EmailEntry{{Email: "alias@corp.com", DisplayName: "Alias"}},
}},
},
}
ranked := rankProfileGroupsByQuery(groups, "alias")
if len(ranked) != 1 {
t.Fatalf("expected match on secondary email, got %d", len(ranked))
}
}

View File

@ -0,0 +1,62 @@
package discovery
import (
"context"
"encoding/json"
"fmt"
"github.com/ultisuite/ulti-backend/internal/websearch"
)
func (s *Service) GetSearchSettings(ctx context.Context, externalUserID string) (websearch.Settings, error) {
return s.loadSearchSettings(ctx, externalUserID)
}
func (s *Service) UpdateSearchSettings(ctx context.Context, externalUserID string, settings websearch.Settings) (websearch.Settings, error) {
if s.db == nil {
return websearch.Settings{}, fmt.Errorf("database unavailable")
}
raw, err := json.Marshal(settings)
if err != nil {
return websearch.Settings{}, err
}
_, err = s.db.Exec(ctx, `
INSERT INTO settings (user_id, preferences)
VALUES (
(SELECT id FROM users WHERE external_id = $1),
jsonb_build_object('search', $2::jsonb)
)
ON CONFLICT (user_id) DO UPDATE SET
preferences = jsonb_set(
COALESCE(settings.preferences, '{}'::jsonb),
'{search}',
$2::jsonb
),
updated_at = NOW()
`, externalUserID, string(raw))
if err != nil {
return websearch.Settings{}, err
}
return s.loadSearchSettings(ctx, externalUserID)
}
func (s *Service) loadSearchSettings(ctx context.Context, externalUserID string) (websearch.Settings, error) {
var raw []byte
err := s.db.QueryRow(ctx, `
SELECT COALESCE(s.preferences->'search', '{}'::jsonb)
FROM users u
LEFT JOIN settings s ON s.user_id = u.id
WHERE u.external_id = $1
`, externalUserID).Scan(&raw)
if err != nil {
return websearch.Settings{}, err
}
var settings websearch.Settings
_ = json.Unmarshal(raw, &settings)
return settings, nil
}
func searchSettingsConfigured(settings websearch.Settings) bool {
_, err := websearch.ResolveProvider(settings)
return err == nil
}

View File

@ -0,0 +1,497 @@
package discovery
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/llm"
"github.com/ultisuite/ulti-backend/internal/websearch"
)
type Service struct {
db *pgxpool.Pool
nc ncLister
llm *llm.Client
websearch *websearch.Client
logger *slog.Logger
scanCancels sync.Map // scanID -> context.CancelFunc
}
type ncLister interface {
ListContacts(ctx context.Context, userID, bookPath string) ([]ncContact, error)
}
type ncContact struct {
UID string
FullName string
Email string
Phone string
Org string
}
func NewService(db *pgxpool.Pool) *Service {
return &Service{
db: db,
llm: llm.NewClient(),
websearch: websearch.NewClient(),
logger: slog.Default().With("component", "contact-discovery"),
}
}
func (s *Service) SetNextcloud(l ncLister) {
s.nc = l
}
type mailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
}
func (s *Service) GetScan(ctx context.Context, externalUserID, scanID string) (Scan, error) {
var scan Scan
var completedAt *time.Time
var phase string
err := s.db.QueryRow(ctx, `
SELECT sc.id::text, sc.status, sc.phase, sc.messages_scanned, sc.total_messages,
sc.profiles_found, COALESCE(sc.profiles_total, 0), COALESCE(sc.error_message, ''), sc.started_at, sc.updated_at,
sc.completed_at
FROM contact_discovery_scans sc
JOIN users u ON sc.user_id = u.id
WHERE u.external_id = $1 AND sc.id = $2::uuid
`, externalUserID, scanID).Scan(
&scan.ID, &scan.Status, &phase, &scan.MessagesScanned, &scan.TotalMessages,
&scan.ProfilesFound, &scan.ProfilesTotal, &scan.ErrorMessage, &scan.StartedAt, &scan.UpdatedAt, &completedAt,
)
if err != nil {
return Scan{}, err
}
scan.Phase = ScanPhase(phase)
scan.CompletedAt = completedAt
scan.ProgressPercent = scanProgressPercent(scan.Phase, scan.MessagesScanned, scan.TotalMessages, scan.ProfilesFound, scan.ProfilesTotal)
return scan, nil
}
func (s *Service) runScan(externalUserID, ncUserID, bookID, scanID string) {
scanCtx, cancel := context.WithCancel(context.Background())
s.scanCancels.Store(scanID, cancel)
defer func() {
cancel()
s.scanCancels.Delete(scanID)
if r := recover(); r != nil {
s.logger.Error("contact discovery scan panicked", "scan_id", scanID, "panic", r)
_, _ = s.db.Exec(context.Background(), `
UPDATE contact_discovery_scans
SET status = 'failed', phase = 'done',
error_message = 'scan crashed',
completed_at = NOW(), updated_at = NOW()
WHERE id = $1::uuid AND user_id = (SELECT id FROM users WHERE external_id = $2)
AND status = 'running'
`, scanID, externalUserID)
}
}()
err := s.executeScan(scanCtx, externalUserID, ncUserID, bookID, scanID)
if errors.Is(err, context.Canceled) {
return
}
status := ScanCompleted
phase := PhaseDone
errMsg := ""
if err != nil {
status = ScanFailed
errMsg = err.Error()
s.logger.Error("contact discovery scan failed", "scan_id", scanID, "error", err)
}
_, _ = s.db.Exec(scanCtx, `
UPDATE contact_discovery_scans
SET status = $3, phase = $4, error_message = NULLIF($5, ''), completed_at = NOW(), updated_at = NOW()
WHERE id = $1::uuid AND user_id = (SELECT id FROM users WHERE external_id = $2)
AND status = 'running'
`, scanID, externalUserID, status, phase, errMsg)
}
func (s *Service) enrichHeartbeat(ctx context.Context, scanID, externalUserID string, messagesScanned, enrichDone, totalMessages, enrichTotal int) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
progress := enrichDone + 1
if progress > enrichTotal {
progress = enrichTotal
}
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseEnriching, messagesScanned, progress, totalMessages, enrichTotal)
}
}
}
func (s *Service) executeScan(ctx context.Context, externalUserID, ncUserID, bookID, scanID string) error {
rejections, err := s.loadRejections(ctx, externalUserID)
if err != nil {
return err
}
existingEmails, err := s.loadExistingContactEmails(ctx, externalUserID)
if err != nil {
return err
}
var totalMessages int
_ = s.db.QueryRow(ctx, `
SELECT COUNT(*)::int FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalUserID).Scan(&totalMessages)
aggs := map[string]*addressAgg{}
messagesScanned := 0
lastID := ""
for {
batch, err := s.scanMessageBatch(ctx, externalUserID, lastID, scanBatchSize)
if err != nil {
return err
}
if len(batch) == 0 {
break
}
for _, row := range batch {
s.processMessageRow(row, aggs, rejections, existingEmails)
lastID = row.ID
messagesScanned++
if messagesScanned%scanProgressEvery == 0 {
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseScanning, messagesScanned, 0, totalMessages, 0)
}
}
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseScanning, messagesScanned, 0, totalMessages, 0)
if len(batch) < scanBatchSize {
break
}
}
profilesTotal := len(aggs)
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseProfiles, messagesScanned, 0, totalMessages, profilesTotal)
llmSettings, _ := s.loadLLMSettings(ctx, externalUserID)
llmEnabled := llmSettingsHasProvider(llmSettings)
enrichCandidates := selectPreEnrichCandidates(aggs, maxLLMEnrichPerScan)
enrichSet := enrichCandidateSet(enrichCandidates)
profilesFound := 0
profileIndex := 0
profileIDs := make(map[string]string, len(aggs))
for email, agg := range aggs {
if err := ctx.Err(); err != nil {
return err
}
isML, isDisp, isSpamHeavy, reason := classifyAddress(agg)
allEmailsJSON, _ := json.Marshal([]EmailEntry{{
Email: email, DisplayName: agg.DisplayName, MessageCount: agg.MessageCount,
}})
accountsJSON, _ := accountHitsToJSON(agg.Accounts)
var profileID string
err := s.db.QueryRow(ctx, `
INSERT INTO contact_discovered_profiles (
user_id, scan_id, display_name, primary_email, all_emails,
message_count, sent_count, received_count,
outbound_count, inbound_from_cc_count, copresence_cc_bcc_count,
spam_count, forwarded_count,
is_mailing_list, is_disposable, is_spam_heavy, classification_reason,
last_message_at, enrichment_status, status, detected_in_accounts
)
VALUES (
(SELECT id FROM users WHERE external_id = $1), $2::uuid, $3, $4, $5::jsonb,
$6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NULLIF($17, ''), $18,
'pending', 'suggested', $19::jsonb
)
ON CONFLICT (user_id, primary_email) DO UPDATE SET
scan_id = EXCLUDED.scan_id,
display_name = CASE WHEN EXCLUDED.display_name != '' THEN EXCLUDED.display_name ELSE contact_discovered_profiles.display_name END,
all_emails = EXCLUDED.all_emails,
message_count = EXCLUDED.message_count,
sent_count = EXCLUDED.sent_count,
received_count = EXCLUDED.received_count,
outbound_count = EXCLUDED.outbound_count,
inbound_from_cc_count = EXCLUDED.inbound_from_cc_count,
copresence_cc_bcc_count = EXCLUDED.copresence_cc_bcc_count,
spam_count = EXCLUDED.spam_count,
forwarded_count = EXCLUDED.forwarded_count,
is_mailing_list = EXCLUDED.is_mailing_list,
is_disposable = EXCLUDED.is_disposable,
is_spam_heavy = EXCLUDED.is_spam_heavy,
classification_reason = EXCLUDED.classification_reason,
last_message_at = EXCLUDED.last_message_at,
detected_in_accounts = EXCLUDED.detected_in_accounts,
status = CASE
WHEN contact_discovered_profiles.status IN ('rejected', 'ignored', 'blocked', 'accepted')
THEN contact_discovered_profiles.status
ELSE 'suggested'
END,
updated_at = NOW()
RETURNING id::text
`, externalUserID, scanID, agg.DisplayName, email, string(allEmailsJSON),
agg.MessageCount, agg.SentCount, agg.ReceivedCount,
agg.OutboundCount, agg.InboundFromCCCount, agg.CopresenceCCBCCCount,
agg.SpamCount, agg.ForwardedCount,
isML, isDisp, isSpamHeavy, reason, agg.LastSeen, string(accountsJSON),
).Scan(&profileID)
if err != nil {
return err
}
profileIDs[email] = profileID
profilesFound++
profileIndex++
sigs := agg.Signatures
if len(sigs) > 5 {
sigs = sigs[:5]
}
if len(sigs) > 0 {
_, _ = s.db.Exec(ctx, `DELETE FROM contact_discovered_signatures WHERE profile_id = $1::uuid`, profileID)
for _, sig := range sigs {
_, _ = s.db.Exec(ctx, `
INSERT INTO contact_discovered_signatures (profile_id, message_id, signature_text, message_date, confidence)
VALUES ($1::uuid, NULLIF($2, '')::uuid, $3, $4, $5)
`, profileID, sig.MessageID, sig.SignatureText, sig.MessageDate, sig.Confidence)
}
}
skipEnrich := EnrichSkipped
if !enrichSet[email] || !llmEnabled || len(sigs) == 0 {
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles SET enrichment_status = $2 WHERE id = $1::uuid
`, profileID, skipEnrich)
}
if profileIndex%profileProgressEvery == 0 {
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseProfiles, messagesScanned, profileIndex, totalMessages, profilesTotal)
}
}
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseProfiles, messagesScanned, profilesTotal, totalMessages, profilesTotal)
enrichDone := 0
enrichTotal := len(enrichCandidates)
for _, c := range enrichCandidates {
if err := ctx.Err(); err != nil {
return err
}
email := c.email
agg := c.agg
profileID := profileIDs[email]
if profileID == "" {
enrichDone++
continue
}
progress := enrichDone + 1
if progress > enrichTotal {
progress = enrichTotal
}
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseEnriching, messagesScanned, progress, totalMessages, enrichTotal)
s.fillSignaturesFromStoredMessages(ctx, email, agg)
sigs := agg.Signatures
if len(sigs) > 5 {
sigs = sigs[:5]
}
if len(sigs) == 0 {
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles SET enrichment_status = $2 WHERE id = $1::uuid
`, profileID, EnrichSkipped)
enrichDone++
continue
}
sigEntries := make([]SignatureEntry, 0, len(sigs))
for _, sig := range sigs {
sigEntries = append(sigEntries, SignatureEntry{
MessageID: sig.MessageID,
SignatureText: sig.SignatureText,
MessageDate: sig.MessageDate,
Confidence: sig.Confidence,
})
}
if enrichDone > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(llmEnrichDelay):
}
}
heartbeatCtx, heartbeatCancel := context.WithCancel(context.Background())
go s.enrichHeartbeat(heartbeatCtx, scanID, externalUserID, messagesScanned, enrichDone, totalMessages, enrichTotal)
enriched, enrichErr := enrichWithLLMTimeout(ctx, s.llm, llmSettings, email, agg.DisplayName, sigEntries, llmEnrichTimeout)
heartbeatCancel()
if enrichErr != nil {
s.logger.Warn("llm enrichment failed", "email", email, "error", enrichErr)
_, _ = s.db.Exec(ctx, `
UPDATE contact_discovered_profiles SET enrichment_status = 'failed' WHERE id = $1::uuid
`, profileID)
enrichDone++
continue
}
_ = s.applyEnrichmentResults(ctx, externalUserID, profileID, email, enriched, ncUserID, bookID, rejections)
enrichDone++
_ = s.updateScanProgress(ctx, scanID, externalUserID, PhaseEnriching, messagesScanned, enrichDone, totalMessages, enrichTotal)
}
_ = s.assignPersonGroups(ctx, externalUserID)
s.inferMissingCompanies(ctx, externalUserID, ncUserID, bookID)
_, err = s.db.Exec(ctx, `
UPDATE contact_discovery_scans
SET messages_scanned = $3, profiles_found = $4, profiles_total = $5,
total_messages = $6, phase = 'done', updated_at = NOW()
WHERE id = $1::uuid AND user_id = (SELECT id FROM users WHERE external_id = $2)
`, scanID, externalUserID, messagesScanned, profilesFound, profilesTotal, totalMessages)
return err
}
func llmSettingsHasProvider(settings llm.Settings) bool {
_, _, err := llm.ResolveProvider(settings, "")
return err == nil
}
type authInfo struct {
ListUnsubscribe string `json:"list_unsubscribe"`
MailedBy string `json:"mailed_by"`
}
func parseAuthInfo(raw []byte) authInfo {
var a authInfo
_ = json.Unmarshal(raw, &a)
return a
}
func (row messageRow) rowIsMailingList(auth authInfo) bool {
if strings.TrimSpace(auth.ListUnsubscribe) != "" {
return true
}
for _, addr := range parseAddresses(row.FromAddr) {
if isMailingListDomain(emailDomain(addr.Address)) {
return true
}
}
return false
}
func parseAddresses(raw []byte) []mailAddress {
if len(raw) == 0 {
return nil
}
var addrs []mailAddress
if err := json.Unmarshal(raw, &addrs); err != nil {
return nil
}
for i := range addrs {
addrs[i].Address = strings.ToLower(strings.TrimSpace(addrs[i].Address))
}
return addrs
}
func isSpamMessage(flags, labels []string) bool {
for _, f := range flags {
if strings.EqualFold(f, "spam") || strings.EqualFold(f, "junk") {
return true
}
}
for _, l := range labels {
ll := strings.ToLower(l)
if strings.Contains(ll, "spam") || strings.Contains(ll, "junk") || strings.Contains(ll, "indésirable") {
return true
}
}
return false
}
func isOwnAddress(email, accountEmail string) bool {
return strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(accountEmail))
}
func accountIsMessageRecipient(accountEmail string, to, cc, bcc []mailAddress) bool {
accountEmail = strings.ToLower(strings.TrimSpace(accountEmail))
if accountEmail == "" {
return false
}
for _, list := range [][]mailAddress{to, cc, bcc} {
for _, addr := range list {
if strings.ToLower(strings.TrimSpace(addr.Address)) == accountEmail {
return true
}
}
}
return false
}
func (s *Service) loadRejections(ctx context.Context, externalUserID string) (map[string]bool, error) {
out := map[string]bool{}
rows, err := s.db.Query(ctx, `
SELECT rejection_key FROM contact_discovery_rejections
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalUserID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, err
}
out[key] = true
}
return out, rows.Err()
}
func (s *Service) loadExistingContactEmails(ctx context.Context, externalUserID string) (map[string]bool, error) {
// Profiles already accepted are excluded; for now we rely on CardDAV sync on frontend
// Backend stores linked_contact_uid for accepted profiles
out := map[string]bool{}
rows, err := s.db.Query(ctx, `
SELECT lower(primary_email) FROM contact_discovered_profiles
WHERE user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'accepted'
`, externalUserID)
if err != nil {
return out, nil
}
defer rows.Close()
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return out, nil
}
out[email] = true
}
return out, nil
}
func (s *Service) loadLLMSettings(ctx context.Context, externalUserID string) (llm.Settings, error) {
var raw []byte
err := s.db.QueryRow(ctx, `
SELECT COALESCE(s.preferences->'llm', '{}'::jsonb)
FROM users u
LEFT JOIN settings s ON s.user_id = u.id
WHERE u.external_id = $1
`, externalUserID).Scan(&raw)
if err != nil {
return llm.Settings{}, err
}
var settings llm.Settings
_ = json.Unmarshal(raw, &settings)
return settings, nil
}

View File

@ -0,0 +1,219 @@
package discovery
import (
"html"
"regexp"
"strings"
)
var (
sigDelimiterRe = regexp.MustCompile(`(?m)^--\s*$`)
forwardedHeaderRe = regexp.MustCompile(`(?i)(?:^|\n)(?:-{5,}\s*)?(?:forwarded message|message transféré|message transmis|-----Original Message-----)`)
quotedReplyStartRe = regexp.MustCompile(`(?is)(?:^|\n)\s*(?:` +
`Le\s+.{4,200}?\s+a\s+écrit\s*:` +
`|On\s+.{4,200}?\s+wrote\s*:` +
`|Am\s+.{4,200}?\s+schrieb\s*:` +
`|El\s+.{4,200}?\s+escribió\s*:` +
`|Il\s+.{4,200}?\s+ha\s+scritto\s*:` +
`|-----Original Message-----` +
`|\n_{5,}` +
`)`)
replyHeaderBlockRe = regexp.MustCompile(`(?is)\n\s*(?:De|From)\s*:\s*.+\n\s*(?:Envoyé|Sent|Date)\s*:`)
replyHeaderInSigRe = regexp.MustCompile(`(?i)(?:\bwrote\s*:|\ba\s+écrit\s*:|-----Original|Envoyé\s*:|Sent\s*:|schrieb\s*:|escribió\s*:|ha\s+scritto\s*:)`)
phoneInSigRe = regexp.MustCompile(`(?:\+?\d[\d\s().-]{7,}\d)`)
emailInSigRe = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
titleKeywordsRe = regexp.MustCompile(`(?i)(?:directeur|director|manager|ceo|cto|engineer|consultant|developer|president|founder|co-founder|chef|responsable)`)
)
func stripHTMLTags(s string) string {
if len(s) > 8000 {
s = s[len(s)-8000:]
}
s = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)</p>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
return html.UnescapeString(s)
}
func stripQuotedPrefixLines(raw string) string {
lines := strings.Split(raw, "\n")
cut := len(lines)
for i, line := range lines {
if i < 2 {
continue
}
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, ">") {
cut = i
break
}
}
if cut < len(lines) {
raw = strings.TrimSpace(strings.Join(lines[:cut], "\n"))
}
return raw
}
func stripQuotedReplyContent(raw string) string {
if loc := forwardedHeaderRe.FindStringIndex(raw); loc != nil {
raw = raw[:loc[0]]
}
if loc := quotedReplyStartRe.FindStringIndex(raw); loc != nil && loc[0] >= 15 {
raw = raw[:loc[0]]
}
if loc := replyHeaderBlockRe.FindStringIndex(raw); loc != nil && loc[0] >= 15 {
raw = raw[:loc[0]]
}
return stripQuotedPrefixLines(raw)
}
func emailsInText(s string) []string {
found := emailInSigRe.FindAllString(strings.ToLower(s), -1)
if len(found) == 0 {
return nil
}
seen := map[string]struct{}{}
var out []string
for _, e := range found {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, ok := seen[e]; ok {
continue
}
seen[e] = struct{}{}
out = append(out, e)
}
return out
}
func signatureMatchesSender(candidate, senderEmail, displayName string) bool {
if replyHeaderInSigRe.MatchString(candidate) {
return false
}
sender := strings.ToLower(strings.TrimSpace(senderEmail))
if sender == "" {
return true
}
for _, e := range emailsInText(candidate) {
if e != sender {
return false
}
}
if displayName != "" {
nameTokens := strings.Fields(strings.ToLower(displayName))
if len(nameTokens) >= 2 {
candidateLower := strings.ToLower(candidate)
matches := 0
for _, tok := range nameTokens {
if len(tok) < 2 {
continue
}
if strings.Contains(candidateLower, tok) {
matches++
}
}
if matches == 0 && !phoneInSigRe.MatchString(candidate) && len(emailsInText(candidate)) == 0 {
return false
}
}
}
return true
}
func extractSignature(bodyText, bodyHTML, senderEmail, displayName string) (text string, confidence float64) {
raw := strings.TrimSpace(bodyText)
if len(raw) > 8000 {
raw = raw[len(raw)-8000:]
}
if raw == "" && bodyHTML != "" {
raw = stripHTMLTags(bodyHTML)
}
if raw == "" {
return "", 0
}
raw = stripQuotedReplyContent(raw)
raw = strings.TrimSpace(raw)
if raw == "" {
return "", 0
}
parts := sigDelimiterRe.Split(raw, -1)
candidate := strings.TrimSpace(raw)
confidence = 0.3
if len(parts) > 1 {
candidate = strings.TrimSpace(parts[len(parts)-1])
confidence = 0.7
} else {
lines := strings.Split(raw, "\n")
if len(lines) > 4 {
start := len(lines) - 8
if start < 0 {
start = 0
}
tail := strings.Join(lines[start:], "\n")
if phoneInSigRe.MatchString(tail) || emailInSigRe.MatchString(tail) || titleKeywordsRe.MatchString(tail) {
candidate = strings.TrimSpace(tail)
confidence = 0.55
}
}
}
candidate = strings.TrimSpace(candidate)
if len(candidate) < 10 || len(candidate) > 2000 {
return "", 0
}
if !signatureMatchesSender(candidate, senderEmail, displayName) {
return "", 0
}
senderLower := strings.ToLower(strings.TrimSpace(senderEmail))
if senderLower != "" {
local := strings.Split(senderLower, "@")[0]
if local != "" && !strings.Contains(strings.ToLower(candidate), local) {
if !emailInSigRe.MatchString(candidate) && !phoneInSigRe.MatchString(candidate) {
confidence *= 0.6
}
}
}
if confidence < 0.35 {
return "", 0
}
return candidate, confidence
}
func detectForwardedAddresses(bodyText, bodyHTML string) []string {
raw := bodyText
if raw == "" && bodyHTML != "" {
raw = stripHTMLTags(bodyHTML)
}
if raw == "" {
return nil
}
var out []string
seen := map[string]struct{}{}
fromLineRe := regexp.MustCompile(`(?im)^(?:from|de|expéditeur)\s*:\s*(?:[^<\n]*<)?([a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,})>?`)
for _, m := range fromLineRe.FindAllStringSubmatch(raw, -1) {
if len(m) > 1 {
email := strings.ToLower(strings.TrimSpace(m[1]))
if _, ok := seen[email]; !ok && email != "" {
seen[email] = struct{}{}
out = append(out, email)
}
}
}
return out
}

View File

@ -0,0 +1,60 @@
package discovery
import (
"strings"
"testing"
)
func TestExtractSignature_StripsQuotedReply(t *testing.T) {
body := `Merci pour votre retour.
Cordialement,
Alice Martin
Directrice Acme SAS
alice@acme.com
+33 1 23 45 67 89
Le lun. 3 juin 2024 à 10:15, Bob Dupont <bob@client.com> a écrit :
> Bonjour Alice,
> Pouvez-vous me rappeler ?
>
> Cordialement,
> Bob Dupont
> bob@client.com
> +33 6 11 22 33 44`
text, conf := extractSignature(body, "", "alice@acme.com", "Alice Martin")
if text == "" || conf < 0.35 {
t.Fatalf("expected Alice signature, got %q conf=%v", text, conf)
}
if strings.Contains(text, "bob@client.com") || strings.Contains(text, "Bob Dupont") {
t.Fatalf("quoted reply signature leaked: %q", text)
}
}
func TestExtractSignature_RejectsOtherEmailInBlock(t *testing.T) {
body := `Bonjour,
Cordialement,
Bob Dupont
bob@client.com`
_, conf := extractSignature(body, "", "alice@acme.com", "Alice Martin")
if conf >= 0.35 {
t.Fatalf("expected rejection when signature email != sender, got conf=%v", conf)
}
}
func TestExtractSignature_AcceptsDelimiterBlock(t *testing.T) {
body := `Hello, see attached.
--
Jane Doe
Engineer
jane@corp.io`
text, conf := extractSignature(body, "", "jane@corp.io", "Jane Doe")
if text == "" || conf < 0.5 {
t.Fatalf("expected signature after --, got %q conf=%v", text, conf)
}
}

View File

@ -0,0 +1,144 @@
package discovery
import (
"context"
"sort"
"time"
)
func (s *Service) fillSignaturesFromStoredMessages(ctx context.Context, email string, agg *addressAgg) {
if len(agg.fromMessageRefs) == 0 {
return
}
ids := make([]string, 0, len(agg.fromMessageRefs))
for _, ref := range agg.fromMessageRefs {
ids = append(ids, ref.id)
}
rows, err := s.db.Query(ctx, `
SELECT id::text,
LEFT(COALESCE(body_text, ''), $2),
LEFT(COALESCE(body_html, ''), $2),
date
FROM messages
WHERE id = ANY($1::uuid[])
ORDER BY date DESC
LIMIT $3
`, ids, maxBodyChars, maxSignatureMsgsPerEmail)
if err != nil {
return
}
defer rows.Close()
agg.Signatures = agg.Signatures[:0]
for rows.Next() {
var id, bodyText, bodyHTML string
var msgDate time.Time
if err := rows.Scan(&id, &bodyText, &bodyHTML, &msgDate); err != nil {
continue
}
sigText, conf := extractSignature(bodyText, bodyHTML, email, agg.DisplayName)
if sigText == "" {
continue
}
agg.Signatures = append(agg.Signatures, signatureCandidate{
MessageID: id,
SignatureText: sigText,
MessageDate: msgDate,
Confidence: conf,
})
}
sort.Slice(agg.Signatures, func(i, j int) bool {
return agg.Signatures[i].MessageDate.After(agg.Signatures[j].MessageDate)
})
if len(agg.Signatures) > 5 {
agg.Signatures = agg.Signatures[:5]
}
}
func (s *Service) fillSignaturesForEmail(ctx context.Context, externalUserID, email string, agg *addressAgg) {
if len(agg.fromMessageRefs) > 0 {
s.fillSignaturesFromStoredMessages(ctx, email, agg)
return
}
rows, err := s.db.Query(ctx, `
SELECT m.id::text,
LEFT(COALESCE(m.body_text, ''), $3),
LEFT(COALESCE(m.body_html, ''), $3),
m.date
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1)
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(m.from_addr) a
WHERE lower(coalesce(a->>'address', '')) = lower($2)
)
ORDER BY m.date DESC
LIMIT $4
`, externalUserID, email, maxBodyChars, maxSignatureMsgsPerEmail)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var id, bodyText, bodyHTML string
var msgDate time.Time
if err := rows.Scan(&id, &bodyText, &bodyHTML, &msgDate); err != nil {
continue
}
sigText, conf := extractSignature(bodyText, bodyHTML, email, agg.DisplayName)
if sigText == "" {
continue
}
agg.Signatures = append(agg.Signatures, signatureCandidate{
MessageID: id,
SignatureText: sigText,
MessageDate: msgDate,
Confidence: conf,
})
}
sort.Slice(agg.Signatures, func(i, j int) bool {
return agg.Signatures[i].MessageDate.After(agg.Signatures[j].MessageDate)
})
if len(agg.Signatures) > 5 {
agg.Signatures = agg.Signatures[:5]
}
}
type enrichCandidate struct {
email string
agg *addressAgg
}
func selectPreEnrichCandidates(aggs map[string]*addressAgg, limit int) []enrichCandidate {
var candidates []enrichCandidate
for email, agg := range aggs {
isML, isDisp, isSpamHeavy, _ := classifyAddress(agg)
if isML || isDisp || isSpamHeavy {
continue
}
if agg.MessageCount < minMessagesForLLM {
continue
}
if _, ok := agg.Roles["from"]; !ok {
continue
}
candidates = append(candidates, enrichCandidate{email: email, agg: agg})
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].agg.MessageCount > candidates[j].agg.MessageCount
})
if len(candidates) > limit {
candidates = candidates[:limit]
}
return candidates
}
func enrichCandidateSet(candidates []enrichCandidate) map[string]bool {
out := make(map[string]bool, len(candidates))
for _, c := range candidates {
out[c.email] = true
}
return out
}

View File

@ -0,0 +1,169 @@
package discovery
import (
"strings"
)
// suggestableProfilesSQL filters profiles that only have a bare email address.
const suggestableProfilesSQL = `
AND (
EXISTS (
SELECT 1 FROM contact_discovered_signatures s
WHERE s.profile_id = p.id
)
OR (
COALESCE(trim(p.display_name), '') != ''
AND p.display_name NOT ILIKE '%@%'
AND lower(trim(p.display_name)) != lower(split_part(p.primary_email, '@', 1))
)
OR (
p.enriched_data IS NOT NULL
AND (
nullif(trim(p.enriched_data->>'first_name'), '') IS NOT NULL
OR nullif(trim(p.enriched_data->>'last_name'), '') IS NOT NULL
OR nullif(trim(p.enriched_data->>'company'), '') IS NOT NULL
OR nullif(trim(p.enriched_data->>'department'), '') IS NOT NULL
OR nullif(trim(p.enriched_data->>'job_title'), '') IS NOT NULL
OR nullif(trim(p.enriched_data->>'website'), '') IS NOT NULL
OR nullif(trim(p.enriched_data->>'notes'), '') IS NOT NULL
OR jsonb_array_length(COALESCE(p.enriched_data->'social_profiles', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(p.enriched_data->'phones', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(p.enriched_data->'addresses', '[]'::jsonb)) > 0
)
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(p.all_emails, '[]'::jsonb)) AS e
WHERE nullif(trim(e->>'display_name'), '') IS NOT NULL
AND (e->>'display_name') NOT ILIKE '%@%'
AND lower(trim(e->>'display_name')) != lower(split_part(COALESCE(e->>'email', ''), '@', 1))
)
)`
func hasMeaningfulDisplayName(name, email string) bool {
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, "@") {
return false
}
local := emailLocalPart(email)
if local == "" {
return true
}
return strings.ToLower(name) != local
}
func emailLocalPart(email string) string {
email = strings.TrimSpace(email)
at := strings.LastIndex(email, "@")
if at <= 0 {
return strings.ToLower(email)
}
return strings.ToLower(email[:at])
}
func enrichedDataHasValueBeyondEmail(data *EnrichedContactData) bool {
if data == nil {
return false
}
if strings.TrimSpace(data.FirstName) != "" ||
strings.TrimSpace(data.LastName) != "" ||
strings.TrimSpace(data.Company) != "" ||
strings.TrimSpace(data.Department) != "" ||
strings.TrimSpace(data.JobTitle) != "" ||
strings.TrimSpace(data.Website) != "" ||
strings.TrimSpace(data.Notes) != "" {
return true
}
for _, sp := range data.SocialProfiles {
if strings.TrimSpace(sp.Value) != "" {
return true
}
}
for _, p := range data.Phones {
if strings.TrimSpace(p.Value) != "" {
return true
}
}
for _, a := range data.Addresses {
if strings.TrimSpace(a.Street) != "" ||
strings.TrimSpace(a.City) != "" ||
strings.TrimSpace(a.Region) != "" ||
strings.TrimSpace(a.PostalCode) != "" ||
strings.TrimSpace(a.Country) != "" {
return true
}
}
return false
}
// ProfileHasValueBeyondEmail reports whether a profile has contact info beyond a bare email.
func ProfileHasValueBeyondEmail(p Profile) bool {
if len(p.Signatures) > 0 {
return true
}
if enrichedDataHasValueBeyondEmail(p.EnrichedData) {
return true
}
if hasMeaningfulDisplayName(p.DisplayName, p.PrimaryEmail) {
return true
}
for _, e := range p.AllEmails {
if hasMeaningfulDisplayName(e.DisplayName, e.Email) {
return true
}
}
return false
}
func profileGroupHasValueBeyondEmail(group ProfileGroup) bool {
for _, p := range group.Profiles {
if ProfileHasValueBeyondEmail(p) {
return true
}
}
return ProfileHasValueBeyondEmail(group.Profile)
}
func profileHasNoReplyEmail(p Profile) bool {
if isNoReplyEmail(p.PrimaryEmail) {
return true
}
for _, e := range p.AllEmails {
if isNoReplyEmail(e.Email) {
return true
}
}
if p.EnrichedData != nil {
for _, e := range p.EnrichedData.Emails {
if isNoReplyEmail(e.Value) {
return true
}
}
}
return false
}
func profileGroupHasNoReplyEmail(group ProfileGroup) bool {
for _, p := range group.Profiles {
if profileHasNoReplyEmail(p) {
return true
}
}
return profileHasNoReplyEmail(group.Profile)
}
func filterSuggestableGroups(groups []ProfileGroup) []ProfileGroup {
if len(groups) == 0 {
return groups
}
out := make([]ProfileGroup, 0, len(groups))
for _, g := range groups {
if profileGroupHasNoReplyEmail(g) {
continue
}
if profileGroupHasValueBeyondEmail(g) {
out = append(out, g)
}
}
return out
}

View File

@ -0,0 +1,73 @@
package discovery
import "testing"
func TestProfileHasValueBeyondEmail(t *testing.T) {
tests := []struct {
name string
p Profile
want bool
}{
{
name: "email only",
p: Profile{
PrimaryEmail: "alice@corp.com",
DisplayName: "",
},
want: false,
},
{
name: "display name equals local part",
p: Profile{
PrimaryEmail: "alice@corp.com",
DisplayName: "alice",
},
want: false,
},
{
name: "meaningful display name",
p: Profile{
PrimaryEmail: "alice@corp.com",
DisplayName: "Alice Martin",
},
want: true,
},
{
name: "signature",
p: Profile{
PrimaryEmail: "alice@corp.com",
Signatures: []SignatureEntry{{
SignatureText: "Alice Martin\nCorp",
}},
},
want: true,
},
{
name: "enriched company",
p: Profile{
PrimaryEmail: "alice@corp.com",
EnrichedData: &EnrichedContactData{Company: "Corp"},
},
want: true,
},
{
name: "all emails display name",
p: Profile{
PrimaryEmail: "alice@corp.com",
AllEmails: []EmailEntry{{
Email: "alice@corp.com",
DisplayName: "Alice Martin",
}},
},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := ProfileHasValueBeyondEmail(tc.p); got != tc.want {
t.Fatalf("ProfileHasValueBeyondEmail() = %v, want %v", got, tc.want)
}
})
}
}

View File

@ -0,0 +1,223 @@
package discovery
import "time"
type ScanStatus string
const (
ScanPending ScanStatus = "pending"
ScanRunning ScanStatus = "running"
ScanCompleted ScanStatus = "completed"
ScanFailed ScanStatus = "failed"
)
type ProfileStatus string
const (
ProfileSuggested ProfileStatus = "suggested"
ProfileAccepted ProfileStatus = "accepted"
ProfileRejected ProfileStatus = "rejected"
ProfileIgnored ProfileStatus = "ignored"
ProfileBlocked ProfileStatus = "blocked"
)
type EnrichmentStatus string
const (
EnrichPending EnrichmentStatus = "pending"
EnrichEnriching EnrichmentStatus = "enriching"
EnrichEnriched EnrichmentStatus = "enriched"
EnrichSkipped EnrichmentStatus = "skipped"
EnrichFailed EnrichmentStatus = "failed"
)
type SuggestionStatus string
const (
SuggestionPending SuggestionStatus = "pending"
SuggestionAccepted SuggestionStatus = "accepted"
SuggestionRejected SuggestionStatus = "rejected"
)
type ScanPhase string
const (
PhasePending ScanPhase = "pending"
PhaseScanning ScanPhase = "scanning_messages"
PhaseProfiles ScanPhase = "building_profiles"
PhaseEnriching ScanPhase = "enriching"
PhaseDone ScanPhase = "done"
)
type Scan struct {
ID string `json:"id"`
Status ScanStatus `json:"status"`
Phase ScanPhase `json:"phase"`
MessagesScanned int `json:"messages_scanned"`
TotalMessages int `json:"total_messages"`
ProfilesFound int `json:"profiles_found"`
ProfilesTotal int `json:"profiles_total"`
ProgressPercent float64 `json:"progress_percent"`
ErrorMessage string `json:"error_message,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type AccountDetection struct {
AccountID string `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountName string `json:"account_name"`
MessageCount int `json:"message_count"`
}
type EmailEntry struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Roles []string `json:"roles,omitempty"`
MessageCount int `json:"message_count"`
}
type SignatureEntry struct {
ID string `json:"id"`
MessageID string `json:"message_id,omitempty"`
SignatureText string `json:"signature_text"`
MessageDate time.Time `json:"message_date"`
Confidence float64 `json:"confidence"`
}
type EnrichedContactData struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Company string `json:"company,omitempty"`
Department string `json:"department,omitempty"`
JobTitle string `json:"job_title,omitempty"`
Emails []FieldWithLabel `json:"emails,omitempty"`
Phones []FieldWithLabel `json:"phones,omitempty"`
Addresses []AddressField `json:"addresses,omitempty"`
Website string `json:"website,omitempty"`
SocialProfiles []FieldWithLabel `json:"social_profiles,omitempty"`
Notes string `json:"notes,omitempty"`
}
type FieldWithLabel struct {
Value string `json:"value"`
Label string `json:"label"`
}
type AddressField struct {
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
Label string `json:"label"`
}
type ProfileGroup struct {
GroupKey string `json:"group_key"`
ProfileIDs []string `json:"profile_ids"`
DisplayName string `json:"display_name"`
PrimaryEmail string `json:"primary_email"`
MessageCount int `json:"message_count"`
Profile Profile `json:"profile"`
Profiles []Profile `json:"profiles"`
}
type Profile struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
PrimaryEmail string `json:"primary_email"`
AllEmails []EmailEntry `json:"all_emails"`
MessageCount int `json:"message_count"`
SentCount int `json:"sent_count"`
ReceivedCount int `json:"received_count"`
OutboundCount int `json:"outbound_count"`
InboundFromCCCount int `json:"inbound_from_cc_count"`
CopresenceCCBCCCount int `json:"copresence_cc_bcc_count"`
SpamCount int `json:"spam_count"`
ForwardedCount int `json:"forwarded_count"`
IsMailingList bool `json:"is_mailing_list"`
IsDisposable bool `json:"is_disposable"`
IsSpamHeavy bool `json:"is_spam_heavy"`
ClassificationReason string `json:"classification_reason,omitempty"`
LinkedContactUID string `json:"linked_contact_uid,omitempty"`
EnrichmentStatus EnrichmentStatus `json:"enrichment_status"`
EnrichedData *EnrichedContactData `json:"enriched_data,omitempty"`
Status ProfileStatus `json:"status"`
Signatures []SignatureEntry `json:"signatures,omitempty"`
DetectedInAccounts []AccountDetection `json:"detected_in_accounts,omitempty"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
}
type Suggestion struct {
ID string `json:"id"`
ProfileID string `json:"profile_id,omitempty"`
TargetContactUID string `json:"target_contact_uid,omitempty"`
SuggestionType string `json:"suggestion_type"`
FieldPath string `json:"field_path"`
SuggestedValue string `json:"suggested_value"`
SuggestedLabel string `json:"suggested_label"`
Confidence float64 `json:"confidence"`
Status SuggestionStatus `json:"status"`
Profile *Profile `json:"profile,omitempty"`
}
type ScanResult struct {
Scan Scan `json:"scan"`
Profiles []Profile `json:"profiles,omitempty"`
}
type fromMessageRef struct {
id string
date time.Time
}
type addressAgg struct {
Email string
DisplayName string
Roles map[string]struct{}
MessageCount int
SentCount int
ReceivedCount int
OutboundCount int
InboundFromCCCount int
CopresenceCCBCCCount int
SpamCount int
ForwardedCount int
LastSeen time.Time
Signatures []signatureCandidate
fromMessageRefs []fromMessageRef
ListUnsub int
MailingList int
Accounts map[string]*AccountDetection
}
type signatureCandidate struct {
MessageID string
SignatureText string
SignatureHTML string
MessageDate time.Time
Confidence float64
}
type messageRow struct {
ID string
Subject string
FromAddr []byte
ToAddrs []byte
CcAddrs []byte
BccAddrs []byte
ReplyTo []byte
Snippet string
BodyText string
BodyHTML string
Date time.Time
Flags []string
Labels []string
AuthInfo []byte
FolderType string
AccountID string
AccountEmail string
AccountName string
}

208
internal/llm/client.go Normal file
View File

@ -0,0 +1,208 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key,omitempty"`
DefaultModel string `json:"default_model"`
}
type Settings struct {
DefaultProviderID string `json:"default_provider_id"`
Providers []Provider `json:"providers"`
ContactDiscoveryModel string `json:"contact_discovery_model,omitempty"`
ContactDiscoveryProvider string `json:"contact_discovery_provider_id,omitempty"`
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
type modelsResponse struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
type Client struct {
http *http.Client
}
func NewClient() *Client {
return &Client{http: &http.Client{Timeout: 90 * time.Second}}
}
func (c *Client) Complete(ctx context.Context, provider Provider, model, systemPrompt, userPrompt string) (string, error) {
baseURL := strings.TrimRight(strings.TrimSpace(provider.BaseURL), "/")
if baseURL == "" {
return "", fmt.Errorf("llm provider base_url is required")
}
model = strings.TrimSpace(model)
if model == "" {
model = strings.TrimSpace(provider.DefaultModel)
}
if model == "" {
return "", fmt.Errorf("llm model is required")
}
reqBody := chatRequest{
Model: model,
Messages: []ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
Temperature: 0.2,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := baseURL + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(provider.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(provider.APIKey))
}
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("llm request failed (%d): %s", resp.StatusCode, string(body))
}
var parsed chatResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", err
}
if parsed.Error != nil && parsed.Error.Message != "" {
return "", fmt.Errorf("llm error: %s", parsed.Error.Message)
}
if len(parsed.Choices) == 0 {
return "", fmt.Errorf("llm returned no choices")
}
return strings.TrimSpace(parsed.Choices[0].Message.Content), nil
}
func (c *Client) ListModels(ctx context.Context, provider Provider) ([]string, error) {
baseURL := strings.TrimRight(strings.TrimSpace(provider.BaseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("llm provider base_url is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
if err != nil {
return nil, err
}
if strings.TrimSpace(provider.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(provider.APIKey))
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("llm models request failed (%d): %s", resp.StatusCode, string(body))
}
var parsed modelsResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if parsed.Error != nil && parsed.Error.Message != "" {
return nil, fmt.Errorf("llm error: %s", parsed.Error.Message)
}
models := make([]string, 0, len(parsed.Data))
seen := make(map[string]struct{}, len(parsed.Data))
for _, item := range parsed.Data {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
models = append(models, id)
}
return models, nil
}
func ResolveProvider(settings Settings, providerID string) (Provider, string, error) {
if providerID == "" {
providerID = strings.TrimSpace(settings.ContactDiscoveryProvider)
}
if providerID == "" {
providerID = strings.TrimSpace(settings.DefaultProviderID)
}
for _, p := range settings.Providers {
if p.ID == providerID {
model := strings.TrimSpace(settings.ContactDiscoveryModel)
if model == "" {
model = strings.TrimSpace(p.DefaultModel)
}
return p, model, nil
}
}
if len(settings.Providers) > 0 {
p := settings.Providers[0]
model := strings.TrimSpace(settings.ContactDiscoveryModel)
if model == "" {
model = strings.TrimSpace(p.DefaultModel)
}
return p, model, nil
}
return Provider{}, "", fmt.Errorf("no llm provider configured")
}

View File

@ -0,0 +1,42 @@
package llm
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestListModels(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("unexpected auth header: %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-4o-mini"},{"id":"gpt-4o"},{"id":""}]}`))
}))
defer srv.Close()
client := NewClient()
models, err := client.ListModels(context.Background(), Provider{
BaseURL: srv.URL + "/v1",
APIKey: "test-key",
})
if err != nil {
t.Fatalf("ListModels: %v", err)
}
if len(models) != 2 || models[0] != "gpt-4o-mini" || models[1] != "gpt-4o" {
t.Fatalf("unexpected models: %#v", models)
}
}
func TestListModelsRequiresBaseURL(t *testing.T) {
client := NewClient()
_, err := client.ListModels(context.Background(), Provider{})
if err == nil {
t.Fatal("expected error for empty base_url")
}
}

View File

@ -5,11 +5,14 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
) )
type Client struct { type Client struct {
baseURL string baseURL string
publicURL string
drivePublicURL string
httpClient *http.Client httpClient *http.Client
adminUser string adminUser string
adminPass string adminPass string
@ -18,7 +21,7 @@ type Client struct {
func NewClient(baseURL, adminUser, adminPass string) *Client { func NewClient(baseURL, adminUser, adminPass string) *Client {
return &Client{ return &Client{
baseURL: baseURL, baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
@ -27,6 +30,14 @@ func NewClient(baseURL, adminUser, adminPass string) *Client {
} }
} }
func (c *Client) WithPublicURL(publicURL string) *Client {
if c == nil {
return nil
}
c.publicURL = strings.TrimRight(strings.TrimSpace(publicURL), "/")
return c
}
func (c *Client) WithDAVCredentials(store *DAVCredentialStore) *Client { func (c *Client) WithDAVCredentials(store *DAVCredentialStore) *Client {
if c == nil { if c == nil {
return nil return nil
@ -35,6 +46,18 @@ func (c *Client) WithDAVCredentials(store *DAVCredentialStore) *Client {
return c return c
} }
// webDAVDestination builds the Destination header for MOVE/COPY.
// Nextcloud validates this against OVERWRITECLIURL (e.g. http://localhost/cloud).
func (c *Client) webDAVDestination(davPath string) string {
if !strings.HasPrefix(davPath, "/") {
davPath = "/" + davPath
}
if c.publicURL != "" {
return c.publicURL + davPath
}
return SameServerDestinationHeader(davPath)
}
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) { func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
url := c.baseURL + path url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, body) req, err := http.NewRequestWithContext(ctx, method, url, body)
@ -68,6 +91,7 @@ func (c *Client) doAsUser(ctx context.Context, method, path string, body io.Read
} }
req.SetBasicAuth(userID, token) req.SetBasicAuth(userID, token)
req.Header.Set("OCS-APIRequest", "true")
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }

View File

@ -115,11 +115,12 @@ func (c *Client) SyncContacts(ctx context.Context, userID, bookPath, syncToken s
} }
} }
func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) error { func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) (*Contact, error) {
vcard := buildVCard(contact) vcard := contactVCardPayload(contact)
uid := contact.UID uid := contact.UID
if uid == "" { if uid == "" {
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano()) uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
contact.UID = uid
} }
contactPath := fmt.Sprintf("%s%s.vcf", bookPath, uid) contactPath := fmt.Sprintf("%s%s.vcf", bookPath, uid)
@ -127,18 +128,19 @@ func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, con
"Content-Type": "text/vcard; charset=utf-8", "Content-Type": "text/vcard; charset=utf-8",
}) })
if err != nil { if err != nil {
return err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 201 && resp.StatusCode != 204 { if resp.StatusCode != 201 && resp.StatusCode != 204 {
return fmt.Errorf("create contact failed: %d", resp.StatusCode) return nil, fmt.Errorf("create contact failed: %d", resp.StatusCode)
} }
return nil return c.GetContact(ctx, userID, contactPath)
} }
func (c *Client) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *Contact) (string, error) { func (c *Client) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *Contact) (string, error) {
vcard := buildVCard(contact) contactPath = normalizeDAVHref(contactPath)
vcard := contactVCardPayload(contact)
headers := map[string]string{ headers := map[string]string{
"Content-Type": "text/vcard; charset=utf-8", "Content-Type": "text/vcard; charset=utf-8",
} }
@ -161,6 +163,7 @@ func (c *Client) UpdateContact(ctx context.Context, userID, contactPath, ifMatch
} }
func (c *Client) GetContact(ctx context.Context, userID, contactPath string) (*Contact, error) { func (c *Client) GetContact(ctx context.Context, userID, contactPath string) (*Contact, error) {
contactPath = normalizeDAVHref(contactPath)
resp, err := c.DoAsUser(ctx, "GET", contactPath, nil, userID, nil) resp, err := c.DoAsUser(ctx, "GET", contactPath, nil, userID, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -181,6 +184,7 @@ func (c *Client) GetContact(ctx context.Context, userID, contactPath string) (*C
} }
func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error { func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error {
contactPath = normalizeDAVHref(contactPath)
resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil) resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
if err != nil { if err != nil {
return err return err
@ -225,6 +229,15 @@ func (c *Client) SearchContacts(ctx context.Context, userID, bookPath, query str
return parseContactList(strings.NewReader(string(raw))) return parseContactList(strings.NewReader(string(raw)))
} }
func contactVCardPayload(contact *Contact) string {
if contact != nil {
if raw := strings.TrimSpace(contact.RawVCard); raw != "" {
return raw
}
}
return buildVCard(contact)
}
func buildVCard(contact *Contact) string { func buildVCard(contact *Contact) string {
var b strings.Builder var b strings.Builder
b.WriteString("BEGIN:VCARD\r\n") b.WriteString("BEGIN:VCARD\r\n")
@ -274,11 +287,20 @@ func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error
func normalizeDAVHref(href string) string { func normalizeDAVHref(href string) string {
href = strings.TrimSpace(href) href = strings.TrimSpace(href)
if strings.HasPrefix(href, "/cloud/") { for {
return strings.TrimPrefix(href, "/cloud") switch {
case strings.HasPrefix(href, "/cloud/"):
href = strings.TrimPrefix(href, "/cloud")
case strings.HasPrefix(href, "cloud/"):
href = strings.TrimPrefix(href, "cloud")
default:
if href != "" && !strings.HasPrefix(href, "/") {
href = "/" + href
} }
return href return href
} }
}
}
func buildSyncCollectionRequest(syncToken string) string { func buildSyncCollectionRequest(syncToken string) string {
var b strings.Builder var b strings.Builder
@ -355,7 +377,7 @@ func contactFromCardProp(href string, prop cardProp) (Contact, bool) {
return Contact{}, false return Contact{}, false
} }
contact := parseVCard(vcard) contact := parseVCard(vcard)
contact.Path = href contact.Path = normalizeDAVHref(href)
contact.ETag = normalizeETag(prop.ETag) contact.ETag = normalizeETag(prop.ETag)
contact.RawVCard = vcard contact.RawVCard = vcard
return contact, true return contact, true

View File

@ -25,6 +25,16 @@ func (c *Client) WebDAVPath(userID, path string) string {
return "/remote.php/dav/files/" + userSeg + "/" + encoded return "/remote.php/dav/files/" + userSeg + "/" + encoded
} }
// SameServerDestinationHeader builds a relative WebDAV Destination header value.
// Nextcloud/Sabre rejects absolute URIs when the host differs from OVERWRITEHOST
// (e.g. internal http://nextcloud:80 vs public http://localhost/cloud).
func SameServerDestinationHeader(davPath string) string {
if !strings.HasPrefix(davPath, "/") {
return "/" + davPath
}
return davPath
}
func decodeDAVSegment(seg string) string { func decodeDAVSegment(seg string) string {
if seg == "" { if seg == "" {
return seg return seg

View File

@ -33,6 +33,36 @@ func TestFileNameFromDAVPropDecodesHref(t *testing.T) {
} }
} }
func TestSameServerDestinationHeader(t *testing.T) {
got := SameServerDestinationHeader("/remote.php/dav/files/user/doc.pdf")
want := "/remote.php/dav/files/user/doc.pdf"
if got != want {
t.Fatalf("SameServerDestinationHeader() = %q, want %q", got, want)
}
got = SameServerDestinationHeader("remote.php/dav/files/user/doc.pdf")
if got != want {
t.Fatalf("SameServerDestinationHeader(relative) = %q, want %q", got, want)
}
}
func TestWebDAVDestinationUsesPublicURL(t *testing.T) {
c := (&Client{}).WithPublicURL("http://localhost/cloud")
got := c.webDAVDestination("/remote.php/dav/files/user/doc.pdf")
want := "http://localhost/cloud/remote.php/dav/files/user/doc.pdf"
if got != want {
t.Fatalf("webDAVDestination() = %q, want %q", got, want)
}
}
func TestWebDAVDestinationFallbackRelative(t *testing.T) {
c := &Client{}
got := c.webDAVDestination("/remote.php/dav/files/user/doc.pdf")
want := "/remote.php/dav/files/user/doc.pdf"
if got != want {
t.Fatalf("webDAVDestination() = %q, want %q", got, want)
}
}
func TestWebDAVPathEncodesSpaces(t *testing.T) { func TestWebDAVPathEncodesSpaces(t *testing.T) {
c := &Client{} c := &Client{}
got := c.WebDAVPath("user@example.com", "/Documents/My File") got := c.WebDAVPath("user@example.com", "/Documents/My File")

View File

@ -37,8 +37,16 @@ type ShareInfo struct {
InternalURL string `json:"internal_url,omitempty"` InternalURL string `json:"internal_url,omitempty"`
AccessMode string `json:"access_mode,omitempty"` AccessMode string `json:"access_mode,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"` ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
ShareWith string `json:"share_with,omitempty"` ShareWith string `json:"share_with,omitempty"`
ShareWithDisplayName string `json:"share_with_displayname,omitempty"` ShareWithDisplayName string `json:"share_with_displayname,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
OwnerDisplayName string `json:"owner_displayname,omitempty"`
FileOwnerID string `json:"file_owner_id,omitempty"`
FileOwnerDisplayName string `json:"file_owner_displayname,omitempty"`
Note string `json:"note,omitempty"`
ItemType string `json:"item_type,omitempty"`
HasPassword bool `json:"has_password,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
} }
@ -170,11 +178,17 @@ func (c *Client) Delete(ctx context.Context, userID, path string) error {
return nil return nil
} }
func normalizeOperationPath(userID, p string) string {
return NormalizeClientFilePath(userID, NormalizeClientPath(p))
}
func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) error { func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) error {
srcPath = normalizeOperationPath(userID, srcPath)
destPath = normalizeOperationPath(userID, destPath)
davSrc := c.WebDAVPath(userID, srcPath) davSrc := c.WebDAVPath(userID, srcPath)
destURL := c.baseURL + c.WebDAVPath(userID, destPath) destHeader := c.webDAVDestination(c.WebDAVPath(userID, destPath))
resp, err := c.DoAsUser(ctx, "MOVE", davSrc, nil, userID, map[string]string{ resp, err := c.DoAsUser(ctx, "MOVE", davSrc, nil, userID, map[string]string{
"Destination": destURL, "Destination": destHeader,
"Overwrite": "F", "Overwrite": "F",
}) })
if err != nil { if err != nil {
@ -189,10 +203,12 @@ func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) err
} }
func (c *Client) Copy(ctx context.Context, userID, srcPath, destPath string) error { func (c *Client) Copy(ctx context.Context, userID, srcPath, destPath string) error {
srcPath = normalizeOperationPath(userID, srcPath)
destPath = normalizeOperationPath(userID, destPath)
davSrc := c.WebDAVPath(userID, srcPath) davSrc := c.WebDAVPath(userID, srcPath)
destURL := c.baseURL + c.WebDAVPath(userID, destPath) destHeader := c.webDAVDestination(c.WebDAVPath(userID, destPath))
resp, err := c.DoAsUser(ctx, "COPY", davSrc, nil, userID, map[string]string{ resp, err := c.DoAsUser(ctx, "COPY", davSrc, nil, userID, map[string]string{
"Destination": destURL, "Destination": destHeader,
"Overwrite": "F", "Overwrite": "F",
}) })
if err != nil { if err != nil {
@ -224,7 +240,7 @@ func (c *Client) UploadChunk(ctx context.Context, userID, uploadID, chunkName st
func (c *Client) AssembleChunks(ctx context.Context, userID, uploadID, destinationPath string, totalSize int64) error { func (c *Client) AssembleChunks(ctx context.Context, userID, uploadID, destinationPath string, totalSize int64) error {
source := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/.file", userID, uploadID) source := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/.file", userID, uploadID)
destination := c.baseURL + c.WebDAVPath(userID, destinationPath) destination := c.webDAVDestination(c.WebDAVPath(userID, destinationPath))
headers := map[string]string{ headers := map[string]string{
"Destination": destination, "Destination": destination,
} }
@ -468,26 +484,19 @@ func (c *Client) ListShares(ctx context.Context, userID, filePath string) ([]Sha
var ocsResp struct { var ocsResp struct {
OCS struct { OCS struct {
Data []struct { Data json.RawMessage `json:"data"`
ID int `json:"id"`
Path string `json:"path"`
ShareType int `json:"share_type"`
Permissions int `json:"permissions"`
URL string `json:"url"`
Expiration string `json:"expiration"`
ShareWith string `json:"share_with"`
ShareWithDisplayName string `json:"share_with_displayname"`
Label string `json:"label"`
Token string `json:"token"`
} `json:"data"`
} `json:"ocs"` } `json:"ocs"`
} }
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
return nil, err return nil, err
} }
out := make([]ShareInfo, 0, len(ocsResp.OCS.Data)) items, err := decodeOCSShareRecords(ocsResp.OCS.Data)
for _, item := range ocsResp.OCS.Data { if err != nil {
out = append(out, mapOCSShareItem(item.ID, item.Path, item.ShareType, item.Permissions, item.URL, item.Expiration, item.ShareWith, item.ShareWithDisplayName, item.Label, item.Token)) return nil, err
}
out := make([]ShareInfo, 0, len(items))
for _, item := range items {
out = append(out, mapOCSShareRecord(item, filePath))
} }
return out, nil return out, nil
} }
@ -530,21 +539,25 @@ func (c *Client) DeleteShare(ctx context.Context, userID, shareID string) error
return nil return nil
} }
func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string) error { func trashbinItemSeg(trashName string) string {
userSeg := url.PathEscape(strings.TrimSpace(userID))
logical := strings.Trim(strings.TrimPrefix(trashName, "/"), "/") logical := strings.Trim(strings.TrimPrefix(trashName, "/"), "/")
var nameSeg string if logical == "" {
if logical != "" { return ""
}
parts := strings.Split(logical, "/") parts := strings.Split(logical, "/")
for i, p := range parts { for i, p := range parts {
parts[i] = url.PathEscape(p) parts[i] = url.PathEscape(p)
} }
nameSeg = strings.Join(parts, "/") return strings.Join(parts, "/")
} }
func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string) error {
userSeg := url.PathEscape(strings.TrimSpace(userID))
nameSeg := trashbinItemSeg(trashName)
src := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg) src := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg)
destURL := c.baseURL + c.WebDAVPath(userID, "/"+strings.TrimPrefix(trashName, "/")) destHeader := c.webDAVDestination(fmt.Sprintf("/remote.php/dav/trashbin/%s/restore/%s", userSeg, nameSeg))
resp, err := c.DoAsUser(ctx, "MOVE", src, nil, userID, map[string]string{ resp, err := c.DoAsUser(ctx, "MOVE", src, nil, userID, map[string]string{
"Destination": destURL, "Destination": destHeader,
"Overwrite": "T", "Overwrite": "T",
}) })
if err != nil { if err != nil {
@ -557,7 +570,89 @@ func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string)
return nil return nil
} }
func (c *Client) DeleteFromTrash(ctx context.Context, userID, trashName string) error {
userSeg := url.PathEscape(strings.TrimSpace(userID))
nameSeg := trashbinItemSeg(trashName)
apiPath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg)
resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return &HTTPStatusError{Operation: "delete trash", StatusCode: resp.StatusCode}
}
return nil
}
func (c *Client) EmptyTrash(ctx context.Context, userID string) error {
userSeg := url.PathEscape(strings.TrimSpace(userID))
apiPath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash", userSeg)
resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return &HTTPStatusError{Operation: "empty trash", StatusCode: resp.StatusCode}
}
return nil
}
const (
favoritesMaxDirs = 2000
favoritesMaxCollect = 500
)
func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, maxCollect int) ([]FileInfo, error) {
if maxCollect <= 0 {
maxCollect = favoritesMaxCollect
}
basePath = normalizeSearchPath(basePath)
if basePath == "" {
basePath = "/"
}
queue := []string{basePath}
seen := map[string]struct{}{basePath: {}}
results := make([]FileInfo, 0, maxCollect)
visited := 0
for len(queue) > 0 && visited < favoritesMaxDirs && len(results) < maxCollect {
dir := queue[0]
queue = queue[1:]
visited++
files, err := c.ListFiles(ctx, userID, dir)
if err != nil {
continue
}
for _, f := range files {
if f.IsFavorite {
results = append(results, f)
if len(results) >= maxCollect {
break
}
}
if !isDirectoryEntry(f) {
continue
}
child := normalizeSearchPath(f.Path)
if child == "" || child == "/" {
continue
}
if _, ok := seen[child]; ok {
continue
}
seen[child] = struct{}{}
queue = append(queue, child)
}
}
return results, nil
}
func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error { func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
filePath = normalizeOperationPath(userID, filePath)
davPath := c.WebDAVPath(userID, filePath) davPath := c.WebDAVPath(userID, filePath)
val := "0" val := "0"
if favorite { if favorite {
@ -583,60 +678,7 @@ func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favor
} }
func decodeShareResponse(body io.Reader, path string) (*ShareInfo, error) { func decodeShareResponse(body io.Reader, path string) (*ShareInfo, error) {
var ocsResp struct { return decodeOCSShareResponse(body, path)
OCS struct {
Data struct {
ID int `json:"id"`
URL string `json:"url"`
Permissions int `json:"permissions"`
Expiration string `json:"expiration"`
ShareType int `json:"share_type"`
ShareWith string `json:"share_with"`
ShareWithDisplayName string `json:"share_with_displayname"`
Label string `json:"label"`
Token string `json:"token"`
} `json:"data"`
} `json:"ocs"`
}
if err := json.NewDecoder(body).Decode(&ocsResp); err != nil {
return nil, err
}
item := mapOCSShareItem(
ocsResp.OCS.Data.ID,
path,
ocsResp.OCS.Data.ShareType,
ocsResp.OCS.Data.Permissions,
ocsResp.OCS.Data.URL,
ocsResp.OCS.Data.Expiration,
ocsResp.OCS.Data.ShareWith,
ocsResp.OCS.Data.ShareWithDisplayName,
ocsResp.OCS.Data.Label,
ocsResp.OCS.Data.Token,
)
return &item, nil
}
func mapOCSShareItem(id int, path string, shareType, permissions int, shareURL, expiration, shareWith, shareWithDisplayName, label, token string) ShareInfo {
info := ShareInfo{
ID: fmt.Sprintf("%d", id),
Path: path,
ShareType: shareType,
Permissions: permissions,
URL: shareURL,
ExpiresAt: expiration,
ShareWith: shareWith,
ShareWithDisplayName: shareWithDisplayName,
Label: label,
Token: token,
}
if shareType == 3 {
if strings.EqualFold(strings.TrimSpace(label), "internal") {
info.AccessMode = "internal"
} else {
info.AccessMode = "public"
}
}
return info
} }
func (c *Client) CreateShare(ctx context.Context, userID, path string, opts CreateShareOptions) (*ShareInfo, error) { func (c *Client) CreateShare(ctx context.Context, userID, path string, opts CreateShareOptions) (*ShareInfo, error) {
@ -902,6 +944,8 @@ type prop struct {
ShareTypes shareTypes `xml:"http://owncloud.org/ns share-types"` ShareTypes shareTypes `xml:"http://owncloud.org/ns share-types"`
FileID string `xml:"http://owncloud.org/ns fileid"` FileID string `xml:"http://owncloud.org/ns fileid"`
DisplayName string `xml:"displayname"` DisplayName string `xml:"displayname"`
Permissions string `xml:"http://owncloud.org/ns permissions"`
OwnerID string `xml:"http://owncloud.org/ns owner-id"`
CalendarColor string `xml:"calendar-color"` CalendarColor string `xml:"calendar-color"`
} }

View File

@ -10,27 +10,35 @@ import (
"strings" "strings"
) )
func (c *Client) FileID(ctx context.Context, userID, filePath string) (int64, error) { const propfindRevisionBody = `<?xml version="1.0" encoding="UTF-8"?>
filePath = NormalizeClientFilePath(userID, filePath)
davPath := c.WebDAVPath(userID, filePath)
body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> <d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop> <d:prop>
<oc:fileid/> <oc:fileid/>
<d:getetag/>
</d:prop> </d:prop>
</d:propfind>` </d:propfind>`
resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{ // FileRevision holds stable Nextcloud identifiers for a file (same across sharees).
type FileRevision struct {
FileID int64
ETag string
}
func (c *Client) FileRevision(ctx context.Context, userID, filePath string) (FileRevision, error) {
filePath = NormalizeClientFilePath(userID, filePath)
davPath := c.WebDAVPath(userID, filePath)
resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(propfindRevisionBody), userID, map[string]string{
"Depth": "0", "Depth": "0",
"Content-Type": "application/xml", "Content-Type": "application/xml",
}) })
if err != nil { if err != nil {
return 0, err return FileRevision{}, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return 0, &HTTPStatusError{Operation: "propfind fileid", StatusCode: resp.StatusCode} return FileRevision{}, &HTTPStatusError{Operation: "propfind file revision", StatusCode: resp.StatusCode}
} }
var ms struct { var ms struct {
@ -38,27 +46,36 @@ func (c *Client) FileID(ctx context.Context, userID, filePath string) (int64, er
Propstat struct { Propstat struct {
Prop struct { Prop struct {
FileID string `xml:"http://owncloud.org/ns fileid"` FileID string `xml:"http://owncloud.org/ns fileid"`
ETag string `xml:"getetag"`
} `xml:"prop"` } `xml:"prop"`
} `xml:"propstat"` } `xml:"propstat"`
} `xml:"response"` } `xml:"response"`
} }
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return 0, err return FileRevision{}, err
} }
if len(ms.Responses) == 0 { if len(ms.Responses) == 0 {
return 0, fmt.Errorf("fileid propfind: empty response") return FileRevision{}, fmt.Errorf("file revision propfind: empty response")
} }
raw := strings.TrimSpace(ms.Responses[0].Propstat.Prop.FileID) raw := strings.TrimSpace(ms.Responses[0].Propstat.Prop.FileID)
if raw == "" { if raw == "" {
return 0, fmt.Errorf("fileid propfind: missing fileid") return FileRevision{}, fmt.Errorf("file revision propfind: missing fileid")
} }
// Nextcloud may return "00001234" — keep numeric part.
id, err := strconv.ParseInt(raw, 10, 64) id, err := strconv.ParseInt(raw, 10, 64)
if err != nil { if err != nil {
return 0, fmt.Errorf("fileid propfind: invalid fileid %q", raw) return FileRevision{}, fmt.Errorf("file revision propfind: invalid fileid %q", raw)
} }
return id, nil etag := strings.Trim(strings.TrimSpace(ms.Responses[0].Propstat.Prop.ETag), "\"")
return FileRevision{FileID: id, ETag: etag}, nil
}
func (c *Client) FileID(ctx context.Context, userID, filePath string) (int64, error) {
rev, err := c.FileRevision(ctx, userID, filePath)
if err != nil {
return 0, err
}
return rev.FileID, nil
} }
func (c *Client) Preview(ctx context.Context, userID string, fileID int64, width, height int) (io.ReadCloser, string, error) { func (c *Client) Preview(ctx context.Context, userID string, fileID int64, width, height int) (io.ReadCloser, string, error) {

View File

@ -0,0 +1,552 @@
package nextcloud
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// PublicShareView describes a public link share (file or folder).
type PublicShareView struct {
Token string `json:"token"`
Name string `json:"name"`
ItemType string `json:"item_type"`
Path string `json:"path"`
Permissions int `json:"permissions"`
OwnerID string `json:"owner_id,omitempty"`
OwnerDisplayName string `json:"owner_displayname,omitempty"`
Files []FileInfo `json:"files,omitempty"`
File *FileInfo `json:"file,omitempty"`
}
func (c *Client) PublicShareURL(token string) string {
token = strings.TrimSpace(token)
if token == "" || c.drivePublicURL == "" {
return ""
}
return strings.TrimRight(c.drivePublicURL, "/") + "/s/" + url.PathEscape(token)
}
func (c *Client) WithDrivePublicURL(publicURL string) *Client {
if c == nil {
return nil
}
c.drivePublicURL = strings.TrimRight(strings.TrimSpace(publicURL), "/")
return c
}
func publicShareDAVPath(token, relPath string) string {
token = strings.TrimSpace(token)
relPath = NormalizeClientPath(relPath)
base := "/public.php/dav/files/" + url.PathEscape(token)
if relPath == "/" {
return base + "/"
}
trimmed := strings.Trim(relPath, "/")
parts := strings.Split(trimmed, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return base + "/" + strings.Join(parts, "/")
}
func clientPathFromPublicShareHref(href, token string) string {
href = strings.TrimSpace(href)
marker := "/public.php/dav/files/" + url.PathEscape(token)
if idx := strings.Index(href, marker); idx >= 0 {
rest := strings.TrimPrefix(href[idx+len(marker):], "/")
if rest == "" {
return "/"
}
return decodeDAVPath("/" + rest)
}
// Fallback: strip through token segment in path.
parts := strings.Split(strings.Trim(href, "/"), "/")
for i, p := range parts {
if decodeDAVSegment(p) == token && i+1 < len(parts) {
return decodeDAVPath("/" + strings.Join(parts[i+1:], "/"))
}
}
return "/"
}
func (c *Client) GetPublicShare(ctx context.Context, token, relPath, password string) (*PublicShareView, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrInvalidPublicShare
}
relPath = NormalizeClientPath(relPath)
resp, err := c.publicShareRequest(ctx, "PROPFIND", token, relPath, strings.NewReader(propfindListBody), password, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, &HTTPStatusError{Operation: "public share propfind", StatusCode: resp.StatusCode}
}
root, children, permissions, err := parsePublicSharePropfind(resp.Body, token, relPath)
if err != nil {
return nil, err
}
view := &PublicShareView{
Token: token,
Path: relPath,
Name: root.Name,
Permissions: permissions,
}
if sharePerms, permErr := c.GetPublicSharePermissions(ctx, token, password); permErr == nil {
view.Permissions = sharePerms
}
if root.Type == "directory" {
view.ItemType = "folder"
view.Files = children
} else {
view.ItemType = "file"
view.File = &root
}
c.enrichPublicShareOwner(ctx, view, token, password)
return view, nil
}
// PreviewPublicShare returns a thumbnail/preview image for a file in a public link share.
func (c *Client) PreviewPublicShare(ctx context.Context, token, filePath, password string, width, height int) (io.ReadCloser, string, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, "", ErrInvalidPublicShare
}
filePath = NormalizeClientPath(filePath)
if width <= 0 {
width = 400
}
if height <= 0 {
height = 300
}
if width > 2048 {
width = 2048
}
if height > 2048 {
height = 2048
}
q := url.Values{}
q.Set("file", filePath)
q.Set("x", strconv.Itoa(width))
q.Set("y", strconv.Itoa(height))
q.Set("a", "1")
previewPath := fmt.Sprintf(
"/index.php/apps/files_sharing/publicpreview/%s?%s",
url.PathEscape(token),
q.Encode(),
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+previewPath, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if password != "" {
req.SetBasicAuth("", password)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", err
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
resp.Body.Close()
return nil, "", ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", &HTTPStatusError{Operation: "public share preview", StatusCode: resp.StatusCode}
}
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = "image/jpeg"
}
return resp.Body, contentType, nil
}
func (c *Client) DownloadPublicShare(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, "", ErrInvalidPublicShare
}
filePath = NormalizeClientPath(filePath)
body, contentType, err := c.downloadPublicShareAt(ctx, token, filePath, password)
if err == nil {
return body, contentType, nil
}
// Single-file shares expose content at the WebDAV root, not /filename.
var statusErr *HTTPStatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound && filePath != "/" {
return c.downloadPublicShareAt(ctx, token, "/", password)
}
return nil, "", err
}
func (c *Client) downloadPublicShareAt(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) {
davPath := publicShareDAVPath(token, filePath)
resp, err := c.publicShareRequestRaw(ctx, http.MethodGet, davPath, nil, password, nil)
if err != nil {
return nil, "", err
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
resp.Body.Close()
return nil, "", ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", &HTTPStatusError{Operation: "public share download", StatusCode: resp.StatusCode}
}
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = "application/octet-stream"
}
return resp.Body, contentType, nil
}
func (c *Client) publicShareRequest(ctx context.Context, method, token, relPath string, body io.Reader, password string, headers map[string]string) (*http.Response, error) {
return c.publicShareRequestRaw(ctx, method, publicShareDAVPath(token, relPath), body, password, headers)
}
func (c *Client) publicShareRequestRaw(ctx context.Context, method, davPath string, body io.Reader, password string, headers map[string]string) (*http.Response, error) {
if !strings.HasPrefix(davPath, "/") {
davPath = "/" + davPath
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+davPath, body)
if err != nil {
return nil, err
}
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if password != "" {
req.SetBasicAuth("", password)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return c.httpClient.Do(req)
}
const propfindListBody = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
<d:getetag/>
<d:displayname/>
<oc:size/>
<oc:permissions/>
</d:prop>
</d:propfind>`
const propfindOwnerBody = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:owner-id/>
</d:prop>
</d:propfind>`
func (c *Client) enrichPublicShareOwner(ctx context.Context, view *PublicShareView, token, password string) {
if c == nil || view == nil {
return
}
ownerID, err := c.getPublicShareOwnerID(ctx, token, password)
if err != nil || strings.TrimSpace(ownerID) == "" {
return
}
view.OwnerID = ownerID
if name, err := c.UserDisplayName(ctx, ownerID); err == nil && strings.TrimSpace(name) != "" {
view.OwnerDisplayName = strings.TrimSpace(name)
}
}
func (c *Client) getPublicShareOwnerID(ctx context.Context, token, password string) (string, error) {
resp, err := c.publicShareRequest(ctx, "PROPFIND", token, "/", strings.NewReader(propfindOwnerBody), password, map[string]string{
"Depth": "0",
"Content-Type": "application/xml",
})
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return "", ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return "", &HTTPStatusError{Operation: "public share owner", StatusCode: resp.StatusCode}
}
var ms multistatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return "", err
}
if len(ms.Responses) == 0 {
return "", nil
}
return strings.TrimSpace(ms.Responses[0].Propstat.Prop.OwnerID), nil
}
const propfindPermBody = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:permissions/>
</d:prop>
</d:propfind>`
func (c *Client) GetPublicSharePermissions(ctx context.Context, token, password string) (int, error) {
return c.GetPublicSharePathPermissions(ctx, token, "/", password)
}
const propfindPublicRevisionBody = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:fileid/>
<d:getetag/>
</d:prop>
</d:propfind>`
func (c *Client) PublicShareFileRevision(ctx context.Context, token, filePath, password string) (FileRevision, error) {
token = strings.TrimSpace(token)
if token == "" {
return FileRevision{}, ErrInvalidPublicShare
}
filePath = NormalizeClientPath(filePath)
resp, err := c.publicShareRequest(ctx, "PROPFIND", token, filePath, strings.NewReader(propfindPublicRevisionBody), password, map[string]string{
"Depth": "0",
"Content-Type": "application/xml",
})
if err != nil {
return FileRevision{}, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return FileRevision{}, ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return FileRevision{}, &HTTPStatusError{Operation: "public share file revision", StatusCode: resp.StatusCode}
}
var ms multistatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return FileRevision{}, err
}
if len(ms.Responses) == 0 {
return FileRevision{}, fmt.Errorf("public share file revision: empty response")
}
raw := strings.TrimSpace(ms.Responses[0].Propstat.Prop.FileID)
if raw == "" {
return FileRevision{}, fmt.Errorf("public share file revision: missing fileid")
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return FileRevision{}, fmt.Errorf("public share file revision: invalid fileid %q", raw)
}
etag := strings.Trim(strings.TrimSpace(ms.Responses[0].Propstat.Prop.ETag), "\"")
return FileRevision{FileID: id, ETag: etag}, nil
}
func (c *Client) GetPublicSharePathPermissions(ctx context.Context, token, relPath, password string) (int, error) {
token = strings.TrimSpace(token)
if token == "" {
return 0, ErrInvalidPublicShare
}
relPath = NormalizeClientPath(relPath)
resp, err := c.publicShareRequest(ctx, "PROPFIND", token, relPath, strings.NewReader(propfindPermBody), password, map[string]string{
"Depth": "0",
"Content-Type": "application/xml",
})
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return 0, ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return 0, &HTTPStatusError{Operation: "public share permissions", StatusCode: resp.StatusCode}
}
var ms multistatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return 0, err
}
if len(ms.Responses) == 0 {
return 0, fmt.Errorf("public share: empty permissions response")
}
return ParseOCPermissionLetters(ms.Responses[0].Propstat.Prop.Permissions), nil
}
func parsePublicSharePropfind(body io.Reader, token, listDir string) (FileInfo, []FileInfo, int, error) {
var ms multistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return FileInfo{}, nil, 0, err
}
if len(ms.Responses) == 0 {
return FileInfo{}, nil, 0, fmt.Errorf("public share: empty response")
}
root := fileInfoFromPublicDAV(ms.Responses[0], token, listDir)
permissions := ParseOCPermissionLetters(ms.Responses[0].Propstat.Prop.Permissions)
children := make([]FileInfo, 0, len(ms.Responses)-1)
for i := 1; i < len(ms.Responses); i++ {
child := fileInfoFromPublicDAV(ms.Responses[i], token, listDir)
if child.Name == "" {
continue
}
children = append(children, child)
}
return root, children, permissions, nil
}
func fileInfoFromPublicDAV(r response, token, listDir string) FileInfo {
name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href)
clientPath := clientPathFromPublicShareHref(r.Href, token)
if name == "" {
name = pathBaseName(strings.Trim(clientPath, "/"))
}
if clientPath == "/" && name == "" {
name = "Partage"
}
fileType := "file"
if r.Propstat.Prop.ResourceType.Collection != nil {
fileType = "directory"
}
size := r.Propstat.Prop.ContentLength
if r.Propstat.Prop.Size > 0 {
size = r.Propstat.Prop.Size
}
displayPath := clientPath
if displayPath == "/" && fileType == "file" {
// Single-file shares: WebDAV root is the file itself.
displayPath = "/"
} else if displayPath == "/" {
displayPath = "/" + name
}
return FileInfo{
Path: displayPath,
Name: name,
Type: fileType,
Size: size,
MimeType: r.Propstat.Prop.ContentType,
LastModified: r.Propstat.Prop.LastModified,
ETag: strings.Trim(r.Propstat.Prop.ETag, "\""),
}
}
var (
ErrInvalidPublicShare = errors.New("invalid public share token")
ErrPublicSharePasswordRequired = errors.New("public share password required")
ErrPublicShareReadOnly = errors.New("public share read only")
)
func (c *Client) UploadPublicShare(ctx context.Context, token, filePath, password string, content io.Reader, contentType string) error {
token = strings.TrimSpace(token)
if token == "" {
return ErrInvalidPublicShare
}
filePath = NormalizeClientPath(filePath)
headers := map[string]string{}
if contentType != "" {
headers["Content-Type"] = contentType
}
resp, err := c.publicShareRequest(ctx, http.MethodPut, token, filePath, content, password, headers)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return &HTTPStatusError{Operation: "public share upload", StatusCode: resp.StatusCode}
}
return nil
}
func (c *Client) CreatePublicShareFolder(ctx context.Context, token, folderPath, password string) error {
token = strings.TrimSpace(token)
if token == "" {
return ErrInvalidPublicShare
}
folderPath = NormalizeClientPath(folderPath)
resp, err := c.publicShareRequest(ctx, "MKCOL", token, folderPath, nil, password, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusCreated {
return &HTTPStatusError{Operation: "public share mkcol", StatusCode: resp.StatusCode}
}
return nil
}
func (c *Client) DeletePublicShare(ctx context.Context, token, filePath, password string) error {
token = strings.TrimSpace(token)
if token == "" {
return ErrInvalidPublicShare
}
filePath = NormalizeClientPath(filePath)
resp, err := c.publicShareRequest(ctx, http.MethodDelete, token, filePath, nil, password, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return &HTTPStatusError{Operation: "public share delete", StatusCode: resp.StatusCode}
}
return nil
}
func (c *Client) MovePublicShare(ctx context.Context, token, srcPath, destPath, password string) error {
token = strings.TrimSpace(token)
if token == "" {
return ErrInvalidPublicShare
}
srcPath = NormalizeClientPath(srcPath)
destPath = NormalizeClientPath(destPath)
srcDAV := publicShareDAVPath(token, srcPath)
destDAV := publicShareDAVPath(token, destPath)
resp, err := c.publicShareRequestRaw(ctx, "MOVE", srcDAV, nil, password, map[string]string{
"Destination": c.webDAVDestination(destDAV),
"Overwrite": "F",
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return ErrPublicSharePasswordRequired
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
return &HTTPStatusError{Operation: "public share move", StatusCode: resp.StatusCode}
}
return nil
}

View File

@ -0,0 +1,30 @@
package nextcloud
import "strings"
// ParseOCPermissionLetters maps Nextcloud WebDAV oc:permissions letters to OCS bitmask.
// S=share(16), R=read(1), C=create(4), W=update(2), D=delete(8).
func ParseOCPermissionLetters(raw string) int {
raw = strings.ToUpper(strings.TrimSpace(raw))
var perms int
for _, ch := range raw {
switch ch {
case 'S':
perms |= 16
case 'R':
perms |= 1
case 'C':
perms |= 4
case 'W':
perms |= 2
case 'D':
perms |= 8
}
}
return perms
}
func PublicShareCanRead(perms int) bool { return perms&1 != 0 }
func PublicShareCanUpdate(perms int) bool { return perms&2 != 0 }
func PublicShareCanCreate(perms int) bool { return perms&4 != 0 }
func PublicShareCanDelete(perms int) bool { return perms&8 != 0 }

View File

@ -0,0 +1,207 @@
package nextcloud
import (
"context"
"sort"
"strings"
)
const (
searchSuggestLimit = 8
searchDefaultLimit = 100
searchSuggestMaxDirs = 400
searchFullMaxDirs = 2000
searchMaxCollect = 500
)
type SearchScope string
const (
SearchScopeAll SearchScope = "all"
SearchScopeShared SearchScope = "shared"
SearchScopeFolder SearchScope = "folder"
)
type SearchOptions struct {
Query string
Scope SearchScope
BasePath string
Suggest bool
Limit int
MaxDirs int
}
func (c *Client) SearchFiles(ctx context.Context, userID string, opts SearchOptions) ([]FileInfo, error) {
q := strings.ToLower(strings.TrimSpace(opts.Query))
if q == "" {
return []FileInfo{}, nil
}
limit := opts.Limit
if limit <= 0 {
if opts.Suggest {
limit = searchSuggestLimit
} else {
limit = searchDefaultLimit
}
}
maxDirs := opts.MaxDirs
if maxDirs <= 0 {
if opts.Suggest {
maxDirs = searchSuggestMaxDirs
} else {
maxDirs = searchFullMaxDirs
}
}
scope := opts.Scope
if scope == "" {
scope = SearchScopeAll
}
var roots []string
switch scope {
case SearchScopeShared:
shared, err := c.ListSharedWithMe(ctx, userID)
if err != nil {
return nil, err
}
roots = make([]string, 0, len(shared))
for _, item := range shared {
roots = append(roots, item.Path)
}
case SearchScopeFolder:
base := normalizeSearchPath(opts.BasePath)
if base == "" {
base = "/"
}
roots = []string{base}
default:
roots = []string{"/"}
}
results, err := c.searchRecursive(ctx, userID, roots, q, limit, maxDirs)
if err != nil {
return nil, err
}
sortSearchResults(results, q)
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
func normalizeSearchPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return strings.TrimSuffix(path, "/")
}
func fileMatchesQuery(f FileInfo, q string) bool {
return strings.Contains(strings.ToLower(f.Name), q) ||
strings.Contains(strings.ToLower(f.Path), q)
}
func isDirectoryEntry(f FileInfo) bool {
if f.Type == "directory" {
return true
}
return strings.HasPrefix(strings.ToLower(f.MimeType), "httpd/unix-directory")
}
func (c *Client) searchRecursive(
ctx context.Context,
userID string,
roots []string,
q string,
limit, maxDirs int,
) ([]FileInfo, error) {
queue := make([]string, 0, len(roots))
seen := make(map[string]struct{}, len(roots))
for _, root := range roots {
root = normalizeSearchPath(root)
if root == "" {
continue
}
if _, ok := seen[root]; ok {
continue
}
seen[root] = struct{}{}
queue = append(queue, root)
}
results := make([]FileInfo, 0, limit)
visited := 0
collectCap := searchMaxCollect
if limit*5 > collectCap {
collectCap = limit * 5
}
for len(queue) > 0 && visited < maxDirs && len(results) < collectCap {
dir := queue[0]
queue = queue[1:]
visited++
files, err := c.ListFiles(ctx, userID, dir)
if err != nil {
continue
}
for _, f := range files {
if fileMatchesQuery(f, q) {
results = append(results, f)
if len(results) >= collectCap {
break
}
}
if !isDirectoryEntry(f) {
continue
}
child := normalizeSearchPath(f.Path)
if child == "" || child == "/" {
continue
}
if _, ok := seen[child]; ok {
continue
}
seen[child] = struct{}{}
queue = append(queue, child)
}
}
return results, nil
}
func searchMatchScore(f FileInfo, q string) int {
name := strings.ToLower(f.Name)
path := strings.ToLower(f.Path)
switch {
case name == q:
return 1000
case strings.HasPrefix(name, q):
return 800
case strings.Contains(name, q):
return 600
case strings.Contains(path, q):
return 400
default:
return 0
}
}
func sortSearchResults(files []FileInfo, q string) {
sort.SliceStable(files, func(i, j int) bool {
si := searchMatchScore(files[i], q)
sj := searchMatchScore(files[j], q)
if si != sj {
return si > sj
}
if len(files[i].Path) != len(files[j].Path) {
return len(files[i].Path) < len(files[j].Path)
}
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
}

View File

@ -0,0 +1,39 @@
package nextcloud
import "testing"
func TestFileMatchesQuery(t *testing.T) {
f := FileInfo{Name: "Report Q4.pdf", Path: "/Docs/Report Q4.pdf"}
if !fileMatchesQuery(f, "report") {
t.Fatal("expected name match")
}
if fileMatchesQuery(f, "missing") {
t.Fatal("expected no match")
}
}
func TestSortSearchResults_prefersNamePrefix(t *testing.T) {
files := []FileInfo{
{Name: "archive-report.pdf", Path: "/a/archive-report.pdf", Type: "file"},
{Name: "report.pdf", Path: "/b/report.pdf", Type: "file"},
}
sortSearchResults(files, "report")
if files[0].Name != "report.pdf" {
t.Fatalf("got %q, want report.pdf first", files[0].Name)
}
}
func TestNormalizeSearchPath(t *testing.T) {
if got := normalizeSearchPath("foo/bar/"); got != "/foo/bar" {
t.Fatalf("got %q", got)
}
}
func TestIsDirectoryEntry(t *testing.T) {
if !isDirectoryEntry(FileInfo{Type: "directory"}) {
t.Fatal("directory type")
}
if !isDirectoryEntry(FileInfo{MimeType: "httpd/unix-directory"}) {
t.Fatal("directory mime")
}
}

View File

@ -0,0 +1,132 @@
package nextcloud
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
type ocsShareRecord struct {
ID flexShareID `json:"id"`
Path string `json:"path"`
ShareType int `json:"share_type"`
Permissions int `json:"permissions"`
URL string `json:"url"`
Expiration string `json:"expiration"`
ShareWith *string `json:"share_with"`
ShareWithDisplayName string `json:"share_with_displayname"`
Label string `json:"label"`
Token string `json:"token"`
UIDOwner string `json:"uid_owner"`
DisplayNameOwner string `json:"displayname_owner"`
UIDFileOwner string `json:"uid_file_owner"`
DisplayNameFileOwner string `json:"displayname_file_owner"`
Stime int64 `json:"stime"`
Note string `json:"note"`
ItemType string `json:"item_type"`
Password *string `json:"password"`
}
// flexShareID accepts Nextcloud share ids as JSON string or number.
type flexShareID string
func (f *flexShareID) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
*f = flexShareID(strings.TrimSpace(s))
return nil
}
var n json.Number
if err := json.Unmarshal(b, &n); err == nil {
*f = flexShareID(n.String())
return nil
}
return fmt.Errorf("invalid share id")
}
func (f flexShareID) String() string {
return string(f)
}
func decodeOCSShareRecords(raw json.RawMessage) ([]ocsShareRecord, error) {
raw = json.RawMessage(strings.TrimSpace(string(raw)))
if len(raw) == 0 || string(raw) == "null" || string(raw) == "[]" {
return nil, nil
}
if raw[0] == '[' {
var list []ocsShareRecord
if err := json.Unmarshal(raw, &list); err != nil {
return nil, err
}
return list, nil
}
var one ocsShareRecord
if err := json.Unmarshal(raw, &one); err != nil {
return nil, err
}
return []ocsShareRecord{one}, nil
}
func decodeOCSShareResponse(body io.Reader, fallbackPath string) (*ShareInfo, error) {
var ocsResp struct {
OCS struct {
Data json.RawMessage `json:"data"`
} `json:"ocs"`
}
if err := json.NewDecoder(body).Decode(&ocsResp); err != nil {
return nil, err
}
items, err := decodeOCSShareRecords(ocsResp.OCS.Data)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("empty share response")
}
info := mapOCSShareRecord(items[0], fallbackPath)
return &info, nil
}
func mapOCSShareRecord(item ocsShareRecord, fallbackPath string) ShareInfo {
path := strings.TrimSpace(item.Path)
if path == "" {
path = fallbackPath
}
shareWith := ""
if item.ShareWith != nil {
shareWith = strings.TrimSpace(*item.ShareWith)
}
expiration := strings.TrimSpace(item.Expiration)
info := ShareInfo{
ID: item.ID.String(),
Path: path,
ShareType: item.ShareType,
Permissions: item.Permissions,
URL: item.URL,
ExpiresAt: expiration,
ShareWith: shareWith,
ShareWithDisplayName: strings.TrimSpace(item.ShareWithDisplayName),
Label: strings.TrimSpace(item.Label),
Token: strings.TrimSpace(item.Token),
OwnerID: strings.TrimSpace(item.UIDOwner),
OwnerDisplayName: strings.TrimSpace(item.DisplayNameOwner),
FileOwnerID: strings.TrimSpace(item.UIDFileOwner),
FileOwnerDisplayName: strings.TrimSpace(item.DisplayNameFileOwner),
Note: strings.TrimSpace(item.Note),
ItemType: strings.TrimSpace(item.ItemType),
HasPassword: item.Password != nil && strings.TrimSpace(*item.Password) != "",
}
if ts := item.Stime; ts > 0 {
info.CreatedAt = time.Unix(ts, 0).UTC().Format(time.RFC3339)
}
if item.ShareType == 3 {
if strings.EqualFold(info.Label, "internal") {
info.AccessMode = "internal"
} else {
info.AccessMode = "public"
}
}
return info
}

View File

@ -0,0 +1,60 @@
package nextcloud
import (
"strings"
"testing"
)
func TestDecodeOCSShareRecordsArray(t *testing.T) {
raw := `[{"id":"6","share_type":3,"permissions":17,"path":"/docs","share_with":null,"stime":1780572446}]`
items, err := decodeOCSShareRecords([]byte(raw))
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("len = %d, want 1", len(items))
}
if items[0].ID.String() != "6" {
t.Fatalf("id = %q", items[0].ID)
}
}
func TestDecodeOCSShareRecordsSingleObject(t *testing.T) {
raw := `{"id":12,"share_type":0,"permissions":1,"path":"/file.txt","share_with":"admin"}`
items, err := decodeOCSShareRecords([]byte(raw))
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].ID.String() != "12" {
t.Fatalf("unexpected items: %+v", items)
}
if items[0].ShareWith == nil || *items[0].ShareWith != "admin" {
t.Fatalf("share_with = %v", items[0].ShareWith)
}
}
func TestMapOCSShareRecordOwnerAndCreatedAt(t *testing.T) {
with := "user@example.com"
item := ocsShareRecord{
ID: "3",
ShareType: 3,
Permissions: 1,
URL: "http://localhost/s/abc",
ShareWith: &with,
DisplayNameOwner: "Alice",
DisplayNameFileOwner: "Alice",
Stime: 1780572446,
Label: "internal",
ItemType: "file",
}
info := mapOCSShareRecord(item, "/file.txt")
if info.AccessMode != "internal" {
t.Fatalf("access_mode = %q", info.AccessMode)
}
if info.OwnerDisplayName != "Alice" {
t.Fatalf("owner = %q", info.OwnerDisplayName)
}
if !strings.Contains(info.CreatedAt, "2026") {
t.Fatalf("created_at = %q", info.CreatedAt)
}
}

View File

@ -77,6 +77,14 @@ func (c *Client) EnsurePrincipal(ctx context.Context, email, sub, displayName st
return userID, nil return userID, nil
} }
// InvalidatePrincipalCredentials removes stored CardDAV app credentials for a user.
func (c *Client) InvalidatePrincipalCredentials(ctx context.Context, userID string) error {
if c.credStore == nil {
return nil
}
return c.credStore.DeleteToken(ctx, userID)
}
// RefreshPrincipalCredentials rotates the Nextcloud login password and app password for an existing user. // RefreshPrincipalCredentials rotates the Nextcloud login password and app password for an existing user.
func (c *Client) RefreshPrincipalCredentials(ctx context.Context, userID string) error { func (c *Client) RefreshPrincipalCredentials(ctx context.Context, userID string) error {
if c.credStore == nil { if c.credStore == nil {
@ -106,6 +114,41 @@ func (c *Client) UserExists(ctx context.Context, userID string) (bool, error) {
return c.userExists(ctx, userID) return c.userExists(ctx, userID)
} }
// UserDisplayName returns the Nextcloud account display name for a user id.
func (c *Client) UserDisplayName(ctx context.Context, userID string) (string, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return "", fmt.Errorf("empty user id")
}
path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID))
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{
"Accept": "application/json",
})
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", &HTTPStatusError{Operation: "get user display name", StatusCode: resp.StatusCode}
}
var payload struct {
OCS struct {
Data struct {
DisplayName string `json:"displayname"`
ID string `json:"id"`
} `json:"data"`
} `json:"ocs"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
name := strings.TrimSpace(payload.OCS.Data.DisplayName)
if name != "" {
return name, nil
}
return strings.TrimSpace(payload.OCS.Data.ID), nil
}
func (c *Client) userExists(ctx context.Context, userID string) (bool, error) { func (c *Client) userExists(ctx context.Context, userID string) (bool, error) {
path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID)) path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID))
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{ resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{

View File

@ -17,10 +17,27 @@ func TestUserIDFromClaimsFallbackSub(t *testing.T) {
} }
func TestNormalizeDAVHref(t *testing.T) { func TestNormalizeDAVHref(t *testing.T) {
got := normalizeDAVHref("/cloud/remote.php/dav/addressbooks/users/alice/contacts/") tests := []struct {
want := "/remote.php/dav/addressbooks/users/alice/contacts/" in, want string
if got != want { }{
t.Fatalf("normalizeDAVHref() = %q, want %q", got, want) {
"/cloud/remote.php/dav/addressbooks/users/alice/contacts/",
"/remote.php/dav/addressbooks/users/alice/contacts/",
},
{
"cloud/remote.php/dav/addressbooks/users/alice/contacts/uid.vcf",
"/remote.php/dav/addressbooks/users/alice/contacts/uid.vcf",
},
{
"/remote.php/dav/addressbooks/users/alice/contacts/uid.vcf",
"/remote.php/dav/addressbooks/users/alice/contacts/uid.vcf",
},
}
for _, tc := range tests {
got := normalizeDAVHref(tc.in)
if got != tc.want {
t.Fatalf("normalizeDAVHref(%q) = %q, want %q", tc.in, got, tc.want)
}
} }
} }

View File

@ -39,7 +39,7 @@ type Hub struct {
replayBufferSizePerUser int replayBufferSizePerUser int
logger *slog.Logger logger *slog.Logger
verifier *auth.Verifier verifier *auth.Holder
db *pgxpool.Pool db *pgxpool.Pool
} }
@ -66,7 +66,7 @@ var (
errUserProvisioning = errors.New("failed to provision user") errUserProvisioning = errors.New("failed to provision user")
) )
func NewHub(verifier *auth.Verifier, db *pgxpool.Pool) *Hub { func NewHub(verifier *auth.Holder, db *pgxpool.Pool) *Hub {
return &Hub{ return &Hub{
clients: make(map[string]map[*conn]struct{}), clients: make(map[string]map[*conn]struct{}),
sessionCounts: make(map[string]map[string]int), sessionCounts: make(map[string]map[string]int),
@ -138,7 +138,7 @@ func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
} }
func (h *Hub) authenticate(r *http.Request) (string, string, error) { func (h *Hub) authenticate(r *http.Request) (string, string, error) {
if h.verifier == nil { if h.verifier == nil || !h.verifier.Ready() {
return "", "", errVerifierUnavailable return "", "", errVerifierUnavailable
} }
if h.db == nil { if h.db == nil {

View File

@ -0,0 +1,114 @@
package websearch
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var braveSearchURL = "https://api.search.brave.com/res/v1/web/search"
type Result struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
type Client struct {
http *http.Client
}
func NewClient() *Client {
return &Client{http: &http.Client{Timeout: 15 * time.Second}}
}
type braveSearchResponse struct {
Web *struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
func (c *Client) Search(ctx context.Context, provider Provider, query string, count int) ([]Result, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("search query is required")
}
if count <= 0 {
count = 5
}
if count > 20 {
count = 20
}
switch provider.Type {
case ProviderBrave:
return c.searchBrave(ctx, provider.APIKey, query, count)
default:
return nil, fmt.Errorf("unsupported search provider type: %s", provider.Type)
}
}
func (c *Client) searchBrave(ctx context.Context, apiKey, query string, count int) ([]Result, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, fmt.Errorf("brave api key is required")
}
u, err := url.Parse(braveSearchURL)
if err != nil {
return nil, err
}
q := u.Query()
q.Set("q", query)
q.Set("count", fmt.Sprintf("%d", count))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("X-Subscription-Token", apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("brave search failed (%d): %s", resp.StatusCode, string(body))
}
var parsed braveSearchResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if parsed.Web == nil || len(parsed.Web.Results) == 0 {
return []Result{}, nil
}
results := make([]Result, 0, len(parsed.Web.Results))
for _, item := range parsed.Web.Results {
results = append(results, Result{
Title: strings.TrimSpace(item.Title),
URL: strings.TrimSpace(item.URL),
Description: strings.TrimSpace(item.Description),
})
}
return results, nil
}

View File

@ -0,0 +1,86 @@
package websearch
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSearchBrave(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Subscription-Token"); got != "test-key" {
t.Fatalf("unexpected token header: %q", got)
}
if got := r.URL.Query().Get("q"); got != "Jane Doe Acme" {
t.Fatalf("unexpected query: %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"web": {
"results": [
{"title": "Jane Doe - LinkedIn", "url": "https://linkedin.com/in/jane", "description": "CEO at Acme"}
]
}
}`))
}))
defer srv.Close()
oldURL := braveSearchURL
braveSearchURL = srv.URL
t.Cleanup(func() { braveSearchURL = oldURL })
client := NewClient()
results, err := client.Search(context.Background(), Provider{
Type: ProviderBrave,
APIKey: "test-key",
}, "Jane Doe Acme", 5)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Title != "Jane Doe - LinkedIn" {
t.Fatalf("unexpected results: %#v", results)
}
}
func TestBuildContactSearchQuery(t *testing.T) {
got := BuildContactSearchQuery("Jean", "Dupont", "Marie", "Ultimail", "CTO", "Lyon")
want := "Jean Marie Dupont Ultimail CTO Lyon"
if got != want {
t.Fatalf("BuildContactSearchQuery() = %q, want %q", got, want)
}
if BuildContactSearchQuery("", "", "", "", "", "") != "" {
t.Fatal("expected empty query")
}
}
func TestFormatResultsForPrompt(t *testing.T) {
text := FormatResultsForPrompt([]Result{
{Title: "Profil", URL: "https://example.com", Description: "Bio"},
})
if text == "" {
t.Fatal("expected non-empty prompt section")
}
for _, part := range []string{"homonymes", "Profil", "https://example.com", "Bio"} {
if !strings.Contains(text, part) {
t.Fatalf("missing %q in: %s", part, text)
}
}
}
func TestResolveProvider(t *testing.T) {
settings := Settings{
DefaultProviderID: "brave-1",
Providers: []Provider{{
ID: "brave-1", Name: "Brave", Type: ProviderBrave, APIKey: "key",
}},
}
p, err := ResolveProvider(settings)
if err != nil {
t.Fatalf("ResolveProvider: %v", err)
}
if p.ID != "brave-1" {
t.Fatalf("unexpected provider: %#v", p)
}
}

View File

@ -0,0 +1,40 @@
package websearch
import (
"fmt"
"strings"
)
func BuildContactSearchQuery(firstName, lastName, middleName, company, jobTitle, city string) string {
var parts []string
name := strings.TrimSpace(strings.Join([]string{
strings.TrimSpace(firstName),
strings.TrimSpace(middleName),
strings.TrimSpace(lastName),
}, " "))
if name != "" {
parts = append(parts, name)
}
if company = strings.TrimSpace(company); company != "" {
parts = append(parts, company)
}
if jobTitle = strings.TrimSpace(jobTitle); jobTitle != "" {
parts = append(parts, jobTitle)
}
if city = strings.TrimSpace(city); city != "" {
parts = append(parts, city)
}
return strings.Join(parts, " ")
}
func FormatResultsForPrompt(results []Result) string {
if len(results) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Résultats de recherche en ligne, attention ces resultats peuvent n'avoir aucun lien avec le contact ou concerner des homonymes:\n")
for i, r := range results {
fmt.Fprintf(&b, "\n%d. Titre: %s\n URL: %s\n Description: %s", i+1, r.Title, r.URL, r.Description)
}
return b.String()
}

View File

@ -0,0 +1,43 @@
package websearch
import (
"fmt"
"strings"
)
type ProviderType string
const ProviderBrave ProviderType = "brave"
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
Type ProviderType `json:"type"`
APIKey string `json:"api_key,omitempty"`
}
type Settings struct {
DefaultProviderID string `json:"default_provider_id"`
Providers []Provider `json:"providers"`
}
func ResolveProvider(settings Settings) (Provider, error) {
providerID := strings.TrimSpace(settings.DefaultProviderID)
if providerID != "" {
for _, p := range settings.Providers {
if p.ID == providerID && providerConfigured(p) {
return p, nil
}
}
}
for _, p := range settings.Providers {
if providerConfigured(p) {
return p, nil
}
}
return Provider{}, fmt.Errorf("no search provider configured")
}
func providerConfigured(p Provider) bool {
return strings.TrimSpace(p.APIKey) != "" && strings.TrimSpace(string(p.Type)) != ""
}

View File

@ -0,0 +1,5 @@
DROP TABLE IF EXISTS contact_discovery_rejections;
DROP TABLE IF EXISTS contact_enrichment_suggestions;
DROP TABLE IF EXISTS contact_discovered_signatures;
DROP TABLE IF EXISTS contact_discovered_profiles;
DROP TABLE IF EXISTS contact_discovery_scans;

View File

@ -0,0 +1,91 @@
-- Contact discovery from mail messages: pseudo-contacts, signatures, enrichment suggestions.
CREATE TABLE contact_discovery_scans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
messages_scanned INT NOT NULL DEFAULT 0,
profiles_found INT NOT NULL DEFAULT 0,
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_contact_discovery_scans_user ON contact_discovery_scans(user_id, started_at DESC);
CREATE TABLE contact_discovered_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scan_id UUID REFERENCES contact_discovery_scans(id) ON DELETE SET NULL,
person_group_id UUID,
display_name TEXT NOT NULL DEFAULT '',
primary_email TEXT NOT NULL,
all_emails JSONB NOT NULL DEFAULT '[]',
message_count INT NOT NULL DEFAULT 0,
sent_count INT NOT NULL DEFAULT 0,
received_count INT NOT NULL DEFAULT 0,
spam_count INT NOT NULL DEFAULT 0,
forwarded_count INT NOT NULL DEFAULT 0,
is_mailing_list BOOLEAN NOT NULL DEFAULT false,
is_disposable BOOLEAN NOT NULL DEFAULT false,
is_spam_heavy BOOLEAN NOT NULL DEFAULT false,
classification_reason TEXT,
linked_contact_uid TEXT,
enrichment_status TEXT NOT NULL DEFAULT 'pending',
enriched_data JSONB,
enriched_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'suggested',
rejected_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_message_at TIMESTAMPTZ,
UNIQUE(user_id, primary_email)
);
CREATE INDEX idx_contact_discovered_profiles_user_status ON contact_discovered_profiles(user_id, status);
CREATE INDEX idx_contact_discovered_profiles_user_email ON contact_discovered_profiles(user_id, lower(primary_email));
CREATE TABLE contact_discovered_signatures (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES contact_discovered_profiles(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
signature_text TEXT NOT NULL DEFAULT '',
signature_html TEXT,
message_date TIMESTAMPTZ NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_contact_discovered_signatures_profile ON contact_discovered_signatures(profile_id, message_date DESC);
CREATE TABLE contact_enrichment_suggestions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id UUID REFERENCES contact_discovered_profiles(id) ON DELETE CASCADE,
target_contact_uid TEXT,
suggestion_type TEXT NOT NULL,
field_path TEXT NOT NULL,
suggested_value TEXT NOT NULL,
suggested_label TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0.5,
source_signature_id UUID REFERENCES contact_discovered_signatures(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
rejected_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, profile_id, field_path, suggested_value)
);
CREATE INDEX idx_contact_enrichment_suggestions_user ON contact_enrichment_suggestions(user_id, status);
CREATE TABLE contact_discovery_rejections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rejection_key TEXT NOT NULL,
rejection_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, rejection_key, rejection_type)
);
CREATE INDEX idx_contact_discovery_rejections_user ON contact_discovery_rejections(user_id);

View File

@ -0,0 +1,10 @@
DROP INDEX IF EXISTS idx_contact_discovery_scans_active;
ALTER TABLE contact_discovered_profiles DROP COLUMN IF EXISTS detected_in_accounts;
ALTER TABLE contact_discovery_scans
DROP COLUMN IF EXISTS updated_at,
DROP COLUMN IF EXISTS nc_user_id,
DROP COLUMN IF EXISTS book_id,
DROP COLUMN IF EXISTS phase,
DROP COLUMN IF EXISTS total_messages;

View File

@ -0,0 +1,15 @@
-- Scan job progress + mailbox attribution on discovered profiles.
ALTER TABLE contact_discovery_scans
ADD COLUMN IF NOT EXISTS total_messages INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS book_id TEXT NOT NULL DEFAULT 'contacts',
ADD COLUMN IF NOT EXISTS nc_user_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE contact_discovered_profiles
ADD COLUMN IF NOT EXISTS detected_in_accounts JSONB NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_contact_discovery_scans_active
ON contact_discovery_scans(user_id, status)
WHERE status IN ('pending', 'running');

View File

@ -0,0 +1 @@
ALTER TABLE contact_discovery_scans DROP COLUMN IF EXISTS profiles_total;

View File

@ -0,0 +1,2 @@
ALTER TABLE contact_discovery_scans
ADD COLUMN IF NOT EXISTS profiles_total INT NOT NULL DEFAULT 0;

View File

@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_contact_discovered_profiles_person_group;
ALTER TABLE contact_discovered_profiles
DROP COLUMN IF EXISTS user_blocked,
DROP COLUMN IF EXISTS blocked_at,
DROP COLUMN IF EXISTS ignored_at;

View File

@ -0,0 +1,8 @@
ALTER TABLE contact_discovered_profiles
ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS blocked_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS user_blocked BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_contact_discovered_profiles_person_group
ON contact_discovered_profiles(user_id, person_group_id)
WHERE person_group_id IS NOT NULL;

View File

@ -0,0 +1,4 @@
ALTER TABLE contact_discovered_profiles
DROP COLUMN IF EXISTS outbound_count,
DROP COLUMN IF EXISTS inbound_from_cc_count,
DROP COLUMN IF EXISTS copresence_cc_bcc_count;

View File

@ -0,0 +1,4 @@
ALTER TABLE contact_discovered_profiles
ADD COLUMN IF NOT EXISTS outbound_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS inbound_from_cc_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS copresence_cc_bcc_count INT NOT NULL DEFAULT 0;