feat(PROJ-53): Konfigurierbare Listenanzahl pro Seite

- users.list_page_size (Default 25), PATCH /api/auth/preferences,
  Whitelist 25/50/100/200, Wert in login/me-Response
- Settings-UI mit Select, /search nutzt gespeicherte Seitengröße
- /api/search page_size serverseitig auf max. 500 gecappt

fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool
(Backend kompilierte nicht)

feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler,
cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
This commit is contained in:
sysops
2026-06-14 22:25:02 +02:00
parent b73ef55a65
commit 472ba6a087
25 changed files with 1078 additions and 111 deletions
+1
View File
@@ -124,6 +124,7 @@ func main() {
ID: id,
From: pm.From,
To: strings.Join(pm.To, " "),
CC: strings.Join(pm.CC, " "),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
AttachNames: strings.Join(attachNames, " "),
+114 -60
View File
@@ -1,11 +1,13 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -34,12 +36,16 @@ func runExport(args []string) {
dateFrom := fs.String("date-from", "", "filter from date (ISO 8601: 2024-01-01)")
dateTo := fs.String("date-to", "", "filter to date (ISO 8601: 2024-12-31)")
query := fs.String("query", "", "fulltext search query")
tenant := fs.String("tenant", "", "export ALL mails of the given tenant id (mutually exclusive with from/to/date/query)")
force := fs.Bool("force", false, "overwrite existing output file")
jsonOut := fs.Bool("json", false, "machine-readable JSON output")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: archivmail export [flags]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Exports archived mails either by search filters (from/to/date/query)")
fmt.Fprintln(os.Stderr, "or, with --tenant, the complete mail set of a single tenant.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Flags:")
fs.PrintDefaults()
}
@@ -51,6 +57,22 @@ func runExport(args []string) {
os.Exit(1)
}
// --tenant and search filters are mutually exclusive.
var tenantID *int64
if *tenant != "" {
if *from != "" || *to != "" || *dateFrom != "" || *dateTo != "" || *query != "" {
fmt.Fprintln(os.Stderr, "error: --tenant cannot be combined with --from/--to/--date-from/--date-to/--query")
fs.Usage()
os.Exit(1)
}
id, perr := strconv.ParseInt(*tenant, 10, 64)
if perr != nil || id <= 0 {
fmt.Fprintf(os.Stderr, "error: invalid --tenant %q (expected positive integer id)\n", *tenant)
os.Exit(1)
}
tenantID = &id
}
if *format != "eml" && *format != "mbox" {
fmt.Fprintf(os.Stderr, "error: unknown format %q (supported: eml, mbox)\n", *format)
os.Exit(1)
@@ -85,35 +107,38 @@ func runExport(args []string) {
if backend == "" {
backend = "xapian"
}
idx, err := index.New(cfg.Index.Path, batchSize, backend)
if err != nil {
fmt.Fprintf(os.Stderr, "error: index init: %v\n", err)
os.Exit(1)
}
defer idx.Close()
// Build search request
// The fulltext index is only needed when exporting via search filters.
var idx index.Indexer
req := index.SearchRequest{
Query: *query,
From: *from,
To: *to,
PageSize: 500,
}
if *dateFrom != "" {
if t, err := time.Parse(time.DateOnly, *dateFrom); err == nil {
req.DateFrom = &t
} else {
fmt.Fprintf(os.Stderr, "error: invalid --date-from %q (expected YYYY-MM-DD)\n", *dateFrom)
if tenantID == nil {
idx, err = index.New(cfg.Index.Path, batchSize, backend)
if err != nil {
fmt.Fprintf(os.Stderr, "error: index init: %v\n", err)
os.Exit(1)
}
}
if *dateTo != "" {
if t, err := time.Parse(time.DateOnly, *dateTo); err == nil {
t = t.Add(24*time.Hour - time.Second)
req.DateTo = &t
} else {
fmt.Fprintf(os.Stderr, "error: invalid --date-to %q (expected YYYY-MM-DD)\n", *dateTo)
os.Exit(1)
defer idx.Close()
if *dateFrom != "" {
if t, err := time.Parse(time.DateOnly, *dateFrom); err == nil {
req.DateFrom = &t
} else {
fmt.Fprintf(os.Stderr, "error: invalid --date-from %q (expected YYYY-MM-DD)\n", *dateFrom)
os.Exit(1)
}
}
if *dateTo != "" {
if t, err := time.Parse(time.DateOnly, *dateTo); err == nil {
t = t.Add(24*time.Hour - time.Second)
req.DateTo = &t
} else {
fmt.Fprintf(os.Stderr, "error: invalid --date-to %q (expected YYYY-MM-DD)\n", *dateTo)
os.Exit(1)
}
}
}
@@ -146,57 +171,86 @@ func runExport(args []string) {
exported := 0
errors := 0
page := 0
for {
req.Page = page
result, err := idx.Search(req)
// exportID loads a single mail and writes it in the selected format.
// Returns false on a (logged) per-mail error so the caller can count it.
exportID := func(id string) bool {
raw, err := mailStore.Load(id)
if err != nil {
fmt.Fprintf(os.Stderr, "error: search failed: %v\n", err)
fmt.Fprintf(os.Stderr, "warning: load %s: %v\n", id, err)
return false
}
if *format == "mbox" {
if err := writeMboxMessage(mboxFile, raw); err != nil {
fmt.Fprintf(os.Stderr, "warning: write mbox: %v\n", err)
return false
}
} else {
outPath := filepath.Join(*out, id+".eml")
if _, err := os.Stat(outPath); err == nil && !*force {
fmt.Fprintf(os.Stderr, "warning: %s exists, skipping (use --force)\n", outPath)
return false
}
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "warning: write %s: %v\n", outPath, err)
return false
}
}
return true
}
if tenantID != nil {
// Full tenant export: pull all mail IDs directly from storage.
ids, err := mailStore.GetAllIDsByTenant(context.Background(), tenantID)
if err != nil {
fmt.Fprintf(os.Stderr, "error: get tenant ids: %v\n", err)
os.Exit(1)
}
if len(result.Hits) == 0 {
break
}
for _, hit := range result.Hits {
raw, err := mailStore.Load(hit.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: load %s: %v\n", hit.ID, err)
errors++
continue
if len(ids) == 0 {
if !*jsonOut {
fmt.Printf("Hinweis: 0 Mails exportiert (Tenant %d hat keine Mails oder existiert nicht)\n", *tenantID)
}
if *format == "mbox" {
if err := writeMboxMessage(mboxFile, raw); err != nil {
fmt.Fprintf(os.Stderr, "warning: write mbox: %v\n", err)
errors++
continue
}
}
for _, id := range ids {
if exportID(id) {
exported++
} else {
outPath := filepath.Join(*out, hit.ID+".eml")
if _, err := os.Stat(outPath); err == nil && !*force {
fmt.Fprintf(os.Stderr, "warning: %s exists, skipping (use --force)\n", outPath)
errors++
continue
}
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "warning: write %s: %v\n", outPath, err)
errors++
continue
}
errors++
}
exported++
}
if !*jsonOut {
if !*jsonOut && len(ids) > 0 {
fmt.Printf("Progress: %d exported, %d errors\n", exported, errors)
}
} else {
page := 0
for {
req.Page = page
result, err := idx.Search(req)
if err != nil {
fmt.Fprintf(os.Stderr, "error: search failed: %v\n", err)
os.Exit(1)
}
if len(result.Hits) == 0 {
break
}
if exported+errors >= result.Total {
break
for _, hit := range result.Hits {
if exportID(hit.ID) {
exported++
} else {
errors++
}
}
if !*jsonOut {
fmt.Printf("Progress: %d exported, %d errors\n", exported, errors)
}
if exported+errors >= result.Total {
break
}
page++
}
page++
}
if *jsonOut {
+1
View File
@@ -252,6 +252,7 @@ func importMessage(mailStore *storage.Store, idxMgr index.TenantIndexer, raw []b
ID: id,
From: pm.From,
To: strings.Join(pm.To, " "),
CC: strings.Join(pm.CC, " "),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
AttachNames: strings.Join(attachNames, " "),
+1
View File
@@ -119,6 +119,7 @@ func runReindex(args []string) {
ID: id,
From: pm.From,
To: strings.Join(pm.To, ", "),
CC: strings.Join(pm.CC, ", "),
Subject: pm.Subject,
Body: pm.TextBody,
AttachNames: strings.Join(attachNames, " "),
+2
View File
@@ -501,6 +501,7 @@ func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw [
ID: id,
From: pm.From,
To: strings.Join(pm.To, ", "),
CC: strings.Join(pm.CC, ", "),
Subject: pm.Subject,
Body: pm.TextBody,
AttachNames: strings.Join(attachNames, " "),
@@ -631,6 +632,7 @@ func reindexTenant(ctx context.Context, store *storage.Store, mgr index.TenantIn
ID: id,
From: pm.From,
To: strings.Join(pm.To, ", "),
CC: strings.Join(pm.CC, ", "),
Subject: pm.Subject,
Body: pm.TextBody,
AttachNames: strings.Join(attachNames, " "),