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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
@@ -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, " "),
|
||||
|
||||
Reference in New Issue
Block a user