feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen
- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg) - Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist - Feature-Status auf In Review gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/archivmail/config"
|
||||
"github.com/archivmail/internal/index"
|
||||
"github.com/archivmail/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "/etc/archivmail/config.yml", "path to config file")
|
||||
format := flag.String("format", "eml", "export format: eml or pdf")
|
||||
outDir := flag.String("out", "./export", "output directory")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
if *format == "pdf" {
|
||||
fmt.Fprintln(os.Stdout, "PDF export not yet implemented")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *format != "eml" {
|
||||
fmt.Fprintf(os.Stderr, "unknown format: %s (supported: eml, pdf)\n", *format)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mailStore, err := storage.New(cfg.Storage.StorePath)
|
||||
if err != nil {
|
||||
logger.Error("storage init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
indexBackend := cfg.Index.Backend
|
||||
if indexBackend == "" {
|
||||
indexBackend = "xapian"
|
||||
}
|
||||
batchSize := cfg.Index.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend)
|
||||
if err != nil {
|
||||
logger.Error("index init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer idx.Close()
|
||||
|
||||
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||
logger.Error("cannot create output directory", "dir", *outDir, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Fetch all indexed mails using pagination
|
||||
page := 0
|
||||
pageSize := 500
|
||||
exported := 0
|
||||
errors := 0
|
||||
|
||||
for {
|
||||
result, err := idx.Search(index.SearchRequest{
|
||||
PageSize: pageSize,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("search failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(result.Hits) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, hit := range result.Hits {
|
||||
raw, err := mailStore.Load(hit.ID)
|
||||
if err != nil {
|
||||
logger.Error("load failed", "id", hit.ID, "err", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
outPath := filepath.Join(*outDir, hit.ID+".eml")
|
||||
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
|
||||
logger.Error("write failed", "path", outPath, "err", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
exported++
|
||||
}
|
||||
|
||||
logger.Info("export progress", "page", page, "exported", exported, "errors", errors)
|
||||
|
||||
if exported+errors >= result.Total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
logger.Info("export complete",
|
||||
"format", *format,
|
||||
"out", *outDir,
|
||||
"exported", exported,
|
||||
"errors", errors,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/archivmail/config"
|
||||
"github.com/archivmail/internal/index"
|
||||
"github.com/archivmail/internal/storage"
|
||||
"github.com/archivmail/pkg/mailparser"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "/etc/archivmail/config.yml", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: archivmail-import --config <path> <directory-or-file>")
|
||||
os.Exit(1)
|
||||
}
|
||||
target := args[0]
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mailStore, err := storage.New(cfg.Storage.StorePath)
|
||||
if err != nil {
|
||||
logger.Error("storage init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
indexBackend := cfg.Index.Backend
|
||||
if indexBackend == "" {
|
||||
indexBackend = "xapian"
|
||||
}
|
||||
batchSize := cfg.Index.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend)
|
||||
if err != nil {
|
||||
logger.Error("index init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer idx.Close()
|
||||
|
||||
var emlFiles []string
|
||||
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
logger.Error("target not found", "path", target, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
err = filepath.Walk(target, func(path string, fi os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() && strings.HasSuffix(strings.ToLower(fi.Name()), ".eml") {
|
||||
emlFiles = append(emlFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("walk failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
emlFiles = []string{target}
|
||||
}
|
||||
|
||||
logger.Info("found EML files", "count", len(emlFiles))
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
errors := 0
|
||||
|
||||
for i, path := range emlFiles {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
logger.Error("read file failed", "path", path, "err", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
pm, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
logger.Error("parse failed", "path", path, "err", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
id, err := mailStore.Save(raw, pm.Date)
|
||||
if err != nil {
|
||||
logger.Error("save failed", "path", path, "err", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Build attachment names list
|
||||
var attachNames []string
|
||||
for _, att := range pm.Attachments {
|
||||
attachNames = append(attachNames, att.Filename)
|
||||
}
|
||||
|
||||
doc := index.MailDocument{
|
||||
ID: id,
|
||||
From: pm.From,
|
||||
To: strings.Join(pm.To, " "),
|
||||
Subject: pm.Subject,
|
||||
Body: pm.TextBody + " " + pm.HTMLBody,
|
||||
AttachNames: strings.Join(attachNames, " "),
|
||||
HasAttachment: len(pm.Attachments) > 0,
|
||||
Date: pm.Date,
|
||||
Size: int64(len(raw)),
|
||||
}
|
||||
|
||||
if err := idx.IndexSync(doc); err != nil {
|
||||
logger.Error("index failed", "id", id, "err", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
imported++
|
||||
if (i+1)%100 == 0 || i+1 == len(emlFiles) {
|
||||
fmt.Printf("Progress: %d/%d (imported: %d, skipped: %d, errors: %d)\n",
|
||||
i+1, len(emlFiles), imported, skipped, errors)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("import complete",
|
||||
"total", len(emlFiles),
|
||||
"imported", imported,
|
||||
"skipped", skipped,
|
||||
"errors", errors,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/archivmail/config"
|
||||
"github.com/archivmail/internal/api"
|
||||
"github.com/archivmail/internal/audit"
|
||||
"github.com/archivmail/internal/auth"
|
||||
imapstore "github.com/archivmail/internal/imap"
|
||||
"github.com/archivmail/internal/index"
|
||||
"github.com/archivmail/internal/smtpd"
|
||||
"github.com/archivmail/internal/storage"
|
||||
"github.com/archivmail/internal/userstore"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "/etc/archivmail/config.yml", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "path", *configPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Storage
|
||||
mailStore, err := storage.New(cfg.Storage.StorePath)
|
||||
if err != nil {
|
||||
logger.Error("storage init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Index
|
||||
indexBackend := cfg.Index.Backend
|
||||
if indexBackend == "" {
|
||||
indexBackend = "xapian"
|
||||
}
|
||||
batchSize := cfg.Index.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend)
|
||||
if err != nil {
|
||||
logger.Error("index init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer idx.Close()
|
||||
|
||||
// User store
|
||||
users, err := userstore.New(cfg.Database.DSN())
|
||||
if err != nil {
|
||||
logger.Error("userstore init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer users.Close()
|
||||
|
||||
// Audit log
|
||||
audlog, err := audit.New(cfg.Database.DSN(), cfg.Audit.LogPath, logger)
|
||||
if err != nil {
|
||||
logger.Error("audit init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer audlog.Close()
|
||||
|
||||
// Seed default users on first run
|
||||
if err := seedDefaultUsers(users, logger); err != nil {
|
||||
logger.Error("seed users failed", "err", err)
|
||||
}
|
||||
|
||||
// Auth manager
|
||||
authMgr := auth.New(users, nil, cfg.API.Secret)
|
||||
|
||||
// API server
|
||||
apiCfg := config.APIConfig{
|
||||
Bind: cfg.API.Bind,
|
||||
Secret: cfg.API.Secret,
|
||||
}
|
||||
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
|
||||
|
||||
bind := cfg.API.Bind
|
||||
if bind == "" {
|
||||
bind = fmt.Sprintf(":%d", cfg.Server.APIPort)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: bind,
|
||||
Handler: srv,
|
||||
}
|
||||
|
||||
// Start SMTP daemon
|
||||
if cfg.SMTP.Bind == "" {
|
||||
cfg.SMTP.Bind = fmt.Sprintf(":%d", cfg.Server.SMTPPort)
|
||||
}
|
||||
smtpDaemon := smtpd.New(cfg.SMTP, mailStore, logger)
|
||||
if err := smtpDaemon.Start(); err != nil {
|
||||
logger.Error("SMTP daemon failed to start", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer smtpDaemon.Stop()
|
||||
|
||||
// Wire SMTP daemon into API server for status endpoint
|
||||
srv.SetSMTPDaemon(smtpDaemon)
|
||||
|
||||
// IMAP store + importer
|
||||
imapSt, err := imapstore.New(cfg.Database.DSN(), cfg.API.Secret)
|
||||
if err != nil {
|
||||
logger.Error("imap store init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer imapSt.Close()
|
||||
imapImp := imapstore.NewImporter(imapSt, mailStore, idx, logger)
|
||||
srv.SetImap(imapSt, imapImp)
|
||||
|
||||
// Start HTTP API
|
||||
go func() {
|
||||
logger.Info("starting API server", "addr", bind)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("API server error", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
logger.Info("shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// seedDefaultUsers creates default admin and auditor accounts if no users exist yet.
|
||||
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
|
||||
all, err := users.List("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
if len(all) > 0 {
|
||||
return nil // already seeded
|
||||
}
|
||||
defaults := []userstore.CreateUserRequest{
|
||||
{Username: "admin", Email: "admin@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAdmin},
|
||||
{Username: "auditor", Email: "auditor@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAuditor},
|
||||
}
|
||||
for _, req := range defaults {
|
||||
if _, err := users.Create(req); err != nil {
|
||||
return fmt.Errorf("create default user %s: %w", req.Username, err)
|
||||
}
|
||||
logger.Info("created default user", "username", req.Username, "role", req.Role)
|
||||
}
|
||||
logger.Warn("default users created — change passwords immediately!", "admin", "admin", "auditor", "auditor")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user