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:
parent
69bde44b94
commit
556d5f416d
@ -118,7 +118,7 @@ AUTHENTIK_API_URL=http://authentik-server:9000
|
||||
# -----------------------------------------------------------------------------
|
||||
# URL interne (ultid → nginx Nextcloud, racine WebDAV)
|
||||
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_OVERWRITE_PROTOCOL=http
|
||||
NC_ADMIN_USER=admin
|
||||
@ -157,6 +157,8 @@ ONLYOFFICE_JWT_SECRET=changeme-onlyoffice-jwt
|
||||
ONLYOFFICE_OIDC_CLIENT_ID=ulti-onlyoffice
|
||||
# ONLYOFFICE_OIDC_CLIENT_SECRET — defini dans la section Secrets
|
||||
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)
|
||||
|
||||
@ -94,7 +94,13 @@ func main() {
|
||||
|
||||
verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second)
|
||||
if err != nil {
|
||||
slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err)
|
||||
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.OIDCIssuer == "" || cfg.OIDCClientID == "" {
|
||||
@ -103,7 +109,7 @@ func main() {
|
||||
"ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "")
|
||||
os.Exit(1)
|
||||
}
|
||||
if verifier == nil {
|
||||
if !verifierHolder.Ready() {
|
||||
slog.Error("OIDC verifier initialization failed in production")
|
||||
os.Exit(1)
|
||||
}
|
||||
@ -127,8 +133,10 @@ func main() {
|
||||
var ncClient *nextcloud.Client
|
||||
if cfg.NextcloudEnabled {
|
||||
ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass).
|
||||
WithPublicURL(cfg.NextcloudPublicURL).
|
||||
WithDrivePublicURL(cfg.DrivePublicURL).
|
||||
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)
|
||||
@ -146,7 +154,7 @@ func main() {
|
||||
}
|
||||
|
||||
// WebSocket hub
|
||||
hub := realtime.NewHub(verifier, pool)
|
||||
hub := realtime.NewHub(verifierHolder, pool)
|
||||
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
|
||||
|
||||
rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool))
|
||||
@ -214,9 +222,11 @@ func main() {
|
||||
r.Get("/ws", hub.HandleWS)
|
||||
r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback)
|
||||
|
||||
var driveHandler *drive.Handler
|
||||
var driveSvc *drive.Service
|
||||
if ncClient != nil {
|
||||
driveSvc = drive.NewService(ncClient, hub)
|
||||
driveHandler = drive.NewHandler(ncClient, hub)
|
||||
}
|
||||
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
|
||||
officeSvc := office.NewService(ncClient, office.Config{
|
||||
@ -227,11 +237,15 @@ func main() {
|
||||
JWTSecret: cfg.OnlyOfficeJWTSecret,
|
||||
})
|
||||
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.Use(middleware.Auth(verifier, pool, auditLogger))
|
||||
r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
|
||||
|
||||
r.Mount("/api/v1/mail", mailHandler.Routes())
|
||||
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
||||
@ -246,8 +260,8 @@ func main() {
|
||||
TypesenseCollection: cfg.TypesenseCollection,
|
||||
}).Search)
|
||||
|
||||
if ncClient != nil {
|
||||
r.Mount("/api/v1/drive", drive.NewHandler(ncClient, hub).Routes())
|
||||
if driveHandler != nil {
|
||||
r.Mount("/api/v1/drive", driveHandler.Routes())
|
||||
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
|
||||
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient, pool).Routes())
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ services:
|
||||
- REDIS_HOST_PORT=6379
|
||||
- NEXTCLOUD_ADMIN_USER=${NC_ADMIN_USER:-admin}
|
||||
- NEXTCLOUD_ADMIN_PASSWORD=${NC_ADMIN_PASSWORD:-changeme}
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN:-localhost}
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN:-localhost} nextcloud
|
||||
- OBJECTSTORE_S3_BUCKET=nextcloud
|
||||
- OBJECTSTORE_S3_HOST=rustfs
|
||||
- OBJECTSTORE_S3_PORT=9000
|
||||
|
||||
@ -103,6 +103,11 @@ server {
|
||||
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/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $nc_upstream nextcloud;
|
||||
@ -317,6 +322,19 @@ server {
|
||||
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/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||
|
||||
52
deploy/onlyoffice/configure-auto-assembly.sh
Executable file
52
deploy/onlyoffice/configure-auto-assembly.sh
Executable 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."
|
||||
12
deploy/onlyoffice/local.json
Normal file
12
deploy/onlyoffice/local.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
430
internal/api/contacts/discovery_handlers.go
Normal file
430
internal/api/contacts/discovery_handlers.go
Normal 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)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -15,18 +16,28 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
discovery *discovery.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
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{
|
||||
svc: NewService(nc, db),
|
||||
discovery: disc,
|
||||
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("/search", h.SearchContacts)
|
||||
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}/merge-duplicates", h.MergeDuplicateContacts)
|
||||
r.With(write).Post("/improve", h.ImproveContact)
|
||||
r.With(write).Put("/*", h.UpdateContact)
|
||||
r.With(write).Delete("/*", h.DeleteContact)
|
||||
r.Mount("/discovery", h.discoveryRoutes())
|
||||
return r
|
||||
}
|
||||
|
||||
@ -72,13 +86,36 @@ func (h *Handler) writeContactServiceError(w http.ResponseWriter, r *http.Reques
|
||||
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) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
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 {
|
||||
h.writeContactServiceError(w, r, "list address books", err)
|
||||
return
|
||||
@ -123,7 +160,13 @@ func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
h.writeContactServiceError(w, r, "list contacts", err)
|
||||
return
|
||||
@ -173,11 +216,37 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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) {
|
||||
@ -313,3 +382,27 @@ func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
120
internal/api/contacts/match_score.go
Normal file
120
internal/api/contacts/match_score.go
Normal 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
|
||||
}
|
||||
42
internal/api/contacts/match_score_test.go
Normal file
42
internal/api/contacts/match_score_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nextcloud.AddressBookPath(userID, bookID)
|
||||
}
|
||||
@ -63,21 +75,34 @@ func (s *Service) SearchContacts(ctx context.Context, userID, bookID, q string,
|
||||
if searchQ == "" {
|
||||
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 {
|
||||
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{
|
||||
Contacts: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
q = strings.ToLower(strings.TrimSpace(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
|
||||
return rankContactsByQuery(contacts, q)
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
publicOffice PublicOfficeAPI
|
||||
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) {
|
||||
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||
if err != nil {
|
||||
@ -46,7 +51,6 @@ func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
|
||||
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("/trash", h.ListTrash)
|
||||
@ -68,11 +72,13 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.With(write).Post("/copy", h.Copy)
|
||||
r.With(write).Post("/rename", h.Rename)
|
||||
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(admin).Post("/shares", h.CreateShare)
|
||||
r.With(admin).Post("/shares/{shareID}/send-email", h.SendShareEmail)
|
||||
r.With(admin).Put("/shares/{shareID}", h.UpdateShare)
|
||||
r.With(admin).Delete("/shares/{shareID}", h.DeleteShare)
|
||||
r.With(write).Post("/shares", h.CreateShare)
|
||||
r.With(write).Post("/shares/{shareID}/send-email", h.SendShareEmail)
|
||||
r.With(write).Put("/shares/{shareID}", h.UpdateShare)
|
||||
r.With(write).Delete("/shares/{shareID}", h.DeleteShare)
|
||||
|
||||
return r
|
||||
}
|
||||
@ -450,8 +456,11 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
basePath := r.URL.Query().Get("path")
|
||||
result, err := h.svc.Search(r.Context(), ncUser, basePath, params)
|
||||
result, err := h.svc.Search(r.Context(), ncUser, SearchOptions{
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
BasePath: r.URL.Query().Get("path"),
|
||||
Suggest: r.URL.Query().Get("suggest") == "1",
|
||||
}, params)
|
||||
if err != nil {
|
||||
writeDriveError(w, r, err)
|
||||
return
|
||||
@ -590,6 +599,40 @@ func (h *Handler) RestoreTrash(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
|
||||
196
internal/api/drive/public_handlers.go
Normal file
196
internal/api/drive/public_handlers.go
Normal 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)
|
||||
}
|
||||
8
internal/api/drive/public_office.go
Normal file
8
internal/api/drive/public_office.go
Normal 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)
|
||||
}
|
||||
86
internal/api/drive/public_service.go
Normal file
86
internal/api/drive/public_service.go
Normal 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
|
||||
}
|
||||
@ -128,18 +128,20 @@ func (s *Service) ListStarred(ctx context.Context, userID, basePath string, para
|
||||
if 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 {
|
||||
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)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
page, total := paginate.Slice(filtered, params.Offset(), limit)
|
||||
return FilesList{
|
||||
Files: page,
|
||||
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 {
|
||||
source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source))
|
||||
destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination))
|
||||
return mapDriveError(s.nc.Move(ctx, userID, source, destination))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@ -212,12 +218,14 @@ func (s *Service) Rename(ctx context.Context, userID, filePath, newName string)
|
||||
if strings.Contains(newName, "/") {
|
||||
return ErrInvalid
|
||||
}
|
||||
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
||||
dir := path.Dir("/" + strings.TrimPrefix(filePath, "/"))
|
||||
destination := path.Join(dir, newName)
|
||||
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) {
|
||||
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
||||
opts, err := s.buildCreateShareOptions(ctx, req, permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -226,6 +234,7 @@ func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req
|
||||
if err != nil {
|
||||
return nil, mapDriveError(err)
|
||||
}
|
||||
s.rewriteShareURL(share)
|
||||
if shouldSendShareEmail(opts, req) {
|
||||
if sendErr := s.nc.SendShareEmail(ctx, userID, share.ID, ""); sendErr != 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) {
|
||||
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
||||
shares, err := s.nc.ListShares(ctx, userID, filePath)
|
||||
if err != nil {
|
||||
return nil, mapDriveError(err)
|
||||
}
|
||||
s.rewriteShareURLs(shares)
|
||||
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) {
|
||||
share, err := s.nc.UpdateShare(ctx, userID, shareID, permissions, expireDate, password)
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
|
||||
return mapDriveError(s.nc.SetFavorite(ctx, userID, filePath, favorite))
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, userID, basePath string, params query.ListParams) (FilesList, error) {
|
||||
if basePath == "" {
|
||||
basePath = "/"
|
||||
func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions, params query.ListParams) (FilesList, error) {
|
||||
q := strings.TrimSpace(params.Q)
|
||||
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 {
|
||||
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{
|
||||
Files: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type SearchOptions struct {
|
||||
Scope string
|
||||
BasePath string
|
||||
Suggest bool
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
type NewFileKind string
|
||||
|
||||
const (
|
||||
@ -434,7 +543,7 @@ func mapDriveError(err error) error {
|
||||
switch statusErr.StatusCode {
|
||||
case http.StatusNotFound:
|
||||
return ErrNotFound
|
||||
case http.StatusConflict:
|
||||
case http.StatusConflict, http.StatusPreconditionFailed:
|
||||
return ErrConflict
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
return ErrForbidden
|
||||
|
||||
@ -131,6 +131,19 @@ func validateRestoreTrashRequest(req *restoreTrashRequest) *apivalidate.Validati
|
||||
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 {
|
||||
Path string `json:"path"`
|
||||
Favorite bool `json:"favorite"`
|
||||
|
||||
@ -19,10 +19,10 @@ type ctxKey string
|
||||
|
||||
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 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)
|
||||
if audit != nil {
|
||||
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
|
||||
|
||||
@ -90,14 +90,20 @@ func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
h.logger.Error("editor config", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
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{
|
||||
"config": cfg,
|
||||
"config": wrapped,
|
||||
"serverUrl": h.svc.PublicURL(),
|
||||
})
|
||||
}
|
||||
@ -162,14 +168,23 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
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.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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
internal/api/office/keys.go
Normal file
46
internal/api/office/keys.go
Normal 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])
|
||||
}
|
||||
30
internal/api/office/keys_test.go
Normal file
30
internal/api/office/keys_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
150
internal/api/office/public_handlers.go
Normal file
150
internal/api/office/public_handlers.go
Normal 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})
|
||||
}
|
||||
111
internal/api/office/public_share.go
Normal file
111
internal/api/office/public_share.go
Normal 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
|
||||
}
|
||||
@ -2,8 +2,7 @@ package office
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
@ -25,10 +24,15 @@ type Config struct {
|
||||
type Service struct {
|
||||
nc *nextcloud.Client
|
||||
Cfg Config
|
||||
keys *documentKeyStore
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
docType := documentType(filePath)
|
||||
key := documentKey(ncUser, filePath)
|
||||
rev, err := s.nc.FileRevision(ctx, ncUser, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve file revision: %w", err)
|
||||
}
|
||||
|
||||
apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/")
|
||||
sig := ""
|
||||
if s.Cfg.JWTSecret != "" {
|
||||
var err error
|
||||
sig, err = signDocAccess(ncUser, filePath, s.Cfg.JWTSecret)
|
||||
if err != nil {
|
||||
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)
|
||||
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{
|
||||
"fileType": fileExt(filePath),
|
||||
"key": key,
|
||||
"title": path.Base(filePath),
|
||||
"url": downloadURL,
|
||||
"fileType": fileExt(in.filePath),
|
||||
"key": in.documentKey,
|
||||
"title": path.Base(in.filePath),
|
||||
"url": in.downloadURL,
|
||||
"permissions": map[string]any{
|
||||
"comment": true,
|
||||
"copy": true,
|
||||
@ -77,13 +133,18 @@ func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, user
|
||||
},
|
||||
}
|
||||
editorCfg := map[string]any{
|
||||
"mode": mode,
|
||||
"mode": in.mode,
|
||||
"user": map[string]any{
|
||||
"id": ncUser,
|
||||
"name": userName,
|
||||
"id": in.editorUserID,
|
||||
"name": in.userName,
|
||||
},
|
||||
"callbackUrl": in.callbackURL,
|
||||
"coEditing": map[string]any{
|
||||
"mode": "fast",
|
||||
"change": false,
|
||||
},
|
||||
"callbackUrl": callbackURL,
|
||||
"customization": map[string]any{
|
||||
"autosave": true,
|
||||
"forcesave": true,
|
||||
},
|
||||
}
|
||||
@ -92,20 +153,7 @@ func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, user
|
||||
"document": document,
|
||||
"editorConfig": editorCfg,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func documentKey(ncUser, filePath string) string {
|
||||
h := sha256.Sum256([]byte(ncUser + "|" + filePath + "|" + time.Now().Format("2006-01-02")))
|
||||
return hex.EncodeToString(h[:16])
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func documentType(filePath string) string {
|
||||
|
||||
84
internal/auth/holder.go
Normal file
84
internal/auth/holder.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -56,6 +56,7 @@ type Config struct {
|
||||
// Nextcloud
|
||||
NextcloudEnabled bool
|
||||
NextcloudURL string
|
||||
NextcloudPublicURL string
|
||||
NCAdminUser string
|
||||
NCAdminPass string
|
||||
|
||||
@ -66,6 +67,7 @@ type Config struct {
|
||||
OnlyOfficeAPIInternalURL string
|
||||
OnlyOfficeJWTSecret string
|
||||
UltidPublicURL string
|
||||
DrivePublicURL string
|
||||
|
||||
// Jitsi
|
||||
JitsiEnabled bool
|
||||
@ -165,6 +167,7 @@ func Load() (*Config, error) {
|
||||
|
||||
NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true),
|
||||
NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"),
|
||||
NextcloudPublicURL: nextcloudPublicURL(),
|
||||
NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"),
|
||||
NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"),
|
||||
|
||||
@ -174,6 +177,7 @@ func Load() (*Config, error) {
|
||||
OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"),
|
||||
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
|
||||
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
|
||||
DrivePublicURL: drivePublicURL(),
|
||||
|
||||
JitsiEnabled: envBool("JITSI_ENABLED", true),
|
||||
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
|
||||
@ -343,6 +347,24 @@ func joinURL(base, path string) string {
|
||||
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 {
|
||||
trimmed := strings.TrimRight(publicURL, "/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/meet")
|
||||
|
||||
131
internal/contacts/discovery/classify.go
Normal file
131
internal/contacts/discovery/classify.go
Normal 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
|
||||
}
|
||||
115
internal/contacts/discovery/classify_test.go
Normal file
115
internal/contacts/discovery/classify_test.go
Normal 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
|
||||
}
|
||||
150
internal/contacts/discovery/company_infer.go
Normal file
150
internal/contacts/discovery/company_infer.go
Normal 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))
|
||||
}
|
||||
196
internal/contacts/discovery/dispositions.go
Normal file
196
internal/contacts/discovery/dispositions.go
Normal 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
|
||||
}
|
||||
162
internal/contacts/discovery/enrich.go
Normal file
162
internal/contacts/discovery/enrich.go
Normal 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
|
||||
}
|
||||
205
internal/contacts/discovery/enrich_job.go
Normal file
205
internal/contacts/discovery/enrich_job.go
Normal 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
|
||||
}
|
||||
178
internal/contacts/discovery/grouping.go
Normal file
178
internal/contacts/discovery/grouping.go
Normal 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
|
||||
}
|
||||
132
internal/contacts/discovery/improve_contact.go
Normal file
132
internal/contacts/discovery/improve_contact.go
Normal 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
|
||||
}
|
||||
47
internal/contacts/discovery/llm_settings.go
Normal file
47
internal/contacts/discovery/llm_settings.go
Normal 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)
|
||||
}
|
||||
160
internal/contacts/discovery/match.go
Normal file
160
internal/contacts/discovery/match.go
Normal 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
|
||||
}
|
||||
33
internal/contacts/discovery/nc_adapter.go
Normal file
33
internal/contacts/discovery/nc_adapter.go
Normal 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
|
||||
}
|
||||
347
internal/contacts/discovery/other_page.go
Normal file
347
internal/contacts/discovery/other_page.go
Normal 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
|
||||
}
|
||||
77
internal/contacts/discovery/profile_sql.go
Normal file
77
internal/contacts/discovery/profile_sql.go
Normal 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
|
||||
}
|
||||
276
internal/contacts/discovery/query.go
Normal file
276
internal/contacts/discovery/query.go
Normal 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
|
||||
}
|
||||
396
internal/contacts/discovery/scan_job.go
Normal file
396
internal/contacts/discovery/scan_job.go
Normal 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]
|
||||
}
|
||||
}
|
||||
167
internal/contacts/discovery/search_rank.go
Normal file
167
internal/contacts/discovery/search_rank.go
Normal 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
|
||||
}
|
||||
98
internal/contacts/discovery/search_rank_test.go
Normal file
98
internal/contacts/discovery/search_rank_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
62
internal/contacts/discovery/search_settings.go
Normal file
62
internal/contacts/discovery/search_settings.go
Normal 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
|
||||
}
|
||||
497
internal/contacts/discovery/service.go
Normal file
497
internal/contacts/discovery/service.go
Normal 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
|
||||
}
|
||||
219
internal/contacts/discovery/signature.go
Normal file
219
internal/contacts/discovery/signature.go
Normal 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
|
||||
}
|
||||
60
internal/contacts/discovery/signature_test.go
Normal file
60
internal/contacts/discovery/signature_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
144
internal/contacts/discovery/signatures_loader.go
Normal file
144
internal/contacts/discovery/signatures_loader.go
Normal 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
|
||||
}
|
||||
169
internal/contacts/discovery/suggestable.go
Normal file
169
internal/contacts/discovery/suggestable.go
Normal 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
|
||||
}
|
||||
73
internal/contacts/discovery/suggestable_test.go
Normal file
73
internal/contacts/discovery/suggestable_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
223
internal/contacts/discovery/types.go
Normal file
223
internal/contacts/discovery/types.go
Normal 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
208
internal/llm/client.go
Normal 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")
|
||||
}
|
||||
42
internal/llm/client_test.go
Normal file
42
internal/llm/client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
publicURL string
|
||||
drivePublicURL string
|
||||
httpClient *http.Client
|
||||
adminUser string
|
||||
adminPass string
|
||||
@ -18,7 +21,7 @@ type Client struct {
|
||||
|
||||
func NewClient(baseURL, adminUser, adminPass string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
httpClient: &http.Client{
|
||||
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 {
|
||||
if c == nil {
|
||||
return nil
|
||||
@ -35,6 +46,18 @@ func (c *Client) WithDAVCredentials(store *DAVCredentialStore) *Client {
|
||||
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) {
|
||||
url := c.baseURL + path
|
||||
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.Header.Set("OCS-APIRequest", "true")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
vcard := buildVCard(contact)
|
||||
func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) (*Contact, error) {
|
||||
vcard := contactVCardPayload(contact)
|
||||
uid := contact.UID
|
||||
if uid == "" {
|
||||
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
|
||||
contact.UID = 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",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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) {
|
||||
vcard := buildVCard(contact)
|
||||
contactPath = normalizeDAVHref(contactPath)
|
||||
vcard := contactVCardPayload(contact)
|
||||
headers := map[string]string{
|
||||
"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) {
|
||||
contactPath = normalizeDAVHref(contactPath)
|
||||
resp, err := c.DoAsUser(ctx, "GET", contactPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
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 {
|
||||
contactPath = normalizeDAVHref(contactPath)
|
||||
resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -225,6 +229,15 @@ func (c *Client) SearchContacts(ctx context.Context, userID, bookPath, query str
|
||||
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 {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCARD\r\n")
|
||||
@ -274,11 +287,20 @@ func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error
|
||||
|
||||
func normalizeDAVHref(href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if strings.HasPrefix(href, "/cloud/") {
|
||||
return strings.TrimPrefix(href, "/cloud")
|
||||
for {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildSyncCollectionRequest(syncToken string) string {
|
||||
var b strings.Builder
|
||||
@ -355,7 +377,7 @@ func contactFromCardProp(href string, prop cardProp) (Contact, bool) {
|
||||
return Contact{}, false
|
||||
}
|
||||
contact := parseVCard(vcard)
|
||||
contact.Path = href
|
||||
contact.Path = normalizeDAVHref(href)
|
||||
contact.ETag = normalizeETag(prop.ETag)
|
||||
contact.RawVCard = vcard
|
||||
return contact, true
|
||||
|
||||
@ -25,6 +25,16 @@ func (c *Client) WebDAVPath(userID, path string) string {
|
||||
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 {
|
||||
if seg == "" {
|
||||
return seg
|
||||
|
||||
@ -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) {
|
||||
c := &Client{}
|
||||
got := c.WebDAVPath("user@example.com", "/Documents/My File")
|
||||
|
||||
@ -37,8 +37,16 @@ type ShareInfo struct {
|
||||
InternalURL string `json:"internal_url,omitempty"`
|
||||
AccessMode string `json:"access_mode,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
ShareWith string `json:"share_with,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"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
@ -170,11 +178,17 @@ func (c *Client) Delete(ctx context.Context, userID, path string) error {
|
||||
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 {
|
||||
srcPath = normalizeOperationPath(userID, srcPath)
|
||||
destPath = normalizeOperationPath(userID, destPath)
|
||||
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{
|
||||
"Destination": destURL,
|
||||
"Destination": destHeader,
|
||||
"Overwrite": "F",
|
||||
})
|
||||
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 {
|
||||
srcPath = normalizeOperationPath(userID, srcPath)
|
||||
destPath = normalizeOperationPath(userID, destPath)
|
||||
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{
|
||||
"Destination": destURL,
|
||||
"Destination": destHeader,
|
||||
"Overwrite": "F",
|
||||
})
|
||||
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 {
|
||||
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{
|
||||
"Destination": destination,
|
||||
}
|
||||
@ -468,26 +484,19 @@ func (c *Client) ListShares(ctx context.Context, userID, filePath string) ([]Sha
|
||||
|
||||
var ocsResp struct {
|
||||
OCS struct {
|
||||
Data []struct {
|
||||
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"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ShareInfo, 0, len(ocsResp.OCS.Data))
|
||||
for _, item := range ocsResp.OCS.Data {
|
||||
out = append(out, mapOCSShareItem(item.ID, item.Path, item.ShareType, item.Permissions, item.URL, item.Expiration, item.ShareWith, item.ShareWithDisplayName, item.Label, item.Token))
|
||||
items, err := decodeOCSShareRecords(ocsResp.OCS.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ShareInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, mapOCSShareRecord(item, filePath))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@ -530,21 +539,25 @@ func (c *Client) DeleteShare(ctx context.Context, userID, shareID string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string) error {
|
||||
userSeg := url.PathEscape(strings.TrimSpace(userID))
|
||||
func trashbinItemSeg(trashName string) string {
|
||||
logical := strings.Trim(strings.TrimPrefix(trashName, "/"), "/")
|
||||
var nameSeg string
|
||||
if logical != "" {
|
||||
if logical == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(logical, "/")
|
||||
for i, p := range parts {
|
||||
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)
|
||||
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{
|
||||
"Destination": destURL,
|
||||
"Destination": destHeader,
|
||||
"Overwrite": "T",
|
||||
})
|
||||
if err != nil {
|
||||
@ -557,7 +570,89 @@ func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string)
|
||||
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 {
|
||||
filePath = normalizeOperationPath(userID, filePath)
|
||||
davPath := c.WebDAVPath(userID, filePath)
|
||||
val := "0"
|
||||
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) {
|
||||
var ocsResp struct {
|
||||
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
|
||||
return decodeOCSShareResponse(body, path)
|
||||
}
|
||||
|
||||
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"`
|
||||
FileID string `xml:"http://owncloud.org/ns fileid"`
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@ -10,27 +10,35 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *Client) FileID(ctx context.Context, userID, filePath string) (int64, error) {
|
||||
filePath = NormalizeClientFilePath(userID, filePath)
|
||||
davPath := c.WebDAVPath(userID, filePath)
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
const propfindRevisionBody = `<?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>`
|
||||
|
||||
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",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return FileRevision{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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 {
|
||||
@ -38,27 +46,36 @@ func (c *Client) FileID(ctx context.Context, userID, filePath string) (int64, er
|
||||
Propstat struct {
|
||||
Prop struct {
|
||||
FileID string `xml:"http://owncloud.org/ns fileid"`
|
||||
ETag string `xml:"getetag"`
|
||||
} `xml:"prop"`
|
||||
} `xml:"propstat"`
|
||||
} `xml:"response"`
|
||||
}
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return 0, err
|
||||
return FileRevision{}, err
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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) {
|
||||
|
||||
552
internal/nextcloud/public_share.go
Normal file
552
internal/nextcloud/public_share.go
Normal 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
|
||||
}
|
||||
30
internal/nextcloud/public_share_permissions.go
Normal file
30
internal/nextcloud/public_share_permissions.go
Normal 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 }
|
||||
207
internal/nextcloud/search.go
Normal file
207
internal/nextcloud/search.go
Normal 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)
|
||||
})
|
||||
}
|
||||
39
internal/nextcloud/search_test.go
Normal file
39
internal/nextcloud/search_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
132
internal/nextcloud/share_ocs.go
Normal file
132
internal/nextcloud/share_ocs.go
Normal 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
|
||||
}
|
||||
60
internal/nextcloud/share_ocs_test.go
Normal file
60
internal/nextcloud/share_ocs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -77,6 +77,14 @@ func (c *Client) EnsurePrincipal(ctx context.Context, email, sub, displayName st
|
||||
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.
|
||||
func (c *Client) RefreshPrincipalCredentials(ctx context.Context, userID string) error {
|
||||
if c.credStore == nil {
|
||||
@ -106,6 +114,41 @@ func (c *Client) UserExists(ctx context.Context, userID string) (bool, error) {
|
||||
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) {
|
||||
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{
|
||||
|
||||
@ -17,10 +17,27 @@ func TestUserIDFromClaimsFallbackSub(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizeDAVHref(t *testing.T) {
|
||||
got := normalizeDAVHref("/cloud/remote.php/dav/addressbooks/users/alice/contacts/")
|
||||
want := "/remote.php/dav/addressbooks/users/alice/contacts/"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeDAVHref() = %q, want %q", got, want)
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{
|
||||
"/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ type Hub struct {
|
||||
replayBufferSizePerUser int
|
||||
|
||||
logger *slog.Logger
|
||||
verifier *auth.Verifier
|
||||
verifier *auth.Holder
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ var (
|
||||
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{
|
||||
clients: make(map[string]map[*conn]struct{}),
|
||||
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) {
|
||||
if h.verifier == nil {
|
||||
if h.verifier == nil || !h.verifier.Ready() {
|
||||
return "", "", errVerifierUnavailable
|
||||
}
|
||||
if h.db == nil {
|
||||
|
||||
114
internal/websearch/client.go
Normal file
114
internal/websearch/client.go
Normal 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
|
||||
}
|
||||
86
internal/websearch/client_test.go
Normal file
86
internal/websearch/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
internal/websearch/query.go
Normal file
40
internal/websearch/query.go
Normal 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()
|
||||
}
|
||||
43
internal/websearch/settings.go
Normal file
43
internal/websearch/settings.go
Normal 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)) != ""
|
||||
}
|
||||
5
migrations/000022_contact_discovery.down.sql
Normal file
5
migrations/000022_contact_discovery.down.sql
Normal 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;
|
||||
91
migrations/000022_contact_discovery.up.sql
Normal file
91
migrations/000022_contact_discovery.up.sql
Normal 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);
|
||||
10
migrations/000023_contact_discovery_job.down.sql
Normal file
10
migrations/000023_contact_discovery_job.down.sql
Normal 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;
|
||||
15
migrations/000023_contact_discovery_job.up.sql
Normal file
15
migrations/000023_contact_discovery_job.up.sql
Normal 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');
|
||||
1
migrations/000024_contact_discovery_progress.down.sql
Normal file
1
migrations/000024_contact_discovery_progress.down.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE contact_discovery_scans DROP COLUMN IF EXISTS profiles_total;
|
||||
2
migrations/000024_contact_discovery_progress.up.sql
Normal file
2
migrations/000024_contact_discovery_progress.up.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE contact_discovery_scans
|
||||
ADD COLUMN IF NOT EXISTS profiles_total INT NOT NULL DEFAULT 0;
|
||||
@ -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;
|
||||
8
migrations/000025_contact_discovery_dispositions.up.sql
Normal file
8
migrations/000025_contact_discovery_dispositions.up.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user