feat(PROJ-30): Xapian → Manticore Search Migration

- internal/index/manticore.go: ManticoreTenantManager + manticoreIndex (RT-Indizes, CGO-frei)
- internal/index/index.go: TenantIndexer Interface (Xapian + Manticore)
- internal/index/tenant_worker.go: mgr-Typ auf TenantIndexer Interface
- internal/api/server.go: idxMgr auf TenantIndexer Interface
- config/config.go: IndexConfig.ManticoreDSN Feld
- cmd/archivmail/cmd_reindex.go: reindex Subkommando
- cmd/archivmail/main.go: Manticore-Branch + reindex Case
- go.mod: github.com/go-sql-driver/mysql v1.8.1
- update.sh: Manticore auto-install, CGO_ENABLED=0, config.yml migration, auto-reindex

fix(IMAP): TCP-Deadline-Wrapper für steckengebliebene Imports
fix(auth): Email-Claim in JWT für User-Isolation
fix(search): User-Isolation via sess.Email (fail-safe)
fix(ui): Admin-Login Auth-Cache, Logout-Redirect, IMAP-Polling-Resilienz

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-03 21:19:36 +02:00
parent e90d588e30
commit a93a843506
19 changed files with 742 additions and 65 deletions
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"flag"
"log/slog"
"os"
"strings"
"github.com/archivmail/config"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/storage"
"github.com/archivmail/pkg/mailparser"
)
// runReindex re-indexes all (or tenant-specific) emails into the configured index backend.
// Usage: archivmail reindex [-config /path/to/config.yml] [-tenant <id>]
func runReindex(args []string) {
fs := flag.NewFlagSet("reindex", flag.ExitOnError)
configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file")
tenantIDFlag := fs.Int64("tenant", 0, "tenant ID to reindex (0 = all tenants)")
fs.Parse(args)
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)
}
storeCfg := storage.Config{
Dir: cfg.Storage.StorePath,
Keyfile: cfg.Storage.Keyfile,
DSN: cfg.Database.DSN(),
}
mailStore, err := storage.New(storeCfg)
if err != nil {
logger.Error("storage init failed", "err", err)
os.Exit(1)
}
defer mailStore.Close()
indexBackend := cfg.Index.Backend
if indexBackend == "" {
indexBackend = "xapian"
}
batchSize := cfg.Index.BatchSize
if batchSize <= 0 {
batchSize = 100
}
var idxMgr index.TenantIndexer
if indexBackend == "manticore" {
dsn := cfg.Index.ManticoreDSN
if dsn == "" {
dsn = "manticore@tcp(127.0.0.1:9306)/"
}
m, err := index.NewManticoreTenantManager(dsn)
if err != nil {
logger.Error("manticore init failed", "err", err)
os.Exit(1)
}
idxMgr = m
} else {
m, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, indexBackend)
if err != nil {
logger.Error("index manager init failed", "err", err)
os.Exit(1)
}
idxMgr = m
}
defer func() { idxMgr.Close() }()
ctx := context.Background()
var ids []string
if *tenantIDFlag > 0 {
tid := *tenantIDFlag
ids, err = mailStore.GetAllIDsByTenant(ctx, &tid)
} else {
ids, err = mailStore.GetAllIDs(ctx)
}
if err != nil {
logger.Error("failed to list mail IDs", "err", err)
os.Exit(1)
}
logger.Info("reindex: starting", "backend", indexBackend, "total", len(ids))
indexed := 0
errors := 0
for i, id := range ids {
raw, err := mailStore.Load(id)
if err != nil {
logger.Warn("reindex: load failed", "id", id, "err", err)
errors++
continue
}
pm, err := mailparser.Parse(raw)
if err != nil {
logger.Warn("reindex: parse failed", "id", id, "err", err)
errors++
continue
}
tenantID, _ := mailStore.GetTenantForMail(ctx, id)
var attachNames []string
for _, a := range pm.Attachments {
if a.Filename != "" {
attachNames = append(attachNames, a.Filename)
}
}
doc := index.MailDocument{
ID: id,
From: pm.From,
To: strings.Join(pm.To, ", "),
Subject: pm.Subject,
Body: pm.TextBody,
AttachNames: strings.Join(attachNames, " "),
HasAttachment: len(pm.Attachments) > 0,
Date: pm.Date,
Size: int64(len(raw)),
TenantID: tenantID,
}
idx := idxMgr.ForTenant(tenantID)
if err := idx.IndexSync(doc); err != nil {
logger.Warn("reindex: index failed", "id", id, "err", err)
errors++
continue
}
indexed++
if (i+1)%100 == 0 {
logger.Info("reindex: progress", "processed", i+1, "indexed", indexed, "errors", errors)
}
}
logger.Info("reindex: complete", "total", len(ids), "indexed", indexed, "errors", errors)
}
+24 -6
View File
@@ -55,6 +55,9 @@ func main() {
case "migrate-tenants": case "migrate-tenants":
runMigrateTenants(os.Args[2:]) runMigrateTenants(os.Args[2:])
return return
case "reindex":
runReindex(os.Args[2:])
return
case "version": case "version":
fmt.Printf("archivmail %s\n", AppVersion) fmt.Printf("archivmail %s\n", AppVersion)
for mod, ver := range Modules { for mod, ver := range Modules {
@@ -124,12 +127,27 @@ func main() {
if batchSize <= 0 { if batchSize <= 0 {
batchSize = 100 batchSize = 100
} }
idxMgr, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, indexBackend) var idxMgr index.TenantIndexer
if err != nil { if indexBackend == "manticore" {
logger.Error("index manager init failed", "err", err) dsn := cfg.Index.ManticoreDSN
os.Exit(1) if dsn == "" {
dsn = "manticore@tcp(127.0.0.1:9306)/"
}
m, err := index.NewManticoreTenantManager(dsn)
if err != nil {
logger.Error("manticore index manager init failed", "err", err)
os.Exit(1)
}
idxMgr = m
} else {
m, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, indexBackend)
if err != nil {
logger.Error("index manager init failed", "err", err)
os.Exit(1)
}
idxMgr = m
} }
defer idxMgr.Close() defer func() { idxMgr.Close() }()
// Global index reference for backward compatibility (IMAP importer, etc.) // Global index reference for backward compatibility (IMAP importer, etc.)
idx := idxMgr.Global() idx := idxMgr.Global()
@@ -469,7 +487,7 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
// reindexTenant re-indexes all emails belonging to a specific tenant. // reindexTenant re-indexes all emails belonging to a specific tenant.
// Used during migration when switching from global index to per-tenant indexes. // Used during migration when switching from global index to per-tenant indexes.
func reindexTenant(ctx context.Context, store *storage.Store, mgr *index.TenantIndexManager, tenantID int64, logger *slog.Logger) error { func reindexTenant(ctx context.Context, store *storage.Store, mgr index.TenantIndexer, tenantID int64, logger *slog.Logger) error {
tid := tenantID tid := tenantID
ids, err := store.GetAllIDsByTenant(ctx, &tid) ids, err := store.GetAllIDsByTenant(ctx, &tid)
if err != nil { if err != nil {
+1
View File
@@ -106,6 +106,7 @@ type IndexConfig struct {
Backend string `yaml:"backend"` Backend string `yaml:"backend"`
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
AsyncQueueSize int `yaml:"async_queue_size"` AsyncQueueSize int `yaml:"async_queue_size"`
ManticoreDSN string `yaml:"manticore_dsn"` // DSN for Manticore backend (default: "manticore@tcp(127.0.0.1:9306)/")
} }
// AuditConfig holds audit log settings. // AuditConfig holds audit log settings.
+1
View File
@@ -7,6 +7,7 @@ toolchain go1.24.4
require ( require (
github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/jackc/pgx/v5 v5.6.0 github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.48.0
+29 -5
View File
@@ -171,6 +171,18 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs) labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
} }
// SEC: For user role, restrict results to mails the user is involved in
// (From, To, or CC). Email comes from the JWT session — no DB lookup needed.
// If email is missing for a user-role session, block all results (fail-safe).
var userEmailFilter string
if sess.Role == userstore.RoleUser {
userEmailFilter = strings.ToLower(sess.Email)
if userEmailFilter == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"total": 0, "hits": []interface{}{}})
return
}
}
enriched := make([]enrichedHit, 0, len(result.Hits)) enriched := make([]enrichedHit, 0, len(result.Hits))
for _, h := range result.Hits { for _, h := range result.Hits {
eh := enrichedHit{ID: h.ID, Score: h.Score} eh := enrichedHit{ID: h.ID, Score: h.Score}
@@ -186,6 +198,14 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
eh.Date = pm.Date.UTC().Format(time.RFC3339) eh.Date = pm.Date.UTC().Format(time.RFC3339)
} }
eh.HasAttachments = len(pm.Attachments) > 0 eh.HasAttachments = len(pm.Attachments) > 0
// User isolation: skip mails the user is not involved in.
if userEmailFilter != "" && !mailBelongsToUser(pm, userEmailFilter) {
continue
}
} else if userEmailFilter != "" {
// If mail can't be parsed, deny access to user role.
continue
} }
} }
if labelMap != nil { if labelMap != nil {
@@ -233,8 +253,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter) // user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor { if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
u, err := s.users.GetByUsername(sess.Username) if sess.Email == "" || !mailBelongsToUser(pm, sess.Email) {
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -399,16 +418,21 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
w.Write(raw) w.Write(raw)
} }
// mailBelongsToUser checks if the user's email appears in To or CC. // mailBelongsToUser checks if the user's email appears in From, To, or CC.
// Users can access mails they sent as well as mails they received.
// From may contain a display name ("Name <addr>"), so Contains is used.
func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool { func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
email := strings.ToLower(userEmail) email := strings.ToLower(userEmail)
if strings.Contains(strings.ToLower(pm.From), email) {
return true
}
for _, to := range pm.To { for _, to := range pm.To {
if strings.ToLower(to) == email { if strings.Contains(strings.ToLower(to), email) {
return true return true
} }
} }
for _, cc := range pm.CC { for _, cc := range pm.CC {
if strings.ToLower(cc) == email { if strings.Contains(strings.ToLower(cc), email) {
return true return true
} }
} }
+2 -2
View File
@@ -80,7 +80,7 @@ type Server struct {
ldapStore *ldapcfg.Store ldapStore *ldapcfg.Store
tenantStore *tenantstore.Store tenantStore *tenantstore.Store
tenantLdapStore *ldapcfg.TenantStore tenantLdapStore *ldapcfg.TenantStore
idxMgr *index.TenantIndexManager idxMgr index.TenantIndexer
appVersion string appVersion string
moduleVersions map[string]string moduleVersions map[string]string
globalRetentionDays int // from storage config (PROJ-34) globalRetentionDays int // from storage config (PROJ-34)
@@ -109,7 +109,7 @@ func (s *Server) SetPop3(store *pop3store.Store, importer *pop3store.Importer) {
} }
// SetIndexManager wires the per-tenant index manager into the API server (PROJ-21 Phase 4). // SetIndexManager wires the per-tenant index manager into the API server (PROJ-21 Phase 4).
func (s *Server) SetIndexManager(mgr *index.TenantIndexManager) { func (s *Server) SetIndexManager(mgr index.TenantIndexer) {
s.idxMgr = mgr s.idxMgr = mgr
} }
+4
View File
@@ -22,6 +22,7 @@ import (
type Session struct { type Session struct {
UserID int64 UserID int64
Username string Username string
Email string
Role string Role string
JTI string // unique JWT ID JTI string // unique JWT ID
TenantID *int64 TenantID *int64
@@ -193,6 +194,7 @@ func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, err
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"sub": user.Username, "sub": user.Username,
"email": user.Email,
"role": user.Role, "role": user.Role,
"uid": user.ID, "uid": user.ID,
"jti": jti, "jti": jti,
@@ -338,6 +340,7 @@ func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
} }
username, _ := claims["sub"].(string) username, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
role, _ := claims["role"].(string) role, _ := claims["role"].(string)
var userID int64 var userID int64
@@ -364,6 +367,7 @@ func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
return &Session{ return &Session{
UserID: userID, UserID: userID,
Username: username, Username: username,
Email: email,
Role: role, Role: role,
JTI: jti, JTI: jti,
TenantID: tenantID, TenantID: tenantID,
+62 -16
View File
@@ -3,17 +3,35 @@ package imap
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net"
"strings" "strings"
"time"
imapv2 "github.com/emersion/go-imap/v2" imapv2 "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-imap/v2/imapclient"
) )
// FolderInfo describes a single IMAP folder with exclusion metadata. const (
type FolderInfo struct { dialTimeout = 30 * time.Second
Name string `json:"name"` fetchTimeout = 5 * time.Minute // per-batch read/write deadline
Excluded bool `json:"excluded"` )
Reason string `json:"reason,omitempty"`
// Conn wraps an IMAP client with the underlying net.Conn so callers
// can set per-operation deadlines to prevent indefinite blocking.
type Conn struct {
*imapclient.Client
raw net.Conn
}
// SetFetchDeadline sets a 5-minute read/write deadline on the connection.
// Call this before each fetch batch to prevent stalled imports.
func (c *Conn) SetFetchDeadline() {
_ = c.raw.SetDeadline(time.Now().Add(fetchTimeout))
}
// ClearDeadline removes any active deadline from the underlying connection.
func (c *Conn) ClearDeadline() {
_ = c.raw.SetDeadline(time.Time{})
} }
// junkTrashNames lists well-known junk/trash folder names for fallback detection. // junkTrashNames lists well-known junk/trash folder names for fallback detection.
@@ -22,33 +40,61 @@ var junkTrashNames = []string{
"deleted messages", "papierkorb", "gelöschte elemente", "deleted messages", "papierkorb", "gelöschte elemente",
} }
// FolderInfo describes a single IMAP folder with exclusion metadata.
type FolderInfo struct {
Name string `json:"name"`
Excluded bool `json:"excluded"`
Reason string `json:"reason,omitempty"`
}
// Connect establishes an IMAP client connection using the specified TLS mode. // Connect establishes an IMAP client connection using the specified TLS mode.
func Connect(host string, port int, tlsMode string) (*imapclient.Client, error) { // Returns a Conn that exposes the underlying net.Conn for deadline management.
func Connect(host string, port int, tlsMode string) (*Conn, error) {
addr := fmt.Sprintf("%s:%d", host, port) addr := fmt.Sprintf("%s:%d", host, port)
switch tlsMode { switch tlsMode {
case "ssl": case "ssl":
c, err := imapclient.DialTLS(addr, &imapclient.Options{ dialer := &tls.Dialer{
TLSConfig: &tls.Config{ServerName: host}, NetDialer: &net.Dialer{Timeout: dialTimeout},
}) Config: &tls.Config{ServerName: host},
}
raw, err := dialer.Dial("tcp", addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("imap connect ssl: %w", err) return nil, fmt.Errorf("imap connect ssl: %w", err)
} }
return c, nil c, err := imapclient.New(raw, nil)
if err != nil {
raw.Close()
return nil, fmt.Errorf("imap client ssl: %w", err)
}
return &Conn{Client: c, raw: raw}, nil
case "starttls": case "starttls":
c, err := imapclient.DialStartTLS(addr, &imapclient.Options{ raw, err := net.DialTimeout("tcp", addr, dialTimeout)
TLSConfig: &tls.Config{ServerName: host},
})
if err != nil { if err != nil {
return nil, fmt.Errorf("imap connect starttls: %w", err) return nil, fmt.Errorf("imap connect starttls: %w", err)
} }
return c, nil c, err := imapclient.New(raw, &imapclient.Options{
TLSConfig: &tls.Config{ServerName: host},
})
if err != nil {
raw.Close()
return nil, fmt.Errorf("imap client starttls: %w", err)
}
return &Conn{Client: c, raw: raw}, nil
case "none": case "none":
c, err := imapclient.DialInsecure(addr, nil) raw, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("imap connect plain: %w", err) return nil, fmt.Errorf("imap connect plain: %w", err)
} }
return c, nil c, err := imapclient.New(raw, nil)
if err != nil {
raw.Close()
return nil, fmt.Errorf("imap client plain: %w", err)
}
return &Conn{Client: c, raw: raw}, nil
default: default:
return nil, fmt.Errorf("imap: unknown tls mode %q", tlsMode) return nil, fmt.Errorf("imap: unknown tls mode %q", tlsMode)
} }
+11 -10
View File
@@ -23,7 +23,6 @@ type Importer struct {
mailStore *storage.Store mailStore *storage.Store
idx index.Indexer idx index.Indexer
logger *slog.Logger logger *slog.Logger
TenantID *int64 // optional tenant assignment for stored mails
} }
// NewImporter creates a new Importer wired to the storage and index backends. // NewImporter creates a new Importer wired to the storage and index backends.
@@ -88,7 +87,7 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
} }
// List all folders // List all folders
folders, err := ListFolders(c) folders, err := ListFolders(c.Client)
if err != nil { if err != nil {
return 0, fmt.Errorf("list folders: %w", err) return 0, fmt.Errorf("list folders: %w", err)
} }
@@ -159,11 +158,13 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
} }
batch := uids[i:end] batch := uids[i:end]
count, err := imp.fetchBatch(ctx, c, batch, log) // Set per-batch deadline to prevent indefinite blocking on stalled connections.
c.SetFetchDeadline()
count, err := imp.fetchBatch(ctx, c.Client, batch, acc.TenantID, log)
c.ClearDeadline()
if err != nil { if err != nil {
log.Error("batch fetch error", "folder", folder, "offset", i, "err", err) log.Error("batch fetch error — aborting import", "folder", folder, "offset", i, "err", err)
// Continue with the next batch rather than aborting entirely return imported, fmt.Errorf("fetch batch %d in %q: %w", i, folder, err)
continue
} }
imported += count imported += count
@@ -177,7 +178,7 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
} }
// fetchBatch fetches and stores a batch of messages by UID. // fetchBatch fetches and stores a batch of messages by UID.
func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids []imapv2.UID, log *slog.Logger) (int, error) { func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids []imapv2.UID, tenantID *int64, log *slog.Logger) (int, error) {
if len(uids) == 0 { if len(uids) == 0 {
return 0, nil return 0, nil
} }
@@ -212,7 +213,7 @@ func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids
continue continue
} }
if err := imp.storeAndIndex(raw, log); err != nil { if err := imp.storeAndIndex(raw, tenantID, log); err != nil {
log.Warn("failed to store/index message", "err", err) log.Warn("failed to store/index message", "err", err)
continue continue
} }
@@ -229,10 +230,10 @@ func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids
} }
// storeAndIndex saves a raw email to storage and indexes it. // storeAndIndex saves a raw email to storage and indexes it.
func (imp *Importer) storeAndIndex(raw []byte, log *slog.Logger) error { func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, log *slog.Logger) error {
ctx := context.Background() ctx := context.Background()
// Save to file storage (deduplicates by SHA256 automatically) // Save to file storage (deduplicates by SHA256 automatically)
id, err := imp.mailStore.Save(ctx, raw, time.Now(), imp.TenantID) id, err := imp.mailStore.Save(ctx, raw, time.Now(), tenantID)
if err != nil { if err != nil {
return fmt.Errorf("save: %w", err) return fmt.Errorf("save: %w", err)
} }
+7 -4
View File
@@ -207,7 +207,7 @@ func (s *Scheduler) doSync(ctx context.Context, accountID int64) (int, uint32, e
return 0, 0, fmt.Errorf("imap scheduler: login: %w", err) return 0, 0, fmt.Errorf("imap scheduler: login: %w", err)
} }
folders, err := ListFolders(c) folders, err := ListFolders(c.Client)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("imap scheduler: list folders: %w", err) return 0, 0, fmt.Errorf("imap scheduler: list folders: %w", err)
} }
@@ -247,7 +247,7 @@ func (s *Scheduler) doSync(ctx context.Context, accountID int64) (int, uint32, e
// syncFolder syncs new messages from a single IMAP folder. // syncFolder syncs new messages from a single IMAP folder.
func (s *Scheduler) syncFolder( func (s *Scheduler) syncFolder(
ctx context.Context, ctx context.Context,
c *imapclient.Client, c *Conn,
acc *Account, acc *Account,
folder string, folder string,
log *slog.Logger, log *slog.Logger,
@@ -298,7 +298,9 @@ func (s *Scheduler) syncFolder(
} }
batch := uids[i:end] batch := uids[i:end]
count, batchMaxUID, err := s.fetchSyncBatch(c, batch, log) c.SetFetchDeadline()
count, batchMaxUID, err := s.fetchSyncBatch(c.Client, batch, acc.TenantID, log)
c.ClearDeadline()
if err != nil { if err != nil {
log.Warn("imap scheduler: batch error, continuing", log.Warn("imap scheduler: batch error, continuing",
"folder", folder, "offset", i, "err", err) "folder", folder, "offset", i, "err", err)
@@ -320,6 +322,7 @@ func (s *Scheduler) syncFolder(
func (s *Scheduler) fetchSyncBatch( func (s *Scheduler) fetchSyncBatch(
c *imapclient.Client, c *imapclient.Client,
uids []imapv2.UID, uids []imapv2.UID,
tenantID *int64,
log *slog.Logger, log *slog.Logger,
) (int, uint32, error) { ) (int, uint32, error) {
if len(uids) == 0 { if len(uids) == 0 {
@@ -367,7 +370,7 @@ func (s *Scheduler) fetchSyncBatch(
} }
if len(raw) > 0 { if len(raw) > 0 {
if err := s.importer.storeAndIndex(raw, log); err != nil { if err := s.importer.storeAndIndex(raw, tenantID, log); err != nil {
log.Warn("imap scheduler: store/index failed", "err", err) log.Warn("imap scheduler: store/index failed", "err", err)
} else { } else {
imported++ imported++
+7 -3
View File
@@ -40,6 +40,9 @@ type Account struct {
SyncRunning bool `json:"sync_running"` SyncRunning bool `json:"sync_running"`
SyncStatus string `json:"sync_status"` SyncStatus string `json:"sync_status"`
SyncErrorMsg string `json:"sync_error_msg"` SyncErrorMsg string `json:"sync_error_msg"`
// Tenant assignment — mails imported from this account are tagged with this tenant.
TenantID *int64 `json:"tenant_id,omitempty"`
} }
// Store manages IMAP account persistence in PostgreSQL. // Store manages IMAP account persistence in PostgreSQL.
@@ -71,7 +74,7 @@ CREATE TABLE IF NOT EXISTS imap_accounts (
CREATE INDEX IF NOT EXISTS idx_imap_accounts_owner ON imap_accounts (owner); CREATE INDEX IF NOT EXISTS idx_imap_accounts_owner ON imap_accounts (owner);
` `
// migrationSQL adds the PROJ-8 sync columns if they do not yet exist. // migrationSQL adds columns that may not exist in older installations.
const migrationSQL = ` const migrationSQL = `
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_interval_min INTEGER NOT NULL DEFAULT 0; ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_interval_min INTEGER NOT NULL DEFAULT 0;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS last_sync_at TIMESTAMPTZ; ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS last_sync_at TIMESTAMPTZ;
@@ -80,6 +83,7 @@ ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS last_uid BIGINT NOT NULL DEFA
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_running BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_running BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_status TEXT NOT NULL DEFAULT ''; ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_status TEXT NOT NULL DEFAULT '';
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_error_msg TEXT NOT NULL DEFAULT ''; ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_error_msg TEXT NOT NULL DEFAULT '';
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS tenant_id INTEGER REFERENCES tenants(id);
` `
// New creates a new Store, connects to PostgreSQL, and runs the migration. // New creates a new Store, connects to PostgreSQL, and runs the migration.
@@ -138,7 +142,7 @@ const selectColumns = ` id, owner, name, host, port, tls, username, excluded_fol
status, error_msg, last_import_at, last_import_count, status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at, progress_current, progress_total, created_at,
sync_interval_min, last_sync_at, last_sync_count, last_uid, sync_interval_min, last_sync_at, last_sync_count, last_uid,
sync_running, sync_status, sync_error_msg ` sync_running, sync_status, sync_error_msg, tenant_id `
// scanner abstracts pgx.Row and pgx.Rows — both expose Scan(...any) error. // scanner abstracts pgx.Row and pgx.Rows — both expose Scan(...any) error.
type scanner interface { type scanner interface {
@@ -152,7 +156,7 @@ func scanRow(row scanner) (Account, error) {
&a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt, &a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt,
&a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt, &a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt,
&a.SyncIntervalMin, &a.LastSyncAt, &a.LastSyncCount, &a.LastUID, &a.SyncIntervalMin, &a.LastSyncAt, &a.LastSyncCount, &a.LastUID,
&a.SyncRunning, &a.SyncStatus, &a.SyncErrorMsg, &a.SyncRunning, &a.SyncStatus, &a.SyncErrorMsg, &a.TenantID,
) )
return a, err return a, err
} }
+8
View File
@@ -54,6 +54,14 @@ type Indexer interface {
Close() error Close() error
} }
// TenantIndexer manages per-tenant Indexer instances.
// Implemented by TenantIndexManager (Xapian) and ManticoreTenantManager.
type TenantIndexer interface {
ForTenant(tenantID *int64) Indexer
Global() Indexer
Close() error
}
// New creates an Indexer for the specified backend. // New creates an Indexer for the specified backend.
func New(dir string, batchSize int, backend string) (Indexer, error) { func New(dir string, batchSize int, backend string) (Indexer, error) {
switch backend { switch backend {
+327
View File
@@ -0,0 +1,327 @@
package index
import (
"database/sql"
"fmt"
"hash/fnv"
"strings"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
)
// manticoreIndex implements Indexer against a single Manticore RT table.
type manticoreIndex struct {
db *sql.DB
table string
}
// ManticoreTenantManager implements TenantIndexer using Manticore Search
// via the MySQL protocol. No CGO required — pure Go via database/sql.
type ManticoreTenantManager struct {
db *sql.DB
mu sync.RWMutex
pool map[int64]*manticoreIndex
global *manticoreIndex
}
// NewManticoreTenantManager opens a Manticore connection, ensures the global
// RT table exists, and returns a ready manager.
func NewManticoreTenantManager(dsn string) (*ManticoreTenantManager, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("manticore: open: %w", err)
}
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("manticore: ping: %w", err)
}
m := &ManticoreTenantManager{
db: db,
pool: make(map[int64]*manticoreIndex),
}
globalIdx := &manticoreIndex{db: db, table: "emails_global"}
if err := globalIdx.ensureTable(); err != nil {
db.Close()
return nil, fmt.Errorf("manticore: ensure global table: %w", err)
}
m.global = globalIdx
return m, nil
}
// ForTenant returns the Indexer for the given tenant, creating the RT table on first use.
// A nil or zero tenantID falls back to the global index.
func (m *ManticoreTenantManager) ForTenant(tenantID *int64) Indexer {
if tenantID == nil || *tenantID == 0 {
return m.global
}
tid := *tenantID
m.mu.RLock()
idx, ok := m.pool[tid]
m.mu.RUnlock()
if ok {
return idx
}
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring write lock.
if idx, ok = m.pool[tid]; ok {
return idx
}
idx = &manticoreIndex{db: m.db, table: manticoreTableName(&tid)}
if err := idx.ensureTable(); err != nil {
// Return global as safe fallback; error is logged via caller.
return m.global
}
m.pool[tid] = idx
return idx
}
// Global returns the global (non-tenant) Indexer.
func (m *ManticoreTenantManager) Global() Indexer {
return m.global
}
// Close closes the shared database connection.
func (m *ManticoreTenantManager) Close() error {
return m.db.Close()
}
// ── manticoreIndex methods ────────────────────────────────────────────────
// ensureTable creates the RT index if it does not yet exist.
func (idx *manticoreIndex) ensureTable() error {
stmt := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
mail_id string,
subject text,
from_addr text,
to_addr text,
body text,
attachment_names text,
has_attachment uint,
date_ts bigint,
size_bytes bigint
) type='rt' morphology='stem_en,stem_de'`, idx.table)
_, err := idx.db.Exec(stmt)
if err != nil {
return fmt.Errorf("ensureTable %s: %w", idx.table, err)
}
return nil
}
// IndexSync inserts or replaces a document in the RT index.
func (idx *manticoreIndex) IndexSync(doc MailDocument) error {
rowID := hashMailID(doc.ID)
hasAttach := uint64(0)
if doc.HasAttachment {
hasAttach = 1
}
var dateTS int64
if !doc.Date.IsZero() {
dateTS = doc.Date.Unix()
}
_, err := idx.db.Exec(
fmt.Sprintf(`REPLACE INTO %s
(id, mail_id, subject, from_addr, to_addr, body, attachment_names, has_attachment, date_ts, size_bytes)
VALUES (?,?,?,?,?,?,?,?,?,?)`, idx.table),
rowID,
doc.ID,
doc.Subject,
doc.From,
doc.To,
doc.Body,
doc.AttachNames,
hasAttach,
dateTS,
doc.Size,
)
if err != nil {
return fmt.Errorf("manticore IndexSync %s: %w", idx.table, err)
}
return nil
}
// Delete removes a document by mail ID hash.
func (idx *manticoreIndex) Delete(id string) error {
rowID := hashMailID(id)
_, err := idx.db.Exec(
fmt.Sprintf("DELETE FROM %s WHERE id = ?", idx.table),
rowID,
)
if err != nil {
return fmt.Errorf("manticore Delete %s: %w", idx.table, err)
}
return nil
}
// Search executes a full-text + filter query against the RT index.
func (idx *manticoreIndex) Search(req SearchRequest) (*SearchResult, error) {
var matchParts []string
if req.Query != "" {
matchParts = append(matchParts, escapeManticoreMatch(req.Query))
}
if req.From != "" {
matchParts = append(matchParts, fmt.Sprintf("@from_addr %s", escapeManticoreMatch(req.From)))
}
if req.To != "" {
matchParts = append(matchParts, fmt.Sprintf("@to_addr %s", escapeManticoreMatch(req.To)))
}
if req.OwnEmail != "" {
matchParts = append(matchParts, fmt.Sprintf("@(from_addr,to_addr) %s", escapeManticoreMatch(req.OwnEmail)))
}
hasMatch := len(matchParts) > 0
var whereParts []string
var args []interface{}
if hasMatch {
whereParts = append(whereParts, "MATCH(?)")
args = append(args, strings.Join(matchParts, " "))
}
if req.DateFrom != nil {
whereParts = append(whereParts, "date_ts >= ?")
args = append(args, req.DateFrom.Unix())
}
if req.DateTo != nil {
whereParts = append(whereParts, "date_ts <= ?")
args = append(args, req.DateTo.Unix())
}
if req.HasAttachment != nil {
if *req.HasAttachment {
whereParts = append(whereParts, "has_attachment = 1")
} else {
whereParts = append(whereParts, "has_attachment = 0")
}
}
whereClause := ""
if len(whereParts) > 0 {
whereClause = "WHERE " + strings.Join(whereParts, " AND ")
}
// COUNT query for total.
countArgs := make([]interface{}, len(args))
copy(countArgs, args)
countSQL := fmt.Sprintf(
"SELECT COUNT(*) FROM %s %s OPTION max_matches=1000000",
idx.table, whereClause,
)
var total int
if err := idx.db.QueryRow(countSQL, countArgs...).Scan(&total); err != nil {
return nil, fmt.Errorf("manticore Search count %s: %w", idx.table, err)
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
page := req.Page
if page <= 0 {
page = 1
}
offset := (page - 1) * pageSize
// Score expression and ORDER BY.
scoreExpr := "0 as score"
orderBy := "date_ts DESC"
if hasMatch {
scoreExpr = "WEIGHT() as score"
switch req.Sort {
case "relevance":
orderBy = "WEIGHT() DESC, date_ts DESC"
case "date_asc":
orderBy = "date_ts ASC"
default:
orderBy = "date_ts DESC"
}
} else {
switch req.Sort {
case "date_asc":
orderBy = "date_ts ASC"
default:
orderBy = "date_ts DESC"
}
}
selectSQL := fmt.Sprintf(
"SELECT mail_id, %s FROM %s %s ORDER BY %s LIMIT ? OFFSET ? OPTION max_matches=10000",
scoreExpr, idx.table, whereClause, orderBy,
)
selectArgs := make([]interface{}, len(args))
copy(selectArgs, args)
selectArgs = append(selectArgs, pageSize, offset)
rows, err := idx.db.Query(selectSQL, selectArgs...)
if err != nil {
return nil, fmt.Errorf("manticore Search select %s: %w", idx.table, err)
}
defer rows.Close()
var hits []Hit
for rows.Next() {
var mailID string
var score float64
if err := rows.Scan(&mailID, &score); err != nil {
return nil, fmt.Errorf("manticore Search scan: %w", err)
}
hits = append(hits, Hit{ID: mailID, Score: score})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("manticore Search rows: %w", err)
}
return &SearchResult{Total: total, Hits: hits}, nil
}
// Close is a no-op for individual indexes — the shared DB connection is managed
// by ManticoreTenantManager.
func (idx *manticoreIndex) Close() error {
return nil
}
// ── helpers ────────────────────────────────────────────────────────────────
// hashMailID returns a stable uint64 row ID derived from the mail's SHA-256 string ID.
func hashMailID(id string) uint64 {
h := fnv.New64a()
h.Write([]byte(id))
return h.Sum64()
}
// manticoreTableName returns the RT table name for a given tenant.
// nil / 0 → emails_global, otherwise emails_tenant_<id>.
func manticoreTableName(tenantID *int64) string {
if tenantID == nil || *tenantID == 0 {
return "emails_global"
}
return fmt.Sprintf("emails_tenant_%d", *tenantID)
}
// escapeManticoreMatch escapes characters that have special meaning in
// Manticore MATCH() expressions to prevent query injection.
func escapeManticoreMatch(s string) string {
specials := `\()|!@~"/^$=<`
var b strings.Builder
b.Grow(len(s))
for _, c := range s {
if strings.ContainsRune(specials, c) {
b.WriteRune('\\')
}
b.WriteRune(c)
}
return b.String()
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
) )
// TenantIndexWorker processes MailDocument indexing requests asynchronously, // TenantIndexWorker processes MailDocument indexing requests asynchronously,
// routing each document to the correct per-tenant Xapian index via TenantIndexManager. // routing each document to the correct per-tenant index via TenantIndexer.
type TenantIndexWorker struct { type TenantIndexWorker struct {
mgr *TenantIndexManager mgr TenantIndexer
queue chan MailDocument queue chan MailDocument
done chan struct{} done chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
@@ -16,7 +16,7 @@ type TenantIndexWorker struct {
} }
// NewTenantWorker creates a new TenantIndexWorker with the given queue capacity. // NewTenantWorker creates a new TenantIndexWorker with the given queue capacity.
func NewTenantWorker(mgr *TenantIndexManager, queueSize int, logger *slog.Logger) *TenantIndexWorker { func NewTenantWorker(mgr TenantIndexer, queueSize int, logger *slog.Logger) *TenantIndexWorker {
if queueSize <= 0 { if queueSize <= 0 {
queueSize = 1000 queueSize = 1000
} }
+3 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { login } from "@/lib/api"; import { login } from "@/lib/api";
import { getCachedUser, setCachedUser } from "@/lib/auth-cache"; import { getCachedUser, setCachedUser, clearAuthCache } from "@/lib/auth-cache";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -31,6 +31,7 @@ export default function AdminLoginPage() {
setLoading(true); setLoading(true);
try { try {
clearAuthCache();
const res = await login(username, password); const res = await login(username, password);
const role = res?.user?.role ?? ""; const role = res?.user?.role ?? "";
if (!ADMIN_ROLES.includes(role)) { if (!ADMIN_ROLES.includes(role)) {
@@ -38,6 +39,7 @@ export default function AdminLoginPage() {
setError("Kein Zugriff. Dieses Login ist nur für Admins und Auditoren."); setError("Kein Zugriff. Dieses Login ist nur für Admins und Auditoren.");
return; return;
} }
setCachedUser({ username: res.user.username, email: res.user.email, role });
if (role === "auditor") { if (role === "auditor") {
router.push("/search"); router.push("/search");
} else { } else {
+30 -4
View File
@@ -79,8 +79,12 @@ export default function ImapPage() {
// Saving state // Saving state
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Import error state
const [importError, setImportError] = useState<string>("");
// Polling refs // Polling refs
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map()); const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map());
const pollErrorCount = useRef<Map<number, number>>(new Map());
const loadAccounts = useCallback(async () => { const loadAccounts = useCallback(async () => {
try { try {
@@ -102,19 +106,28 @@ export default function ImapPage() {
for (const acc of accounts) { for (const acc of accounts) {
const isActive = acc.status === "running" || acc.sync_running; const isActive = acc.status === "running" || acc.sync_running;
if (isActive && !pollingRefs.current.has(acc.id)) { if (isActive && !pollingRefs.current.has(acc.id)) {
pollErrorCount.current.set(acc.id, 0);
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
const updated = await getImapProgress(acc.id); const updated = await getImapProgress(acc.id);
pollErrorCount.current.set(acc.id, 0);
setAccounts((prev) => setAccounts((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a)) prev.map((a) => (a.id === updated.id ? updated : a))
); );
if (updated.status !== "running" && !updated.sync_running) { if (updated.status !== "running" && !updated.sync_running) {
clearInterval(pollingRefs.current.get(acc.id)!); clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id); pollingRefs.current.delete(acc.id);
pollErrorCount.current.delete(acc.id);
} }
} catch { } catch {
clearInterval(pollingRefs.current.get(acc.id)!); // Only stop polling after 5 consecutive failures (tolerates brief network hiccups)
pollingRefs.current.delete(acc.id); const errors = (pollErrorCount.current.get(acc.id) ?? 0) + 1;
pollErrorCount.current.set(acc.id, errors);
if (errors >= 5) {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
pollErrorCount.current.delete(acc.id);
}
} }
}, 2000); }, 2000);
pollingRefs.current.set(acc.id, interval); pollingRefs.current.set(acc.id, interval);
@@ -203,11 +216,12 @@ export default function ImapPage() {
} }
async function handleStartImport(id: number) { async function handleStartImport(id: number) {
setImportError("");
try { try {
const updated = await startImapImport(id); const updated = await startImapImport(id);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a))); setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch { } catch (err) {
// ignore setImportError(err instanceof Error ? err.message : "Import konnte nicht gestartet werden.");
} }
} }
@@ -336,6 +350,10 @@ export default function ImapPage() {
</Button> </Button>
</div> </div>
{importError && (
<p className="mb-4 text-sm text-destructive" role="alert">{importError}</p>
)}
{loading ? ( {loading ? (
<div className="space-y-4"> <div className="space-y-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
@@ -363,6 +381,14 @@ export default function ImapPage() {
{statusBadge(acc.status)} {statusBadge(acc.status)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{acc.status === "running" && acc.progress_total === 0 && (
<div className="mb-3 space-y-1">
<Progress value={undefined} className="animate-pulse" />
<p className="text-xs text-muted-foreground">
Zaehle E-Mails auf dem Server...
</p>
</div>
)}
{acc.status === "running" && acc.progress_total > 0 && ( {acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1"> <div className="mb-3 space-y-1">
<Progress <Progress
+14 -3
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { login } from "@/lib/api"; import { login } from "@/lib/api";
import { getCachedUser } from "@/lib/auth-cache"; import { getCachedUser, setCachedUser, clearAuthCache } from "@/lib/auth-cache";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -32,12 +32,14 @@ export default function LoginPage() {
setLoading(true); setLoading(true);
try { try {
clearAuthCache();
const res = await login(username, password); const res = await login(username, password);
const role = res?.user?.role ?? ""; const role = res?.user?.role ?? "";
if (ADMIN_ROLES.includes(role)) { if (ADMIN_ROLES.includes(role)) {
setError("Admins und Auditoren bitte über /admin anmelden."); setError("ADMIN_REDIRECT");
return; return;
} }
setCachedUser({ username: res.user.username, email: res.user.email, role });
router.push("/search"); router.push("/search");
} catch { } catch {
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen."); setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
@@ -83,11 +85,20 @@ export default function LoginPage() {
aria-label="Passwort" aria-label="Passwort"
/> />
</div> </div>
{error && ( {error && error !== "ADMIN_REDIRECT" && (
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
{error} {error}
</p> </p>
)} )}
{error === "ADMIN_REDIRECT" && (
<p className="text-sm text-destructive" role="alert">
Admins und Auditoren bitte{" "}
<a href="/admin/login" className="underline font-medium">
hier anmelden
</a>
.
</p>
)}
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? "Anmelden..." : "Anmelden"} {loading ? "Anmelden..." : "Anmelden"}
</Button> </Button>
+3 -1
View File
@@ -21,13 +21,15 @@ interface UserNavProps {
export function UserNav({ username, role }: UserNavProps) { export function UserNav({ username, role }: UserNavProps) {
const router = useRouter(); const router = useRouter();
const ADMIN_ROLES = ["auditor", "admin", "domain_admin", "superadmin"];
async function handleLogout() { async function handleLogout() {
try { try {
await logout(); await logout();
} catch { } catch {
// ignore logout errors // ignore logout errors
} }
router.push("/"); router.push(ADMIN_ROLES.includes(role) ? "/admin/login" : "/");
} }
return ( return (
+62 -7
View File
@@ -50,6 +50,33 @@ command -v node >/dev/null || die "node nicht gefunden"
command -v npm >/dev/null || die "npm nicht gefunden" command -v npm >/dev/null || die "npm nicht gefunden"
command -v go >/dev/null || die "go nicht gefunden — apt-get install golang-go" command -v go >/dev/null || die "go nicht gefunden — apt-get install golang-go"
# ── Manticore Search prüfen / installieren ────────────────────────────────
if ! command -v searchd >/dev/null 2>&1 && ! systemctl is-active --quiet manticore 2>/dev/null; then
info "Manticore Search nicht gefunden — installiere..."
apt-get install -y wget gnupg2 lsb-release 2>/dev/null || true
MANTICORE_CODENAME=$(lsb_release -cs 2>/dev/null || echo "bookworm")
wget -q -O /tmp/manticore.deb \
"https://repo.manticoresearch.com/repository/manticoresearch_${MANTICORE_CODENAME}/pool/main/m/manticoresearch/manticoresearch_6.3.6_amd64.deb" 2>/dev/null \
|| wget -q -O /tmp/manticore.deb \
"https://github.com/manticoresoftware/manticoresearch/releases/download/6.3.6/manticoresearch_6.3.6.202408011246.4c39781ba-1+${MANTICORE_CODENAME}_amd64.deb" 2>/dev/null \
|| true
if [[ -f /tmp/manticore.deb ]]; then
dpkg -i /tmp/manticore.deb 2>/dev/null || apt-get install -f -y 2>/dev/null || true
rm -f /tmp/manticore.deb
log "Manticore Search installiert"
else
warn "Manticore Search konnte nicht automatisch installiert werden — bitte manuell installieren"
warn "Siehe: https://manticoresearch.com/install/"
fi
fi
if systemctl list-unit-files manticore.service >/dev/null 2>&1; then
systemctl enable manticore 2>/dev/null || true
systemctl is-active --quiet manticore || systemctl start manticore 2>/dev/null || warn "Manticore-Dienst konnte nicht gestartet werden"
systemctl is-active --quiet manticore && log "Manticore Search läuft"
fi
# ── Quellcode holen ─────────────────────────────────────────────────────── # ── Quellcode holen ───────────────────────────────────────────────────────
if [[ -d "$BUILD_DIR/.git" ]]; then if [[ -d "$BUILD_DIR/.git" ]]; then
@@ -69,8 +96,8 @@ fi
info "Baue Go Backend..." info "Baue Go Backend..."
cd "$BUILD_DIR" cd "$BUILD_DIR"
go mod download go mod tidy && go mod download
CGO_ENABLED=1 go build -tags xapian -buildvcs=false -o "$BUILD_DIR/archivmail-new" ./cmd/archivmail/ CGO_ENABLED=0 go build -buildvcs=false -o "$BUILD_DIR/archivmail-new" ./cmd/archivmail/
log "Go Backend gebaut" log "Go Backend gebaut"
# ── Next.js Frontend bauen ──────────────────────────────────────────────── # ── Next.js Frontend bauen ────────────────────────────────────────────────
@@ -89,11 +116,26 @@ info "Stoppe Dienste..."
systemctl stop archivmail-web 2>/dev/null || warn "archivmail-web nicht aktiv" systemctl stop archivmail-web 2>/dev/null || warn "archivmail-web nicht aktiv"
systemctl stop archivmail 2>/dev/null || warn "archivmail nicht aktiv" systemctl stop archivmail 2>/dev/null || warn "archivmail nicht aktiv"
# Xapian-Lockfile entfernen (verhindert DatabaseLockError beim Neustart) # ── Manticore als Standard-Backend in config.yml setzen ──────────────────
XAPIAN_LOCK=$(grep -A2 'index:' /etc/archivmail/config.yml 2>/dev/null | awk '/path:/{print $2}') CONFIG_FILE="/etc/archivmail/config.yml"
if [[ -n "$XAPIAN_LOCK" && -f "$XAPIAN_LOCK/flintlock" ]]; then if [[ -f "$CONFIG_FILE" ]]; then
rm -f "$XAPIAN_LOCK/flintlock" # Backend auf manticore umstellen falls noch nicht gesetzt
log "Xapian-Lockfile entfernt" if grep -q 'backend:' "$CONFIG_FILE"; then
if ! grep -q 'backend: manticore' "$CONFIG_FILE"; then
sed -i 's/^\([[:space:]]*\)backend:.*/\1backend: manticore/' "$CONFIG_FILE"
info "Index-Backend auf 'manticore' gesetzt"
fi
else
# backend: Zeile unter index: einfuegen
sed -i '/^index:/a\ backend: manticore' "$CONFIG_FILE"
info "Index-Backend 'manticore' hinzugefuegt"
fi
# manticore_dsn setzen falls nicht vorhanden
if ! grep -q 'manticore_dsn' "$CONFIG_FILE"; then
sed -i '/backend: manticore/a\ manticore_dsn: "manticore@tcp(127.0.0.1:9306)/"' "$CONFIG_FILE"
info "Manticore-DSN gesetzt"
fi
log "Manticore-Konfiguration aktualisiert"
fi fi
# ── Dateien einspielen ──────────────────────────────────────────────────── # ── Dateien einspielen ────────────────────────────────────────────────────
@@ -132,6 +174,19 @@ systemctl start archivmail
systemctl start archivmail-web systemctl start archivmail-web
log "Dienste gestartet" log "Dienste gestartet"
# ── Manticore Reindex (einmalig nach Backend-Umstieg) ─────────────────────
sleep 2
if grep -q 'backend: manticore' /etc/archivmail/config.yml 2>/dev/null \
&& systemctl is-active --quiet archivmail 2>/dev/null \
&& systemctl is-active --quiet manticore 2>/dev/null; then
info "Baue Manticore-Suchindex auf (alle Mails)..."
if timeout 600 /opt/archivmail/bin/archivmail reindex --config /etc/archivmail/config.yml; then
log "Manticore-Index aufgebaut"
else
warn "Reindex nicht abgeschlossen — bei Bedarf manuell: archivmail reindex"
fi
fi
# ── Status prüfen ───────────────────────────────────────────────────────── # ── Status prüfen ─────────────────────────────────────────────────────────
sleep 2 sleep 2