diff --git a/cmd/resanitize-bodies/main.go b/cmd/resanitize-bodies/main.go new file mode 100644 index 0000000..a700032 --- /dev/null +++ b/cmd/resanitize-bodies/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/api/mail" + "github.com/ultisuite/ulti-backend/internal/dbmigrate" + "github.com/ultisuite/ulti-backend/internal/envexpand" + "github.com/ultisuite/ulti-backend/internal/mail/sanitize" +) + +func main() { + accountID := flag.String("account", "", "mail account UUID (optional; all accounts if empty)") + dryRun := flag.Bool("dry-run", false, "scan only, do not write updates") + flag.Parse() + + for _, path := range []string{".env", "../.env"} { + _ = envexpand.ApplyFile(path) + } + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + slog.Error("DATABASE_URL is required") + os.Exit(1) + } + + ctx := context.Background() + if err := dbmigrate.Up(dbURL); err != nil { + slog.Error("migration failed", "error", err) + os.Exit(1) + } + + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + slog.Error("db connect failed", "error", err) + os.Exit(1) + } + defer pool.Close() + + if *dryRun { + scanned, changed, err := scanBodies(ctx, pool, *accountID) + if err != nil { + slog.Error("scan failed", "error", err) + os.Exit(1) + } + fmt.Printf("dry-run: scanned=%d would_update=%d\n", scanned, changed) + return + } + + if *accountID != "" { + svc := mail.NewService(pool, nil, nil, nil, "") + result, err := svc.ResanitizeAccountBodiesByID(ctx, *accountID) + if err != nil { + slog.Error("resanitize failed", "account_id", *accountID, "error", err) + os.Exit(1) + } + fmt.Printf("account=%s scanned=%d updated=%d\n", *accountID, result.Scanned, result.Updated) + return + } + + rows, err := pool.Query(ctx, `SELECT id FROM mail_accounts WHERE is_active = true ORDER BY created_at`) + if err != nil { + slog.Error("list accounts failed", "error", err) + os.Exit(1) + } + defer rows.Close() + + svc := mail.NewService(pool, nil, nil, nil, "") + var totalScanned, totalUpdated int + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + slog.Error("scan account id failed", "error", err) + os.Exit(1) + } + result, err := svc.ResanitizeAccountBodiesByID(ctx, id) + if err != nil { + slog.Error("resanitize failed", "account_id", id, "error", err) + os.Exit(1) + } + fmt.Printf("account=%s scanned=%d updated=%d\n", id, result.Scanned, result.Updated) + totalScanned += result.Scanned + totalUpdated += result.Updated + } + if err := rows.Err(); err != nil { + slog.Error("list accounts failed", "error", err) + os.Exit(1) + } + fmt.Printf("done: accounts scanned_messages=%d updated=%d\n", totalScanned, totalUpdated) +} + +func scanBodies(ctx context.Context, pool *pgxpool.Pool, accountID string) (scanned, changed int, err error) { + query := ` + SELECT id, body_html FROM messages + WHERE body_html <> ''` + args := []any{} + if accountID != "" { + query += ` AND account_id = $1` + args = append(args, accountID) + } + + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return 0, 0, err + } + defer rows.Close() + + for rows.Next() { + var id, body string + if err := rows.Scan(&id, &body); err != nil { + return scanned, changed, err + } + scanned++ + if sanitize.SanitizeHTML(body) != body { + changed++ + } + } + return scanned, changed, rows.Err() +} diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index 64d330a..4cbe57e 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -122,7 +122,8 @@ func main() { // Nextcloud client (nil if disabled) var ncClient *nextcloud.Client if cfg.NextcloudEnabled { - ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass) + ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass). + WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager)) slog.Info("nextcloud enabled", "url", cfg.NextcloudURL) } @@ -163,12 +164,13 @@ func main() { }, rdb) // Start background workers - go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ + syncWorker := imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ Storage: attachmentStorage, AttachBucket: cfg.MailAttachmentsBucket, Rules: rulesEngine, Hub: hub, - }).Start(ctx) + }) + go syncWorker.Start(ctx) sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc) smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) @@ -182,7 +184,8 @@ func main() { ).Start(ctx) sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) - mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL) + mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender) + mailHandler.SetAccountSync(syncWorker) // Router r := chi.NewRouter() diff --git a/deploy/authentik/README.md b/deploy/authentik/README.md index a6748ca..ed1b48e 100644 --- a/deploy/authentik/README.md +++ b/deploy/authentik/README.md @@ -6,6 +6,7 @@ Blueprints in `blueprints/` are mounted into Authentik at `/blueprints/custom` a |---------|------| | `01-ulti-enrollment.yaml` | Inscription self-service (`ulti-enrollment`) | | `02-ulti-brand.yaml` | Branding Ultimail + lien « Créer un compte » sur login | +| `03-ulti-suite-groups.yaml` | Claim OIDC `groups` (RBAC contacts/calendar/drive/photos) | | `ulti-oidc.yaml` | App OIDC Ultimail | | `nextcloud-oidc.yaml` | App OIDC Nextcloud | diff --git a/deploy/authentik/blueprints/03-ulti-suite-groups.yaml b/deploy/authentik/blueprints/03-ulti-suite-groups.yaml new file mode 100644 index 0000000..c92cf5d --- /dev/null +++ b/deploy/authentik/blueprints/03-ulti-suite-groups.yaml @@ -0,0 +1,36 @@ +# Ultimail — claim OIDC `groups` pour RBAC backend (contacts, calendar, drive, photos) +version: 1 +metadata: + name: Ultimail suite groups + labels: + blueprints.goauthentik.io/instantiate: "true" +entries: + - model: authentik_providers_oauth2.scopemapping + id: ulti-suite-groups-mapping + identifiers: + name: ulti-suite-groups + attrs: + name: ulti-suite-groups + scope_name: profile + description: Suite RBAC groups for Ultimail API + expression: | + return { + "groups": [ + "role:user", + "contacts:write", + "calendar:write", + "drive:write", + "photos:write", + ], + } + + - model: authentik_providers_oauth2.oauth2provider + identifiers: + name: ulti-backend-provider + attrs: + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] + - !KeyOf ulti-suite-groups-mapping diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 44537b1..fcde30a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -92,12 +92,13 @@ services: restart: unless-stopped command: server environment: - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} - AUTHENTIK_POSTGRESQL__HOST: ${AUTHENTIK_POSTGRESQL__HOST} - AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER} - AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD} - AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME} - AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST} + # Required at compose parse time — empty ${VAR} would override env_file with "". + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?Set AUTHENTIK_SECRET_KEY in .env and use ./deploy/compose-up.sh} + AUTHENTIK_POSTGRESQL__HOST: ${AUTHENTIK_POSTGRESQL__HOST:-postgres} + AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER:?Set AUTHENTIK_POSTGRESQL__USER in .env} + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD:?Set AUTHENTIK_POSTGRESQL__PASSWORD in .env} + AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME:-authentik} + AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST:-keydb} AUTHENTIK_WEB__PATH: /auth/ AUTHENTIK_HOST: http://${DOMAIN:-localhost} env_file: ../.env.resolved @@ -127,12 +128,12 @@ services: restart: unless-stopped command: worker environment: - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} - AUTHENTIK_POSTGRESQL__HOST: ${AUTHENTIK_POSTGRESQL__HOST} - AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER} - AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD} - AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME} - AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?Set AUTHENTIK_SECRET_KEY in .env and use ./deploy/compose-up.sh} + AUTHENTIK_POSTGRESQL__HOST: ${AUTHENTIK_POSTGRESQL__HOST:-postgres} + AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER:?Set AUTHENTIK_POSTGRESQL__USER in .env} + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD:?Set AUTHENTIK_POSTGRESQL__PASSWORD in .env} + AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME:-authentik} + AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST:-keydb} AUTHENTIK_WEB__PATH: /auth/ AUTHENTIK_HOST: http://${DOMAIN:-localhost} env_file: ../.env.resolved diff --git a/internal/api/contacts/handlers.go b/internal/api/contacts/handlers.go index d09df2b..8222020 100644 --- a/internal/api/contacts/handlers.go +++ b/internal/api/contacts/handlers.go @@ -14,6 +14,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "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/nextcloud" "github.com/ultisuite/ulti-backend/internal/permission" ) @@ -48,12 +49,38 @@ func (h *Handler) Routes() chi.Router { return r } +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 { + h.logger.Error("ensure nextcloud user", "error", err, "sub", claims.Sub, "email", claims.Email) + apivalidate.WriteInternal(w, r) + return "", false + } + return userID, true +} + +func (h *Handler) writeContactServiceError(w http.ResponseWriter, r *http.Request, op string, err error) { + if errors.Is(err, nextcloud.ErrPrincipalNotFound) { + apiresponse.WriteError(w, r, http.StatusNotFound, "contact_book_not_found", "contacts address book not found for user", nil) + return + } + if errors.Is(err, nextcloud.ErrDAVCredentialsMissing) { + apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "contacts_unavailable", "contacts backend credentials need refresh; retry shortly", nil) + return + } + h.logger.Error(op, "error", err) + apivalidate.WriteInternal(w, r) +} + func (h *Handler) ListAddressBooks(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) - books, err := h.svc.ListAddressBooks(r.Context(), claims.Sub) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + books, err := h.svc.ListAddressBooks(r.Context(), ncUser) if err != nil { - h.logger.Error("list address books", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "list address books", err) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"address_books": books}) @@ -61,21 +88,24 @@ func (h *Handler) ListAddressBooks(w http.ResponseWriter, r *http.Request) { func (h *Handler) SyncContacts(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } syncToken, verr := validateSyncToken(r.URL.Query().Get("sync_token")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } - result, err := h.svc.SyncContacts(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), syncToken) + result, err := h.svc.SyncContacts(r.Context(), ncUser, chi.URLParam(r, "bookID"), syncToken) if err != nil { if errors.Is(err, nextcloud.ErrSyncTokenInvalid) { apiresponse.WriteError(w, r, http.StatusConflict, "sync_token_invalid", "sync token is no longer valid; omit sync_token to perform a full resync", nil) return } - h.logger.Error("sync contacts", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "sync contacts", err) return } apiresponse.WriteJSON(w, http.StatusOK, result) @@ -83,16 +113,19 @@ func (h *Handler) SyncContacts(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } - result, err := h.svc.ListContacts(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), params) + result, err := h.svc.ListContacts(r.Context(), ncUser, chi.URLParam(r, "bookID"), params) if err != nil { - h.logger.Error("list contacts", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "list contacts", err) return } apiresponse.WriteJSON(w, http.StatusOK, result) @@ -100,6 +133,10 @@ func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) { func (h *Handler) SearchContacts(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) @@ -112,10 +149,9 @@ func (h *Handler) SearchContacts(w http.ResponseWriter, r *http.Request) { } q := r.URL.Query().Get("q") - result, err := h.svc.SearchContacts(r.Context(), claims.Sub, bookID, q, params) + result, err := h.svc.SearchContacts(r.Context(), ncUser, bookID, q, params) if err != nil { - h.logger.Error("search contacts", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "search contacts", err) return } apiresponse.WriteJSON(w, http.StatusOK, result) @@ -123,6 +159,10 @@ func (h *Handler) SearchContacts(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } var contact nextcloud.Contact if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &contact); err != nil { @@ -133,9 +173,8 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) { return } - if err := h.svc.CreateContact(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), &contact); err != nil { - h.logger.Error("create contact", "error", err) - apivalidate.WriteInternal(w, r) + if err := h.svc.CreateContact(r.Context(), ncUser, chi.URLParam(r, "bookID"), &contact); err != nil { + h.writeContactServiceError(w, r, "create contact", err) return } w.WriteHeader(http.StatusCreated) @@ -143,6 +182,10 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateContact(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) @@ -163,14 +206,13 @@ func (h *Handler) UpdateContact(w http.ResponseWriter, r *http.Request) { return } - etag, err := h.svc.UpdateContact(r.Context(), claims.Sub, contactPath, ifMatch, &contact) + etag, err := h.svc.UpdateContact(r.Context(), ncUser, contactPath, ifMatch, &contact) if err != nil { if errors.Is(err, nextcloud.ErrETagMismatch) { apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) return } - h.logger.Error("update contact", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "update contact", err) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag}) @@ -178,6 +220,10 @@ func (h *Handler) UpdateContact(w http.ResponseWriter, r *http.Request) { func (h *Handler) MergeDuplicateContacts(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } var req MergeDuplicatesRequest if r.ContentLength > 0 { @@ -186,10 +232,9 @@ func (h *Handler) MergeDuplicateContacts(w http.ResponseWriter, r *http.Request) } } - result, err := h.svc.MergeDuplicates(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), req) + result, err := h.svc.MergeDuplicates(r.Context(), ncUser, chi.URLParam(r, "bookID"), req) if err != nil { - h.logger.Error("merge duplicate contacts", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "merge duplicate contacts", err) return } apiresponse.WriteJSON(w, http.StatusOK, result) @@ -215,6 +260,10 @@ func (h *Handler) GetInteractionsByEmail(w http.ResponseWriter, r *http.Request) func (h *Handler) GetContactInteractions(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) @@ -233,7 +282,7 @@ func (h *Handler) GetContactInteractions(w http.ResponseWriter, r *http.Request) limit = val } - result, err := h.svc.ContactInteractionsByPath(r.Context(), claims.Sub, contactPath, limit) + result, err := h.svc.ContactInteractionsByPath(r.Context(), ncUser, contactPath, limit) if err != nil { if errors.Is(err, ErrContactEmailMissing) { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ @@ -241,8 +290,7 @@ func (h *Handler) GetContactInteractions(w http.ResponseWriter, r *http.Request) })) return } - h.logger.Error("contact interactions by path", "error", err) - apivalidate.WriteInternal(w, r) + h.writeContactServiceError(w, r, "contact interactions by path", err) return } apiresponse.WriteJSON(w, http.StatusOK, result) @@ -250,14 +298,17 @@ func (h *Handler) GetContactInteractions(w http.ResponseWriter, r *http.Request) func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } contactPath := chi.URLParam(r, "*") if verr := validateDeletePath(contactPath); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } - if err := h.svc.DeleteContact(r.Context(), claims.Sub, contactPath); err != nil { - h.logger.Error("delete contact", "error", err) - apivalidate.WriteInternal(w, r) + if err := h.svc.DeleteContact(r.Context(), ncUser, contactPath); err != nil { + h.writeContactServiceError(w, r, "delete contact", err) return } w.WriteHeader(http.StatusNoContent) diff --git a/internal/api/contacts/service.go b/internal/api/contacts/service.go index a679eec..e4a7f86 100644 --- a/internal/api/contacts/service.go +++ b/internal/api/contacts/service.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) @@ -24,8 +25,15 @@ func NewService(nc *nextcloud.Client, db *pgxpool.Pool) *Service { return &Service{nc: nc, db: db} } +func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { + if s.nc == nil { + return "", fmt.Errorf("nextcloud unavailable") + } + return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) +} + func bookPath(userID, bookID string) string { - return "/remote.php/dav/addressbooks/users/" + userID + "/" + bookID + "/" + return nextcloud.AddressBookPath(userID, bookID) } func (s *Service) ListAddressBooks(ctx context.Context, userID string) ([]nextcloud.AddressBook, error) { diff --git a/internal/api/mail/folder_filter.go b/internal/api/mail/folder_filter.go new file mode 100644 index 0000000..b19e09d --- /dev/null +++ b/internal/api/mail/folder_filter.go @@ -0,0 +1,40 @@ +package mail + +import ( + "fmt" + "strings" + + "github.com/google/uuid" +) + +var systemFolderSlugs = map[string]string{ + "inbox": "inbox", + "sent": "sent", + "drafts": "drafts", + "trash": "trash", + "archive": "archive", + "spam": "spam", +} + +// folderFilterClause builds a SQL fragment that resolves a folder query param to +// mail_folders rows. System slugs (e.g. "inbox") match folder_type; UUIDs match +// folder id; everything else matches display name case-insensitively. +func folderFilterClause(folder string, argIdx int) (clause string, arg any, ok bool) { + folder = strings.TrimSpace(folder) + if folder == "" { + return "", nil, false + } + if _, err := uuid.Parse(folder); err == nil { + return fmt.Sprintf(" AND m.folder_id = $%d", argIdx), folder, true + } + if folderType, known := systemFolderSlugs[strings.ToLower(folder)]; known { + return fmt.Sprintf( + " AND m.folder_id IN (SELECT id FROM mail_folders WHERE folder_type = $%d AND account_id = m.account_id)", + argIdx, + ), folderType, true + } + return fmt.Sprintf( + " AND m.folder_id IN (SELECT id FROM mail_folders WHERE LOWER(name) = LOWER($%d) AND account_id = m.account_id)", + argIdx, + ), folder, true +} diff --git a/internal/api/mail/folder_filter_test.go b/internal/api/mail/folder_filter_test.go new file mode 100644 index 0000000..f8b5020 --- /dev/null +++ b/internal/api/mail/folder_filter_test.go @@ -0,0 +1,56 @@ +package mail + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestFolderFilterClause(t *testing.T) { + id := uuid.NewString() + + tests := []struct { + name string + folder string + wantOK bool + wantArg any + wantSQL string + }{ + {name: "empty", folder: "", wantOK: false}, + {name: "inbox slug", folder: "inbox", wantOK: true, wantArg: "inbox", wantSQL: "folder_type"}, + {name: "Inbox slug", folder: "Inbox", wantOK: true, wantArg: "inbox", wantSQL: "folder_type"}, + {name: "uuid", folder: id, wantOK: true, wantArg: id, wantSQL: "m.folder_id = $1"}, + {name: "custom name", folder: "Factures", wantOK: true, wantArg: "Factures", wantSQL: "LOWER(name)"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clause, arg, ok := folderFilterClause(tc.folder, 1) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !tc.wantOK { + return + } + if arg != tc.wantArg { + t.Fatalf("arg = %v, want %v", arg, tc.wantArg) + } + if !stringsContains(clause, tc.wantSQL) { + t.Fatalf("clause = %q, want fragment %q", clause, tc.wantSQL) + } + }) + } +} + +func stringsContains(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 +} diff --git a/internal/api/mail/handlers.go b/internal/api/mail/handlers.go index a3c60e4..bb7ee80 100644 --- a/internal/api/mail/handlers.go +++ b/internal/api/mail/handlers.go @@ -22,10 +22,17 @@ import ( type Handler struct { svc ServiceAPI + mailSender MailSender logger *slog.Logger sendLimiter *sendguard.RateLimiter oauth *mailoauth.Service appURL string + accountSync AccountSyncTrigger +} + +// SetAccountSync wires the IMAP sync worker for on-demand account sync. +func (h *Handler) SetAccountSync(trigger AccountSyncTrigger) { + h.accountSync = trigger } func NewHandlerWithService(svc ServiceAPI) *Handler { @@ -44,8 +51,10 @@ func NewHandler( sendLimiter *sendguard.RateLimiter, oauthSvc *mailoauth.Service, appURL string, + mailSender MailSender, ) *Handler { h := NewHandlerWithService(NewService(db, audit, credentialManager, objectStorage, attachmentsBucket)) + h.mailSender = mailSender h.sendLimiter = sendLimiter h.oauth = oauthSvc h.appURL = appURL @@ -74,6 +83,8 @@ func (h *Handler) Routes() chi.Router { r.Get("/accounts/{accountID}", h.GetAccount) r.Put("/accounts/{accountID}", h.UpdateAccount) r.Delete("/accounts/{accountID}", h.DeleteAccount) + r.Post("/accounts/{accountID}/resanitize-bodies", h.ResanitizeAccountBodies) + r.Post("/accounts/{accountID}/sync", h.SyncAccountNow) r.Get("/accounts/{accountID}/identities", h.ListIdentities) r.Post("/accounts/{accountID}/identities", h.CreateIdentity) @@ -104,6 +115,7 @@ func (h *Handler) Routes() chi.Router { r.Get("/messages/{messageID}/attachments", h.ListMessageAttachments) r.Get("/messages/{messageID}/attachments/cid-map", h.MessageAttachmentCIDMap) r.Post("/messages/{messageID}/attachments", h.UploadMessageAttachment) + r.Post("/messages/{messageID}/list-unsubscribe-mailto", h.SendListUnsubscribeMailto) r.Get("/messages/{messageID}", h.GetMessage) r.Put("/messages/{messageID}/labels", h.UpdateLabels) r.Put("/messages/{messageID}/flags", h.UpdateFlags) @@ -365,6 +377,38 @@ func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + messageID := chi.URLParam(r, "messageID") + + if h.mailSender == nil { + apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail send unavailable", nil) + return + } + + target, err := h.svc.SendMailtoListUnsubscribe(r.Context(), claims.Sub, messageID, h.mailSender) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + apivalidate.WriteNotFound(w, r, "not found") + case errors.Is(err, ErrListUnsubscribeNoMailto): + apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, err.Error(), nil) + case errors.Is(err, ErrListUnsubscribeUnavailable): + apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, "no mailto list-unsubscribe", nil) + default: + h.logger.Error("list-unsubscribe mailto send", "message_id", messageID, "error", err) + apivalidate.WriteInternal(w, r) + } + return + } + + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "sent": true, + "mailto": target.Address, + "subject": target.Subject, + }) +} + func (h *Handler) GetThread(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) result, err := h.svc.GetThread(r.Context(), claims.Sub, chi.URLParam(r, "threadID")) diff --git a/internal/api/mail/handlers_account_maintenance.go b/internal/api/mail/handlers_account_maintenance.go new file mode 100644 index 0000000..5c3a5d5 --- /dev/null +++ b/internal/api/mail/handlers_account_maintenance.go @@ -0,0 +1,71 @@ +package mail + +import ( + "context" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" +) + +// AccountSyncTrigger runs an immediate IMAP sync for one mail account. +type AccountSyncTrigger interface { + SyncAccountForUser(ctx context.Context, externalID, accountID string) error +} + +func (h *Handler) ResanitizeAccountBodies(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + + result, err := h.svc.ResanitizeAccountBodies(r.Context(), claims.Sub, accountID) + if err != nil { + if errors.Is(err, ErrAccountNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("resanitize account bodies", "account_id", accountID, "error", err) + apivalidate.WriteInternal(w, r) + return + } + + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) SyncAccountNow(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + accountID := chi.URLParam(r, "accountID") + if d := validateAccountUUID(accountID); d != nil { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if h.accountSync == nil { + apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "sync_unavailable", "mail sync is not configured", nil) + return + } + + if _, err := h.svc.GetAccount(r.Context(), claims.Sub, accountID); err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("load account for sync", "account_id", accountID, "error", err) + apivalidate.WriteInternal(w, r) + return + } + + if err := h.accountSync.SyncAccountForUser(r.Context(), claims.Sub, accountID); err != nil { + h.logger.Error("sync account", "account_id", accountID, "error", err) + apiresponse.WriteError(w, r, http.StatusBadGateway, "sync_failed", "imap sync failed", nil) + return + } + + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/api/mail/handlers_search.go b/internal/api/mail/handlers_search.go index 23923ac..7eef19f 100644 --- a/internal/api/mail/handlers_search.go +++ b/internal/api/mail/handlers_search.go @@ -2,6 +2,8 @@ package mail import ( "net/http" + "net/url" + "strings" "time" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" @@ -12,7 +14,7 @@ import ( func (h *Handler) SearchMessages(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) - params, err := query.ParseListRequest(r) + params, err := query.ParseList(stripNonDateListRangeKeys(r.URL.Query())) if err != nil { apivalidate.WriteQueryError(w, r, err) return @@ -37,7 +39,7 @@ func parseMessageSearchFilter(r *http.Request) (MessageSearchFilter, *apivalidat q := r.URL.Query() filter := MessageSearchFilter{ Query: q.Get("q"), - Sender: q.Get("from"), + Sender: parseSearchSender(q), Label: q.Get("label"), AccountID: q.Get("account_id"), } @@ -84,3 +86,38 @@ func parseMessageSearchFilter(r *http.Request) (MessageSearchFilter, *apivalidat } return filter, nil } + +// stripNonDateListRangeKeys removes from/to when they are sender/recipient filters, +// not YYYY-MM-DD list date bounds (shared param names on /mail/search). +func stripNonDateListRangeKeys(values url.Values) url.Values { + out := values + clone := make(url.Values, len(values)) + for k, vv := range values { + clone[k] = append([]string(nil), vv...) + } + out = clone + for _, key := range []string{"from", "to"} { + raw := strings.TrimSpace(out.Get(key)) + if raw == "" { + continue + } + if _, err := query.ParseDate(raw); err != nil { + out.Del(key) + } + } + return out +} + +func parseSearchSender(q url.Values) string { + if s := strings.TrimSpace(q.Get("sender")); s != "" { + return s + } + from := strings.TrimSpace(q.Get("from")) + if from == "" { + return "" + } + if _, err := query.ParseDate(from); err != nil { + return from + } + return "" +} diff --git a/internal/api/mail/handlers_test.go b/internal/api/mail/handlers_test.go index 80271bb..e92c7d3 100644 --- a/internal/api/mail/handlers_test.go +++ b/internal/api/mail/handlers_test.go @@ -17,6 +17,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/mail/credentials" + "github.com/ultisuite/ulti-backend/internal/mail/listunsubscribe" "github.com/ultisuite/ulti-backend/internal/mail/rules" ) @@ -130,6 +131,10 @@ func (f *fakeMailService) ListMessages(_ context.Context, externalID string, _ M }, nil } +func (f *fakeMailService) SendMailtoListUnsubscribe(context.Context, string, string, MailSender) (*listunsubscribe.Mailto, error) { + return nil, ErrListUnsubscribeUnavailable +} + func (f *fakeMailService) GetMessage(_ context.Context, externalID, messageID string) (map[string]any, error) { if externalID != testExternalID { return nil, ErrUserNotProvisioned @@ -300,6 +305,9 @@ func (f *fakeMailService) CredentialForConnectionTest(context.Context, string, * return credentials.Credential{AuthType: credentials.AuthPassword, Username: "u", Password: "p"}, nil } func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { return nil } +func (f *fakeMailService) ResanitizeAccountBodies(context.Context, string, string) (ResanitizeBodiesResult, error) { + return ResanitizeBodiesResult{}, nil +} func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) { return map[string]any{"messages": []any{}}, nil } @@ -322,7 +330,7 @@ func (f *fakeMailService) DeleteRule(_ context.Context, externalID, ruleID strin return nil } -func (f *fakeMailService) SimulateRule(_ context.Context, externalID string, req *simulateRuleRequest) (rules.SimulationResult, error) { +func (f *fakeMailService) SimulateRule(_ context.Context, externalID string, req *simulateRuleRequest) (any, error) { if externalID != testExternalID { return rules.SimulationResult{}, ErrUserNotProvisioned } diff --git a/internal/api/mail/list_unsubscribe.go b/internal/api/mail/list_unsubscribe.go new file mode 100644 index 0000000..dc1eac5 --- /dev/null +++ b/internal/api/mail/list_unsubscribe.go @@ -0,0 +1,102 @@ +package mail + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/ultisuite/ulti-backend/internal/mail/listunsubscribe" + "github.com/ultisuite/ulti-backend/internal/mail/smtp" +) + +// MailSender sends immediately without outbox persistence. +type MailSender interface { + Send(ctx context.Context, req *smtp.SendRequest) error +} + +var ( + ErrListUnsubscribeUnavailable = errors.New("list-unsubscribe mailto not available") + ErrListUnsubscribeNoMailto = errors.New("list-unsubscribe has no mailto target") +) + +type messageAuthInfo struct { + ListUnsubscribe string `json:"list_unsubscribe"` +} + +// SendMailtoListUnsubscribe sends the RFC 2369 mailto unsubscribe without outbox or sent copy. +func (s *Service) SendMailtoListUnsubscribe( + ctx context.Context, + externalID, messageID string, + sender MailSender, +) (*listunsubscribe.Mailto, error) { + if sender == nil { + return nil, errors.New("mail sender not configured") + } + + var accountID string + var authRaw []byte + err := s.db.QueryRow(ctx, ` + SELECT m.account_id, m.auth_info + FROM messages m + JOIN mail_accounts ma ON m.account_id = ma.id + WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2) + `, messageID, externalID).Scan(&accountID, &authRaw) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + var auth messageAuthInfo + if len(authRaw) > 0 { + _ = json.Unmarshal(authRaw, &auth) + } + parsed := listunsubscribe.Parse(auth.ListUnsubscribe) + if parsed.Mailto == nil { + if parsed.HTTP != "" { + return nil, fmt.Errorf("%w: use http url", ErrListUnsubscribeNoMailto) + } + return nil, ErrListUnsubscribeUnavailable + } + + fromEmail, err := s.resolveAccountFromEmail(ctx, accountID) + if err != nil { + return nil, err + } + + m := parsed.Mailto + req := &smtp.SendRequest{ + AccountID: accountID, + From: fromEmail, + To: []string{m.Address}, + Subject: m.Subject, + BodyText: m.Body, + } + if err := sender.Send(ctx, req); err != nil { + return nil, err + } + return m, nil +} + +func (s *Service) resolveAccountFromEmail(ctx context.Context, accountID string) (string, error) { + var fromEmail string + err := s.db.QueryRow(ctx, ` + SELECT mi.email FROM mail_identities mi + JOIN mail_accounts ma ON mi.account_id = ma.id + WHERE ma.id = $1 AND mi.is_default = true + LIMIT 1 + `, accountID).Scan(&fromEmail) + if err == nil && fromEmail != "" { + return fromEmail, nil + } + if err := s.db.QueryRow(ctx, `SELECT email FROM mail_accounts WHERE id = $1`, accountID).Scan(&fromEmail); err != nil { + return "", err + } + if fromEmail == "" { + return "", errors.New("account has no from address") + } + return fromEmail, nil +} diff --git a/internal/api/mail/resanitize_bodies.go b/internal/api/mail/resanitize_bodies.go new file mode 100644 index 0000000..a7bdbca --- /dev/null +++ b/internal/api/mail/resanitize_bodies.go @@ -0,0 +1,76 @@ +package mail + +import ( + "context" + + "github.com/ultisuite/ulti-backend/internal/mail/sanitize" +) + +const resanitizeBatchSize = 200 + +type ResanitizeBodiesResult struct { + Scanned int `json:"scanned"` + Updated int `json:"updated"` +} + +// ResanitizeAccountBodies re-applies email HTML sanitization to stored messages. +func (s *Service) ResanitizeAccountBodies(ctx context.Context, externalID, accountID string) (ResanitizeBodiesResult, error) { + if err := s.verifyAccountOwnership(ctx, externalID, accountID); err != nil { + return ResanitizeBodiesResult{}, err + } + return s.ResanitizeAccountBodiesByID(ctx, accountID) +} + +// ResanitizeAccountBodiesByID re-sanitizes messages without an ownership check (CLI/admin). +func (s *Service) ResanitizeAccountBodiesByID(ctx context.Context, accountID string) (ResanitizeBodiesResult, error) { + var result ResanitizeBodiesResult + var lastID string + for { + rows, err := s.db.Query(ctx, ` + SELECT id, body_html + FROM messages + WHERE account_id = $1 + AND body_html <> '' + AND ($2 = '' OR id > $2::uuid) + ORDER BY id + LIMIT $3 + `, accountID, lastID, resanitizeBatchSize) + if err != nil { + return result, err + } + + batchCount := 0 + for rows.Next() { + var id, bodyHTML string + if err := rows.Scan(&id, &bodyHTML); err != nil { + rows.Close() + return result, err + } + batchCount++ + result.Scanned++ + lastID = id + + sanitized := sanitize.SanitizeHTML(bodyHTML) + if sanitized == bodyHTML { + continue + } + if _, err := s.db.Exec(ctx, ` + UPDATE messages SET body_html = $2, updated_at = NOW() WHERE id = $1 + `, id, sanitized); err != nil { + rows.Close() + return result, err + } + result.Updated++ + } + if err := rows.Err(); err != nil { + return result, err + } + rows.Close() + + if batchCount < resanitizeBatchSize { + break + } + } + + return result, nil +} diff --git a/internal/api/mail/search_advanced.go b/internal/api/mail/search_advanced.go index fbe6ccd..5d82565 100644 --- a/internal/api/mail/search_advanced.go +++ b/internal/api/mail/search_advanced.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/mail/imap" ) type MessageSearchFilter struct { @@ -102,7 +103,7 @@ func (s *Service) SearchMessages(ctx context.Context, externalID string, filter entry := map[string]any{ "id": id, "message_id": messageID, "subject": subject, "from": json.RawMessage(fromAddr), "to": json.RawMessage(toAddrs), - "date": date, "snippet": snippet, "flags": flags, "labels": labels, + "date": date, "snippet": imap.RepairSnippet(snippet), "flags": flags, "labels": labels, "has_attachments": hasAttachments, } if threadID != nil { diff --git a/internal/api/mail/search_test.go b/internal/api/mail/search_test.go index 975d838..31d4c34 100644 --- a/internal/api/mail/search_test.go +++ b/internal/api/mail/search_test.go @@ -19,6 +19,32 @@ func TestSearchMessages(t *testing.T) { } } +func TestSearchMessagesBySender(t *testing.T) { + svc := newFakeMailService() + router := newTestMailRouter(svc) + + req := httptest.NewRequest(http.MethodGet, "/search?sender=alice@example.com", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestSearchMessagesFromEmailParam(t *testing.T) { + svc := newFakeMailService() + router := newTestMailRouter(svc) + + req := httptest.NewRequest(http.MethodGet, "/search?from=alice@example.com", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + func TestSearchMessagesRequiresFilter(t *testing.T) { svc := newFakeMailService() router := newTestMailRouter(svc) diff --git a/internal/api/mail/service.go b/internal/api/mail/service.go index 0b6110c..0819ac5 100644 --- a/internal/api/mail/service.go +++ b/internal/api/mail/service.go @@ -12,6 +12,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/mail/credentials" + "github.com/ultisuite/ulti-backend/internal/mail/imap" "github.com/ultisuite/ulti-backend/internal/mail/sanitize" "github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/mail/threading" @@ -189,9 +190,9 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me args = append(args, filter.AccountID) argIdx++ } - if filter.Folder != "" { - baseQuery += fmt.Sprintf(" AND m.folder_id = (SELECT id FROM mail_folders WHERE name = $%d AND account_id = m.account_id LIMIT 1)", argIdx) - args = append(args, filter.Folder) + if clause, arg, ok := folderFilterClause(filter.Folder, argIdx); ok { + baseQuery += clause + args = append(args, arg) argIdx++ } @@ -202,7 +203,8 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me } listQuery := ` - SELECT m.id, m.message_id, m.thread_id, m.subject, m.from_addr, m.to_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments + SELECT m.id, m.message_id, m.thread_id, m.subject, m.from_addr, m.to_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments, + left(m.body_text, 8192), left(m.body_html, 8192) ` + baseQuery + fmt.Sprintf(" ORDER BY m.date DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit(), params.Offset()) @@ -217,16 +219,26 @@ func (s *Service) ListMessages(ctx context.Context, externalID string, filter Me var id, messageID, subject, snippet string var threadID *string var fromAddr, toAddrs []byte + var bodyTextSample, bodyHTMLSample string var date any var flags, labels []string var hasAttachments bool - if err := rows.Scan(&id, &messageID, &threadID, &subject, &fromAddr, &toAddrs, &date, &snippet, &flags, &labels, &hasAttachments); err != nil { + if err := rows.Scan(&id, &messageID, &threadID, &subject, &fromAddr, &toAddrs, &date, &snippet, &flags, &labels, &hasAttachments, &bodyTextSample, &bodyHTMLSample); err != nil { return MessagesList{}, err } + bodyTextSample, bodyHTMLSample = imap.RepairStoredBodies(bodyTextSample, bodyHTMLSample) + preview := imap.RepairSnippet(imap.SnippetFromBodies(bodyTextSample, bodyHTMLSample, 200)) + if preview == "" { + preview = imap.RepairSnippet(snippet) + } entry := map[string]any{ - "id": id, "message_id": messageID, "subject": subject, "from": json.RawMessage(fromAddr), - "to": json.RawMessage(toAddrs), "date": date, "snippet": snippet, - "flags": flags, "labels": labels, "has_attachments": hasAttachments, + "id": id, "message_id": messageID, + "subject": imap.RepairSubject(subject, bodyTextSample, bodyHTMLSample, nil), + "from": json.RawMessage(fromAddr), + "to": json.RawMessage(toAddrs), + "date": date, + "snippet": preview, + "flags": flags, "labels": labels, "has_attachments": hasAttachments, } if threadID != nil { entry["thread_id"] = *threadID @@ -255,6 +267,8 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) From []byte To []byte Cc []byte + ReplyTo []byte + AuthInfo []byte Date any Text string HTML string @@ -263,13 +277,13 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) } err := s.db.QueryRow(ctx, ` SELECT m.id, m.message_id, m.thread_id, m.in_reply_to, m.references_header, - m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, + m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.reply_to, m.auth_info, m.date, m.body_text, m.body_html, m.flags, m.labels FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2) `, messageID, externalID).Scan( &msg.ID, &msg.MessageID, &msg.ThreadID, &msg.InReplyTo, &msg.References, - &msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.Date, + &msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.ReplyTo, &msg.AuthInfo, &msg.Date, &msg.Text, &msg.HTML, &msg.Flags, &msg.Labels, ) if err != nil { @@ -278,10 +292,20 @@ func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) } return nil, err } + bodyText, bodyHTML := imap.RepairStoredBodies(msg.Text, msg.HTML) + subject := imap.RepairSubject(msg.Subject, bodyText, bodyHTML, nil) + repairedSnippet := imap.RepairSnippet(imap.SnippetFromBodies(bodyText, bodyHTML, 200)) + if bodyText != msg.Text || bodyHTML != msg.HTML || subject != msg.Subject { + _, _ = s.db.Exec(ctx, ` + UPDATE messages SET body_text = $1, body_html = $2, snippet = $3, subject = $4, updated_at = NOW() + WHERE id = $5 + `, bodyText, bodyHTML, repairedSnippet, subject, msg.ID) + } out := map[string]any{ - "id": msg.ID, "message_id": msg.MessageID, "subject": msg.Subject, + "id": msg.ID, "message_id": msg.MessageID, "subject": subject, "from": json.RawMessage(msg.From), "to": json.RawMessage(msg.To), "cc": json.RawMessage(msg.Cc), - "date": msg.Date, "body_text": msg.Text, "body_html": sanitize.SanitizeHTML(msg.HTML), + "reply_to": json.RawMessage(msg.ReplyTo), "auth_info": json.RawMessage(msg.AuthInfo), + "date": msg.Date, "body_text": bodyText, "body_html": sanitize.SanitizeHTML(bodyHTML), "flags": msg.Flags, "labels": msg.Labels, "in_reply_to": msg.InReplyTo, "references": msg.References, } @@ -351,7 +375,7 @@ func (s *Service) DeleteMessage(ctx context.Context, externalID, messageID strin func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) { rows, err := s.db.Query(ctx, ` - SELECT m.id, m.subject, m.from_addr, m.date, m.snippet, m.flags + SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE m.thread_id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2) ORDER BY m.date ASC @@ -364,15 +388,16 @@ func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (m messages := make([]map[string]any, 0) for rows.Next() { var id, subject, snippet string - var from []byte + var from, toAddrs, ccAddrs []byte var date any - var flags []string - if err := rows.Scan(&id, &subject, &from, &date, &snippet, &flags); err != nil { + var flags, labels []string + if err := rows.Scan(&id, &subject, &from, &toAddrs, &ccAddrs, &date, &snippet, &flags, &labels); err != nil { return nil, err } messages = append(messages, map[string]any{ "id": id, "subject": subject, "from": json.RawMessage(from), - "date": date, "snippet": snippet, "flags": flags, + "to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs), + "date": date, "snippet": snippet, "flags": flags, "labels": labels, }) } if err := rows.Err(); err != nil { @@ -487,7 +512,7 @@ func (s *Service) ListRules(ctx context.Context, externalID string, params query } rows, err := s.db.Query(ctx, ` - SELECT id, name, priority, conditions, actions, is_active, match_count + SELECT id, name, priority, conditions, actions, is_active, match_count, rule_kind, workflow FROM mail_rules WHERE user_id = (SELECT id FROM users WHERE external_id = $1) ORDER BY priority ASC LIMIT $2 OFFSET $3 @@ -502,15 +527,18 @@ func (s *Service) ListRules(ctx context.Context, externalID string, params query var id, name string var priority int var conditions, actions []byte + var workflow []byte + var ruleKind string var isActive bool var matchCount int64 - if err := rows.Scan(&id, &name, &priority, &conditions, &actions, &isActive, &matchCount); err != nil { + if err := rows.Scan(&id, &name, &priority, &conditions, &actions, &isActive, &matchCount, &ruleKind, &workflow); err != nil { return RulesList{}, err } rules = append(rules, map[string]any{ "id": id, "name": name, "priority": priority, "conditions": json.RawMessage(conditions), "actions": json.RawMessage(actions), "is_active": isActive, "match_count": matchCount, + "rule_kind": ruleKind, "workflow": json.RawMessage(workflow), }) } if err := rows.Err(); err != nil { @@ -525,7 +553,18 @@ func (s *Service) ListRules(ctx context.Context, externalID string, params query func (s *Service) CreateRule(ctx context.Context, userID string, req *createRuleRequest) (string, error) { condJSON, _ := json.Marshal(req.Conditions) + if req.Conditions == nil { + condJSON = []byte("[]") + } actJSON, _ := json.Marshal(req.Actions) + if req.Actions == nil { + actJSON = []byte("[]") + } + wfJSON, _ := json.Marshal(req.Workflow) + ruleKind := req.RuleKind + if ruleKind == "" { + ruleKind = "rule" + } if req.AccountID != "" { var exists bool @@ -542,10 +581,10 @@ func (s *Service) CreateRule(ctx context.Context, userID string, req *createRule var id string err := s.db.QueryRow(ctx, ` - INSERT INTO mail_rules (user_id, account_id, name, priority, conditions, actions) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO mail_rules (user_id, account_id, name, priority, conditions, actions, rule_kind, workflow) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id - `, userID, nilIfEmpty(req.AccountID), req.Name, req.Priority, condJSON, actJSON).Scan(&id) + `, userID, nilIfEmpty(req.AccountID), req.Name, req.Priority, condJSON, actJSON, ruleKind, wfJSON).Scan(&id) if err != nil { return "", err } @@ -554,12 +593,23 @@ func (s *Service) CreateRule(ctx context.Context, userID string, req *createRule func (s *Service) UpdateRule(ctx context.Context, externalID, ruleID string, req *updateRuleRequest) error { condJSON, _ := json.Marshal(req.Conditions) + if req.Conditions == nil { + condJSON = []byte("[]") + } actJSON, _ := json.Marshal(req.Actions) + if req.Actions == nil { + actJSON = []byte("[]") + } + wfJSON, _ := json.Marshal(req.Workflow) + ruleKind := req.RuleKind + if ruleKind == "" { + ruleKind = "rule" + } result, err := s.db.Exec(ctx, ` - UPDATE mail_rules SET name=$1, priority=$2, is_active=$3, conditions=$4, actions=$5, updated_at=NOW() - WHERE id=$6 AND user_id=(SELECT id FROM users WHERE external_id=$7) - `, req.Name, req.Priority, req.IsActive, condJSON, actJSON, ruleID, externalID) + UPDATE mail_rules SET name=$1, priority=$2, is_active=$3, conditions=$4, actions=$5, rule_kind=$6, workflow=$7, updated_at=NOW() + WHERE id=$8 AND user_id=(SELECT id FROM users WHERE external_id=$9) + `, req.Name, req.Priority, req.IsActive, condJSON, actJSON, ruleKind, wfJSON, ruleID, externalID) if err != nil { return err } diff --git a/internal/api/mail/service_iface.go b/internal/api/mail/service_iface.go index 6965f65..f631415 100644 --- a/internal/api/mail/service_iface.go +++ b/internal/api/mail/service_iface.go @@ -7,7 +7,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/mail/credentials" - "github.com/ultisuite/ulti-backend/internal/mail/rules" + "github.com/ultisuite/ulti-backend/internal/mail/listunsubscribe" ) // ServiceAPI is the mail handler service boundary. *Service implements it in production. @@ -26,8 +26,10 @@ type ServiceAPI interface { UpdateAccount(ctx context.Context, externalID, accountID string, req *updateAccountRequest) error CredentialForConnectionTest(ctx context.Context, externalID string, req *testAccountRequest) (credentials.Credential, error) DeleteAccount(ctx context.Context, externalID, accountID string) error + ResanitizeAccountBodies(ctx context.Context, externalID, accountID string) (ResanitizeBodiesResult, error) ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) + SendMailtoListUnsubscribe(ctx context.Context, externalID, messageID string, sender MailSender) (*listunsubscribe.Mailto, error) UpdateLabels(ctx context.Context, externalID, messageID string, labels []string) error UpdateFlags(ctx context.Context, externalID, messageID string, flags []string) error DeleteMessage(ctx context.Context, externalID, messageID string) error @@ -45,7 +47,7 @@ type ServiceAPI interface { CreateRule(ctx context.Context, userID string, req *createRuleRequest) (string, error) UpdateRule(ctx context.Context, externalID, ruleID string, req *updateRuleRequest) error DeleteRule(ctx context.Context, externalID, ruleID string) error - SimulateRule(ctx context.Context, externalID string, req *simulateRuleRequest) (rules.SimulationResult, error) + SimulateRule(ctx context.Context, externalID string, req *simulateRuleRequest) (any, error) ListWebhooks(ctx context.Context, externalID string, params query.ListParams) (WebhooksList, error) CreateWebhook(ctx context.Context, externalID string, req *createWebhookRequest, method string, maxRetries int) (string, error) UpdateWebhook(ctx context.Context, externalID, webhookID string, req *updateWebhookRequest, method string, maxRetries int) error diff --git a/internal/api/mail/service_rules_simulate.go b/internal/api/mail/service_rules_simulate.go index 235607d..4df0d99 100644 --- a/internal/api/mail/service_rules_simulate.go +++ b/internal/api/mail/service_rules_simulate.go @@ -10,12 +10,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/mail/rules" ) -func (s *Service) SimulateRule(ctx context.Context, externalID string, req *simulateRuleRequest) (rules.SimulationResult, error) { - conditions, actions, err := s.resolveSimulateRule(ctx, externalID, req) - if err != nil { - return rules.SimulationResult{}, err - } - +func (s *Service) SimulateRule(ctx context.Context, externalID string, req *simulateRuleRequest) (any, error) { msg := &rules.Message{ ID: "simulation", From: req.Message.From, @@ -23,38 +18,74 @@ func (s *Service) SimulateRule(ctx context.Context, externalID string, req *simu Subject: req.Message.Subject, BodyText: req.Message.BodyText, HasAttachments: req.Message.HasAttachments, + Labels: req.Message.Labels, } engine := rules.NewEngine(s.db) + + wf, conditions, actions, err := s.resolveSimulateRulePayload(ctx, externalID, req) + if err != nil { + return nil, err + } + + if wf != nil && len(wf.Nodes) > 0 { + var userID string + _ = s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID) + return engine.SimulateWorkflow(ctx, userID, wf, msg, &rules.EventContext{Type: rules.TriggerMessageReceived}), nil + } + return engine.SimulateRule(ctx, conditions, actions, msg), nil } -func (s *Service) resolveSimulateRule(ctx context.Context, externalID string, req *simulateRuleRequest) ([]rules.Condition, []rules.Action, error) { +func (s *Service) resolveSimulateRulePayload(ctx context.Context, externalID string, req *simulateRuleRequest) (*rules.Workflow, []rules.Condition, []rules.Action, error) { if req.RuleID != "" { - var condJSON, actJSON []byte + var condJSON, actJSON, wfJSON []byte err := s.db.QueryRow(ctx, ` - SELECT conditions, actions + SELECT conditions, actions, workflow FROM mail_rules WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2) - `, req.RuleID, externalID).Scan(&condJSON, &actJSON) + `, req.RuleID, externalID).Scan(&condJSON, &actJSON, &wfJSON) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, ErrNotFound + return nil, nil, nil, ErrNotFound } - return nil, nil, err + return nil, nil, nil, err + } + wf, err := rules.ParseWorkflow(wfJSON) + if err != nil { + return nil, nil, nil, err + } + if wf != nil && len(wf.Nodes) > 0 { + return wf, nil, nil, nil + } + conditions, actions, err := unmarshalRuleConditionsActions(condJSON, actJSON) + return nil, conditions, actions, err + } + + if req.Rule.Workflow != nil { + wfJSON, err := json.Marshal(req.Rule.Workflow) + if err != nil { + return nil, nil, nil, err + } + wf, err := rules.ParseWorkflow(wfJSON) + if err != nil { + return nil, nil, nil, err + } + if wf != nil && len(wf.Nodes) > 0 { + return wf, nil, nil, nil } - return unmarshalRuleConditionsActions(condJSON, actJSON) } condJSON, err := json.Marshal(req.Rule.Conditions) if err != nil { - return nil, nil, err + return nil, nil, nil, err } actJSON, err := json.Marshal(req.Rule.Actions) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return unmarshalRuleConditionsActions(condJSON, actJSON) + conditions, actions, err := unmarshalRuleConditionsActions(condJSON, actJSON) + return nil, conditions, actions, err } func unmarshalRuleConditionsActions(condJSON, actJSON []byte) ([]rules.Condition, []rules.Action, error) { diff --git a/internal/api/mail/validate.go b/internal/api/mail/validate.go index 5d8e143..923c7c3 100644 --- a/internal/api/mail/validate.go +++ b/internal/api/mail/validate.go @@ -486,8 +486,10 @@ type createRuleRequest struct { Name string `json:"name"` AccountID string `json:"account_id"` Priority int `json:"priority"` + RuleKind string `json:"rule_kind"` Conditions any `json:"conditions"` Actions any `json:"actions"` + Workflow any `json:"workflow"` } func validateCreateRule(req *createRuleRequest) *apivalidate.ValidationError { @@ -497,11 +499,17 @@ func validateCreateRule(req *createRuleRequest) *apivalidate.ValidationError { } else if len(req.Name) > maxRuleName { details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"}) } - if req.Conditions == nil { - details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"}) + hasWorkflow := req.Workflow != nil + if !hasWorkflow { + if req.Conditions == nil { + details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"}) + } + if req.Actions == nil { + details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"}) + } } - if req.Actions == nil { - details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"}) + if req.RuleKind != "" && req.RuleKind != "rule" && req.RuleKind != "function" { + details = append(details, apivalidate.FieldDetail{Field: "rule_kind", Message: "invalid"}) } if len(details) == 0 { return nil @@ -513,8 +521,10 @@ type updateRuleRequest struct { Name string `json:"name"` Priority int `json:"priority"` IsActive bool `json:"is_active"` + RuleKind string `json:"rule_kind"` Conditions any `json:"conditions"` Actions any `json:"actions"` + Workflow any `json:"workflow"` } type simulateRuleSampleMessage struct { @@ -523,11 +533,13 @@ type simulateRuleSampleMessage struct { Subject string `json:"subject"` BodyText string `json:"body_text"` HasAttachments bool `json:"has_attachments"` + Labels []string `json:"labels,omitempty"` } type simulateRuleInlineRule struct { Conditions any `json:"conditions"` Actions any `json:"actions"` + Workflow any `json:"workflow"` } type simulateRuleRequest struct { @@ -550,10 +562,10 @@ func validateSimulateRule(req *simulateRuleRequest) *apivalidate.ValidationError details = append(details, apivalidate.FieldDetail{Field: "rule_id", Message: "rule_id or rule required"}) } if hasInlineRule { - if req.Rule.Conditions == nil { + if req.Rule.Conditions == nil && req.Rule.Workflow == nil { details = append(details, apivalidate.FieldDetail{Field: "rule.conditions", Message: "required"}) } - if req.Rule.Actions == nil { + if req.Rule.Actions == nil && req.Rule.Workflow == nil { details = append(details, apivalidate.FieldDetail{Field: "rule.actions", Message: "required"}) } } @@ -570,12 +582,15 @@ func validateUpdateRule(req *updateRuleRequest) *apivalidate.ValidationError { } else if len(req.Name) > maxRuleName { details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"}) } - if req.Conditions == nil { + if req.Conditions == nil && req.Workflow == nil { details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"}) } - if req.Actions == nil { + if req.Actions == nil && req.Workflow == nil { details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"}) } + if req.RuleKind != "" && req.RuleKind != "rule" && req.RuleKind != "function" { + details = append(details, apivalidate.FieldDetail{Field: "rule_kind", Message: "invalid"}) + } if len(details) == 0 { return nil } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index ff045e3..8f8852d 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -10,6 +10,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/securityaudit" "github.com/ultisuite/ulti-backend/internal/users" ) @@ -71,6 +72,7 @@ func Auth(verifier *auth.Verifier, db *pgxpool.Pool, audit *securityaudit.Logger } return } + claims.Groups = permission.WithSuiteDefaults(claims.Groups) if db != nil { if _, err := users.EnsureUser(r.Context(), db, claims); err != nil { diff --git a/internal/api/query/query.go b/internal/api/query/query.go index a675588..f7437c8 100644 --- a/internal/api/query/query.go +++ b/internal/api/query/query.go @@ -12,7 +12,7 @@ import ( const ( DefaultPage = 1 DefaultPageSize = 50 - MaxPageSize = 200 + MaxPageSize = 500 dateLayout = "2006-01-02" ) diff --git a/internal/api/query/query_test.go b/internal/api/query/query_test.go index b6c7cee..4484e80 100644 --- a/internal/api/query/query_test.go +++ b/internal/api/query/query_test.go @@ -92,7 +92,7 @@ func TestParseList_invalidPageSize(t *testing.T) { }{ {"zero", "0"}, {"negative", "-5"}, - {"too_large", "201"}, + {"too_large", "501"}, {"non_numeric", "large"}, } @@ -138,7 +138,7 @@ func TestParseList_invalidDates(t *testing.T) { func TestParseList_multipleErrors(t *testing.T) { _, err := ParseList(url.Values{ "page": {"0"}, - "page_size": {"500"}, + "page_size": {"501"}, "from": {"bad-date"}, }) var verr *ValidationError diff --git a/internal/mail/imap/body_repair.go b/internal/mail/imap/body_repair.go new file mode 100644 index 0000000..1987ec3 --- /dev/null +++ b/internal/mail/imap/body_repair.go @@ -0,0 +1,187 @@ +package imap + +import ( + "io" + "mime/quotedprintable" + "strings" + "unicode" + + "github.com/ultisuite/ulti-backend/internal/mail/sanitize" +) + +const minBareBase64Len = 24 + +// RepairStoredBodies fixes bodies stored as raw MIME, quoted-printable, or base64. +func RepairStoredBodies(text, html string) (string, string) { + text, html = repairRawMIME(text, html) + text = decodeBareQuotedPrintableIfNeeded(text) + html = decodeBareQuotedPrintableIfNeeded(html) + text = decodeBareBase64IfNeeded(text) + html = decodeBareBase64IfNeeded(html) + text = stripPlainTextPreheaderPadding(text) + return text, html +} + +func repairRawMIME(text, html string) (string, string) { + if !looksLikeRawMIME(text) && !looksLikeRawMIME(html) { + return text, html + } + raw := text + if raw == "" { + raw = html + } + t, h := parseBody([]byte(raw)) + if t == "" && h == "" { + return text, html + } + if looksLikeRawMIME(t) || looksLikeRawMIME(h) { + return text, html + } + return t, h +} + +// RepairSnippet fixes list/search previews stored as undecoded base64 or raw MIME. +func RepairSnippet(snippet string) string { + if snippet == "" { + return snippet + } + if decoded := decodeBareQuotedPrintableIfNeeded(snippet); decoded != snippet { + snippet = decoded + } + if decoded := decodeBareBase64IfNeeded(snippet); decoded != snippet { + snippet = decoded + } + snippet = stripPlainTextPreheaderPadding(snippet) + if looksLikeRawMIME(snippet) { + t, h, ok := parseEmbeddedMIME([]byte(snippet)) + if ok { + return SnippetFromBodies(t, h, 200) + } + } + return snippet +} + +// SnippetFromBodies builds a short preview from repaired plain/html bodies. +func SnippetFromBodies(text, html string, maxLen int) string { + text = strings.TrimSpace(text) + if text != "" { + return truncate(text, maxLen) + } + html = strings.TrimSpace(stripHTMLForSnippet(html)) + if html != "" { + return truncate(html, maxLen) + } + return "" +} + +func stripPlainTextPreheaderPadding(text string) string { + return sanitize.StripInvisibleTextRuns(text) +} + +func stripHTMLForSnippet(html string) string { + if html == "" { + return "" + } + html = sanitize.StripHiddenEmailHTML(html) + var b strings.Builder + inTag := false + for _, r := range html { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + case !inTag && r != '\r': + if r == '\n' { + if b.Len() > 0 && b.String()[b.Len()-1] != ' ' { + b.WriteRune(' ') + } + } else if !unicode.IsControl(r) { + b.WriteRune(r) + } + } + } + return sanitize.StripInvisibleTextRuns(strings.Join(strings.Fields(b.String()), " ")) +} + +func decodeBareQuotedPrintableIfNeeded(s string) string { + if s == "" || !looksLikeQuotedPrintable(s) { + return s + } + decoded, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(s))) + if err != nil || len(decoded) == 0 || !isMostlyReadableText(decoded) { + return s + } + return string(decoded) +} + +func looksLikeQuotedPrintable(s string) bool { + if strings.Contains(s, "=\r\n") || strings.Contains(s, "=\n") { + return true + } + if strings.Contains(s, "=3D") || strings.Contains(s, "=C3=") || strings.Contains(s, "=E2=") { + return true + } + return len(qpHexSeqRE.FindAllString(s, -1)) >= 3 +} + +func decodeBareBase64IfNeeded(s string) string { + if s == "" { + return s + } + trimmed := strings.TrimSpace(s) + if len(trimmed) < minBareBase64Len { + return s + } + clean := stripBase64Whitespace(trimmed) + if !isLikelyBase64(clean) { + return s + } + decoded, err := decodeBase64Body([]byte(clean)) + if err != nil || len(decoded) == 0 || !isMostlyReadableText(decoded) { + return s + } + return string(decoded) +} + +func stripBase64Whitespace(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\r', '\n', ' ', '\t': + continue + default: + b.WriteRune(r) + } + } + return b.String() +} + +func isLikelyBase64(s string) bool { + if len(s) < minBareBase64Len || len(s)%4 != 0 { + return false + } + for _, r := range s { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '+', r == '/', r == '=': + continue + default: + return false + } + } + return strings.Contains(s, "=") || len(s) >= 32 +} + +func isMostlyReadableText(b []byte) bool { + if len(b) == 0 { + return false + } + printable := 0 + for _, c := range b { + if c == '\n' || c == '\r' || c == '\t' || (c >= 32 && c < 127) || c >= 0xc0 { + printable++ + } + } + return float64(printable)/float64(len(b)) >= 0.85 +} diff --git a/internal/mail/imap/body_repair_test.go b/internal/mail/imap/body_repair_test.go new file mode 100644 index 0000000..9641165 --- /dev/null +++ b/internal/mail/imap/body_repair_test.go @@ -0,0 +1,100 @@ +package imap + +import ( + "strings" + "testing" +) + +func TestDecodeBareBase64IfNeeded_samsungMessage(t *testing.T) { + const encoded = "U0FNU1VORwpSw6lzZXJ2w6kgYXV4IHByb2Zlc3Npb25uZWxzCgrigIoKVm9zIMOpcXVpcGVzIG9u\r\n" + + "dCBiZXNvaW4KZGUgc29sdXRpb25zIG1vYmlsZXMKZXQgcm9idXN0ZXMuCgpTYW1zdW5nIFBybyBy\r\n" + + "w6lwb25kIGF1eCBtw6l0aWVycyBkZSBsYSBjb25zdHJ1" + + decoded := decodeBareBase64IfNeeded(encoded) + if decoded == encoded { + t.Fatal("expected base64 decode") + } + if !strings.HasPrefix(decoded, "SAMSUNG") { + t.Fatalf("decoded = %q", decoded) + } + if !strings.Contains(decoded, "professionnels") { + t.Fatalf("decoded = %q, want utf-8 text", decoded) + } +} + +func TestDecodeBareQuotedPrintableIfNeeded_frenchMarketing(t *testing.T) { + const qp = "Hello = Eliott,\n\nNous pouvons faire appara=C3=AEtre votre marque en premi=C3=A8re =\n" + + " position dans les Google Suggests" + + decoded := decodeBareQuotedPrintableIfNeeded(qp) + if decoded == qp { + t.Fatal("expected quoted-printable decode") + } + if !strings.Contains(decoded, "apparaître") { + t.Fatalf("decoded = %q, want apparaître", decoded) + } + if strings.Contains(decoded, "=C3=") { + t.Fatalf("still contains qp escapes: %q", decoded) + } +} + +func TestRepairStoredBodies_quotedPrintableHTML(t *testing.T) { + const qpHTML = `
Hello Eliott, Nous pouvons faire apparaître votre marque.