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