feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+57
View File
@@ -0,0 +1,57 @@
APP = archivmail
IMPORTER = archivmail-import
EXPORTER = archivmail-export
VERSION ?= 0.1.0
LDFLAGS = -X main.Version=$(VERSION) -s -w
.PHONY: all build clean install
all: build
# Build — requires: apt install libxapian-dev
build:
go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(APP) ./cmd/archivmail
go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(IMPORTER) ./cmd/archivmail-import
go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(EXPORTER) ./cmd/archivmail-export
clean:
rm -rf bin/
test:
go test -v -race -count=1 ./internal/storage/... ./internal/userstore/... \
./internal/auth/... ./internal/audit/... ./internal/index/... \
./pkg/mailparser/...
test-all:
go test -v -race -count=1 ./...
test-short:
go test -short ./...
test-cover:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
smoke: build
@echo "Starting daemon in background for smoke test..."
mkdir -p /tmp/archivmail-test/{store,xapian,logs}
./bin/archivmail --config config/config.test.yml &
sleep 2
bash smoke_test.sh
pkill archivmail || true
vet:
go vet ./...
install: build
install -D -m 755 bin/$(APP) /usr/bin/$(APP)
install -D -m 755 bin/$(IMPORTER) /usr/bin/$(IMPORTER)
install -D -m 755 bin/$(EXPORTER) /usr/bin/$(EXPORTER)
install -D -m 644 config/config.yml /etc/archivmail/config.yml
install -D -m 644 debian/archivmail.service /lib/systemd/system/archivmail.service
useradd --system --no-create-home --shell /usr/sbin/nologin archivmail 2>/dev/null || true
mkdir -p /var/archivmail/{store,astore,xapian}
mkdir -p /var/log/archivmail
chown -R archivmail:archivmail /var/archivmail /var/log/archivmail
systemctl daemon-reload
+74 -303
View File
@@ -1,323 +1,94 @@
# AI Coding Starter Kit
# archivmail
> Build production-ready web apps faster with AI-powered Skills handling Requirements, Architecture, Development, QA, and Deployment.
Selbst gehostetes Mail-Archiv-System für Unternehmen. E-Mails werden aus IMAP, SMTP und EML/MBOX-Quellen importiert, volltext-indexiert (Xapian) und verschlüsselt archiviert.
This template uses [Claude Code](https://docs.anthropic.com/en/docs/claude-code) with modern Skills, Rules, and Sub-Agents to provide a complete AI-powered development workflow.
## Features
## Quick Start
- Import via IMAP, SMTP-Journaling und EML/MBOX-Upload
- Volltext-Suche über Xapian
- AES-256-GCM-Verschlüsselung der archivierten Mails
- Anhang-Deduplizierung
- Rollenmodell: `user`, `auditor`, `admin`
- Audit-Log (PostgreSQL + Append-only Logdatei)
### 1. Clone & Install
## Installation
```bash
git clone https://github.com/YOUR_USERNAME/ai-coding-starter-kit.git my-project
cd my-project
npm install
# Systembenutzer anlegen
useradd -r -s /sbin/nologin archivmail
# Verzeichnisse anlegen
mkdir -p /etc/archivmail
mkdir -p /var/archivmail/store
mkdir -p /var/archivmail/astore
mkdir -p /var/lib/archivmail/xapian
mkdir -p /var/log/archivmail
# Berechtigungen setzen
chown -R archivmail:archivmail /var/archivmail /var/lib/archivmail /var/log/archivmail
# Encryption Key generieren
openssl rand -base64 32 > /etc/archivmail/keyfile
chmod 400 /etc/archivmail/keyfile
chown archivmail:archivmail /etc/archivmail/keyfile
# Konfiguration
cp config.example.yml /etc/archivmail/config.yml
# config.yml anpassen (Datenbank, SMTP-Port, etc.)
# Systemd-Service aktivieren
cp archivmail.service /lib/systemd/system/
systemctl daemon-reload
systemctl enable --now archivmail
```
### 2. (Optional) Supabase Setup
If you need a backend:
1. Create Supabase Project: [supabase.com](https://supabase.com)
2. Copy `.env.local.example` to `.env.local`
3. Add your Supabase credentials
4. Uncomment the Supabase client in `src/lib/supabase.ts`
Skip this step if you're building frontend-only (landing pages, portfolios, etc.)
### 3. Start Development
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
### 4. Initialize Your Project
Open Claude Code and describe your project. The `/requirements` skill automatically detects that this is a fresh project and enters **Init Mode**:
## Verzeichnisstruktur
```
/requirements I want to build a project management tool for small teams
where users can create projects, assign tasks, and track progress.
/etc/archivmail/
├── config.yml # Hauptkonfiguration
└── keyfile # AES-256-GCM Key (chmod 400)
/var/archivmail/
├── store/ # Mailkörper (.m, verschlüsselt)
│ └── <server_id>/<customer_id>/<hash>/xxxxx.m
└── astore/ # Anhänge dedupliziert (verschlüsselt)
└── <hash>
/var/lib/archivmail/
└── xapian/ # Volltext-Index
/var/log/archivmail/
└── audit.log # Audit-Log (JSON Lines, append-only)
```
The skill will:
1. Ask interactive questions to clarify your vision, target users, and MVP scope
2. Create your **Product Requirements Document** (`docs/PRD.md`)
3. Break the project into individual features (Single Responsibility)
4. Create all **feature specs** (`features/PROJ-1.md`, `PROJ-2.md`, etc.)
5. Update **feature tracking** (`features/INDEX.md`)
6. Recommend which feature to build first
## Standard-Zugangsdaten
You don't need to put everything in the first prompt - a brief description is enough. The skill asks follow-up questions interactively.
> **Wichtig:** Passwörter nach dem ersten Login ändern!
### 5. Build Features
| Benutzer | Passwort | Rolle |
|----------|----------|-------|
| `admin@archivmail` | `archivmailrockz` | Admin (Konfiguration, Nutzerverwaltung) |
| `auditor@archivmail` | `archivmailrockz` | Auditor (alle E-Mails + Audit-Log) |
After project initialization, build features one at a time using skills:
### Rollenübersicht
```
/architecture Design the tech approach for features/PROJ-1-user-auth.md
/frontend Build the UI for features/PROJ-1-user-auth.md
/backend Build the API for features/PROJ-1-user-auth.md
/qa Test features/PROJ-1-user-auth.md
/deploy Deploy to Vercel
```
| Rolle | E-Mails (eigene) | E-Mails (alle) | Audit-Log | Konfiguration |
|-------|:-:|:-:|:-:|:-:|
| `user` | ✅ | ❌ | ❌ | ❌ |
| `auditor` | ✅ | ✅ | ✅ | ❌ |
| `admin` | ❌ | ❌ | ❌ | ✅ |
Each skill suggests the next step when it finishes. Handoffs are always user-initiated.
## Technologie
To add more features later, run `/requirements` again - it detects the existing PRD and adds a single feature.
| Komponente | Technologie |
|-----------|-------------|
| Backend | Go |
| Datenbank | PostgreSQL |
| Volltext-Index | Xapian |
| Verschlüsselung | AES-256-GCM |
| Webserver | eingebettet (Go net/http) |
---
## Lizenz
## Available Skills
| Skill | Command | What It Does |
|-------|---------|-------------|
| Requirements Engineer | `/requirements` | Creates feature specs with user stories, acceptance criteria, edge cases |
| Solution Architect | `/architecture` | Designs PM-friendly tech architecture (no code, only high-level design) |
| Frontend Developer | `/frontend` | Builds UI with React, Tailwind CSS, and shadcn/ui |
| Backend Developer | `/backend` | Builds APIs, database schemas, RLS policies with Supabase |
| QA Engineer | `/qa` | Tests features against acceptance criteria + security audit |
| DevOps | `/deploy` | Deploys to Vercel with production-ready checks |
| Help | `/help` | Context-aware guide: shows where you are and what to do next |
### How Skills Work
- **Skills** are defined in `.claude/skills/` and auto-discovered by Claude Code
- **Rules** in `.claude/rules/` are auto-applied based on file context (no manual loading)
- **Sub-Agents** run heavy tasks (frontend, backend, QA) in isolated contexts for cost efficiency
- **CLAUDE.md** provides project context automatically at every session start
---
## Development Workflow
```
1. Define /requirements --> Feature spec in features/PROJ-X.md
2. Design /architecture --> Tech design added to feature spec
3. Build /frontend --> UI components implemented
/backend --> APIs + database (if needed)
4. Test /qa --> Test results added to feature spec
5. Ship /deploy --> Deployed to Vercel
```
### Feature Tracking
Features are tracked in `features/INDEX.md`:
| ID | Feature | Status | Spec |
|----|---------|--------|------|
| PROJ-1 | User Login | Deployed | [Spec](features/PROJ-1-user-login.md) |
| PROJ-2 | Dashboard | In Progress | [Spec](features/PROJ-2-dashboard.md) |
Every skill reads this file at start and updates it when done, preventing duplicate work.
---
## Tech Stack
| Category | Tool | Why? |
|----------|------|------|
| **Framework** | Next.js 16 | React + Server Components + App Router |
| **Language** | TypeScript | Type safety |
| **Styling** | Tailwind CSS | Utility-first CSS |
| **UI Library** | shadcn/ui | Copy-paste, customizable components |
| **Backend** | Supabase (optional) | PostgreSQL + Auth + Storage + Realtime |
| **Deployment** | Vercel | Zero-config Next.js hosting |
| **Validation** | Zod | Runtime type validation |
---
## Project Structure
```
ai-coding-starter-kit/
+-- CLAUDE.md <-- Auto-loaded project context
+-- .claude/
| +-- settings.json <-- Team permissions (committed)
| +-- settings.local.json <-- Personal overrides (gitignored)
| +-- rules/ <-- Auto-applied coding rules
| | +-- general.md Git workflow, feature tracking
| | +-- frontend.md shadcn/ui, component standards
| | +-- backend.md RLS, validation, queries
| | +-- security.md Secrets, headers, auth
| +-- skills/ <-- Invocable workflows (/command)
| | +-- requirements/SKILL.md /requirements
| | +-- architecture/SKILL.md /architecture
| | +-- frontend/SKILL.md /frontend (runs as sub-agent)
| | +-- backend/SKILL.md /backend (runs as sub-agent)
| | +-- qa/SKILL.md /qa (runs as sub-agent)
| | +-- deploy/SKILL.md /deploy
| | +-- help/SKILL.md /help
| +-- agents/ <-- Sub-agent configs
| +-- frontend-dev.md Model, tools, limits
| +-- backend-dev.md
| +-- qa-engineer.md
+-- features/ <-- Feature specifications
| +-- INDEX.md Status tracking
| +-- README.md Spec format documentation
+-- docs/
| +-- PRD.md <-- Product Requirements Document
| +-- production/ <-- Production setup guides
| +-- error-tracking.md Sentry setup (5 min)
| +-- security-headers.md XSS/Clickjacking protection
| +-- performance.md Lighthouse, optimization
| +-- database-optimization.md Indexing, N+1, caching
| +-- rate-limiting.md Upstash Redis
+-- src/
| +-- app/ <-- Pages (Next.js App Router)
| +-- components/
| | +-- ui/ <-- shadcn/ui components (35+ installed)
| +-- hooks/ <-- Custom React hooks
| +-- lib/ <-- Utilities
+-- public/ <-- Static files
```
---
## Getting Started
### 1. Fill Out Your PRD
Define your product vision in `docs/PRD.md`:
- What are you building and why?
- Who are the target users?
- What features are on the roadmap?
### 2. Build Your First Feature
Run `/requirements` with your feature idea. The skill will:
- Ask interactive questions to clarify requirements
- Create a feature spec in `features/PROJ-1-name.md`
- Update `features/INDEX.md` with the new feature
- Suggest running `/architecture` as the next step
### 3. Add shadcn/ui Components (as needed)
35+ components are pre-installed. Add more as needed:
```bash
npx shadcn@latest add [component-name]
```
### 4. Production Setup (first deployment)
When you're ready to deploy, the `/deploy` skill guides you through:
- Vercel setup and deployment
- Error tracking with Sentry
- Security headers configuration
- Performance monitoring with Lighthouse
See `docs/production/` for detailed setup guides.
---
## How It Works Under the Hood
### Skills (`.claude/skills/`)
Each skill is a structured workflow that Claude Code discovers automatically. Skills can run inline (in the main conversation) or as forked sub-agents (isolated context window).
| Skill | Execution | Why? |
|-------|-----------|------|
| `/requirements` | Inline | Needs live interaction with user |
| `/architecture` | Inline | Short output, user reviews in real-time |
| `/frontend` | Sub-agent (forked) | Heavy file editing, lots of output |
| `/backend` | Sub-agent (forked) | Heavy file editing, SQL, API code |
| `/qa` | Sub-agent (forked) | Systematic testing, lots of output |
| `/deploy` | Inline | Deployment needs user oversight |
| `/help` | Inline | Quick status check and guidance |
### Rules (`.claude/rules/`)
Coding standards that are auto-applied based on which files Claude is working with. No manual loading needed.
### Sub-Agent Configs (`.claude/agents/`)
Lightweight configurations that define model, tool access, and turn limits for forked skills.
### CLAUDE.md
Auto-loaded at every session start. Contains tech stack, conventions, and references to PRD and feature index.
---
## Context Engineering
AI agents work best with clean, structured context - not longer prompts. This template is designed around these principles:
### State lives in files, not in memory
Every skill reads `features/INDEX.md` and the relevant feature spec at start. After context compaction or a new session, nothing is lost - the agent simply re-reads the files. Progress tracking, acceptance criteria, and tech designs all live in markdown files, not in the conversation.
### Context is layered
Not everything is loaded at once. Information is layered by relevance:
| Layer | What | When loaded |
|-------|------|-------------|
| `CLAUDE.md` | Tech stack, conventions, commands | Every session (auto) |
| `.claude/rules/` | Coding standards | When editing matching files (auto) |
| Skill `SKILL.md` | Workflow instructions | When skill is invoked |
| Feature spec | Requirements, AC, tech design | On demand (skill reads it) |
| `docs/production/` | Deployment guides | Only when referenced |
### Context is isolated
Heavy implementation skills (`/frontend`, `/backend`, `/qa`) run as **forked sub-agents** with their own context window. Research noise from one skill doesn't pollute another. Each fork starts clean and loads only what it needs.
### Context recovery is built in
All forked skills include a **Context Recovery** section: if the context is compacted mid-task, the agent re-reads the feature spec, checks `git diff` for progress, and continues without restarting or duplicating work.
### Always read, never guess
A global rule (`rules/general.md`) enforces: always read a file before modifying it, never assume contents from memory, verify import paths and API routes by reading. This prevents hallucinated code references - the most common source of AI coding errors.
---
## Customization for Your Team
This template is designed as a starting point. Customize it for your team:
1. **Edit CLAUDE.md** - Add your project-specific conventions and build commands
2. **Edit docs/PRD.md** - Define your product vision and roadmap
3. **Edit .claude/rules/** - Adjust coding standards for your team
4. **Edit .claude/skills/** - Modify workflows to match your process
5. **Edit .claude/settings.json** - Configure team permissions
---
## Production Guides
Standalone guides in `docs/production/`:
| Guide | Setup Time | What It Does |
|-------|-----------|-------------|
| [Error Tracking](docs/production/error-tracking.md) | 5 min | Sentry integration for automatic error capture |
| [Security Headers](docs/production/security-headers.md) | 2 min | XSS, Clickjacking, MIME sniffing protection |
| [Performance](docs/production/performance.md) | 10 min | Lighthouse checks, image optimization, caching |
| [Database Optimization](docs/production/database-optimization.md) | 15 min | Indexing, N+1 prevention, query optimization |
| [Rate Limiting](docs/production/rate-limiting.md) | 10 min | Upstash Redis for API abuse prevention |
---
## Scripts
```bash
npm run dev # Development server (localhost:3000)
npm run build # Production build
npm run start # Production server
npm run lint # ESLint
```
---
## Author
Created by **Alex Sprogis** AI Product Engineer & Content Creator.
- [YouTube](https://www.youtube.com/@alex.sprogis)
- [Website](https://alexsprogis.de)
---
## License
MIT License - feel free to use for your projects!
Proprietär
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/archivmail/config"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/storage"
)
func main() {
configPath := flag.String("config", "/etc/archivmail/config.yml", "path to config file")
format := flag.String("format", "eml", "export format: eml or pdf")
outDir := flag.String("out", "./export", "output directory")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if *format == "pdf" {
fmt.Fprintln(os.Stdout, "PDF export not yet implemented")
os.Exit(0)
}
if *format != "eml" {
fmt.Fprintf(os.Stderr, "unknown format: %s (supported: eml, pdf)\n", *format)
os.Exit(1)
}
cfg, err := config.Load(*configPath)
if err != nil {
logger.Error("failed to load config", "err", err)
os.Exit(1)
}
mailStore, err := storage.New(cfg.Storage.StorePath)
if err != nil {
logger.Error("storage init failed", "err", err)
os.Exit(1)
}
indexBackend := cfg.Index.Backend
if indexBackend == "" {
indexBackend = "xapian"
}
batchSize := cfg.Index.BatchSize
if batchSize <= 0 {
batchSize = 100
}
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend)
if err != nil {
logger.Error("index init failed", "err", err)
os.Exit(1)
}
defer idx.Close()
if err := os.MkdirAll(*outDir, 0o755); err != nil {
logger.Error("cannot create output directory", "dir", *outDir, "err", err)
os.Exit(1)
}
// Fetch all indexed mails using pagination
page := 0
pageSize := 500
exported := 0
errors := 0
for {
result, err := idx.Search(index.SearchRequest{
PageSize: pageSize,
Page: page,
})
if err != nil {
logger.Error("search failed", "err", err)
os.Exit(1)
}
if len(result.Hits) == 0 {
break
}
for _, hit := range result.Hits {
raw, err := mailStore.Load(hit.ID)
if err != nil {
logger.Error("load failed", "id", hit.ID, "err", err)
errors++
continue
}
outPath := filepath.Join(*outDir, hit.ID+".eml")
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
logger.Error("write failed", "path", outPath, "err", err)
errors++
continue
}
exported++
}
logger.Info("export progress", "page", page, "exported", exported, "errors", errors)
if exported+errors >= result.Total {
break
}
page++
}
logger.Info("export complete",
"format", *format,
"out", *outDir,
"exported", exported,
"errors", errors,
)
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/archivmail/config"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/storage"
"github.com/archivmail/pkg/mailparser"
)
func main() {
configPath := flag.String("config", "/etc/archivmail/config.yml", "path to config file")
flag.Parse()
args := flag.Args()
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: archivmail-import --config <path> <directory-or-file>")
os.Exit(1)
}
target := args[0]
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := config.Load(*configPath)
if err != nil {
logger.Error("failed to load config", "err", err)
os.Exit(1)
}
mailStore, err := storage.New(cfg.Storage.StorePath)
if err != nil {
logger.Error("storage init failed", "err", err)
os.Exit(1)
}
indexBackend := cfg.Index.Backend
if indexBackend == "" {
indexBackend = "xapian"
}
batchSize := cfg.Index.BatchSize
if batchSize <= 0 {
batchSize = 100
}
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend)
if err != nil {
logger.Error("index init failed", "err", err)
os.Exit(1)
}
defer idx.Close()
var emlFiles []string
info, err := os.Stat(target)
if err != nil {
logger.Error("target not found", "path", target, "err", err)
os.Exit(1)
}
if info.IsDir() {
err = filepath.Walk(target, func(path string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if !fi.IsDir() && strings.HasSuffix(strings.ToLower(fi.Name()), ".eml") {
emlFiles = append(emlFiles, path)
}
return nil
})
if err != nil {
logger.Error("walk failed", "err", err)
os.Exit(1)
}
} else {
emlFiles = []string{target}
}
logger.Info("found EML files", "count", len(emlFiles))
imported := 0
skipped := 0
errors := 0
for i, path := range emlFiles {
raw, err := os.ReadFile(path)
if err != nil {
logger.Error("read file failed", "path", path, "err", err)
errors++
continue
}
pm, err := mailparser.Parse(raw)
if err != nil {
logger.Error("parse failed", "path", path, "err", err)
errors++
continue
}
id, err := mailStore.Save(raw, pm.Date)
if err != nil {
logger.Error("save failed", "path", path, "err", err)
errors++
continue
}
// Build attachment names list
var attachNames []string
for _, att := range pm.Attachments {
attachNames = append(attachNames, att.Filename)
}
doc := index.MailDocument{
ID: id,
From: pm.From,
To: strings.Join(pm.To, " "),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
AttachNames: strings.Join(attachNames, " "),
HasAttachment: len(pm.Attachments) > 0,
Date: pm.Date,
Size: int64(len(raw)),
}
if err := idx.IndexSync(doc); err != nil {
logger.Error("index failed", "id", id, "err", err)
errors++
continue
}
imported++
if (i+1)%100 == 0 || i+1 == len(emlFiles) {
fmt.Printf("Progress: %d/%d (imported: %d, skipped: %d, errors: %d)\n",
i+1, len(emlFiles), imported, skipped, errors)
}
}
logger.Info("import complete",
"total", len(emlFiles),
"imported", imported,
"skipped", skipped,
"errors", errors,
)
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/archivmail/config"
"github.com/archivmail/internal/api"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/auth"
imapstore "github.com/archivmail/internal/imap"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/smtpd"
"github.com/archivmail/internal/storage"
"github.com/archivmail/internal/userstore"
)
func main() {
configPath := flag.String("config", "/etc/archivmail/config.yml", "path to config file")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := config.Load(*configPath)
if err != nil {
logger.Error("failed to load config", "path", *configPath, "err", err)
os.Exit(1)
}
// Storage
mailStore, err := storage.New(cfg.Storage.StorePath)
if err != nil {
logger.Error("storage init failed", "err", err)
os.Exit(1)
}
// Index
indexBackend := cfg.Index.Backend
if indexBackend == "" {
indexBackend = "xapian"
}
batchSize := cfg.Index.BatchSize
if batchSize <= 0 {
batchSize = 100
}
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend)
if err != nil {
logger.Error("index init failed", "err", err)
os.Exit(1)
}
defer idx.Close()
// User store
users, err := userstore.New(cfg.Database.DSN())
if err != nil {
logger.Error("userstore init failed", "err", err)
os.Exit(1)
}
defer users.Close()
// Audit log
audlog, err := audit.New(cfg.Database.DSN(), cfg.Audit.LogPath, logger)
if err != nil {
logger.Error("audit init failed", "err", err)
os.Exit(1)
}
defer audlog.Close()
// Seed default users on first run
if err := seedDefaultUsers(users, logger); err != nil {
logger.Error("seed users failed", "err", err)
}
// Auth manager
authMgr := auth.New(users, nil, cfg.API.Secret)
// API server
apiCfg := config.APIConfig{
Bind: cfg.API.Bind,
Secret: cfg.API.Secret,
}
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
bind := cfg.API.Bind
if bind == "" {
bind = fmt.Sprintf(":%d", cfg.Server.APIPort)
}
httpServer := &http.Server{
Addr: bind,
Handler: srv,
}
// Start SMTP daemon
if cfg.SMTP.Bind == "" {
cfg.SMTP.Bind = fmt.Sprintf(":%d", cfg.Server.SMTPPort)
}
smtpDaemon := smtpd.New(cfg.SMTP, mailStore, logger)
if err := smtpDaemon.Start(); err != nil {
logger.Error("SMTP daemon failed to start", "err", err)
os.Exit(1)
}
defer smtpDaemon.Stop()
// Wire SMTP daemon into API server for status endpoint
srv.SetSMTPDaemon(smtpDaemon)
// IMAP store + importer
imapSt, err := imapstore.New(cfg.Database.DSN(), cfg.API.Secret)
if err != nil {
logger.Error("imap store init failed", "err", err)
os.Exit(1)
}
defer imapSt.Close()
imapImp := imapstore.NewImporter(imapSt, mailStore, idx, logger)
srv.SetImap(imapSt, imapImp)
// Start HTTP API
go func() {
logger.Info("starting API server", "addr", bind)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("API server error", "err", err)
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
}
// seedDefaultUsers creates default admin and auditor accounts if no users exist yet.
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
all, err := users.List("")
if err != nil {
return fmt.Errorf("list users: %w", err)
}
if len(all) > 0 {
return nil // already seeded
}
defaults := []userstore.CreateUserRequest{
{Username: "admin", Email: "admin@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAdmin},
{Username: "auditor", Email: "auditor@archivmail.local", Password: "archivmailrockz", Role: userstore.RoleAuditor},
}
for _, req := range defaults {
if _, err := users.Create(req); err != nil {
return fmt.Errorf("create default user %s: %w", req.Username, err)
}
logger.Info("created default user", "username", req.Username, "role", req.Role)
}
logger.Warn("default users created — change passwords immediately!", "admin", "admin", "auditor", "auditor")
return nil
}
+35
View File
@@ -0,0 +1,35 @@
database:
host: 127.0.0.1
port: 5432
name: archivmail_test
user: archivmail
password: testpass
sslmode: disable
storage:
path: /tmp/archivmail-test/store
max_size_mb: 100
smtp:
enabled: true
bind: ":2525"
domain: "localhost"
tls_cert: ""
tls_key: ""
max_size_mb: 10
imap: []
api:
bind: ":8080"
secret: "dev-secret-change-in-production-min32"
tls: false
index:
path: /tmp/archivmail-test/xapian
backend: xapian
batch_size: 10
logging:
path: /tmp/archivmail-test/logs
level: debug
+100
View File
@@ -0,0 +1,100 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// APIConfig holds configuration for the HTTP API server.
type APIConfig struct {
Bind string `yaml:"bind"`
Secret string `yaml:"secret"`
}
// Config is the full application configuration loaded from YAML.
type Config struct {
Server ServerConfig `yaml:"server"`
Storage StorageConfig `yaml:"storage"`
Database DatabaseConfig `yaml:"database"`
SMTP SMTPConfig `yaml:"smtp"`
API APIConfig `yaml:"api"`
Index IndexConfig `yaml:"index"`
Audit AuditConfig `yaml:"audit"`
Logging LoggingConfig `yaml:"logging"`
}
// ServerConfig holds port settings for the main services.
type ServerConfig struct {
APIPort int `yaml:"api_port"`
SMTPPort int `yaml:"smtp_port"`
}
// StorageConfig holds file system paths for email storage.
type StorageConfig struct {
StorePath string `yaml:"store_path"`
AStorePath string `yaml:"astore_path"`
XapianPath string `yaml:"xapian_path"`
Keyfile string `yaml:"keyfile"`
}
// DatabaseConfig holds PostgreSQL connection settings.
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Name string `yaml:"name"`
User string `yaml:"user"`
Password string `yaml:"password"`
SSLMode string `yaml:"sslmode"`
}
// DSN builds a PostgreSQL connection string from the config fields.
func (d DatabaseConfig) DSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
d.User, d.Password, d.Host, d.Port, d.Name, d.SSLMode)
}
// SMTPConfig holds settings for the embedded SMTP server.
type SMTPConfig struct {
Enabled bool `yaml:"enabled"`
Bind string `yaml:"bind"`
Domain string `yaml:"domain"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
MaxSizeMB int `yaml:"max_size_mb"`
AllowedIPs []string `yaml:"allowed_ips"`
}
// IndexConfig holds full-text index settings.
type IndexConfig struct {
Path string `yaml:"path"`
Backend string `yaml:"backend"`
BatchSize int `yaml:"batch_size"`
AsyncQueueSize int `yaml:"async_queue_size"`
}
// AuditConfig holds audit log settings.
type AuditConfig struct {
LogPath string `yaml:"log_path"`
RetentionDays int `yaml:"retention_days"`
}
// LoggingConfig holds application logging settings.
type LoggingConfig struct {
Path string `yaml:"path"`
Level string `yaml:"level"`
}
// Load reads a YAML config file from path and returns a parsed Config.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
+41 -13
View File
@@ -1,29 +1,57 @@
# Product Requirements Document
## Vision
_Describe what you are building and why._
Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System, das E-Mails aus verschiedenen Quellen (IMAP, SMTP, EML/MBOX) importiert, volltext-indexiert und DSGVO-konform langzeitarchiviert. Admins verwalten Nutzer und Postfächer; alle Mitarbeiter können gezielt im Archiv suchen.
## Target Users
_Who will use this product? Describe their needs and pain points._
**Primär: Unternehmen (5500 Mitarbeiter)**
- **Endnutzer:** Mitarbeiter, die E-Mails im Archiv suchen und lesen wollen
- **Admins:** IT-Verantwortliche, die Nutzer, Postfächer und Import-Quellen verwalten
- **Compliance-Verantwortliche:** Prüfen Audit-Logs, exportieren E-Mails für Anfragen
**Pain Points:**
- E-Mail-Clients bieten keine revisionssichere Langzeitarchivierung
- Suche über mehrere Konten und Postfächer ist unzuverlässig
- Datenschutzbedenken bei Cloud-basierten Archivlösungen
- Nachweis der Unverändertheit von E-Mails für Compliance erforderlich
## Core Features (Roadmap)
| Priority | Feature | Status |
|----------|---------|--------|
| P0 (MVP) | _Feature 1_ | Planned |
| P0 (MVP) | _Feature 2_ | Planned |
| P1 | _Feature 3_ | Planned |
| P2 | _Feature 4_ | Planned |
| P0 (MVP) | Nutzer-Authentifizierung & Rollen (User/Admin) | Planned |
| P0 (MVP) | E-Mail-Import: SMTP-Eingang via BCC *(primärer Eingangsweg)* | Planned |
| P1 | E-Mail-Import: IMAP-Verbindung *(sekundär, Altbestände)* | Planned |
| P1 | E-Mail-Import: POP3-Verbindung *(sekundär, Altbestände)* | Planned |
| P1 | E-Mail-Import: EML/MBOX Upload *(sekundär, Migration)* | Planned |
| P0 (MVP) | E-Mail-Speicherung & Volltext-Indexierung | Planned |
| P0 (MVP) | Volltext-Suche & Filterung | Planned |
| P0 (MVP) | E-Mail-Ansicht (Lesen & Anhänge) | Planned |
| P1 | Automatischer IMAP-Sync (Cron-Job) | Planned |
| P1 | Ordner- & Label-Verwaltung | Planned |
| P1 | Admin-Bereich: Nutzer- & Postfachverwaltung | Planned |
| P2 | Audit-Log & Compliance-Berichte | Planned |
| P2 | E-Mail-Export (EML/PDF) | Planned |
| P2 | REST API für externe CRM-Anbindung | Planned |
## Success Metrics
_How will you measure success? (e.g., user signups, retention, task completion rate)_
- Import und Indexierung von 100.000+ E-Mails ohne Performanceprobleme
- Volltext-Suchanfragen liefern Ergebnisse in < 2 Sekunden
- SMTP-Journaling empfängt E-Mails in Echtzeit (< 5 Sekunden Verzögerung)
- Automatischer IMAP-Sync läuft stabil über Monate ohne manuelle Eingriffe
- Audit-Log erfasst alle Zugriffe lückenlos
## Constraints
_Budget, timeline, technical limitations, team size._
- **Self-hosted:** Kein Zwang zu externen Cloud-Diensten läuft on-premise oder auf eigenem Server
- **DSGVO-konform:** E-Mails verlassen nie den eigenen Server; Löschkonzept vorhanden
- **Großes Volumen:** Architektur skaliert auf > 100.000 E-Mails
- **Einfache Architektur:** Go-Backend REST API + Next.js-Frontend, kleines Team (12 Devs)
- **Tech Stack:** Go (Backend, SMTP-Daemon, Storage, Xapian) + Next.js/TypeScript (Web-GUI) + PostgreSQL
## Non-Goals
_What are you explicitly NOT building in this version?_
---
Use `/requirements` to create detailed feature specifications for each item in the roadmap above.
- Kein vollständiger E-Mail-Client (kein eigenständiges Senden von E-Mails durch Nutzer)
- Keine Ende-zu-Ende-Verschlüsselung im MVP
- Kein Enterprise-LDAP/SSO im MVP (nur lokale Accounts)
- Keine mobile App (nur Web-Interface)
- Keine automatische GoBD/SOX-Zertifizierung im MVP
+252
View File
@@ -0,0 +1,252 @@
# archivmail REST API v1
> **Lese-API** alle Endpunkte sind read-only (`GET`). Schreiboperationen sind nicht verfügbar.
## Authentifizierung
Jede Anfrage muss einen gültigen API-Key im HTTP-Header mitschicken:
```
Authorization: Bearer <api-key>
```
API-Keys werden vom Admin im Admin-Bereich generiert und verwaltet. Ein API-Key hat eine zugewiesene Rolle (`user` oder `auditor`), die den Zugriffsumfang bestimmt.
| Rolle | Zugriff |
|-------|---------|
| `user` | Nur E-Mails aus zugewiesenen Postfächern |
| `auditor` | Alle E-Mails (postfachübergreifend) |
---
## Fehlercodes
| Code | Bedeutung |
|------|-----------|
| `200 OK` | Erfolg |
| `400 Bad Request` | Ungültige Parameter |
| `401 Unauthorized` | API-Key fehlt, ungültig oder deaktiviert |
| `403 Forbidden` | API-Key hat keine Berechtigung für diese Ressource |
| `404 Not Found` | E-Mail nicht gefunden |
| `405 Method Not Allowed` | Schreibmethode (POST, PUT, DELETE etc.) nicht erlaubt |
| `429 Too Many Requests` | Rate-Limit überschritten (Standard: 60 Anfragen/Minute) |
| `500 Internal Server Error` | Serverfehler |
---
## Endpunkte
---
### E-Mails suchen / filtern
```
GET /api/v1/mails
```
**Query-Parameter (alle optional, kombinierbar):**
| Parameter | Typ | Beschreibung | Beispiel |
|-----------|-----|-------------|---------|
| `q` | string | Volltext-Suche (Xapian QueryParser) | `q=Rechnung+2024` |
| `from` | string | Absender (exakt oder Partial-Match) | `from=alice@firma.de` |
| `to` | string | Empfänger | `to=bob@firma.de` |
| `cc` | string | CC-Empfänger | `cc=team@firma.de` |
| `contact` | string | From **oder** To **oder** CC enthält diese Adresse | `contact=kunde@example.com` |
| `subject` | string | Betreff enthält diesen Text | `subject=Angebot` |
| `date_from` | string (ISO 8601) | Mails ab diesem Datum | `date_from=2024-01-01` |
| `date_to` | string (ISO 8601) | Mails bis zu diesem Datum | `date_to=2024-12-31` |
| `has_attachments` | boolean | Nur Mails mit/ohne Anhänge | `has_attachments=true` |
| `page` | integer | Seite (Standard: 1) | `page=2` |
| `limit` | integer | Ergebnisse pro Seite (Standard: 25, max: 100) | `limit=50` |
| `sort` | string | Sortierung: `date_asc`, `date_desc` (Standard), `relevance` | `sort=date_asc` |
**Beispiel-Request:**
```
GET /api/v1/mails?contact=kunde@example.com&date_from=2024-01-01&limit=25
Authorization: Bearer am_abc123...
```
**Antwort:**
```json
{
"total": 142,
"page": 1,
"limit": 25,
"pages": 6,
"mails": [
{
"message_id": "<abc123@mailserver.firma.de>",
"from": "alice@firma.de",
"to": ["kunde@example.com"],
"cc": [],
"subject": "Angebot vom 15.03.2024",
"date": "2024-03-15T10:23:00Z",
"size": 24680,
"has_attachments": true,
"attachments": [
{
"filename": "Angebot_2024.pdf",
"mime_type": "application/pdf",
"size": 18432
}
]
}
]
}
```
---
### Einzelne E-Mail abrufen (Metadaten + Body)
```
GET /api/v1/mails/{message_id}
```
**Pfad-Parameter:**
| Parameter | Beschreibung |
|-----------|-------------|
| `message_id` | RFC-2822 Message-ID (URL-encoded) |
**Beispiel-Request:**
```
GET /api/v1/mails/%3Cabc123%40mailserver.firma.de%3E
Authorization: Bearer am_abc123...
```
**Antwort:**
```json
{
"message_id": "<abc123@mailserver.firma.de>",
"from": "alice@firma.de",
"to": ["kunde@example.com"],
"cc": [],
"subject": "Angebot vom 15.03.2024",
"date": "2024-03-15T10:23:00Z",
"size": 24680,
"body_plain": "Sehr geehrte Damen und Herren,\n\nim Anhang finden Sie...",
"body_html": "<html>...</html>",
"has_attachments": true,
"attachments": [
{
"filename": "Angebot_2024.pdf",
"mime_type": "application/pdf",
"size": 18432,
"download_url": "/api/v1/mails/%3Cabc123%40mailserver.firma.de%3E/attachments/0"
}
]
}
```
---
### Original-E-Mail als EML herunterladen
```
GET /api/v1/mails/{message_id}/raw
```
Gibt die originale, unveränderte E-Mail im RFC-2822-Format zurück.
**Antwort-Header:**
```
Content-Type: message/rfc822
Content-Disposition: attachment; filename="<message_id>.eml"
```
---
### Einzelnen Anhang herunterladen
```
GET /api/v1/mails/{message_id}/attachments/{index}
```
**Pfad-Parameter:**
| Parameter | Beschreibung |
|-----------|-------------|
| `message_id` | RFC-2822 Message-ID (URL-encoded) |
| `index` | Position des Anhangs (0-basiert, aus der Attachment-Liste) |
**Antwort-Header:**
```
Content-Type: application/pdf (je nach MIME-Type des Anhangs)
Content-Disposition: attachment; filename="Angebot_2024.pdf"
```
---
### Server-Info
```
GET /api/v1/info
```
Gibt Version und Status des Servers zurück. Nützlich zum Testen der Verbindung.
**Antwort:**
```json
{
"version": "1.0.0",
"status": "ok",
"mail_count": 142857
}
```
---
## Volltext-Suche Syntax (`q`-Parameter)
Der `q`-Parameter unterstützt Xapian QueryParser-Syntax:
| Syntax | Beispiel | Bedeutung |
|--------|---------|-----------|
| Einfacher Begriff | `Rechnung` | Enthält "Rechnung" |
| Mehrere Begriffe | `Rechnung Mahnung` | Enthält beide Begriffe (AND) |
| Phrasensuche | `"offene Rechnung"` | Exakter Ausdruck |
| OR-Verknüpfung | `Rechnung OR Angebot` | Eines von beiden |
| Ausschließen | `Rechnung NOT Storno` | Rechnung aber nicht Storno |
| Wildcard | `Rechnun*` | Beginnt mit "Rechnun" |
| Feldspezifisch | `subject:Angebot` | Nur im Betreff suchen |
| Feldspezifisch | `from:alice@firma.de` | Nur von diesem Absender |
---
## Paginierung
Alle Listen-Endpunkte sind paginiert:
```
GET /api/v1/mails?page=2&limit=50
```
Die Antwort enthält immer:
- `total` Gesamtanzahl der Treffer
- `page` aktuelle Seite
- `limit` Einträge pro Seite
- `pages` Gesamtanzahl der Seiten
Maximum: 100 Einträge pro Anfrage. Für größere Abfragen mehrere Seiten iterieren.
---
## Rate Limiting
Standard: **60 Anfragen pro Minute** pro API-Key (konfigurierbar durch Admin).
Bei Überschreitung:
```
HTTP 429 Too Many Requests
Retry-After: 30
```
---
## Changelog
| Version | Datum | Änderungen |
|---------|-------|-----------|
| v1.0 | 2026-03-13 | Initiale Version |
+19 -1
View File
@@ -12,7 +12,25 @@
| ID | Feature | Status | Spec | Created |
|----|---------|--------|------|---------|
| PROJ-1 | Nutzer-Authentifizierung & Rollen (User/Admin) | In Progress | [PROJ-1](PROJ-1-authentifizierung-und-rollen.md) | 2026-03-12 |
| PROJ-2 | E-Mail-Import: EML/MBOX Upload | In Progress | [PROJ-2](PROJ-2-import-eml-mbox.md) | 2026-03-12 |
| PROJ-3 | E-Mail-Import: IMAP-Verbindung | In Progress | [PROJ-3](PROJ-3-import-imap.md) | 2026-03-12 |
| PROJ-4 | E-Mail-Import: SMTP-Eingang via BCC (primär) | In Progress | [PROJ-4](PROJ-4-import-smtp.md) | 2026-03-12 |
| PROJ-5 | E-Mail-Speicherung & Volltext-Indexierung | In Progress | [PROJ-5](PROJ-5-speicherung-und-indexierung.md) | 2026-03-12 |
| PROJ-6 | Volltext-Suche & Filterung | In Progress | [PROJ-6](PROJ-6-volltext-suche.md) | 2026-03-12 |
| PROJ-7 | E-Mail-Ansicht (Lesen & Anhänge) | In Progress | [PROJ-7](PROJ-7-email-ansicht.md) | 2026-03-12 |
| PROJ-8 | Automatischer IMAP-Sync (Cron-Job) | In Progress | [PROJ-8](PROJ-8-imap-auto-sync.md) | 2026-03-12 |
| PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 |
| PROJ-10 | Admin-Bereich: Nutzer- & Postfachverwaltung | In Progress | [PROJ-10](PROJ-10-admin-bereich.md) | 2026-03-12 |
| PROJ-11 | Audit-Log & Compliance-Berichte | In Progress | [PROJ-11](PROJ-11-audit-log.md) | 2026-03-12 |
| PROJ-12 | E-Mail-Export (EML/PDF) | In Progress | [PROJ-12](PROJ-12-export.md) | 2026-03-12 |
| PROJ-13 | REST API für externe CRM-Anbindung | In Progress | [PROJ-13](PROJ-13-rest-api-crm.md) | 2026-03-13 |
| PROJ-14 | E-Mail-Import: POP3-Verbindung | In Progress | [PROJ-14](PROJ-14-import-pop3.md) | 2026-03-13 |
| PROJ-15 | CLI Import & Export (archivmail-User) | In Progress | [PROJ-15](PROJ-15-cli-import-export.md) | 2026-03-13 |
| PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 |
| PROJ-17 | Admin Dashboard Systemauslastung & Archiv-Übersicht | In Review | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 |
<!-- Add features above this line -->
## Next Available ID: PROJ-1
## Next Available ID: PROJ-18
@@ -0,0 +1,185 @@
# PROJ-1: Nutzer-Authentifizierung & Rollen
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- PROJ-16 (LDAP / Active Directory Anbindung) — optionale Erweiterung des Login-Flows
## Rollen-Übersicht
| Rolle | Zugriff |
|-------|---------|
| `user` | Suche und Lesen eigener archivierter E-Mails |
| `auditor` | Alle E-Mails lesen und suchen (postfachübergreifend) + Audit-Log einsehen und exportieren keine Konfiguration |
| `admin` | Konfiguration, Nutzerverwaltung, Import-Quellen, Systemeinstellungen kein Zugriff auf E-Mails, kein Audit-Log |
## User Stories
- Als Admin möchte ich mich mit Benutzername/Passwort einloggen, damit nur autorisierte Personen Zugriff haben.
- Als Admin möchte ich neue Nutzer anlegen und ihnen eine Rolle zuweisen (`user`, `auditor`, `admin`).
- Als Auditor möchte ich den Audit-Log einsehen und als CSV exportieren, ohne Zugriff auf E-Mails oder Konfiguration zu haben.
- Als Nutzer möchte ich mich abmelden können, damit meine Session sicher beendet wird.
- Als Admin möchte ich Passwörter zurücksetzen können, damit gesperrte Nutzer wieder Zugang erhalten.
- Als System möchte ich Sessions nach Inaktivität automatisch beenden, damit unbefugter Zugriff verhindert wird.
## Acceptance Criteria
- [ ] Login-Seite mit E-Mail/Benutzername und Passwort-Formular
- [ ] Fehlermeldung bei falschen Zugangsdaten (kein Hinweis ob E-Mail oder Passwort falsch)
- [ ] Session-Token wird sicher gespeichert (httpOnly Cookie oder JWT)
- [ ] Sessions laufen nach konfigurierbarer Inaktivität ab (Standard: 8 Stunden)
- [ ] Drei Rollen: `user`, `auditor`, `admin` strikt getrennte Zugriffsrechte
- [ ] `auditor` hat Zugriff auf alle E-Mails (postfachübergreifend, auch fremde Postfächer) + Audit-Log keine Konfiguration
- [ ] `admin` hat ausschließlich Zugriff auf Konfiguration und Nutzerverwaltung kein Zugriff auf E-Mails und kein Zugriff auf Audit-Log
- [ ] Admin kann Nutzer anlegen, deaktivieren und löschen
- [ ] Admin kann Passwörter zurücksetzen (temporäres Passwort)
- [ ] Alle API-Endpunkte prüfen Authentifizierung und Rolle
- [ ] Logout löscht die Session serverseitig
## Edge Cases
- Login mit deaktiviertem Account → klare Fehlermeldung, kein Zugang
- Mehrfaches Fehllogin → Rate-Limiting oder Account-Sperre nach X Versuchen
- Session-Token abgelaufen → automatische Weiterleitung zur Login-Seite
- Erster Start: Zwei feste Default-User werden beim ersten Start automatisch angelegt:
- `admin@archivmail` / `archivmailrockz` (Rolle: `admin`)
- `auditor@archivmail` / `archivmailrockz` (Rolle: `auditor`)
- Passwörter sollten nach dem ersten Login geändert werden (Hinweis in der UI)
- Admin löscht sich selbst → verhindern wenn letzter Admin
## Technical Requirements
- Passwörter mit bcrypt gehasht (min. Cost 12)
- Alle Routen außer `/login` erfordern gültige Session
- Admin-Routen (`/admin/*`) nur für `admin`-Rolle
- Audit-Routen (`/audit/*`) und E-Mail-Suche/Ansicht nur für `auditor`- und `user`-Rolle
- `admin` erhält bei E-Mail-Endpunkten HTTP 403 keine Ausnahmen
- Keine Rolle vereint `admin` + `auditor` strikte Funktionstrennung
- Audit-Log-Eintrag bei Login, Logout, fehlgeschlagenem Login
---
<!-- Sections below are added by subsequent skills -->
## Tech Design (Solution Architect)
### Systemübersicht: Two-Tier Architektur
```
Browser (Next.js App) Go REST API Backend
│ │
│ POST /api/auth/login │
│ {email, password} │
│ ─────────────────────────────────► │
│ 1. Lokaler Account? → bcrypt verify
│ 2. Nicht gefunden + LDAP aktiv?
│ → LDAP-Bind (Service Account)
│ → User-DN suchen
│ → User-Bind mit Passwort
│ → AD-Gruppen → Rolle bestimmen
│ → UpsertLDAPUser in PostgreSQL
│ 3. Session-Token erstellen
│ Session in PostgreSQL speichern
│ ◄─────────────────────────────────
│ Set-Cookie: session=TOKEN │
│ (httpOnly, Secure, SameSite) │
│ │
│ GET /api/search?q=... │
│ Cookie: session=TOKEN │
│ ─────────────────────────────────► │
│ Session-Middleware: Token prüfen
│ Role-Middleware: Route erlaubt?
│ ◄─────────────────────────────────
│ JSON-Antwort │
```
> **LDAP ist vollständig optional.** Wenn `ldap.enabled: false` (Standard), verhält sich das System exakt wie ohne LDAP. Lokale Accounts funktionieren immer — auch wenn LDAP aktiviert ist (Fallback bei LDAP-Fehler).
### Komponentenstruktur
**Next.js Frontend:**
```
src/app/
├── /login ← Login-Seite (öffentlich)
├── /search ← Suche + E-Mail-Ansicht (user + auditor)
├── /audit ← Audit-Log (nur auditor)
└── /admin ← Admin-Bereich (nur admin)
src/components/
├── LoginForm ← E-Mail + Passwort, Fehlermeldungen
├── RoleGuard ← Schützt Routen clientseitig, redirect auf /login
└── PasswordChangePrompt ← Hinweis bei Default-Passwort
```
**Go Backend:**
```
HTTP-Server
├── POST /api/auth/login ← Session ausstellen
├── POST /api/auth/logout ← Session löschen
├── Session Middleware ← prüft Token bei allen /api/* Routen
├── Role Middleware
│ ├── /api/admin/* → nur `admin`
│ ├── /api/audit/* → nur `auditor`
│ └── /api/* → `user` + `auditor` (admin → 403)
├── Password Manager ← bcrypt Hash + Verify
├── User Store ← PostgreSQL users-Tabelle
└── Bootstrap ← Default-User beim ersten Start
```
### Datenmodell
**Tabelle `users`:**
| Feld | Beschreibung |
|------|-------------|
| `id` | Interne ID |
| `email` | Login-E-Mail (eindeutig) |
| `password_hash` | bcrypt-Hash (Cost 12) — NULL bei LDAP-Usern |
| `role` | `user` / `auditor` / `admin` |
| `source` | `local` oder `ldap` — Herkunft des Accounts |
| `active` | Deaktivierte Nutzer können sich nicht einloggen |
| `created_at` | Erstellungszeitpunkt |
| `last_login_at` | Letzter erfolgreicher Login |
**Tabelle `sessions`:**
| Feld | Beschreibung |
|------|-------------|
| `token` | Zufälliger 32-Byte-Token |
| `user_id` | Referenz auf `users` |
| `expires_at` | Ablaufzeitpunkt (rollierend, +8h bei Aktivität) |
| `last_active_at` | Wird bei jeder Anfrage aktualisiert |
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Next.js Frontend + Go REST API** | Klare Trennung: Next.js rendert die UI, Go verwaltet Daten und Sicherheit. Kein Java. |
| **Session-Cookie (httpOnly)** | Next.js sendet Cookie automatisch mit kein manuelles Token-Handling im Frontend-Code nötig |
| **Server-side Sessions (nicht JWT)** | Logout und Admin-Deaktivierung wirken sofort JWT wäre bis Ablauf weiterhin gültig |
| **Role-Check im Go-Backend** | Sicherheits-kritische Prüfung liegt im Backend, nicht im Next.js-Client (der wäre manipulierbar) |
| **RoleGuard in Next.js zusätzlich** | Verhindert kurzes Aufblitzen falscher Seiten rein UX, kein Sicherheits-Feature |
| **bcrypt Cost 12** | Ausreichend langsam gegen Brute-Force |
| **LDAP als optionaler Fallback** | Login versucht erst lokalen Account, dann LDAP Reihenfolge garantiert, dass lokale Admins immer funktionieren |
| **LDAP-User in PostgreSQL gespiegelt** | Nach erstem Login landet LDAP-User in `users`-Tabelle (`source: ldap`) einheitliche Session-Verwaltung, kein Sonder-Code |
| **AD-Gruppen → Rollen-Mapping** | Rolle wird bei jedem Login aus AD-Gruppenmitgliedschaft neu bestimmt Rollen-Änderung in AD wirkt beim nächsten Login |
### Abhängigkeiten
**Go Backend:**
| Paket | Zweck |
|---|---|
| `golang.org/x/crypto/bcrypt` | Passwort-Hashing |
| `crypto/rand` | Sichere Token-Generierung (Stdlib) |
| `github.com/go-ldap/ldap/v3` | LDAP/AD-Authentifizierung (PROJ-16) |
**Next.js Frontend:**
| Paket | Zweck |
|---|---|
| `react-hook-form` + `zod` | Login-Formular-Validierung (bereits im Template) |
| `shadcn/ui` | UI-Komponenten (bereits installiert) |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+161
View File
@@ -0,0 +1,161 @@
# PROJ-10: Admin-Bereich: Nutzer- & Postfachverwaltung
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- Requires: PROJ-1 (Authentifizierung & Rollen) nur Admins haben Zugang
## User Stories
- Als Admin möchte ich alle Nutzer auflisten, bearbeiten, deaktivieren und löschen.
- Als Admin möchte ich Postfächer (IMAP-Verbindungen) verwalten und Nutzern zuweisen.
- Als Admin möchte ich Systemstatistiken sehen (Gesamtanzahl E-Mails, Speicher, aktive Nutzer).
- Als Admin möchte ich Import-Konfigurationen (IMAP, SMTP) verwalten.
- Als Admin möchte ich globale Systemeinstellungen konfigurieren (Sync-Intervall, max. Upload-Größe, Retention-Policy).
## Acceptance Criteria
- [ ] Admin-Dashboard mit Übersicht: Nutzeranzahl, E-Mail-Anzahl, Speicherverbrauch
- [ ] Nutzerliste: Anzeige aller Nutzer mit Rolle, Status, letztem Login
- [ ] Nutzer anlegen / bearbeiten / deaktivieren / löschen (mit Bestätigungsdialog)
- [ ] Postfach-Verwaltung: IMAP-Verbindungen anlegen, bearbeiten, testen, löschen
- [ ] Postfach-Zuweisung: Nutzer einem oder mehreren Postfächern zuordnen
- [ ] System-Einstellungen: Sync-Intervall, SMTP-Port, max. Upload-Größe, Retention-Tage
- [ ] Alle Admin-Aktionen werden im Audit-Log erfasst
## Edge Cases
- Admin löscht Nutzer mit archivierten E-Mails → E-Mails bleiben im Archiv, Nutzer wird anonymisiert (DSGVO)
- Letzten Admin löschen/deaktivieren → verhindern mit Fehlermeldung
- Postfach löschen mit laufendem Sync → Sync abbrechen, dann löschen
## Technical Requirements
- Admin-Bereich unter eigenem Route-Prefix (/admin/*)
- Alle Admin-API-Endpunkte prüfen Admin-Rolle
- Änderungen an Systemeinstellungen erfordern Server-Neustart nur wenn unvermeidlich
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend (/admin/*):**
```
/admin
├── Dashboard ← Einstiegsseite
│ ├── StatCard: Gesamtanzahl Mails
│ ├── StatCard: Speicherverbrauch (store + astore)
│ ├── StatCard: Aktive Nutzer
│ ├── StatCard: Letzter SMTP-Eingang
│ └── ImportQueue-Status (laufende Imports)
├── /admin/users ← Nutzerverwaltung
│ ├── NutzerTabelle (Name, Rolle, Status, letzter Login)
│ ├── [Nutzer anlegen] Button → NutzerFormular
│ └── NutzerRow-Aktionen
│ ├── Bearbeiten (Rolle, Passwort zurücksetzen)
│ ├── Deaktivieren / Aktivieren
│ └── Löschen (Bestätigungsdialog + DSGVO-Hinweis)
├── /admin/imap ← IMAP-Verbindungen (PROJ-3 + PROJ-8)
│ ├── IMAP-Verbindungsliste
│ └── Postfach-Zuweisung (Nutzer ↔ IMAP-Account)
├── /admin/pop3 ← POP3-Verbindungen (PROJ-14)
├── /admin/smtp ← SMTP-Daemon-Status (PROJ-4)
│ ├── Status (läuft / gestoppt), Port, TLS
│ ├── Anzahl empfangener Mails (heute / gesamt)
│ └── IP-Allowlist verwalten
├── /admin/upload ← EML/MBOX Upload (PROJ-2)
├── /admin/apikeys ← API-Keys (PROJ-13)
├── /admin/labels ← Globale Labels + Auto-Regeln (PROJ-9)
└── /admin/settings ← Systemeinstellungen
├── max. Upload-Größe (MB)
├── Retention-Tage (0 = unbegrenzt)
├── Session-Timeout (Stunden)
└── SMTP-Port (Hinweis: Neustart erforderlich)
```
**Go Backend (/api/admin/*):**
```
Admin-Router (alle Routen prüfen admin-Rolle)
├── GET /api/admin/stats ← Dashboard-Zahlen
│ (Mail-Count, Speicher, aktive User, letzter SMTP-Eingang)
├── Nutzer-Verwaltung
│ ├── GET /api/admin/users
│ ├── POST /api/admin/users ← anlegen
│ ├── PATCH /api/admin/users/{id} ← bearbeiten
│ ├── DELETE /api/admin/users/{id} ← löschen (DSGVO-Anonymisierung)
│ └── POST /api/admin/users/{id}/reset-password
├── Postfach-Zuweisung
│ ├── GET /api/admin/users/{id}/mailboxes
│ ├── POST /api/admin/users/{id}/mailboxes/{account_id}
│ └── DELETE /api/admin/users/{id}/mailboxes/{account_id}
└── Systemeinstellungen
├── GET /api/admin/settings
└── PATCH /api/admin/settings
```
### DSGVO-Löschfluss (Nutzer löschen)
```
Admin klickt "Nutzer löschen"
Bestätigungsdialog:
"E-Mails bleiben im Archiv.
Nutzerdaten werden anonymisiert."
DELETE /api/admin/users/{id}
├── Ist letzter Admin? → 409 Conflict (verhindern)
├── E-Mails des Nutzers → bleiben im Archiv (immutable)
├── Audit-Log-Einträge → user_id → "anonymized"
│ IP-Adressen → gelöscht
├── Sessions → alle gelöscht
├── Labels des Nutzers → gelöscht
└── User-Eintrag → gelöscht
```
### Datenmodell
**Tabelle `settings`** Key-Value-Store für Systemeinstellungen:
| Key | Standardwert | Beschreibung |
|-----|-------------|-------------|
| `max_upload_mb` | `500` | Max. Upload-Größe in MB |
| `retention_days` | `0` | 0 = unbegrenzt |
| `session_timeout_hours` | `8` | Session-Inaktivitäts-Timeout |
| `smtp_port` | `2525` | SMTP-Daemon-Port (Neustart nötig) |
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Admin-Bereich als eigene Next.js-Route** | Klare Trennung von User-Frontend RoleGuard blockiert Nicht-Admins sofort |
| **Dashboard-Stats vom Backend** | Mail-Count, Speicher aus DB/Dateisystem kein Client-seitiges Berechnen |
| **Settings als DB-Key-Value** | Einstellungen zur Laufzeit änderbar ohne Dateisystem-Zugriff oder Neustart (außer SMTP-Port) |
| **DSGVO-Anonymisierung statt Hard-Delete** | Archiv-Integrität bleibt erhalten E-Mails im Archiv haben keinen Personenbezug mehr nach Anonymisierung |
| **Letzter-Admin-Schutz** | Verhindert Aussperrung Backend prüft vor jedem Delete/Deaktivieren |
### Abhängigkeiten
**Next.js:** shadcn/ui Table, Dialog, Form (bereits installiert).
**Go Backend:** Nur pgx + Stdlib (bereits vorhanden).
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+145
View File
@@ -0,0 +1,145 @@
# PROJ-11: Audit-Log & Compliance-Berichte
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-13
## Dependencies
- Requires: PROJ-1 (Authentifizierung) Audit-Einträge sind an Nutzer geknüpft
## User Stories
- Als Admin möchte ich sicherheitsrelevante Ereignisse im Audit-Log einsehen, damit ich Zugriffe und Änderungen nachvollziehen kann.
- Als Admin möchte ich den Audit-Log nach Datum, Nutzer und Ereignistyp filtern.
- Als Admin möchte ich den Audit-Log als CSV exportieren, damit ich ihn für externe Prüfungen verwenden kann.
## Erfasste Ereignisse
**Ja wird geloggt:**
- Login (Erfolg und Fehlschlag) inkl. IP-Adresse
- Logout
- Suchanfragen (Suchbegriff, Anzahl Treffer, Nutzer)
- Import gestartet / abgeschlossen / fehlgeschlagen (Quelle, Anzahl E-Mails)
- Export gestartet / abgeschlossen (Format, Anzahl E-Mails)
**Nein wird nicht geloggt:**
- Lesezugriff auf einzelne E-Mails (kein per-Mail-Leselogging)
## Acceptance Criteria
- [ ] Jeder Log-Eintrag enthält: Zeitstempel (UTC), Nutzer-ID, Ereignistyp, Details (z.B. Suchbegriff, Import-Quelle), IP-Adresse
- [ ] Audit-Log-Ansicht **nur für Auditoren** unter `/audit/` kein Zugriff für `admin` oder `user`
- [ ] Filterung nach: Datum (vonbis), Nutzer, Ereignistyp
- [ ] Paginierung (50 Einträge pro Seite)
- [ ] Export als CSV (gefilterte oder vollständige Ansicht, Streaming-Download)
- [ ] Einträge sind unveränderlich: kein UPDATE/DELETE durch Admin oder Anwendung möglich
- [ ] Retention konfigurierbar in `config.yml` (`audit.retention_days`), kein Standardwert erzwungen
- [ ] Doppelte Speicherung: PostgreSQL (für GUI-Abfragen) + Append-only Logdatei auf Disk (als unveränderliches Backup)
- [ ] Logdatei-Format: JSON Lines (ein Eintrag pro Zeile)
## Edge Cases
- Audit-Log über Jahre sehr groß → paginierte DB-Abfragen mit Index auf `(timestamp, event_type, user_id)`, kein Full-Table-Scan
- Nutzer DSGVO-gelöscht → Audit-Einträge behalten, `user_id` durch `"anonymized"` ersetzen, IP-Adresse löschen
- Logdatei nicht beschreibbar beim Start → Warnung loggen, Dienst läuft weiter (DB-Log bleibt aktiv)
- Gleichzeitige Schreibzugriffe auf Logdatei → Append mit file lock
## Technical Requirements
- PostgreSQL: separate Tabelle `audit_log`, kein DELETE/UPDATE per DB-Constraint (Row-Level Security oder Trigger)
- Logdatei: `/var/log/archivmail/audit.log` (Pfad konfigurierbar), append-only, JSON Lines
- Log-Rotation über `logrotate` (extern konfiguriert), Datei wird nie vom Dienst selbst rotiert oder gelöscht
- Zeitstempel immer UTC (RFC 3339)
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend (`/audit/*`):**
```
/audit ← nur für Auditoren (RoleGuard)
└── AuditTable
├── Spalten: Zeitstempel, Nutzer, Ereignis, Details, IP
├── Filter-Leiste
│ ├── Datepicker: von bis
│ ├── Dropdown: Nutzer auswählen
│ └── Dropdown: Ereignistyp
├── Paginierung (50 pro Seite)
└── [CSV exportieren] Button → Streaming-Download
```
**Go Backend:**
```
Audit-Service (intern, kein eigener HTTP-Handler)
├── WriteEvent(event AuditEvent)
│ ├── → INSERT INTO audit_log (kein UPDATE/DELETE je möglich)
│ └── → Append zu /var/log/archivmail/audit.log (JSON Line)
│ └── File-Lock beim Schreiben (sync.Mutex)
└── Audit-API (nur für Auditoren)
├── GET /api/audit/events ← Paginiert, gefiltert
└── GET /api/audit/export ← Streaming-CSV-Download
```
### Datenmodell
**Tabelle `audit_log`** (append-only via DB-Trigger):
| Feld | Beschreibung |
|------|-------------|
| `id` | Sequentielle ID |
| `timestamp` | UTC, RFC 3339 |
| `user_id` | Nutzer-ID (NULL nach DSGVO-Löschung) |
| `user_email` | E-Mail zum Zeitpunkt des Events (für Lesbarkeit nach Anonymisierung) |
| `event_type` | `login_ok`, `login_fail`, `logout`, `search`, `import_start`, `import_done`, `import_fail`, `export_start`, `export_done` |
| `details` | JSON: Suchbegriff / Import-Quelle / Anzahl / etc. |
| `ip_address` | IPv4/IPv6 (NULL nach DSGVO-Löschung) |
**DB-Constraint:** PostgreSQL-Trigger verhindert `UPDATE` und `DELETE` auf der gesamten Tabelle → physische Unveränderlichkeit.
**Logdatei-Format** (`/var/log/archivmail/audit.log`, JSON Lines):
```
{"ts":"2024-03-01T10:00:00Z","user":"alice@firma.de","event":"search","details":{"q":"Rechnung","hits":42},"ip":"192.168.1.1"}
```
### Schreibfluss
```
Beliebige Aktion (Login, Suche, Import...)
audit.WriteEvent() aufgerufen
├── PostgreSQL INSERT (non-blocking, Goroutine)
└── File-Append mit sync.Mutex
(Datei nicht beschreibbar? → Warnung auf stderr, Dienst läuft weiter)
```
### DSGVO-Löschfluss (Nutzer anonymisieren)
```
DELETE /api/admin/users/{id}
├── audit_log: user_id → NULL, ip_address → NULL
│ user_email → "anonymized"
└── Logdatei: bleibt unverändert (tamper-evident)
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Audit-Ansicht nur für Auditoren** | Strict role separation — Admin hat keine Einsicht in Zugriffsprotokolle |
| **DB-Trigger für Unveränderlichkeit** | Applikationscode kann versehentlich löschen — Trigger ist eine härtere Garantie |
| **Doppelte Speicherung** | DB für GUI-Abfragen; Logdatei als tamper-evident Backup für externe Prüfungen |
| **logrotate extern** | Dienst rotiert nie selbst — Logdatei bleibt unter Systemadmin-Kontrolle |
| **DSGVO: IP-Adresse löschen, Event behalten** | Personenbezug entfernen, Compliance-Nachweis bleibt erhalten |
| **Composite Index `(timestamp, event_type, user_id)`** | Schnelle gefilterte Abfragen auch bei sehr großem Log über Jahre |
### Abhängigkeiten
**Go Backend:** Nur Stdlib + pgx (bereits vorhanden).
**Next.js:** shadcn/ui Table, Select, DatePicker (bereits installiert).
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+122
View File
@@ -0,0 +1,122 @@
# PROJ-12: E-Mail-Export (EML / PDF)
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-13
## Dependencies
- Requires: PROJ-1 (Authentifizierung)
- Requires: PROJ-6 (Volltext-Suche) Export aus Suchergebnissen
- Requires: PROJ-7 (E-Mail-Ansicht)
- Requires: PROJ-11 (Audit-Log) Export-Aktionen werden geloggt
## User Stories
- Als Nutzer möchte ich eine einzelne E-Mail als EML-Datei exportieren, damit ich sie in einem E-Mail-Client öffnen kann.
- Als Nutzer möchte ich eine E-Mail als PDF drucken/exportieren, damit ich sie für Behörden oder Verträge verwenden kann.
- Als Admin möchte ich mehrere E-Mails als ZIP-Archiv exportieren, damit ich bei einer Anfrage mehrere Mails auf einmal liefern kann.
- Als System möchte ich jeden Export im Audit-Log erfassen.
## Acceptance Criteria
- [ ] Einzelexport EML: Original-MIME-Inhalt wird unverändert heruntergeladen
- [ ] Einzelexport PDF: E-Mail-Header + Body als gut lesbares PDF gerendert, Anhänge als separate Dateien erwähnt
- [ ] Massenexport: Auswahl mehrerer E-Mails (Checkbox in Suchergebnissen), ZIP-Download
- [ ] ZIP enthält: EML-Dateien + optionale Anhänge + Manifest (CSV mit Metadaten)
- [ ] Massenexport-Limit konfigurierbar (Standard: max. 500 E-Mails pro Export)
- [ ] Jeder Export wird im Audit-Log erfasst (Nutzer, Anzahl E-Mails, Format)
- [ ] Zugriffsschutz: Nutzer kann nur eigene E-Mails exportieren
## Edge Cases
- Export von 500 E-Mails mit großen Anhängen → Streaming-ZIP, kein Speicher-Overflow
- PDF-Rendering von komplexem HTML → graceful Fallback auf Plain-Text-PDF
- Nutzer wählt E-Mails aus, auf die er keinen Zugriff hat → diese werden aus Export-Liste entfernt
## Technical Requirements
- ZIP-Erstellung als Stream (nicht komplett in Memory)
- PDF-Generierung serverseitig (z.B. wkhtmltopdf oder Go-PDF-Bibliothek)
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend:**
```
Suchergebnisse (PROJ-6, Erweiterung)
├── Checkbox pro Treffer (Multi-Select)
├── [Exportieren] Button (aktiv wenn ≥1 ausgewählt)
└── Export-Dialog
├── Format: EML | PDF
├── Anhänge einschließen: ja / nein
└── [Download starten] → POST /api/export/zip (Streaming)
E-Mail-Ansicht (PROJ-7, Erweiterung)
├── [Als EML herunterladen] Button → GET /api/export/eml/{id}
└── [Als PDF exportieren] Button → GET /api/export/pdf/{id}
```
**Go Backend:**
```
Export-Handler
├── GET /api/export/eml/{id} ← Einzelexport EML
│ └── Original-.m-Datei lesen, AES-256-GCM entschlüsseln, direkt streamen
├── GET /api/export/pdf/{id} ← Einzelexport PDF
│ ├── .m-Datei lesen + entschlüsseln
│ ├── Header + Body extrahieren
│ └── → PDF-Bibliothek → PDF streamen
│ Fallback: Plain-Text-PDF wenn HTML zu komplex
└── POST /api/export/zip ← Massenexport
├── Body: { ids: [...], format: "eml"|"pdf", attachments: bool }
├── Zugriffscheck: Nutzer darf nur eigene Mails exportieren
├── Max-Limit prüfen (konfigurierbar, Standard: 500)
├── Streaming-ZIP (archive/zip Writer → ResponseWriter direkt)
│ ├── Pro Mail: <message_id>.eml oder <message_id>.pdf
│ ├── Anhänge: attachments/<hash>/<filename> (wenn aktiviert)
│ └── manifest.csv (Message-ID, From, To, Subject, Date, Dateiname)
└── → Audit-Log: export_start + export_done (Anzahl, Format)
```
### Export-Fluss (Massenexport)
```
POST /api/export/zip
├── Zugriffsfilter: IDs auf user-eigene Mails beschränken
├── Audit-Log: export_start
├── ZIP-Stream öffnen (Content-Type: application/zip)
└── Für jede Mail:
.m-Datei lesen → AES-256-GCM entschlüsseln
→ Zu ZIP hinzufügen (kein vollständiges RAM-Buffering)
manifest.csv Zeile anhängen
ZIP schließen → Audit-Log: export_done
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Streaming-ZIP** | 500 Mails mit Anhängen können mehrere GB sein — kein RAM-Overhead |
| **Serverseitiges PDF** | Browser-Print ist nicht reproduzierbar; serverseitiges PDF ist auditierbar und einheitlich |
| **Plain-Text-Fallback für PDF** | Komplexes HTML kann PDF-Renderer zum Absturz bringen — graceful degradation |
| **Zugriffscheck im Export-Handler** | Serverseitige Filterung verhindert Datenlecks durch manipulierte IDs |
| **manifest.csv im ZIP** | Nachvollziehbarkeit bei Behördenanfragen ohne jede EML einzeln öffnen zu müssen |
| **Audit-Log für jeden Export** | Compliance-Anforderung — wer hat wann was exportiert |
### Abhängigkeiten
| Paket | Zweck |
|---|---|
| `archive/zip` (Stdlib) | Streaming-ZIP-Erstellung ohne externe Abhängigkeit |
| `github.com/SebastiaanKlippert/go-wkhtmltopdf` | PDF-Generierung aus HTML (serverseitig) |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+178
View File
@@ -0,0 +1,178 @@
# PROJ-13: REST API für externe CRM-Anbindung
## Status: In Progress
**Created:** 2026-03-13
**Last Updated:** 2026-03-13
## Dependencies
- Requires: PROJ-1 (Authentifizierung) API-Keys sind an Nutzer/Rollen gebunden
- Requires: PROJ-5 (Speicherung & Indexierung) Daten kommen aus dem Archiv
- Requires: PROJ-6 (Volltext-Suche) Suche über API nutzbar
## Hinweis
Externe Systeme (CRM, ERP, Helpdesk etc.) sollen über eine dokumentierte REST API auf das Archiv zugreifen können **ausschließlich lesend**. Schreiboperationen (Importieren, Löschen, Labeln etc.) sind über die API nicht möglich und werden nicht implementiert. Authentifizierung über API-Keys, nicht über Session-Cookies.
## User Stories
- Als CRM-Administrator möchte ich einen API-Key generieren, damit mein CRM-System auf das Archiv zugreifen kann.
- Als CRM-System möchte ich E-Mails eines bestimmten Kontakts (E-Mail-Adresse) abrufen, damit ich die Kommunikationshistorie im CRM anzeigen kann.
- Als CRM-System möchte ich E-Mails nach Datum, Absender oder Betreff durchsuchen, damit ich gezielt relevante Mails finden kann.
- Als Admin möchte ich API-Keys verwalten (anlegen, deaktivieren, löschen), damit ich den Zugriff kontrollieren kann.
- Als Admin möchte ich sehen, welcher API-Key wann welche Anfragen gestellt hat.
## Acceptance Criteria
- [ ] API-Key-Verwaltung im Admin-Bereich: anlegen, benennen, deaktivieren, löschen
- [ ] API-Keys haben eine konfigurierbare Rolle (`user` oder `auditor`) bestimmt Zugriffsumfang (Leserechte)
- [ ] Nur `GET`-Methoden erlaubt `POST`, `PUT`, `PATCH`, `DELETE` geben generisch 405 Method Not Allowed zurück
- [ ] Authentifizierung via HTTP-Header: `Authorization: Bearer <api-key>`
- [ ] Endpunkt: `GET /api/v1/mails?from=&to=&subject=&date_from=&date_to=` Suche/Filterung
- [ ] Endpunkt: `GET /api/v1/mails/{message_id}` einzelne E-Mail abrufen (Metadaten)
- [ ] Endpunkt: `GET /api/v1/mails/{message_id}/raw` Original-EML herunterladen
- [ ] Endpunkt: `GET /api/v1/mails?contact=email@firma.de` alle Mails eines Kontakts (From oder To)
- [ ] Antwortformat: JSON für Metadaten, `application/octet-stream` für Raw-EML
- [ ] Paginierung: `?page=1&limit=25` (max. 100 pro Anfrage)
- [ ] API-Zugriffe werden im Audit-Log erfasst (API-Key-Name, Endpunkt, Zeitstempel)
- [ ] OpenAPI/Swagger-Dokumentation unter `/api/v1/docs`
## Edge Cases
- Ungültiger oder deaktivierter API-Key → 401 Unauthorized
- API-Key mit `user`-Rolle fragt Mails ab, auf die er keinen Zugriff hat → 403
- Rate-Limiting: zu viele Anfragen pro API-Key → 429 Too Many Requests
- Sehr große Ergebnismengen (>10.000 Treffer) → Paginierung erzwingen, kein Full-Dump
- CRM fragt nicht existierende Message-ID ab → 404
## Technical Requirements
- **Reine Lese-API** ausschließlich `GET`-Endpunkte, keine Schreiboperationen
- Eigener Route-Prefix `/api/v1/` für externe API (getrennt von interner `/api/`)
- API-Keys: zufällig generiert (32 Byte, Base64), bcrypt-gehasht in der DB (nie im Klartext)
- Rate-Limiting pro API-Key konfigurierbar (Standard: 60 Anfragen/Minute)
- OpenAPI 3.0 Spec wird aus Code generiert oder manuell gepflegt
- Versionierung: `/api/v1/` spätere Versionen brechen bestehende Clients nicht
---
## Tech Design (Solution Architect)
### Systemübersicht
```
CRM / ERP / Helpdesk
│ GET /api/v1/mails?contact=kunde@example.com
│ Authorization: Bearer <api-key>
Go Backend Externer API-Router (/api/v1/*)
├── API-Key Middleware ← statt Session-Cookie
├── Rate Limiter
├── Shared Search Service ←──── dieselbe Logik wie interne Suche (PROJ-6)
└── Shared Mail Service ←──── dieselbe Logik wie Mail-Abruf (PROJ-7)
```
### Komponentenstruktur
**Go Backend:**
```
/api/v1/* (externer Prefix, getrennt von internem /api/*)
├── API-Key Middleware ← ersetzt Session-Middleware
│ ├── Bearer-Token aus Header lesen
│ ├── SHA-256-Hash → DB-Lookup ← schneller Lookup ohne bcrypt-Overhead
│ ├── Key deaktiviert? → 401
│ ├── Rolle laden (user/auditor)
│ └── Rate-Limit-Konfiguration laden
├── Rate Limiter ← Token-Bucket pro API-Key
│ └── Limit überschritten → 429 + Retry-After Header
├── Method Guard ← alles außer GET → 405
├── Shared Search Service ← identische Logik wie /api/search (PROJ-6)
│ ├── Xapian QueryParser
│ ├── Rollen-Filter (user/auditor)
│ └── PostgreSQL Metadaten-Lookup
├── Shared Mail Service ← identische Logik wie /api/mails (PROJ-7)
│ ├── .m-Datei lesen + entschlüsseln
│ ├── MIME-Parser
│ └── Anhang-Streaming
├── Audit Logger ← API-Key-Name + Endpunkt + Zeitstempel
└── API-Key-Verwaltung (Admin)
├── POST /api/admin/apikeys ← Key generieren (einmalige Anzeige)
├── GET /api/admin/apikeys ← Liste (Name + Rolle + letzter Zugriff)
└── DELETE /api/admin/apikeys/{id}
```
### API-Key Authentifizierungsfluss
```
CRM-System
│ Authorization: Bearer am_a1b2c3d4e5f6...
API-Key Middleware
├─ Präfix "am_" prüfen
├─ SHA-256(token) → DB-Lookup (indiziert)
├─ Key gefunden + aktiv? Nein → 401
└─ Ja → Rolle + Rate-Limit laden
Rate Limiter (Token-Bucket)
├─ Limit erreicht? → 429 + Retry-After: 30
└─ OK → weiter
Method Guard
├─ POST/PUT/DELETE? → 405
└─ GET → weiter
Shared Service Layer
Audit Logger → API-Key-Name + Endpunkt + Zeitstempel
```
### Datenmodell
**Tabelle `api_keys`:**
| Feld | Beschreibung |
|------|-------------|
| `id` | Interne ID |
| `name` | Bezeichnung (z.B. "CRM Salesforce") |
| `token_hash` | SHA-256 des Tokens (für schnellen Lookup, indiziert) |
| `role` | `user` oder `auditor` |
| `active` | `true` / `false` |
| `rate_limit` | Anfragen pro Minute (Standard: 60) |
| `created_at` | Erstellungszeitpunkt |
| `last_used_at` | Letzter erfolgreicher Zugriff |
**Key-Format:**
```
Generiert: am_<32-Byte-random-Base64>
Gespeichert: SHA-256(token) in DB
Angezeigt: einmalig im Admin-UI danach nicht mehr abrufbar
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Shared Service Layer** | Suche und Mail-Abruf teilen dieselbe Go-Logik mit der internen API kein doppelter Code |
| **SHA-256 statt bcrypt** | API-Keys sind kryptografisch zufällig (32 Byte) SHA-256 reicht, bcrypt wäre bei jeder Anfrage zu langsam |
| **`am_`-Präfix** | Erkennungsmerkmal für archivmail-Keys einfach filterbar in Logs |
| **Token einmalig anzeigen** | Nur Hash gespeichert kein späteres Auslesen möglich (wie GitHub PAT) |
| **Token-Bucket Rate Limiter** | Gleichmäßige Anfragen erlaubt, kurze Bursts toleriert |
| **`/api/v1/` Prefix** | Klare Versionierung zukünftige `/api/v2/` bricht bestehende Clients nicht |
| **Audit-Log bei API-Zugriffen** | Externe Zugriffe werden geloggt (anders als interne Lesezugriffe) |
### Abhängigkeiten
Kein zusätzliches Paket Rate-Limiter und SHA-256 aus der Go-Stdlib (`crypto/sha256`, `sync`).
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+147
View File
@@ -0,0 +1,147 @@
# PROJ-14: E-Mail-Import: POP3-Verbindung
## Status: In Progress
**Created:** 2026-03-13
**Last Updated:** 2026-03-13
## Dependencies
- Requires: PROJ-1 (Authentifizierung) nur Admins verwalten POP3-Verbindungen
- Requires: PROJ-5 (Speicherung & Indexierung) importierte E-Mails werden gespeichert
## Hinweis
POP3 kennt keine Ordnerstruktur es gibt nur eine Inbox. Alle Mails werden importiert. Da POP3 keine UID-basierte Synchronisation unterstützt, ist nur ein einmaliger Initial-Import sinnvoll (kein regelmäßiger Sync wie bei IMAP).
## User Stories
- Als Admin möchte ich einen POP3-Server konfigurieren (Host, Port, Zugangsdaten), damit ich E-Mails von dort importieren kann.
- Als System möchte ich alle vorhandenen E-Mails vom POP3-Server herunterladen und archivieren.
- Als Admin möchte ich den Verbindungsstatus und Importfortschritt sehen.
- Als System möchte ich Duplikate (gleiche Message-ID) überspringen.
## Acceptance Criteria
- [ ] Konfigurationsformular: Host, Port, Verbindungsmodus (SSL/TLS, STARTTLS, None), Benutzername, Passwort
- [ ] **Verbindungsmodi:**
- `SSL/TLS` direkte TLS-Verbindung (Port 995)
- `STARTTLS` startet unverschlüsselt, wird auf TLS hochgestuft (Port 110)
- `None` unverschlüsselt, nur für lokale/Testumgebungen
- [ ] Verbindungstest vor dem Speichern (Timeout: 10 Sekunden)
- [ ] Passwörter AES-256-GCM verschlüsselt in der DB gespeichert
- [ ] Import: alle Mails vom Server herunterladen
- [ ] Duplikate (Message-ID) werden übersprungen
- [ ] Fortschrittsanzeige während Import (X von Y Mails)
- [ ] Abschlussbericht: importiert / übersprungen / Fehler
- [ ] Mails bleiben nach dem Import auf dem POP3-Server (kein DELE-Befehl)
## Edge Cases
- POP3-Server nicht erreichbar → Fehlermeldung mit Retry-Option
- Falsche Zugangsdaten → klare Fehlermeldung
- Mail ohne Message-ID → synthetische ID generieren (SHA-256 des Inhalts)
- Verbindungsabbruch während Import → bei Neustart von vorne (POP3 hat keine UIDs zum Weiterführen)
- Sehr großes Postfach (10.000+ Mails) → sequenzielles Herunterladen, kein Speicher-Overflow
## Technical Requirements
- **Verbindungsmodi:** SSL/TLS (Port 995), STARTTLS (Port 110), None
- POP3 unterstützt keine Ordner es gibt nur die Inbox, keine Ordner-Erkennung nötig
- Kein regelmäßiger Sync nur manueller Import (POP3 bietet keine zuverlässige Duplikatserkennung über Sessions hinaus)
- Zugangsdaten AES-256-GCM verschlüsselt in der DB
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend (Admin-Bereich):**
```
/admin/pop3
├── POP3-Verbindungsliste
│ └── VerbindungsCard
│ ├── Name, Host, Status
│ ├── Letzter Import + Anzahl
│ └── Aktionen: Bearbeiten / Löschen / Import starten
├── Verbindung-Formular
│ ├── Host, Port, Verbindungsmodus (SSL/TLS | STARTTLS | None)
│ ├── Benutzername, Passwort
│ └── [Verbindung testen] Button
└── Import-Fortschrittsanzeige
├── Fortschrittsbalken
└── Abschlussbericht
```
**Go Backend:**
```
POP3-Dienst
├── POST /api/admin/pop3 ← Verbindung anlegen
├── POST /api/admin/pop3/test ← Verbindung testen
├── GET /api/admin/pop3 ← auflisten
├── DELETE /api/admin/pop3/{id} ← löschen
├── POP3-Client
│ ├── SSL/TLS + STARTTLS Handler
│ ├── USER/PASS Login
│ ├── STAT → Anzahl Mails + Gesamtgröße
│ ├── LIST → Message-Nummern
│ └── RETR → Mail herunterladen (kein DELE)
└── Import-Worker (Hintergrund-Goroutine)
├── Sequenziell: RETR 1, RETR 2, ...
├── Duplikat-Check (Message-ID)
├── → Storage Coordinator (PROJ-5)
└── Fortschritt in DB
```
### Verbindungsmodus-Übersicht
| Modus | Port | Ablauf |
|-------|------|--------|
| `SSL/TLS` | 995 | TLS direkt beim Verbindungsaufbau |
| `STARTTLS` | 110 | Verbindung startet plain → STLS-Befehl → TLS |
| `None` | 110 | Unverschlüsselt (nur Testumgebung) |
### Importfluss
```
Admin klickt "Import starten"
POP3-Client verbindet (SSL/TLS oder STARTTLS)
STAT → Gesamtanzahl Mails (z.B. 3.842)
LIST → Message-Nummern [1, 2, 3, ..., 3842]
Für jede Message-Nummer:
RETR <n> → rohe Mail (RFC 2822)
Message-ID Duplikat? → überspringen
→ Storage Coordinator (PROJ-5)
Fortschritt: n / 3842
Kein DELE → Mails bleiben auf dem Server
QUIT → Verbindung trennen
Abschlussbericht speichern
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Kein DELE** | Archiv löscht nichts vom Quellserver nur lesen und archivieren |
| **Kein regelmäßiger Sync** | POP3 hat keine UIDs es gibt keine zuverlässige Möglichkeit festzustellen welche Mails bereits importiert wurden |
| **Synthetische Message-ID bei Fehlen** | POP3-Mails ohne Message-ID bekommen SHA-256(Inhalt) als ID Duplikatserkennung bleibt konsistent |
| **Gleiche Codebasis wie IMAP-Worker** | Import-Worker-Struktur identisch nur POP3-Client statt IMAP-Client |
### Abhängigkeiten
| Paket | Zweck |
|---|---|
| `github.com/emersion/go-message` | POP3-Client mit TLS/STARTTLS |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+195
View File
@@ -0,0 +1,195 @@
# PROJ-15: CLI Import & Export
## Status: In Progress
**Created:** 2026-03-13
**Last Updated:** 2026-03-13
## Dependencies
- Requires: PROJ-5 (Speicherung & Indexierung) Import nutzt Storage Coordinator
- Requires: PROJ-1 (Authentifizierung) CLI läuft als Systembenutzer `archivmail`, kein Web-Login
## Hinweis
Die CLI läuft direkt auf dem Server als Systembenutzer `archivmail` kein Web-Login, kein API-Key. Zugriff über den gleichen Storage Coordinator wie der Daemon. Gedacht für automatisierte Skripte, Cron-Jobs und administrative Bulk-Operationen.
## User Stories
- Als Systemadministrator möchte ich EML/MBOX-Dateien per CLI importieren, damit ich Bulk-Importe skriptbasiert automatisieren kann.
- Als Systemadministrator möchte ich E-Mails per CLI exportieren (EML/MBOX), damit ich Sicherungen oder Migrationen durchführen kann.
- Als Systemadministrator möchte ich Import/Export mit Pfadangabe starten, damit ich Quell- und Zielverzeichnisse flexibel festlegen kann.
- Als System möchte ich Import-Fortschritt und Ergebnis auf stdout ausgeben, damit Skripte den Status auswerten können.
## Acceptance Criteria
### Import
- [ ] `archivmail import --file /pfad/zu/datei.eml` einzelne EML importieren
- [ ] `archivmail import --file /pfad/zu/archiv.mbox` MBOX importieren
- [ ] `archivmail import --dir /pfad/zum/verzeichnis/` alle EML-Dateien in einem Verzeichnis importieren (rekursiv optional: `--recursive`)
- [ ] Fortschrittsausgabe auf stdout (eine Zeile pro Mail oder Fortschrittsbalken)
- [ ] Exit-Code 0 bei Erfolg, 1 bei Fehler
- [ ] Duplikate werden übersprungen (gleiche Message-ID), kein Fehler
- [ ] `--dry-run` Flag: zeigt was importiert würde ohne tatsächlich zu speichern
### Export
- [ ] `archivmail export --out /pfad/ziel/` alle Mails als EML-Dateien exportieren
- [ ] `archivmail export --out /pfad/archiv.mbox` alle Mails als MBOX exportieren
- [ ] `archivmail export --from alice@firma.de --out /pfad/` Filter nach Absender
- [ ] `archivmail export --date-from 2024-01-01 --date-to 2024-12-31 --out /pfad/` Filter nach Datum
- [ ] `archivmail export --query "Rechnung" --out /pfad/` Filter per Volltext-Suche (Xapian)
- [ ] Exportierte Mails werden entschlüsselt (Klartext EML auf Disk)
- [ ] `--format eml` (Standard) oder `--format mbox`
### Allgemein
- [ ] CLI läuft als Systembenutzer `archivmail` liest Key aus `/etc/archivmail/keyfile`
- [ ] Fehler werden auf stderr ausgegeben
- [ ] `archivmail help` zeigt Übersicht aller Befehle
- [ ] `archivmail version` zeigt Version
## Edge Cases
- Verzeichnis beim Import enthält keine EML-Dateien → Hinweis + Exit-Code 0
- Zieldatei beim Export bereits vorhanden → Fehler mit `--force` Flag zum Überschreiben
- Kein Lese-/Schreibrecht auf Pfad → klare Fehlermeldung auf stderr
- Import unterbrochen (Ctrl+C) → partiell importierte Mails werden gespeichert, kein Rollback (Archiv ist append-only)
- Export bei leerem Archiv → leeres Verzeichnis / leere MBOX, Exit-Code 0
## Technical Requirements
- CLI ist Teil desselben Go-Binaries (`archivmail`) Subcommands via `archivmail <command>`
- Zugriff auf Storage Coordinator direkt (kein HTTP-Umweg über den laufenden Daemon)
- Key-Datei muss lesbar sein (`/etc/archivmail/keyfile`, `chmod 400`, Owner `archivmail`)
- Kann parallel zum laufenden Daemon betrieben werden (Xapian WritableDatabase: Lock beachten)
- Strukturierte Ausgabe optional: `--json` Flag für maschinenlesbare Ausgabe
---
## Tech Design (Solution Architect)
### CLI-Struktur
```
archivmail <command> [flags]
Commands:
import E-Mails importieren (EML, MBOX, Verzeichnis)
export E-Mails exportieren (EML, MBOX)
version Version anzeigen
help Hilfe anzeigen
archivmail import
--file /pfad/datei.eml oder .mbox
--dir /pfad/verzeichnis/
--recursive Unterverzeichnisse einschließen (mit --dir)
--dry-run Simulation ohne Speichern
--json Maschinenlesbare Ausgabe (JSON)
archivmail export
--out /pfad/ziel/ oder /pfad/archiv.mbox (Pflicht)
--format eml (Standard) | mbox
--from Absender-Filter
--to Empfänger-Filter
--date-from Datum von (ISO 8601: 2024-01-01)
--date-to Datum bis (ISO 8601: 2024-12-31)
--query Volltext-Suche (Xapian QueryParser)
--force Zieldatei überschreiben
--json Maschinenlesbare Ausgabe (JSON)
```
### Komponentenstruktur
```
archivmail (Go-Binary)
├── main.go ← Subcommand-Router (import / export / ...)
├── cmd/import.go
│ ├── Flag-Parsing
│ ├── Dateityp-Erkennung (.eml / .mbox / Verzeichnis)
│ ├── EML-Parser
│ ├── MBOX-Parser (zeilenweise)
│ └── → Storage Coordinator (PROJ-5, direkt, kein HTTP)
└── cmd/export.go
├── Flag-Parsing
├── Filter-Builder (from, to, date, query)
├── → Xapian ReadonlyDatabase (Suche/Filter)
├── → PostgreSQL Metadaten-Lookup
├── → .m-Datei lesen + AES-256-GCM entschlüsseln
└── Schreiben als EML-Dateien oder MBOX
```
### Import-Fluss
```
$ archivmail import --dir /backup/mails/ --recursive
Key laden aus /etc/archivmail/keyfile
Verzeichnis scannen → 3.842 .eml-Dateien gefunden
[████████░░] 2.150 / 3.842 (übersprungen: 12 Duplikate)
Fertig:
Importiert: 2.130
Übersprungen: 12 (Duplikate)
Fehler: 0
```
### Export-Fluss
```
$ archivmail export --from alice@firma.de \
--date-from 2024-01-01 \
--out /backup/export/
Key laden aus /etc/archivmail/keyfile
Xapian: 847 Mails gefunden (Filter: from=alice, date>=2024-01-01)
Exportiere nach /backup/export/
[████████████] 847 / 847
Fertig:
Exportiert: 847 EML-Dateien
Ziel: /backup/export/
```
### JSON-Ausgabe (--json Flag)
```json
{
"status": "done",
"imported": 2130,
"skipped": 12,
"errors": 0,
"duration_sec": 42
}
```
### Xapian-Lock beim parallelen Betrieb
```
Daemon läuft (WritableDatabase hält Lock für Index-Worker)
CLI export → ReadonlyDatabase → kein Lock-Konflikt ✓
CLI import → Storage Coordinator → WritableDatabase
└── Lock bereits gehalten?
→ Warten (max. 30 Sek.) → dann Fehlermeldung:
"Index locked by running daemon. Stop daemon or retry."
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Gleiche Binary, Subcommands** | Kein separates CLI-Tool `archivmail import` und `archivmail serve` teilen Code und Storage Coordinator |
| **Direkter Speicherzugriff, kein HTTP** | CLI läuft als `archivmail`-User mit Dateisystem-Zugriff kein laufender Daemon nötig für Import/Export |
| **`--dry-run`** | Sicher testen ohne Daten zu verändern wichtig für große Bulk-Imports |
| **`--json` Flag** | Maschinenlesbar für Cron-Jobs, Monitoring-Skripte, Ansible-Playbooks |
| **Exit-Codes** | 0 = Erfolg, 1 = Fehler Standard für Shell-Skripting |
| **Xapian ReadonlyDatabase für Export** | Export kann parallel zum Daemon laufen ohne Lock-Konflikte |
### Abhängigkeiten
| Paket | Zweck |
|---|---|
| `github.com/spf13/cobra` | Subcommand-CLI-Framework |
| Xapian CGo-Bindings | Volltext-Filter beim Export (bereits PROJ-5) |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+109
View File
@@ -0,0 +1,109 @@
---
id: PROJ-16
title: LDAP / Active Directory Anbindung
status: In Progress
priority: P1
created: 2026-03-13
---
# PROJ-16 — LDAP / Active Directory Anbindung
## Ziel
Authentifizierung gegen einen LDAP-Server (OpenLDAP, Microsoft Active Directory, Samba AD).
Lokale Accounts bleiben weiterhin nutzbar. LDAP-User werden beim ersten Login automatisch
in der Datenbank angelegt (`source: ldap`) und bei jedem Login synchronisiert.
## User Stories
- **Als Admin** möchte ich LDAP in `config.yml` konfigurieren, damit Mitarbeiter ihre
bestehenden Windows/AD-Zugangsdaten nutzen können.
- **Als Endnutzer** möchte ich mich mit meinem AD-Passwort anmelden, ohne einen separaten
archivmail-Account zu benötigen.
- **Als Admin** möchte ich LDAP-User einer Rolle (user/auditor/admin) zuweisen können,
entweder per fester Zuordnung oder über AD-Gruppen.
- **Als Admin** möchte ich LDAP deaktivieren können, ohne den restlichen Betrieb zu stören.
## Akzeptanzkriterien
- [ ] Login mit LDAP-Credentials funktioniert wenn `ldap.enabled: true`
- [ ] Lokale Accounts funktionieren weiterhin (Fallback wenn LDAP fehlschlägt oder deaktiviert)
- [ ] LDAP-User werden beim Login automatisch via `UpsertLDAPUser` angelegt/aktualisiert
- [ ] Rollen-Mapping via AD-Gruppen konfigurierbar (optional, Fallback: default_role)
- [ ] STARTTLS und LDAPS (Port 636) werden unterstützt
- [ ] Bind-User (Service Account) für AD-Suche konfigurierbar
- [ ] Fehlermeldung bei falschem Passwort ist identisch zu lokalem Login (kein Info-Leak)
- [ ] LDAP-Fehler landen im Audit-Log
- [ ] Konfigurierbar per `config.yml` Abschnitt `ldap:`
## Konfigurationsformat (`config.yml`)
```yaml
ldap:
enabled: true
url: "ldap://192.168.1.10:389" # oder ldaps://...
bind_dn: "CN=archivmail-svc,OU=ServiceAccounts,DC=corp,DC=local"
bind_password: "geheim"
base_dn: "OU=Users,DC=corp,DC=local"
user_filter: "(sAMAccountName=%s)" # %s wird durch eingegebenen Username ersetzt
tls: false # STARTTLS
tls_skip_verify: false
default_role: "user" # Rolle für neue LDAP-User
group_mappings: # optional: AD-Gruppe → archivmail-Rolle
- group_dn: "CN=archivmail-admins,OU=Groups,DC=corp,DC=local"
role: "admin"
- group_dn: "CN=archivmail-auditors,OU=Groups,DC=corp,DC=local"
role: "auditor"
```
## Technische Umsetzung
### Neues Paket: `internal/ldapauth`
```
internal/ldapauth/
ldap.go — Client, Bind, Search, Authenticate
ldap_test.go — Tests mit Mock-LDAP
```
**Abhängigkeit:** `github.com/go-ldap/ldap/v3`
### Ablauf Login mit LDAP
1. `auth.Manager.Login(username, password)` prüft zuerst lokale DB
2. Wenn lokaler User nicht gefunden UND LDAP aktiviert → LDAP-Auth versuchen
3. LDAP-Bind mit Service Account → User-DN per `user_filter` suchen
4. User-Bind mit gefundener DN + eingegebenem Passwort
5. Optional: Gruppen-Mitgliedschaft abfragen → Rolle bestimmen
6. `userstore.UpsertLDAPUser(username, email, role)` aufrufen
7. JWT-Token wie bei lokalem Login ausstellen
### Felder aus LDAP lesen
| LDAP-Attribut | archivmail-Feld |
|--------------|-----------------|
| `sAMAccountName` / `uid` | username |
| `mail` | email |
| `memberOf` | → Gruppen-Mapping → role |
| `displayName` | (für spätere Anzeige) |
### API-Endpunkt: `GET /api/admin/ldap/test` (admin only)
Testet die LDAP-Verbindung und gibt Status zurück:
```json
{"ok": true, "message": "LDAP-Verbindung erfolgreich", "users_found": 42}
```
## Nicht in diesem Feature
- Automatische User-Synchronisation (Bulk-Import aller AD-User) — separates Feature
- LDAP-Gruppen als Postfach-Zuweisungen
- Kerberos / SAML / OAuth2 (separate Features)
## Dateien
- `internal/ldapauth/ldap.go` (neu)
- `internal/auth/auth.go` (erweitert: LDAP-Fallback)
- `config/config.go` (erweitert: `LDAPConfig`)
- `cmd/archivmail/main.go` (erweitert: LDAP-Client initialisieren)
- `internal/api/server.go` (erweitert: `/api/admin/ldap/test`)
- `install.sh` (erweitert: LDAP-Kommentar in config.yml)
+86
View File
@@ -0,0 +1,86 @@
# PROJ-17: Admin Dashboard Systemauslastung & Archiv-Übersicht
## Status: In Review
**Created:** 2026-03-14
**Last Updated:** 2026-03-14
## Dependencies
- Requires: PROJ-1 (Authentifizierung) nur Admins sehen das Dashboard
- Requires: PROJ-5 (Speicherung) erste/letzte Mail aus dem Archiv
## User Stories
- Als Admin möchte ich die aktuelle CPU-Auslastung sehen, damit ich Engpässe erkennen kann.
- Als Admin möchte ich die RAM-Auslastung (gesamt / verwendet / frei) sehen.
- Als Admin möchte ich alle eingebundenen Festplatten/Partitionen mit Füllstand sehen (Balken).
- Als Admin möchte ich die älteste und neueste archivierte Mail sehen (Datum, Von, Betreff), damit ich den Archivierungszeitraum auf einen Blick erkenne.
## Acceptance Criteria
- [x] CPU: Load Average (1min / 5min / 15min) aus `/proc/loadavg`
- [x] RAM: MemTotal, MemUsed, MemAvailable aus `/proc/meminfo`; Prozentbalken
- [x] Disk: alle physischen Partitionen (keine tmpfs/proc/sysfs/devtmpfs/overlay) via `syscall.Statfs`; je Partition: Mountpoint, Gesamt, Belegt, Frei, Prozent
- [x] Erste Mail im Archiv: Datum, Von, Betreff (älteste Datei im Store)
- [x] Letzte Mail im Archiv: Datum, Von, Betreff (neueste Datei im Store)
- [x] Endpoint: `GET /api/admin/system/stats` (Admin-only)
- [x] Storage-Erweiterung: `store.FirstAndLastMail()` liefert Metadaten der ältesten und neuesten Mail
## API Response Schema
```json
{
"cpu": {
"load1": 0.42,
"load5": 0.38,
"load15": 0.31,
"num_cpu": 4
},
"ram": {
"total_bytes": 8388608000,
"used_bytes": 3221225472,
"free_bytes": 5167382528,
"used_pct": 38.4
},
"disks": [
{
"mount": "/",
"total_bytes": 53687091200,
"used_bytes": 12884901888,
"free_bytes": 40802189312,
"used_pct": 24.0,
"fstype": "ext4"
}
],
"archive": {
"first_mail": { "id": "abc123", "date": "2024-01-15T08:00:00Z", "from": "...", "subject": "..." },
"last_mail": { "id": "def456", "date": "2026-03-14T10:08:00Z", "from": "...", "subject": "..." }
}
}
```
## Technical Design
### Backend (`internal/api/server.go`)
- Neuer Handler `handleSystemStats`
- CPU: `/proc/loadavg` parsen → load1, load5, load15 + `runtime.NumCPU()`
- RAM: `/proc/meminfo` parsen → MemTotal, MemFree, MemAvailable, Buffers, Cached
- `used = total - available`
- Disks: `/proc/mounts` lesen, für jeden Eintrag `syscall.Statfs()` aufrufen
- Ausschließen: fstype in {tmpfs, proc, sysfs, devtmpfs, cgroup, cgroup2, overlay, squashfs, debugfs, tracefs, securityfs, pstore, efivarfs, bpf, hugetlbfs, mqueue, ramfs}
- Erste/letzte Mail: `store.FirstAndLastMail()` → walk store dir, min/max ModTime
### Storage (`internal/storage/storage.go`)
- Neue Methode `FirstAndLastMail() (*MailRef, *MailRef, error)`
- `MailRef{ID, ModTime}` → ID wird an `handleSystemStats` übergeben, der dann via `mailparser.Parse()` From+Subject+Date extrahiert
### Frontend (`src/app/admin/page.tsx`)
- Neue Kacheln im Dashboard-Tab:
- **CPU-Auslastung**: Load Average mit `num_cpu` Kontext
- **Arbeitsspeicher**: Fortschrittsbalken (used/total), Zahlen darunter
- **Festplatten**: eine Karte pro Partition mit Balken + Zahlen
- **Archivzeitraum**: erste und letzte Mail als kompakte Zeilen (Datum · Von · Betreff)
## Implementation Notes
- **Backend:** `handleSystemStats` in `internal/api/server.go` — CPU via `/proc/loadavg`, RAM via `/proc/meminfo`, alle Disks via `/proc/mounts` + `syscall.Statfs`, Archiv-Zeitspanne via `store.FirstAndLastMail()`
- **Storage:** `FirstAndLastMail()` + `MailRef` in `internal/storage/storage.go` — walkt Store-Verzeichnis, liefert älteste/neueste Mail per ModTime
- **Route:** `GET /api/admin/system/stats` (Admin-only, Token-Auth)
- **Frontend:** Dashboard-Tab in `src/app/admin/page.tsx` mit CPU, RAM, Disk-Partitionen und Archivzeitraum; Auto-Refresh alle 30 Sekunden
- **Bereit für Test auf** `root@192.168.1.131`
+146
View File
@@ -0,0 +1,146 @@
# PROJ-2: E-Mail-Import: EML/MBOX Upload
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- Requires: PROJ-1 (Authentifizierung) nur eingeloggte Admins dürfen importieren
- Requires: PROJ-5 (Speicherung & Indexierung) importierte E-Mails werden gespeichert
## User Stories
- Als Admin möchte ich EML-Dateien per Drag-and-Drop hochladen, damit ich einzelne E-Mails archivieren kann.
- Als Admin möchte ich MBOX-Dateien importieren, damit ich ganze Postfach-Exporte auf einmal archivieren kann.
- Als Admin möchte ich den Fortschritt eines laufenden Imports sehen, damit ich weiß wie weit der Import ist.
- Als Admin möchte ich nach dem Import eine Zusammenfassung sehen (importiert, übersprungen, Fehler), damit ich Probleme nachvollziehen kann.
- Als System möchte ich Duplikate erkennen und überspringen, damit E-Mails nicht doppelt archiviert werden.
## Acceptance Criteria
- [ ] Upload-Interface akzeptiert .eml und .mbox Dateien (auch mehrere gleichzeitig)
- [ ] Maximale Dateigröße konfigurierbar (Standard: 500 MB pro Upload)
- [ ] EML-Parser liest Envelope-Header (From, To, CC, BCC, Date, Subject, Message-ID)
- [ ] MBOX-Parser iteriert über alle enthaltenen E-Mails in der Datei
- [ ] Anhänge werden extrahiert und getrennt gespeichert
- [ ] Fortschrittsanzeige während des Imports (Anzahl verarbeitet / gesamt)
- [ ] Duplikate (gleiche Message-ID) werden erkannt und übersprungen
- [ ] Import-Zusammenfassung: Anzahl importiert, übersprungen, fehlerhaft
- [ ] Fehlerhafte E-Mails (korrupte Dateien) werden geloggt und übersprungen, brechen Import nicht ab
## Edge Cases
- MBOX-Datei mit 100.000+ E-Mails → chunked processing, kein Timeout
- EML-Datei ohne Message-ID → synthetische ID generieren (Hash des Inhalts)
- E-Mail mit verschachtelten MIME-Teilen (multipart/mixed, multipart/alternative)
- Encoding-Probleme (ISO-8859-1, Windows-1252) → automatische Konvertierung zu UTF-8
- Upload wird unterbrochen → partiell importierte Daten bereinigen
## Technical Requirements
- Streaming-Upload für große Dateien (kein komplettes In-Memory-Laden)
- MBOX-Parsing als Background-Job mit Statusrückmeldung via WebSocket oder Polling
- Maximale Anhang-Größe pro E-Mail konfigurierbar
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend (Admin-Bereich):**
```
/admin/upload
├── DropZone ← Drag-and-Drop + Datei-Dialog
│ ├── akzeptiert: .eml, .mbox
│ └── Mehrfachauswahl möglich
├── Upload-Queue ← Liste der hochgeladenen Dateien
│ └── FileItem (pro Datei)
│ ├── Dateiname + Größe
│ ├── Typ-Badge (EML / MBOX)
│ └── Status (wartend / läuft / fertig / Fehler)
├── Fortschrittsanzeige (pro Datei)
│ ├── Fortschrittsbalken (X von Y Mails verarbeitet)
│ └── Aktueller Status
└── Abschlussbericht
├── Importiert: X
├── Übersprungen (Duplikate): Y
└── Fehler: Z (mit Liste der fehlerhaften Mails)
```
**Go Backend:**
```
POST /api/admin/upload
├── Session Middleware (Admin)
├── Multipart-Stream-Handler ← Datei wird nicht komplett in RAM geladen
├── Dateityp-Erkennung (.eml / .mbox)
└── Import-Worker starten (Hintergrund-Goroutine)
Import-Worker
├── EML-Modus
│ └── Einzelne Mail direkt parsen → Storage Coordinator
├── MBOX-Modus
│ ├── MBOX-Parser (iteriert über "From "-Trennzeilen)
│ ├── Für jede Mail:
│ │ ├── Duplikat-Check (Message-ID)
│ │ └── → Storage Coordinator (PROJ-5)
│ └── Fortschritt in DB schreiben
└── Encoding-Normalisierer
└── ISO-8859-1 / Windows-1252 → UTF-8
GET /api/admin/upload/{job_id}/progress ← Polling alle 2 Sek.
```
### Upload- und Importfluss
```
Admin zieht Datei in DropZone
│ POST /api/admin/upload (multipart/form-data, streaming)
Go Backend empfängt Stream
├── .eml? → direkt parsen → Storage Coordinator → fertig
└── .mbox? → Import-Worker (Hintergrund)
MBOX-Parser liest zeilenweise
Trenner: Zeilen die mit "From " beginnen
└── Pro Mail:
Encoding-Erkennung + UTF-8-Normalisierung
Message-ID vorhanden?
Nein → SHA-256(Inhalt) als ID
Duplikat? → überspringen
→ Storage Coordinator (PROJ-5)
Fortschritt in DB aktualisieren
Abschlussbericht in DB speichern
Next.js pollt GET /progress alle 2 Sek.
→ Fortschrittsbalken aktualisieren
→ Bei status:"done" → Abschlussbericht anzeigen
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Streaming-Upload** | 500 MB MBOX nie komplett in RAM Go liest den HTTP-Body als Stream direkt in den Parser |
| **MBOX zeilenweises Parsen** | MBOX-Format trennt Mails durch `From `Zeilen kein vollständiges Einlesen der Datei nötig |
| **Background-Worker + Polling** | MBOX mit 100k+ Mails dauert Minuten HTTP-Request darf nicht so lange offen bleiben |
| **Encoding-Normalisierung** | E-Mail-Exporte aus Outlook/Thunderbird kommen oft als ISO-8859-1 Index und DB erwarten UTF-8 |
| **Fehler überspringen, nicht abbrechen** | Eine korrupte Mail soll nicht den gesamten Import einer 50k-MBOX-Datei stoppen |
| **Synthetische Message-ID** | EML-Dateien ohne Message-ID (selten aber möglich) bekommen SHA-256(Inhalt) Duplikatschutz bleibt konsistent |
### Abhängigkeiten
| Paket | Zweck |
|---|---|
| `golang.org/x/text/encoding` | ISO-8859-1 / Windows-1252 → UTF-8 Konvertierung |
| `mime`, `mime/multipart` | EML + MBOX MIME-Parsing (Stdlib) |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+205
View File
@@ -0,0 +1,205 @@
# PROJ-3: E-Mail-Import: IMAP-Verbindung
## Status: In Review
**Created:** 2026-03-12
**Last Updated:** 2026-03-14
## Dependencies
- Requires: PROJ-1 (Authentifizierung) nur Admins verwalten IMAP-Verbindungen
- Requires: PROJ-5 (Speicherung & Indexierung) importierte E-Mails werden gespeichert
## User Stories
- Als Admin oder User möchte ich einen IMAP-Server konfigurieren (Host, Port, Zugangsdaten), damit das System E-Mails von dort abholen kann.
- Als Admin oder User möchte ich beim ersten Verbinden alle vorhandenen E-Mails eines Postfachs importieren (Initial-Import).
- Als System möchte ich beim Verbinden automatisch Junk- und Trash-Ordner per IMAP erkennen und ausschließen, damit kein Spam ins Archiv gelangt. Alle anderen Ordner werden importiert.
- Als Admin oder User möchte ich den Verbindungsstatus sehen (verbunden, Fehler, letzter Sync), damit ich Probleme erkennen kann.
- Als System möchte ich die IMAP-Verbindung testen bevor sie gespeichert wird, damit Konfigurationsfehler früh erkannt werden.
- Als Admin möchte ich alle IMAP-Konten aller Nutzer sehen und verwalten können.
- Als User möchte ich nur meine eigenen IMAP-Konten sehen und verwalten (keine fremden).
## Acceptance Criteria
- [ ] Konfigurationsformular: Host, Port, TLS/SSL, Benutzername, Passwort
- [ ] Verbindungstest vor dem Speichern (Timeout: 10 Sekunden)
- [ ] Passwörter werden verschlüsselt in der Datenbank gespeichert (nie im Klartext)
- [ ] Automatische Ordner-Erkennung via IMAP LIST-EXTENDED (RFC 6154 Special-Use Flags: `\Junk`, `\Trash`)
- [ ] Fallback auf bekannte Ordnernamen wenn Special-Use Flags fehlen: `Junk`, `Spam`, `Trash`, `Deleted Items`, `Deleted Messages`, `Papierkorb`
- [ ] Erkannte Ausschluss-Ordner werden dem Admin vor dem Import angezeigt (mit Option zur manuellen Korrektur)
- [ ] Initial-Import: **alle Ordner außer Junk/Trash** Ordnerstruktur wird verworfen, nur E-Mail-Inhalt archiviert
- [ ] Fortschrittsanzeige während Initial-Import
- [ ] Duplikate (Message-ID) werden übersprungen
- [ ] Verbindungsstatus-Übersicht im Admin-Bereich
## Edge Cases
- IMAP-Server nicht erreichbar → Fehlermeldung mit Retry-Option
- Falsche Zugangsdaten → klare Fehlermeldung
- IMAP-Server trennt Verbindung während Import → automatischer Reconnect
- Postfach mit 200.000+ E-Mails → paginierter Import, kein Speicher-Overflow
- OAuth2/XOAUTH2 für Gmail/Outlook → als spätere Erweiterung markiert (nicht MVP)
## Technical Requirements
- **Verbindungsmodi:**
- `SSL/TLS` direkte TLS-Verbindung ab dem ersten Byte (Port 993)
- `STARTTLS` Verbindung startet unverschlüsselt, wird per STARTTLS-Befehl auf TLS hochgestuft (Port 143)
- `None` unverschlüsselt, nur für lokale/Testumgebungen
- IMAP IDLE-Unterstützung für Echtzeit-Benachrichtigungen (optional)
- Zugangsdaten AES-256-GCM verschlüsselt in der DB (gleicher Key wie Mail-Store)
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend (Admin-Bereich):**
```
/admin/imap
├── IMAP-Verbindungsliste
│ └── VerbindungsCard (pro Konto)
│ ├── Name, Host, Status (OK / Fehler)
│ ├── Letzter Import + Anzahl importierter Mails
│ └── Aktionen: Bearbeiten / Löschen / Import starten
├── Verbindung-Formular (anlegen / bearbeiten)
│ ├── Host, Port, TLS-Auswahl
│ ├── Benutzername, Passwort
│ └── [Verbindung testen] Button
├── Ordner-Vorschau (nach erfolgreichem Test)
│ ├── Automatisch erkannte Ausschlüsse (Junk/Trash) markiert
│ └── Manuelle Korrektur möglich (Checkbox pro Ordner)
└── Import-Fortschrittsanzeige
├── Fortschrittsbalken (X von Y E-Mails)
├── Aktueller Status
└── Abschlussbericht (importiert / übersprungen / Fehler)
```
**Go Backend:**
```
IMAP-Dienst
├── Verbindungsverwaltung
│ ├── POST /api/admin/imap ← Verbindung anlegen
│ ├── POST /api/admin/imap/test ← testen + Ordner-Erkennung
│ ├── GET /api/admin/imap ← alle Verbindungen auflisten
│ └── DELETE /api/admin/imap/{id} ← Verbindung löschen
├── IMAP-Client
│ ├── TLS/SSL + STARTTLS Handler
│ ├── Ordner-Erkenner (Special-Use + Fallback)
│ ├── SELECT + FETCH UID-basiert
│ └── Reconnect-Handler
├── Import-Worker (Hintergrund-Goroutine)
│ ├── Batch-weise FETCH (50 Mails pro Batch)
│ ├── Duplikat-Check (Message-ID)
│ ├── → Storage Coordinator (PROJ-5)
│ └── Fortschritt in DB schreiben
└── GET /api/admin/imap/{id}/progress ← Polling durch Frontend
```
### Ordner-Erkennungslogik
```
IMAP-Verbindung aufgebaut
LIST-EXTENDED "" "*" RETURN (SPECIAL-USE) ← RFC 6154
├── \Junk gefunden? → Ordner ausschließen
├── \Trash gefunden? → Ordner ausschließen
└── Flags nicht unterstützt? → Fallback:
Ordnernamen prüfen (case-insensitive):
"junk", "spam", "trash", "deleted items",
"deleted messages", "papierkorb"
→ übereinstimmende Ordner ausschließen
Ordnerliste mit Markierungen an Frontend:
INBOX ✓ (wird importiert)
Sent ✓ (wird importiert)
Drafts ✓ (wird importiert)
Junk [\Junk erkannt] ✗ (ausgeschlossen)
Trash [\ Trash erkannt] ✗ (ausgeschlossen)
Admin kann Ausschlüsse manuell korrigieren
→ Speichern → Import starten
```
### Importfluss
```
Import-Worker startet
Für jeden nicht ausgeschlossenen Ordner:
├── IMAP UID SEARCH ALL → alle UIDs
└── Batch-weise (50 UIDs):
├── IMAP FETCH RFC822
├── Message-ID Duplikat? → überspringen
└── Storage Coordinator (PROJ-5)
→ verschlüsseln + speichern + indexieren
Fortschritt in DB → Frontend pollt alle 2 Sek.
```
### Datenmodell
**Tabelle `imap_accounts`:**
| Feld | Beschreibung |
|------|-------------|
| `id` | Interne ID |
| `name` | Bezeichnung |
| `host` | IMAP-Hostname |
| `port` | Port (143 / 993) |
| `tls` | `ssl` / `starttls` / `none` |
| `username` | IMAP-Benutzername |
| `password_enc` | AES-256-GCM verschlüsseltes Passwort |
| `excluded_folders` | JSON-Array ausgeschlossener Ordner |
| `last_import_at` | Zeitpunkt des letzten Imports |
| `last_import_count` | Anzahl importierter Mails |
| `status` | `idle` / `running` / `error` |
| `error_msg` | Letzter Fehler |
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **RFC 6154 Special-Use Flags** | Standard-Weg für Junk/Trash-Erkennung funktioniert bei Dovecot, Exchange, Gmail |
| **Fallback auf Ordnernamen** | Ältere oder nicht-standardkonforme Server kennen Special-Use nicht Fallback deckt die gängigsten Namen ab |
| **Admin-Korrektur möglich** | Automatik kann irren Admin sieht die Erkennungsergebnisse und kann vor dem Import eingreifen |
| **Nur Ausschlüsse konfigurieren** | Einfacher als Whitelist: alle Ordner importieren außer den erkannten Ausreißern |
| **UID-basierter Fetch** | Bei Reconnect kann genau dort weitergemacht werden wo abgebrochen wurde |
| **Batch-Größe 50** | Balance zwischen RAM-Verbrauch und IMAP-Roundtrips |
### Abhängigkeiten
| Paket | Zweck |
|---|---|
| `github.com/emersion/go-imap` | IMAP-Client (RFC 6154 LIST-EXTENDED, TLS, FETCH) |
## Implementation Notes (2026-03-14)
### Go Backend
- **`internal/imap/store.go`**: DB CRUD for `imap_accounts` table with AES-256-GCM password encryption. Auto-migrates table on startup. Index on `owner` column.
- **`internal/imap/client.go`**: IMAP client wrapper using `go-imap/v2` (beta.8). Supports SSL/STARTTLS/plaintext. Folder detection via RFC 6154 special-use flags with name-based fallback.
- **`internal/imap/importer.go`**: Background import worker. Fetches all UIDs per folder, processes in batches of 50, stores via `storage.Store.Save()` (SHA256 dedup), indexes via `index.Indexer.IndexSync()`. Progress written to DB for frontend polling.
- **`internal/api/server.go`**: 6 new IMAP endpoints (`GET/POST /api/imap`, `DELETE /api/imap/{id}`, `POST /api/imap/test`, `POST /api/imap/{id}/import`, `GET /api/imap/{id}/progress`). All auth-protected, ownership enforced (admin sees all, user sees own).
- **`cmd/archivmail/main.go`**: Wires IMAP store and importer into API server.
### Frontend
- **`src/app/imap/page.tsx`**: Full IMAP management page with account cards, add dialog (with connection test and folder preview), progress polling, delete confirmation.
- **`src/lib/api.ts`**: IMAP types and 6 API functions.
- **`src/components/navbar.tsx`**: Added "IMAP Import" link for all roles.
### Deviations from spec
- Routes use `/api/imap` (not `/api/admin/imap`) since all authenticated users can manage their own IMAP accounts.
- Using `go-imap/v2` beta.8 (latest available) instead of beta.5.
- IMAP page at `/imap` (not `/admin/imap`) to match the route pattern.
## QA Test Results
_To be added by /qa_
## Deployment
Deployed to 192.168.1.131 on 2026-03-14. Both `archivmail` and `archivmail-web` services restarted and active. Database table `imap_accounts` auto-created with index.
+164
View File
@@ -0,0 +1,164 @@
# PROJ-4: E-Mail-Import: SMTP-Eingang (primär via BCC)
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Hinweis
**Dies ist der primäre Eingangsweg.** archivmail enthält einen eingebetteten SMTP-Daemon, der **ausschließlich E-Mails empfängt** kein Versand, keine Weiterleitung, kein MTA. Postfix (oder ein anderer Mailserver) wird per BCC-Mapping oder Always-BCC-Regel so konfiguriert, dass er eine Kopie jeder E-Mail an archivmails SMTP-Daemon zustellt.
```
Absender → Postfix (MTA) → Empfänger
└── BCC/always_bcc → archivmail SMTP-Daemon (nur Empfang)
Storage Coordinator
```
IMAP und EML/MBOX-Upload sind sekundäre/ergänzende Methoden (z.B. für Altbestände).
## Dependencies
- Requires: PROJ-5 (Speicherung & Indexierung) eingehende E-Mails werden gespeichert
- Kein Login nötig für den Empfang SMTP-Eingang läuft unabhängig vom HTTP-Server
## User Stories
- Als Mailserver möchte ich E-Mails per BCC an archivmail zustellen, damit diese automatisch archiviert werden.
- Als Admin möchte ich den eingebetteten SMTP-Server konfigurieren (Port, TLS, erlaubte Absender-IPs).
- Als Admin möchte ich festlegen, welche Absender-IPs/Domains akzeptiert werden, damit nur der eigene Mailserver zustellen darf.
- Als System möchte ich eingehende E-Mails sofort nach Empfang indexieren, damit sie innerhalb von Sekunden durchsuchbar sind.
- Als Admin möchte ich den Status des SMTP-Empfängers sehen (läuft, Port, letzte empfangene E-Mail).
## Acceptance Criteria
- [ ] Eingebetteter SMTP-Server lauscht auf konfigurierbarem Port (Standard: 25 oder 2525)
- [ ] TLS/STARTTLS-Unterstützung für verschlüsselte Übertragung
- [ ] IP-Allowlist: nur eingetragene Mailserver-IPs dürfen zustellen (Standard: nur localhost/127.0.0.1)
- [ ] Optionale Domain-Allowlist als zusätzliche Prüfebene
- [ ] E-Mails werden sofort nach Empfang gespeichert und indexiert
- [ ] SMTP-Quittierung (250 OK) erst nach erfolgreicher Speicherung
- [ ] Admin-UI zeigt: Port, TLS-Status, Anzahl empfangener E-Mails, letzte Aktivität
- [ ] Fehlerhafte/abgelehnte E-Mails werden geloggt
## Edge Cases
- E-Mail ohne Absender (Envelope-From leer) → annehmen aber markieren
- Sehr große E-Mail (> 50 MB) → konfigurierbare Maximalgröße, Ablehnung mit 552-Fehlercode
- SMTP-Server-Port bereits belegt → klare Fehlermeldung beim Start
- Parallele Verbindungen (viele E-Mails gleichzeitig) → Connection-Pooling
- Duplicate Message-ID → überspringen wie bei anderen Import-Methoden
## Technical Requirements
- RFC 5321 (SMTP) konformer **reiner Empfänger** kein SMTP-Versand, keine Queue, kein Relay
- Kein SMTP AUTH Zugang ausschließlich über IP-Allowlist (nur Postfix-IP eingetragen)
- Maximale Nachrichtengröße konfigurierbar (Standard: 50 MB)
- Startet als eigenständiger Goroutine/Service neben dem HTTP-Server
- Postfix-Konfiguration (außerhalb von archivmail, Dokumentation in README):
- `always_bcc = archiv@archivmail-host` in Postfix `main.cf`, oder
- Sender/Recipient BCC-Maps für granulare Kontrolle
---
## Tech Design (Solution Architect)
### Systemübersicht
```
Absender → Postfix (MTA) → Empfänger (normale Zustellung)
└── always_bcc / BCC-Map
▼ SMTP (Port 2525)
archivmail SMTP-Daemon
(nur Empfang, kein Versand)
Storage Coordinator (PROJ-5)
(speichern + indexieren)
```
### Komponentenstruktur
```
archivmail (Go-Binary)
├── HTTP-Server (Web-GUI + API)
└── SMTP-Daemon ← startet parallel zum HTTP-Server
├── TCP Acceptor ← lauscht auf Port 2525 (konfigurierbar)
├── IP Allowlist Guard ← prüft Absender-IP vor SMTP-Dialog
├── Session Handler (pro Verbindung, eigene Goroutine)
│ ├── TLS/STARTTLS Handler ← optional, Zertifikat aus config.yml
│ └── Size Limiter ← bricht DATA-Phase bei Überschreitung ab
└── Handoff → Storage Coordinator ← übergibt E-Mail nach vollständigem Empfang
```
### SMTP-Dialogfluss
```
Postfix
│ TCP-Verbindung auf Port 2525
IP Allowlist Guard
├─ IP unbekannt → Verbindung trennen (kein SMTP-Dialog)
└─ IP erlaubt → weiter
220 archivmail SMTP ready
EHLO mail.firma.de
250 OK (kein AUTH angeboten reiner Empfänger)
MAIL FROM: <absender@firma.de>
250 OK
RCPT TO: <archiv@archivmail>
250 OK
DATA
354 Start input
… E-Mail-Inhalt … (max. 50 MB)
.
├─ Zu groß → 552 Message size exceeds limit
├─ Duplikat (Message-ID) → 250 OK (still, kein Fehler Postfix soll nicht retrying)
└─ Neu → Storage Coordinator → verschlüsselt speichern + indexieren
250 OK ← erst nach erfolgreicher Speicherung
QUIT
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Reiner Empfänger, kein MTA** | archivmail ist kein Mailserver keine ausgehende Queue, kein Relay-Risiko, kein Open-Relay |
| **Kein SMTP AUTH** | Vertrauen basiert auf IP, nicht auf Passwort Postfix und archivmail laufen im gleichen Netz |
| **250 OK bei Duplikat** | Postfix würde bei Fehler die Mail in die Retry-Queue stellen sinnlos, da Duplikat bereits archiviert |
| **250 OK erst nach Speicherung** | Solange Postfix keine Bestätigung hat, behält er die Mail und versucht erneut kein Datenverlust |
| **Port 2525** | Port 25 erfordert root-Rechte; 2525 läuft als unprivilegierter `archivmail`-Systembenutzer |
| **Eine Goroutine pro Session** | Viele parallele Verbindungen ohne Blocking; jede Session ist isoliert |
### Postfix-Konfiguration (Dokumentation, außerhalb von archivmail)
```
# /etc/postfix/main.cf einfachste Variante (alle Mails)
always_bcc = archiv@archivmail-host
# Oder granular per Sender-BCC-Map:
# sender_bcc_maps = hash:/etc/postfix/sender_bcc
# empfänger@firma.de archiv@archivmail-host
```
### Go-Abhängigkeiten
| Paket | Zweck |
|---|---|
| `github.com/emersion/go-smtp` | Eingebetteter SMTP-Daemon (RFC 5321, nur Empfang) |
| `crypto/tls` | TLS/STARTTLS (Go Stdlib) |
| `net` | IP-Prüfung (Go Stdlib) |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
@@ -0,0 +1,194 @@
# PROJ-5: E-Mail-Speicherung & Volltext-Indexierung
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- None (Basis-Feature, wird von Import-Features genutzt)
## User Stories
- Als System möchte ich E-Mails unveränderlich (immutable) speichern, damit die Archivintegrität gewährleistet ist.
- Als System möchte ich E-Mail-Inhalte (Betreff, Absender, Empfänger, Body, Anhang-Namen) volltext-indexieren, damit schnelle Suche möglich ist.
- Als Admin möchte ich den Speicherverbrauch einsehen können, damit ich die Kapazität planen kann.
- Als System möchte ich Anhänge getrennt vom E-Mail-Body speichern, damit der Speicher effizient genutzt wird.
## Acceptance Criteria
- [ ] Jede E-Mail wird mit ihrer originalen MIME-Struktur gespeichert (kein Datenverlust)
- [ ] Metadaten in PostgreSQL: `message_id`, `from`, `to`, `cc`, `subject`, `date`, `size` (Bytes), Attachment-Infos (Dateiname, MIME-Type, Größe, Hash)
- [ ] Kein E-Mail-Body in der DB Body liegt ausschließlich in `/var/archivmail/store/` verschlüsselt auf Disk
- [ ] Volltext-Index umfasst: Betreff, Absender, Empfänger, CC, BCC, Plain-Text-Body
- [ ] Anhang-Dateinamen und MIME-Types werden indexiert (Inhalt von Anhängen optional)
- [ ] Deduplizierung: Gleiche Message-ID wird nur einmal gespeichert
- [ ] SHA-256-Hash des originalen RFC-2822-Inhalts für Integritätsprüfung gespeichert
- [ ] Admin-Dashboard zeigt: Gesamtanzahl E-Mails, Speicherverbrauch (Store + Astore)
- [ ] Mailkörper gespeichert unter `/var/archivmail/store/<server_id>/<customer_id>/<hash>/xxxxx.m` (AES-256-GCM verschlüsselt)
- [ ] Anhänge gespeichert unter `/var/archivmail/astore/<hash>` (AES-256-GCM verschlüsselt)
- [ ] Anhänge werden dedupliziert: gleicher Hash → eine Datei, mehrere Referenzen in der DB
- [ ] Verschlüsselungsschlüssel wird beim Start aus `/etc/archivmail/keyfile` geladen (Pfad konfigurierbar)
- [ ] Key-Datei: `chmod 400`, Owner `archivmail`-Systembenutzer
- [ ] Server verweigert Start wenn Key-Datei fehlt, nicht lesbar oder Schlüssel ≠ 32 Byte nach Base64-Dekodierung
- [ ] Xapian-Index enthält keinen vollständigen E-Mail-Body (nur Terme/Tokens)
- [ ] PostgreSQL speichert ausschließlich Metadaten + Dateipfade kein E-Mail-Body in der DB
## Edge Cases
- E-Mail ohne Body (nur Anhang) → Body als leer speichern, Anhang indexieren
- HTML-Body ohne Plain-Text-Alternative → HTML zu Plain-Text konvertieren für Index
- E-Mail mit sehr vielen Empfängern (> 500) → TO/CC/BCC werden vollständig gespeichert
- Sonderzeichen und Nicht-ASCII in Headern (RFC 2047 encoded) → dekodieren
- Anhang-Deduplizierung: gleicher Inhalt in 1000 E-Mails → nur eine Datei in `astore/`, DB zählt Referenzen; Löschen einer E-Mail dekrementiert Referenzzähler, Datei erst bei 0 gelöscht
- Speicherplatz voll → Import-Fehler mit klarer Meldung, keine partiellen Einträge
- Verschlüsselungsschlüssel fehlt beim Start → Server startet nicht, klare Fehlermeldung
- Schlüssel-Rotation: alte `.enc`-Dateien müssen mit neuem Schlüssel re-verschlüsselt werden (Admin-Tool, nicht automatisch)
## Technical Requirements
- **Volltext-Index: Xapian** (via CGo-Bindings, z.B. `github.com/rcaught/go-xapian` oder direkte CGo-Integration)
- Xapian-Datenbank liegt auf dem Dateisystem (kein externer Dienst nötig)
- Felder als Xapian-Terms und -Values indexiert: Subject, From, To, CC, BCC, Body
- Stemming für Deutsch und Englisch (Xapian Snowball Stemmer)
- Anhang-Dateinamen als zusätzliche Terms indexiert
- **Speicherung: Verschlüsselt im Dateisystem (AES-256-GCM)**
- Mailkörper (ohne Anhänge) als `.m`-Datei:
```
/var/archivmail/store/<server_id>/<customer_id>/<hash>/xxxxx.m
```
- Anhänge dedupliziert in separatem Store (ein Anhang = eine Datei, unabhängig wie oft er vorkommt):
```
/var/archivmail/astore/<hash>
```
- Hash = SHA-256 des Inhalts → dient gleichzeitig als Pfad und Integritätsprüfung
- Beide Stores AES-256-GCM verschlüsselt auf Disk
- Verschlüsselungsschlüssel (32 Byte) aus dedizierter Key-Datei: `/etc/archivmail/keyfile`
- Dateiformat: Base64-kodierter 32-Byte-Schlüssel, eine Zeile
- Dateiberechtigungen: `chmod 400`, Owner: `archivmail` (Systembenutzer des Dienstes)
- Pfad zur Key-Datei konfigurierbar in `config.yml` (`encryption.keyfile`)
- Schlüssel wird beim Start einmalig in den Prozessspeicher geladen danach keine Disk-Zugriffe mehr auf die Key-Datei
- Server verweigert Start wenn Key-Datei fehlt, nicht lesbar oder Inhalt nicht exakt 32 Byte (nach Base64-Dekodierung)
- PostgreSQL speichert folgende Metadaten (kein Mail-Body):
- `message_id`, `from`, `to`, `cc`, `subject`, `date`, `size`
- Attachment-Tabelle: `filename`, `mime_type`, `size`, `hash` (→ Pfad in `astore/`)
- Pfadreferenz zur `.m`-Datei in `store/`
- Xapian-Datenbank liegt unverschlüsselt auf Disk (enthält nur Text-Terme, keinen vollständigen Body)
- Xapian-Schreibzugriffe serialisiert (WritableDatabase nicht thread-safe) Background-Worker-Queue
- Indexierung innerhalb 5 Sekunden nach E-Mail-Eingang
- Retention-Policy: konfigurierbare automatische Löschung alter E-Mails (DSGVO) löscht sowohl DB-Eintrag als auch Xapian-Dokument
---
## Tech Design (Solution Architect)
### Komponentenstruktur
```
archivmail (Go-Binary)
├── Storage Coordinator ← Einziger Eintrittspunkt für alle Schreibvorgänge
│ ├── MIME Parser ← Zerlegt eingehende E-Mail in Body + Anhänge
│ ├── Mail Store ← Schreibt .m-Datei verschlüsselt auf Disk
│ │ └── Encryption Layer ← AES-256-GCM (Schlüssel aus /etc/archivmail/keyfile)
│ ├── Attachment Store ← Schreibt Anhänge in astore/, prüft Duplikate per Hash
│ │ └── Encryption Layer ← gleiche AES-256-GCM Instanz
│ └── Metadata Writer ← Schreibt Metadaten in PostgreSQL
├── Index Worker (Hintergrund) ← Serialisierte Warteschlange für Xapian-Schreibzugriffe
│ ├── Text Extractor ← HTML → Plain-Text, RFC 2047 Header-Dekodierung
│ └── Xapian WritableDatabase ← Ein Schreiber gleichzeitig (Queue verhindert Konflikte)
└── Xapian ReadonlyDatabase ← Beliebig viele parallele Lesezugriffe (Suche)
```
### Datenfluss: E-Mail eingehend
```
E-Mail (RFC 2822) primär via SMTP-BCC
MIME Parser
┌────┴──────────────────────────────────┐
│ │
▼ ▼
Body (ohne Anhänge) Anhänge (0..n)
│ │
├─ SHA-256(Body) → Hash ├─ SHA-256(Anhang) → Hash
├─ AES-256-GCM verschlüsseln ├─ Hash in astore/ vorhanden? → nur ref_count++
└─ /var/archivmail/store/ ├─ AES-256-GCM verschlüsseln
<server>/<customer>/<hash>/x.m └─ /var/archivmail/astore/<hash>
+ ref_count++ in DB
│ │
└──────────────┬────────────────────────┘
PostgreSQL (Metadaten)
message_id, from, to, cc,
subject, date, size,
store_path, sha256,
indexed_at = NULL
Index Worker Queue (Channel)
Text Extractor
(HTML→Text, Encoding-Normalisierung)
Xapian WritableDatabase
Subject, From, To, CC, Body als Terms
indexed_at = NOW() in PostgreSQL
```
### Datenmodell
**Tabelle `emails`** eine Zeile pro archivierter E-Mail:
| Feld | Beschreibung |
|------|-------------|
| `message_id` | RFC-2822 Message-ID (Primärschlüssel, Duplikatschutz) |
| `from` | Absender |
| `to` | Empfänger |
| `cc` | CC-Empfänger |
| `subject` | Betreff |
| `date` | Sendedatum (UTC) |
| `size` | Größe des Originals in Bytes |
| `store_path` | Pfad zur .m-Datei |
| `sha256` | Hash des Originals (Integritätsprüfung) |
| `indexed_at` | Zeitpunkt der Xapian-Indexierung (NULL = ausstehend) |
**Tabelle `attachments`** ein Eintrag pro einzigartigem Anhang:
| Feld | Beschreibung |
|------|-------------|
| `hash` | SHA-256 des Inhalts (= Dateiname in astore/) |
| `filename` | Originaldateiname |
| `mime_type` | z.B. application/pdf |
| `size` | Größe in Bytes |
| `ref_count` | Anzahl E-Mails die diesen Anhang referenzieren |
**Tabelle `email_attachments`** Verknüpfung E-Mail ↔ Anhang (n:m)
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| Body und Anhänge getrennt | Anhang-Deduplizierung: gleicher PDF in 1000 Mails = eine Datei auf Disk |
| SHA-256 als Dateipfad | Hash dient gleichzeitig als Pfad und Integritätsprüfung kein separates Mapping |
| AES-256-GCM | Authentifizierte Verschlüsselung erkennt Dateimanipulationen (Tamper Detection) |
| Index Worker Queue | Xapian erlaubt nur einen Schreiber Queue serialisiert ohne Datenverlust |
| `indexed_at` NULL-Flag | Nach Absturz können nicht-indexierte Mails beim Neustart nachindexiert werden |
| Metadaten in PostgreSQL, Body auf Disk | Filterabfragen (Datum, Absender) ohne Disk-Zugriff; Body nur bei Bedarf lesen |
| Storage Coordinator als Single Entry Point | Alle Importwege (SMTP, IMAP, EML/MBOX) rufen dieselbe Schreiblogik auf |
### Go-Abhängigkeiten
| Paket | Zweck |
|---|---|
| Xapian CGo-Bindings | Volltext-Index |
| `pgx` | PostgreSQL-Treiber |
| `crypto/aes`, `crypto/cipher` | AES-256-GCM (Go Stdlib) |
| `crypto/sha256` | Hashing (Go Stdlib) |
| `mime`, `mime/multipart` | MIME-Parsing (Go Stdlib) |
| `golang.org/x/net/html` | HTML → Plain-Text für Index |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+128
View File
@@ -0,0 +1,128 @@
# PROJ-6: Volltext-Suche & Filterung
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- Requires: PROJ-1 (Authentifizierung) Suche nur für eingeloggte Nutzer
- Requires: PROJ-5 (Speicherung & Indexierung) Suchergebnisse kommen aus dem Index
## User Stories
- Als Nutzer möchte ich nach Schlüsselwörtern suchen, damit ich relevante E-Mails schnell finden kann.
- Als Nutzer möchte ich Suchergebnisse nach Absender, Empfänger, Datum und Anhang filtern, damit ich die Treffermenge eingrenzen kann.
- Als Nutzer möchte ich Suchergebnisse nach Datum sortieren können (neueste/älteste zuerst).
- Als Nutzer möchte ich Suchbegriffe in den Ergebnissen hervorgehoben sehen (Highlighting).
- Als Nutzer sehe ich nur E-Mails, auf die ich Zugriffsrecht habe, damit Datenschutz gewahrt bleibt.
## Acceptance Criteria
- [ ] Sucheingabe mit Echtzeit-Vorschau (oder sofortiger Submit)
- [ ] Suche in: Betreff, Absender, Empfänger, Body-Text
- [ ] Filteroptionen: Datum (vonbis), Absender-Domain, hat Anhang (ja/nein), Label
- [ ] Sortierung: nach Relevanz, nach Datum (auf-/absteigend)
- [ ] Suchergebnisse paginiert (Standard: 25 pro Seite)
- [ ] Suchbegriff in Betreff und Body-Snippet hervorgehoben
- [ ] Suchanfragen liefern Ergebnisse in < 2 Sekunden (bei 100.000+ E-Mails)
- [ ] Nutzer sehen nur E-Mails in ihren zugewiesenen Postfächern
## Edge Cases
- Suchanfrage ohne Ergebnisse → "Keine Ergebnisse" Meldung mit Vorschlägen
- Sonderzeichen in Suchanfrage (", *, ?) → Escaping oder Query-Syntax erlauben
- Suche bei sehr großem Index (1M+ Mails) → Performance-Test erforderlich
- Gleichzeitige Suchanfragen von vielen Nutzern → kein Query-Blocking
## Technical Requirements
- **Such-Engine: Xapian** die Web-GUI sucht ausschließlich über den Xapian-Index
- Kein SQL-Fulltext-Query gegen PostgreSQL DB wird nur für Metadaten-Lookup nach Treffern genutzt
- Suchfluss: Web-GUI → API → Xapian-Query → Treffer-IDs → PostgreSQL-Metadaten-Lookup → Antwort
- Xapian QueryParser: AND, OR, NOT, Phrasen (`"exakter Text"`), Wildcards (`word*`), Feldpräfixe (`from:`, `subject:`)
- Relevanz-Ranking über Xapian BM25Weight
- Snippet/Highlighting über `Xapian::MSet::snippet()`
- `ReadonlyDatabase` für parallele Lesezugriffe (mehrere Nutzer gleichzeitig möglich)
- Antwortzeit < 2 Sekunden für Volltext-Suche über 100.000 E-Mails
- Suchanfragen werden für Audit-Log erfasst (optional, konfigurierbar)
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend:**
```
/search
├── SearchBar ← Eingabefeld, Submit bei Enter oder Button
├── FilterPanel ← aufklappbar
│ ├── DateRangePicker ← vonbis Datum
│ ├── DomainFilter ← Absender-Domain Freitext
│ ├── AttachmentToggle ← nur Mails mit Anhang
│ └── LabelFilter ← Label-Auswahl (Mehrfachauswahl)
├── SortControls ← Relevanz / Datum aufsteigend / absteigend
├── ResultsList
│ └── MailCard (pro Treffer)
│ ├── Betreff (Suchbegriff hervorgehoben)
│ ├── Von / An / Datum / Größe
│ ├── Body-Snippet (Suchbegriff hervorgehoben)
│ └── Anhang-Indikator
├── Pagination ← Seiten-Navigation
└── EmptyState ← "Keine Ergebnisse" mit Suchtipps
```
**Go Backend:**
```
GET /api/search
├── Session Middleware ← Auth prüfen
├── Role Filter Builder ← user: nur eigene Postfächer / auditor: alle
├── Xapian QueryParser ← Nutzer-Query parsen (AND/OR/NOT/Wildcards)
├── Xapian ReadonlyDatabase ← Query ausführen, MSet zurückgeben
│ ├── BM25 Relevanz-Ranking ← beste Treffer zuerst
│ └── MSet::snippet() ← Highlighting-Snippets erzeugen
├── PostgreSQL Metadaten-Lookup ← From, To, Subject, Date, Size, Attachments
└── JSON Response Assembly ← Ergebnis zusammenbauen
```
### Suchfluss
```
Next.js (Browser) Go Backend
│ │
│ GET /api/search │
│ ?q=Rechnung&date_from=2024-01 │
│ &has_attachments=true&page=2 │
│ ────────────────────────────────► │
│ Session prüfen
│ Rolle ermitteln:
│ user → Filter: nur eigene Postfach-IDs
│ auditor → kein Filter
│ │
│ Xapian QueryParser
│ → Query + Datumsfilter + Anhang-Filter
│ │
│ Xapian ReadonlyDatabase
│ → MSet: [doc_id_1, doc_id_5, ...]
│ → Snippets mit Highlighting
│ │
│ PostgreSQL
│ → Metadaten für doc_ids laden
│ │
│ ◄────────────────────────────────
│ { total, page, mails: [...] } │
│ MailCards rendern + highlighten │
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Xapian für alles, kein SQL-Fulltext** | Optimiert für Volltext PostgreSQL LIKE-Suche wäre bei 100k+ Mails zu langsam |
| **ReadonlyDatabase** | Beliebig viele parallele Lesezugriffe kein Blocking bei gleichzeitigen Nutzern |
| **Rolle als Xapian-Term** | Postfach-ID beim Indexieren als Term gespeichert Rollenfilter läuft in Xapian, nicht nachträglich in der DB |
| **Snippets aus Xapian** | `MSet::snippet()` hebt Suchbegriff im Originaltext hervor kein separates Rendering nötig |
| **Paginierung über Xapian Offset** | Nur angefragter Seitenausschnitt zurückgegeben kein Full-Scan pro Seite |
| **PostgreSQL nur für Metadaten** | Nach Xapian-Suche werden nur gefundene IDs nachgeschlagen minimale DB-Last |
| **URL-State mit `nuqs`** | Suchparameter in der URL → Back-Button funktioniert, Suchergebnisse sind verlinkbar |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+184
View File
@@ -0,0 +1,184 @@
# PROJ-7: E-Mail-Ansicht (Lesen & Anhänge)
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- Requires: PROJ-1 (Authentifizierung) nur eingeloggte Nutzer mit Zugriffsrecht
- Requires: PROJ-5 (Speicherung & Indexierung) E-Mail-Daten aus der Datenbank
## User Stories
- Als Nutzer möchte ich eine E-Mail aus den Suchergebnissen öffnen und lesen, damit ich den vollständigen Inhalt sehe.
- Als Nutzer möchte ich Anhänge herunterladen, damit ich auf angefügte Dokumente zugreifen kann.
- Als Nutzer möchte ich die originalen E-Mail-Header einsehen (technische Details), damit ich Routing und Authentizität prüfen kann.
- Als Nutzer möchte ich E-Mails im HTML-Format sehen (mit sanitizierten externen Inhalten), damit die Formatierung erhalten bleibt.
- Als Nutzer möchte ich die Originalmail als EML herunterladen können.
## Acceptance Criteria
- [ ] E-Mail-Detailansicht zeigt: Von, An, CC, Datum, Betreff, Body
- [ ] HTML-Body wird **original** dargestellt kein Entfernen oder Verändern von Inhalten
- [ ] Darstellung in einem vollständig isolierten `<iframe sandbox>` kein JavaScript aus der Mail kann ausgeführt werden
- [ ] Externe Bilder/Ressourcen standardmäßig blockiert, Nutzer kann per Button "externe Inhalte laden" freischalten
- [ ] Fallback auf Plain-Text wenn kein HTML vorhanden
- [ ] Anhang-Liste mit Dateiname, Typ und Größe
- [ ] Anhänge einzeln herunterladbar
- [ ] Header-Ansicht (klappbar) zeigt alle Original-MIME-Header
- [ ] Download der Original-E-Mail als .eml Datei
- [ ] Zugriffsschutz: Nutzer kann nur E-Mails aus eigenen Postfächern öffnen
- [ ] Jeder Zugriff auf eine E-Mail wird im Audit-Log erfasst
## Edge Cases
- E-Mail mit nur Plain-Text → normales Rendering ohne HTML
- HTML mit JavaScript → Script wird durch iframe-Sandbox blockiert, HTML-Inhalt bleibt unverändert sichtbar
- Externe Tracker (Pixel, Links) → standardmäßig blockiert durch CSP, auf Wunsch des Nutzers freischaltbar
- E-Mail mit sehr großen Anhängen (> 100 MB) → Download-Streaming, kein Speicher-Overflow
- E-Mail mit verschachteltem MIME (E-Mail in E-Mail als Anhang) → als EML-Anhang anzeigen
- Nicht unterstützte Zeichenkodierung → graceful Fallback mit Hinweis
## Technical Requirements
- **Kein HTML-Sanitizing** originale Darstellung ohne Veränderung des Inhalts
- Isolation über `<iframe sandbox="allow-same-origin">` JavaScript blockiert, Inhalt originalgetreu
- Externe Ressourcen über CSP (`Content-Security-Policy`) serverseitig blockiert, opt-in per Nutzer-Aktion
- Anhang-Downloads als Stream (kein vollständiges In-Memory-Laden)
- Audit-Log-Eintrag: Nutzer-ID, E-Mail-ID, Zeitstempel bei jedem Lesezugriff
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend:**
```
/mail/[message_id]
├── MailHeader
│ ├── Betreff
│ ├── Von / An / CC / Datum / Größe
│ └── HeaderToggle (klappbar)
│ └── RawHeaderView ← alle Original-MIME-Header als Text
├── ActionBar
│ ├── EML-Download Button ← lädt Original-Mail herunter
│ ├── Externe Inhalte laden ← Button, standardmäßig deaktiviert
│ └── Zurück zur Suche
├── MailBody
│ ├── HtmlView ← originales HTML in <iframe sandbox>
│ │ └── ExternalContentBanner ← Hinweis "Externe Inhalte blockiert [Laden]"
│ └── PlainTextView ← Fallback wenn kein HTML vorhanden
└── AttachmentList
└── AttachmentItem (pro Anhang)
├── Icon (nach MIME-Type)
├── Dateiname + Typ + Größe
└── Download-Button ← direkter Stream vom Go-Backend
```
**Go Backend:**
```
GET /api/mails/{message_id}
├── Session Middleware ← Auth prüfen
├── Zugriffsrecht prüfen ← user: nur eigenes Postfach / auditor: alle
├── .m-Datei von Disk lesen ← Pfad aus PostgreSQL
├── AES-256-GCM entschlüsseln ← Schlüssel aus Prozessspeicher
├── MIME-Parser ← Body + Header + Anhang-Metadaten extrahieren
└── JSON-Antwort ← Metadaten + originaler HTML-Body + Anhang-Liste
GET /api/mails/{message_id}/attachments/{index}
├── Session Middleware
├── Zugriffsrecht prüfen
├── Hash aus PostgreSQL ← welche astore/-Datei?
├── astore/-Datei öffnen
├── AES-256-GCM entschlüsseln (stream)
└── HTTP-Streaming-Response ← Content-Disposition: attachment
GET /api/mails/{message_id}/raw
├── Session Middleware
├── Zugriffsrecht prüfen
├── .m-Datei entschlüsseln
└── HTTP-Streaming-Response ← Content-Type: message/rfc822
```
### Datenabruf-Fluss
```
Browser klickt MailCard aus Suchergebnissen
│ GET /api/mails/<message_id>
Zugriffsrecht prüfen
PostgreSQL → store_path
.m-Datei lesen + AES-256-GCM entschlüsseln
MIME-Parser → body_html (original, unverändert), body_plain, headers[], attachments[]
JSON-Antwort an Next.js
Next.js:
├── HTML → <iframe sandbox="allow-same-origin">
│ └── CSP-Header blockiert externe Ressourcen
│ Nutzer klickt "Externe Inhalte laden"
│ → iframe neu laden ohne CSP-Restriction
└── Anhang-Liste → Download-Links
```
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Kein HTML-Sanitizing** | Originale Darstellung kein Inhalt wird verändert oder entfernt |
| **`<iframe sandbox>`** | JavaScript aus der Mail wird blockiert ohne den HTML-Inhalt zu verändern Inhalt bleibt originalgetreu |
| **CSP für externe Ressourcen** | Tracking-Pixel und externe Bilder standardmäßig blockiert Nutzer kann bewusst freischalten |
| **Entschlüsselung nur im Backend** | Verschlüsselte Rohdaten verlassen den Server nie |
| **Anhang-Download als Stream** | Große Anhänge (>100 MB) nie komplett in RAM direkt von Disk zum Browser |
| **Kein Audit-Log bei Lesezugriff** | Bewusste Entscheidung (PROJ-11): Lesezugriffe werden nicht geloggt |
### Abhängigkeiten
**Go Backend:**
| Paket | Zweck |
|---|---|
| `mime`, `mime/multipart` | MIME-Parsing (Stdlib) |
**Next.js Frontend:** Nur shadcn/ui (bereits installiert), kein zusätzliches Paket nötig.
## QA Test Results
_To be added by /qa_
## Deployment
### Lokal bauen
```bash
# Im Projektverzeichnis
npm run build
```
Build-Artefakt liegt danach in `.next/`.
### Auf Server übertragen (192.168.1.131)
```bash
# Next.js-Build + Abhängigkeiten übertragen
rsync -avz --delete \
.next/ \
package.json \
package-lock.json \
next.config.ts \
root@192.168.1.131:/opt/archivmail/frontend/
# Auf dem Server: Abhängigkeiten installieren & Dienst neu starten
ssh root@192.168.1.131 "cd /opt/archivmail/frontend && npm ci --omit=dev && systemctl restart archivmail-frontend"
```
### Voraussetzungen auf dem Server
- Node.js ≥ 20 installiert (`node -v`)
- Verzeichnis `/opt/archivmail/frontend/` existiert
- Systemd-Unit `archivmail-frontend` läuft `npm run start` (Port 3000)
- Go-Backend läuft auf Port 8080, Next.js proxied `/api/*` dorthin (siehe `next.config.ts`)
+154
View File
@@ -0,0 +1,154 @@
# PROJ-8: Automatischer IMAP-Sync (Cron-Job)
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- Requires: PROJ-3 (IMAP-Import) IMAP-Verbindungen müssen konfiguriert sein
- Requires: PROJ-5 (Speicherung & Indexierung)
## User Stories
- Als Admin möchte ich ein Sync-Intervall konfigurieren (z.B. alle 15 Minuten), damit neue E-Mails automatisch archiviert werden.
- Als Admin möchte ich den letzten Sync-Zeitpunkt und -Status pro IMAP-Verbindung sehen.
- Als Admin möchte ich den Sync manuell auslösen können, damit ich nicht auf den nächsten Intervall warten muss.
- Als System möchte ich beim Sync nur neue E-Mails (seit letztem Sync) abholen, damit kein unnötiger Traffic entsteht.
## Acceptance Criteria
- [ ] Sync-Intervall pro IMAP-Verbindung konfigurierbar (min. 5 Minuten, max. 24 Stunden)
- [ ] IMAP UID-basierter inkrementeller Sync (nur neue E-Mails seit letztem Sync)
- [ ] Admin-UI zeigt: letzter Sync, Status (Erfolg/Fehler), Anzahl importierter E-Mails
- [ ] Manueller "Sync jetzt"-Button im Admin-Bereich
- [ ] Bei Sync-Fehler: Retry mit exponential backoff (max. 3 Versuche)
- [ ] Sync-Fehler nach allen Versuchen → Fehlermeldung im Admin-Dashboard
## Edge Cases
- IMAP-Server temporär nicht erreichbar → Retry ohne Abbruch des gesamten Sync-Jobs
- Sync läuft noch wenn neuer Intervall beginnt → kein paralleler Sync für dieselbe Verbindung
- E-Mails auf dem Server wurden gelöscht → im Archiv behalten (Archiv ist immutable)
- Zeitzonenprobleme beim Datum-Vergleich → immer UTC intern verwenden
## Technical Requirements
- Cron-Scheduler eingebettet (z.B. robfig/cron für Go)
- Sync-Status persistent in DB gespeichert (überlebt Server-Neustart)
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend (Admin-Bereich):**
```
/admin/imap (integriert in IMAP-Verbindungsliste aus PROJ-3)
└── VerbindungsCard (pro Konto)
├── Sync-Intervall (Dropdown: 5min / 15min / 1h / 6h / 24h)
├── Letzter Sync: Zeitpunkt + Status (✓ OK / ✗ Fehler)
├── Anzahl importierter Mails beim letzten Sync
├── Fehlermeldung (wenn letzter Sync fehlgeschlagen)
└── [Sync jetzt] Button
```
**Go Backend:**
```
Sync-Scheduler (startet beim Binary-Start)
├── Cron-Loop ← prüft jede Minute alle IMAP-Accounts
│ └── Für jeden Account:
│ ├── Intervall abgelaufen? → Sync-Worker starten
│ └── Sync läuft bereits? → überspringen (kein Parallel-Sync)
├── Sync-Worker (pro Account, Goroutine)
│ ├── IMAP verbinden (gleicher Client wie PROJ-3)
│ ├── Letzte bekannte UID aus DB laden
│ ├── UID SEARCH UID <last_uid>:* → nur neue Mails
│ ├── FETCH neue Mails
│ ├── → Storage Coordinator (PROJ-5)
│ ├── Letzte UID + Zeitstempel in DB speichern
│ └── Bei Fehler: Retry mit Exponential Backoff
│ (1. Versuch: sofort, 2.: +1min, 3.: +5min → dann Fehler)
└── POST /api/admin/imap/{id}/sync ← manueller Trigger
└── Sync-Worker sofort starten (ignoriert Intervall)
```
### Sync-Fluss
```
Cron-Loop (jede Minute)
└── Account "Firmen-Postfach" Intervall: 15 min
last_sync_at = vor 16 Minuten → fällig
sync_running = false → starten
IMAP verbinden
last_uid = 4821 (aus DB)
UID SEARCH UID 4822:*
→ [4822, 4823, 4830, 4831] (4 neue Mails)
FETCH 4822:4831 RFC822
Für jede Mail:
Duplikat? → überspringen
→ Storage Coordinator
last_uid = 4831 in DB speichern
last_sync_at = NOW() (UTC)
sync_status = "ok"
sync_count = 4
```
### Exponential Backoff bei Fehlern
```
Sync-Fehler (z.B. IMAP nicht erreichbar)
├── Versuch 1: sofort → Fehler
├── Versuch 2: +1 Minute → Fehler
├── Versuch 3: +5 Minuten → Fehler
└── Aufgeben:
sync_status = "error"
error_msg = "Connection refused after 3 attempts"
→ Admin-Dashboard zeigt Fehler
→ nächster regulärer Intervall versucht es erneut
```
### Datenmodell (Ergänzung zu `imap_accounts`)
| Feld | Beschreibung |
|------|-------------|
| `sync_interval_min` | Sync-Intervall in Minuten (51440) |
| `last_sync_at` | Zeitpunkt des letzten Syncs (UTC) |
| `last_sync_count` | Anzahl importierter Mails beim letzten Sync |
| `last_uid` | Höchste bekannte IMAP-UID (Startpunkt für nächsten Sync) |
| `sync_running` | `true` wenn Sync gerade läuft (verhindert parallelen Sync) |
| `sync_status` | `ok` / `error` / `running` |
| `sync_error_msg` | Letzte Fehlermeldung |
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **UID-basierter inkrementeller Sync** | Nur neue Mails seit letzter bekannter UID werden abgeholt minimaler Traffic, kein Re-Download |
| **`sync_running`-Flag in DB** | Verhindert parallelen Sync derselben Verbindung auch nach Server-Neustart |
| **Cron-Loop jede Minute** | Einfacher als individuelle Timer pro Account skaliert auf viele Accounts ohne Overhead |
| **Exponential Backoff** | Temporäre Ausfälle (Netz, Server-Neustart) werden automatisch überbrückt ohne Admin-Eingriff |
| **Status persistent in DB** | Server-Neustart verliert keinen Sync-Fortschritt Scheduler macht nahtlos weiter |
| **Manueller Trigger** | Admin kann sofortigen Sync anstoßen ohne auf Intervall zu warten |
### Abhängigkeiten
| Paket | Zweck |
|---|---|
| `github.com/robfig/cron` | Eingebetteter Cron-Scheduler |
| `github.com/emersion/go-imap` | IMAP-Client (bereits PROJ-3) |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+157
View File
@@ -0,0 +1,157 @@
# PROJ-9: Ordner- & Label-Verwaltung
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## Dependencies
- Requires: PROJ-1 (Authentifizierung)
- Requires: PROJ-5 (Speicherung & Indexierung)
## User Stories
- Als Nutzer möchte ich E-Mails mit Labels versehen, damit ich sie thematisch organisieren kann.
- Als Admin möchte ich globale Labels definieren, die automatisch beim Import vergeben werden (z.B. nach Absender-Domain oder Import-Quelle).
- Als Nutzer möchte ich meine Suchergebnisse auf ein bestimmtes Label einschränken.
- Als Nutzer möchte ich Labels erstellen, umbenennen und löschen.
## Acceptance Criteria
- [ ] Nutzer können Labels erstellen (Name, Farbe)
- [ ] E-Mails können mit mehreren Labels versehen werden
- [ ] Label-Filter in der Suche verfügbar
- [ ] **Keine IMAP-Ordnerstruktur** das System ist ein Archiv, keine Ordnerhierarchie wird übernommen
- [ ] Admin kann Regeln für automatische Label-Vergabe beim Import definieren (z.B. nach Absender-Domain)
- [ ] Admin kann globale Labels definieren (für alle Nutzer sichtbar)
- [ ] Löschen eines Labels entfernt es von allen E-Mails, löscht E-Mails nicht
- [ ] Label-Übersicht in der Seitenleiste mit E-Mail-Anzahl pro Label
## Edge Cases
- Label-Name bereits vergeben → Fehlermeldung
- E-Mail wird gelöscht aber Labels bleiben → Labels bleiben erhalten, E-Mail-Referenz entfernt
- Sehr viele Labels (> 100) → Suchfeld in der Label-Auswahl
## Technical Requirements
- Labels: n:m-Beziehung zwischen E-Mails und Labels
- Performance: Label-Filter darf Suchantwortzeit nicht verdoppeln
---
## Tech Design (Solution Architect)
### Komponentenstruktur
**Next.js Frontend:**
```
Seitenleiste (global, alle Seiten)
└── LabelList
├── Label-Eintrag (Name, Farbe, Anzahl) ← klickbar → filtert Suche
├── [+ Label erstellen] Button
└── Suchfeld (bei > 10 Labels)
Label-Verwaltung (Inline / Modal)
├── LabelForm
│ ├── Name (Textfeld)
│ └── Farbe (Color-Picker, 8 Vorschläge)
└── LabelItem-Aktionen
├── Umbenennen
└── Löschen (mit Bestätigung)
E-Mail-Ansicht (PROJ-7, Erweiterung)
└── LabelPicker
├── Aktuelle Labels der Mail (als Badges)
├── Dropdown: Labels hinzufügen/entfernen
└── [+ Neues Label] Shortcut
Admin-Bereich (/admin/labels)
├── Globale Labels verwalten
└── Auto-Label-Regeln
├── RegelListe
└── RegelForm
├── Bedingung: from-Domain / Import-Quelle / Betreff enthält
└── Aktion: Label zuweisen
```
**Go Backend:**
```
Label-API
├── GET /api/labels ← alle Labels des Nutzers + globale
├── POST /api/labels ← neues Label anlegen
├── PATCH /api/labels/{id} ← umbenennen / Farbe ändern
├── DELETE /api/labels/{id} ← löschen (entfernt von allen Mails)
├── POST /api/mails/{id}/labels ← Label einer Mail zuweisen
└── DELETE /api/mails/{id}/labels/{label_id} ← Label entfernen
Admin Label-API
├── POST /api/admin/labels ← globales Label anlegen
├── GET /api/admin/label-rules ← Auto-Label-Regeln
├── POST /api/admin/label-rules ← Regel anlegen
└── DELETE /api/admin/label-rules/{id}
Label-Filter in Suche (Erweiterung PROJ-6)
└── Xapian-Term "label:<label_id>" pro Mail
→ Label-Filter läuft direkt in Xapian
```
### Datenmodell
**Tabelle `labels`:**
| Feld | Beschreibung |
|------|-------------|
| `id` | Interne ID |
| `name` | Label-Name (eindeutig pro Nutzer) |
| `color` | Hex-Farbe (z.B. `#e74c3c`) |
| `owner_id` | Nutzer-ID (NULL = globales Admin-Label) |
| `created_at` | Erstellungszeitpunkt |
**Tabelle `email_labels`** n:m Verknüpfung:
| Feld | Beschreibung |
|------|-------------|
| `email_id` | Referenz auf `emails` |
| `label_id` | Referenz auf `labels` |
| `assigned_at` | Zeitpunkt der Zuweisung |
| `assigned_by` | `user` / `auto-rule` / `import` |
**Tabelle `label_rules`** Auto-Label beim Import:
| Feld | Beschreibung |
|------|-------------|
| `id` | Interne ID |
| `condition_field` | `from_domain` / `source` / `subject_contains` |
| `condition_value` | z.B. `example.com` oder `imap-account-1` |
| `label_id` | Welches Label vergeben |
### Label-Filter in Xapian
Beim Indexieren einer Mail werden ihre Labels als Xapian-Terms gespeichert:
```
Label "Kunde" → Term: "label:42"
Label "Projekt" → Term: "label:17"
```
Suche mit Label-Filter läuft vollständig in Xapian kein zusätzlicher DB-Join nötig. Labels werden beim Zuweisen/Entfernen sofort im Xapian-Dokument aktualisiert.
### Technische Entscheidungen
| Entscheidung | Begründung |
|---|---|
| **Labels statt Ordner** | Archiv hat keine Hierarchie eine Mail kann mehrere Labels haben, aber nicht in mehreren Ordnern gleichzeitig sein |
| **Label-Terms in Xapian** | Filter läuft direkt bei der Suche kein nachträglicher DB-Join, keine Verdopplung der Antwortzeit |
| **Globale Labels (owner_id NULL)** | Admin definiert unternehmensweite Labels Nutzer können sie nicht löschen, nur zuweisen |
| **Auto-Label-Regeln** | Importierte Mails werden sofort kategorisiert kein manueller Aufwand für Bulk-Importe |
| **`assigned_by`-Feld** | Nachvollziehbar ob Label manuell, per Regel oder beim Import vergeben wurde |
### Abhängigkeiten
**Next.js Frontend:**
| Paket | Zweck |
|---|---|
| `shadcn/ui` | Badge, Popover, Color-Picker-Basis (bereits installiert) |
**Go Backend:** Nur Stdlib + pgx (bereits vorhanden).
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_
+26
View File
@@ -0,0 +1,26 @@
module github.com/archivmail
go 1.23
toolchain go1.24.4
require (
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-smtp v0.24.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.23.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.15.0 // indirect
)
+77
View File
@@ -0,0 +1,77 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Executable
+261
View File
@@ -0,0 +1,261 @@
#!/bin/bash
# archivmail Server-Installer
# Unterstützte Systeme: Debian 12 / 13
# Aufruf: bash install.sh
# Mit eigenem DB-Passwort: DB_PASSWORD=geheim bash install.sh
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${GREEN}[OK]${NC} $*"; }
info() { echo -e "${BLUE}[..]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
die() { echo -e "${RED}[ERR]${NC} $*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen: sudo bash install.sh"
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)}"
API_SECRET="${API_SECRET:-$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 64)}"
INSTALL_DIR="/opt/archivmail"
STORE_DIR="/var/archivmail"
LOG_DIR="/var/log/archivmail"
CONFIG_DIR="/etc/archivmail"
AM_USER="archivmail"
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ archivmail Installer v1.0 ║"
echo " ╚══════════════════════════════════════╝"
echo ""
# ── 1. Pakete ─────────────────────────────────────────────────────────────────
info "Installiere Systempakete..."
apt-get update -qq
apt-get install -y -qq \
golang-go nodejs npm postgresql nginx \
libxapian-dev pkg-config build-essential \
curl git logrotate openssl
log "Pakete installiert"
# ── 2. Systembenutzer ─────────────────────────────────────────────────────────
info "Lege Systembenutzer '$AM_USER' an..."
id "$AM_USER" &>/dev/null \
&& log "Benutzer '$AM_USER' existiert bereits" \
|| { useradd --system --shell /bin/false --home "$STORE_DIR" --create-home "$AM_USER"; log "Benutzer angelegt"; }
# ── 3. Verzeichnisstruktur ────────────────────────────────────────────────────
info "Erstelle Verzeichnisstruktur..."
mkdir -p "$STORE_DIR/store" "$STORE_DIR/astore" "$STORE_DIR/xapian"
mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$INSTALL_DIR" "$INSTALL_DIR/web"
chown -R "$AM_USER:$AM_USER" "$STORE_DIR" "$LOG_DIR"
chmod 755 "$STORE_DIR"
chmod 700 "$STORE_DIR/store" "$STORE_DIR/astore" "$STORE_DIR/xapian"
log "Verzeichnisse erstellt"
# ── 4. Keyfile ────────────────────────────────────────────────────────────────
info "Generiere Verschlüsselungs-Keyfile..."
if [ ! -f "$CONFIG_DIR/keyfile" ]; then
openssl rand -base64 32 > "$CONFIG_DIR/keyfile"
chmod 400 "$CONFIG_DIR/keyfile"
chown "$AM_USER:$AM_USER" "$CONFIG_DIR/keyfile"
log "Keyfile generiert: $CONFIG_DIR/keyfile"
else
log "Keyfile existiert bereits wird nicht überschrieben"
fi
# ── 5. PostgreSQL ─────────────────────────────────────────────────────────────
info "Richte PostgreSQL ein..."
systemctl enable postgresql --quiet
systemctl start postgresql
su -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='archivmail'\" | grep -q 1 \
&& psql -c \"ALTER USER archivmail WITH PASSWORD '$DB_PASSWORD'\" \
|| psql -c \"CREATE USER archivmail WITH PASSWORD '$DB_PASSWORD'\"" postgres
su -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='archivmail'\" | grep -q 1 || \
psql -c \"CREATE DATABASE archivmail OWNER archivmail\"" postgres
su -c "psql archivmail -c \"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO archivmail;\"" postgres
su -c "psql archivmail -c \"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO archivmail;\"" postgres
su -c "psql archivmail -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO archivmail;\"" postgres
su -c "psql archivmail -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO archivmail;\"" postgres
log "PostgreSQL-Schema angelegt"
log "Standard-Benutzer werden beim ersten Daemon-Start angelegt"
# ── 6. Konfiguration ──────────────────────────────────────────────────────────
info "Erstelle Konfigurationsdatei..."
cat > "$CONFIG_DIR/config.yml" << CONFIG
# archivmail Konfiguration generiert am $(date -u +%Y-%m-%dT%H:%M:%SZ)
server:
api_port: 8080
smtp_port: 2525
database:
host: 127.0.0.1
port: 5432
name: archivmail
user: archivmail
password: ${DB_PASSWORD}
sslmode: disable
storage:
store_path: ${STORE_DIR}/store
astore_path: ${STORE_DIR}/astore
xapian_path: ${STORE_DIR}/xapian
keyfile: ${CONFIG_DIR}/keyfile
api:
bind: ":8080"
secret: ${API_SECRET}
index:
path: ${STORE_DIR}/xapian
backend: xapian
batch_size: 100
audit:
log_path: ${LOG_DIR}/audit.log
retention_days: 0
smtp:
bind: ":2525"
allowed_ips:
- 127.0.0.1
CONFIG
chmod 640 "$CONFIG_DIR/config.yml"
chown "root:$AM_USER" "$CONFIG_DIR/config.yml"
log "Konfiguration: $CONFIG_DIR/config.yml"
# ── 7. Nginx ──────────────────────────────────────────────────────────────────
info "Konfiguriere Nginx..."
cat > /etc/nginx/sites-available/archivmail << 'NGINX'
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 512M;
}
access_log /var/log/nginx/archivmail.access.log;
error_log /var/log/nginx/archivmail.error.log;
}
NGINX
ln -sf /etc/nginx/sites-available/archivmail /etc/nginx/sites-enabled/archivmail
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl enable nginx --quiet
systemctl restart nginx
log "Nginx konfiguriert"
# ── 8. logrotate ──────────────────────────────────────────────────────────────
cat > /etc/logrotate.d/archivmail << LOGROTATE
${LOG_DIR}/audit.log {
daily
rotate 365
compress
delaycompress
missingok
notifempty
create 640 ${AM_USER} ${AM_USER}
}
LOGROTATE
log "logrotate konfiguriert"
# ── 9. systemd Units ──────────────────────────────────────────────────────────
info "Erstelle systemd Units..."
cat > /etc/systemd/system/archivmail.service << UNIT
[Unit]
Description=archivmail Mail Archive Daemon
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=${AM_USER}
Group=${AM_USER}
ExecStart=${INSTALL_DIR}/archivmail --config ${CONFIG_DIR}/config.yml
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=archivmail
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=${STORE_DIR} ${LOG_DIR}
ReadOnlyPaths=${CONFIG_DIR}
[Install]
WantedBy=multi-user.target
UNIT
cat > /etc/systemd/system/archivmail-web.service << UNIT
[Unit]
Description=archivmail Web Frontend
After=network.target archivmail.service
[Service]
Type=simple
User=${AM_USER}
Group=${AM_USER}
WorkingDirectory=${INSTALL_DIR}/web
ExecStart=/usr/bin/node server.js
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=archivmail-web
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
log "systemd Units erstellt"
# ── Abschlussbericht ──────────────────────────────────────────────────────────
echo ""
echo " ╔══════════════════════════════════════════════════════════╗"
echo " ║ Installation abgeschlossen! ║"
echo " ╚══════════════════════════════════════════════════════════╝"
echo ""
echo " Pfade:"
echo " Binaries: $INSTALL_DIR/"
echo " Mail-Speicher: $STORE_DIR/"
echo " Konfiguration: $CONFIG_DIR/config.yml"
echo " Keyfile: $CONFIG_DIR/keyfile (chmod 400 sicher aufbewahren!)"
echo " Logs: $LOG_DIR/"
echo ""
echo " Datenbank:"
echo " Host: 127.0.0.1:5432 / archivmail"
printf " Passwort: %s\n" "$DB_PASSWORD"
echo ""
echo " Standard-Zugangsdaten (nach Deployment ändern!):"
echo " Admin: admin@archivmail / archivmailrockz"
echo " Auditor: auditor@archivmail / archivmailrockz"
echo ""
echo " Nächste Schritte nach Deployment:"
echo " 1. cp archivmail $INSTALL_DIR/"
echo " 2. cp -r web/ $INSTALL_DIR/web/"
echo " 3. systemctl enable --now archivmail archivmail-web"
echo ""
warn "DB-Passwort steht in $CONFIG_DIR/config.yml (chmod 640, root:archivmail)"
warn "Standardpasswörter unbedingt nach dem ersten Login ändern!"
echo ""
+269
View File
@@ -0,0 +1,269 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/archivmail/config"
"github.com/archivmail/internal/api"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/auth"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/storage"
"github.com/archivmail/internal/userstore"
)
type testEnv struct {
server *api.Server
users *userstore.Store
store *storage.Store
idx index.Indexer
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
store, err := storage.New(filepath.Join(dir, "store"))
if err != nil {
t.Fatal(err)
}
idx, err := index.New(filepath.Join(dir, "index"), 100, "xapian")
if err != nil {
t.Skip("xapian not available:", err)
}
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
}
// Create isolated schemas for this test
schemaUsers := "apitest_users_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
schemaAudit := "apitest_audit_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
if len(schemaUsers) > 63 {
schemaUsers = schemaUsers[:63]
}
if len(schemaAudit) > 63 {
schemaAudit = schemaAudit[:63]
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaUsers)
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaAudit)
conn.Close(ctx)
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
usersDSN := dsn + sep + "search_path=" + schemaUsers
auditDSN := dsn + sep + "search_path=" + schemaAudit
users, err := userstore.New(usersDSN)
if err != nil {
t.Fatalf("userstore.New: %v", err)
}
audlog, err := audit.New(auditDSN, dir, logger)
if err != nil {
t.Fatalf("audit.New: %v", err)
}
// Seed users
users.Create(userstore.CreateUserRequest{Username: "admin", Email: "admin@x.com", Password: "adminpass", Role: userstore.RoleAdmin})
users.Create(userstore.CreateUserRequest{Username: "auditor", Email: "auditor@x.com", Password: "auditorpass", Role: userstore.RoleAuditor})
users.Create(userstore.CreateUserRequest{Username: "user1", Email: "user1@x.com", Password: "userpass", Role: userstore.RoleUser})
authMgr := auth.New(users, nil, "test-secret-must-be-long-enough-32")
cfg := config.APIConfig{Bind: ":18080", Secret: "test-secret-must-be-long-enough-32"}
srv := api.New(cfg, store, idx, authMgr, users, audlog, logger)
t.Cleanup(func() {
idx.Close()
users.Close()
audlog.Close()
conn2, _ := pgx.Connect(context.Background(), dsn)
if conn2 != nil {
conn2.Exec(context.Background(), "DROP SCHEMA "+schemaUsers+" CASCADE")
conn2.Exec(context.Background(), "DROP SCHEMA "+schemaAudit+" CASCADE")
conn2.Close(context.Background())
}
})
return &testEnv{server: srv, users: users, store: store, idx: idx}
}
func (e *testEnv) do(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
e.server.ServeHTTP(w, req)
return w
}
func (e *testEnv) login(t *testing.T, username, password string) string {
t.Helper()
w := e.do(t, "POST", "/api/auth/login",
map[string]string{"username": username, "password": password}, "")
if w.Code != 200 {
t.Fatalf("login %s: status %d, body: %s", username, w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
return resp["token"].(string)
}
// ---- Tests ----
func TestHealth(t *testing.T) {
env := newTestEnv(t)
w := env.do(t, "GET", "/api/health", nil, "")
if w.Code != 200 {
t.Errorf("health: status %d", w.Code)
}
}
func TestLoginAndMe(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "admin", "adminpass")
w := env.do(t, "GET", "/api/auth/me", nil, token)
if w.Code != 200 {
t.Fatalf("me: status %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["username"] != "admin" {
t.Errorf("me username = %q", resp["username"])
}
if resp["role"] != "admin" {
t.Errorf("me role = %q", resp["role"])
}
}
func TestLoginWrongCredentials(t *testing.T) {
env := newTestEnv(t)
w := env.do(t, "POST", "/api/auth/login",
map[string]string{"username": "admin", "password": "wrong"}, "")
if w.Code != 401 {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestUnauthenticatedSearchBlocked(t *testing.T) {
env := newTestEnv(t)
w := env.do(t, "GET", "/api/search?q=test", nil, "")
if w.Code != 401 {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestLogout(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "admin", "adminpass")
w := env.do(t, "POST", "/api/auth/logout", nil, token)
if w.Code != 200 {
t.Fatalf("logout: status %d", w.Code)
}
// Token should now be invalid
w2 := env.do(t, "GET", "/api/auth/me", nil, token)
if w2.Code != 401 {
t.Errorf("after logout, me should return 401, got %d", w2.Code)
}
}
func TestAdminUserCRUD(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "admin", "adminpass")
// List users
w := env.do(t, "GET", "/api/users", nil, token)
if w.Code != 200 {
t.Fatalf("list users: status %d", w.Code)
}
var users []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &users)
if len(users) != 3 { // admin + auditor + user1
t.Errorf("expected 3 users, got %d", len(users))
}
// Create user
w = env.do(t, "POST", "/api/users",
map[string]string{"username": "newuser", "email": "new@x.com", "password": "pw123", "role": "user"},
token)
if w.Code != 201 {
t.Fatalf("create user: status %d, body: %s", w.Code, w.Body.String())
}
}
func TestNonAdminCannotManageUsers(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "user1", "userpass")
w := env.do(t, "GET", "/api/users", nil, token)
if w.Code != 403 {
t.Errorf("user role should not list users, got %d", w.Code)
}
}
func TestAuditorCanAccessAuditLog(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "auditor", "auditorpass")
w := env.do(t, "GET", "/api/audit", nil, token)
if w.Code != 200 {
t.Errorf("auditor should access audit log, got %d", w.Code)
}
}
func TestUserCannotAccessAuditLog(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "user1", "userpass")
w := env.do(t, "GET", "/api/audit", nil, token)
if w.Code != 403 {
t.Errorf("user role should not access audit log, got %d", w.Code)
}
}
func TestSearchReturnsResults(t *testing.T) {
env := newTestEnv(t)
token := env.login(t, "admin", "adminpass")
w := env.do(t, "GET", "/api/search?q=test", nil, token)
if w.Code != 200 {
t.Fatalf("search: status %d, body: %s", w.Code, w.Body.String())
}
var result map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &result)
if _, ok := result["total"]; !ok {
t.Error("search response missing 'total' field")
}
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
package audit
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
EventLogin = "login"
EventLogout = "logout"
EventSearch = "search"
EventMailView = "mail_view"
EventImport = "import"
EventExport = "export"
EventUserMgmt = "user_mgmt"
)
// Entry is a single audit log record.
type Entry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
EventType string `json:"event_type"`
Username string `json:"username"`
IPAddress string `json:"ip_address"`
Query string `json:"query"`
MailID string `json:"mail_id"`
Success bool `json:"success"`
Detail string `json:"detail"`
}
// QueryFilter specifies filtering options for audit log queries.
type QueryFilter struct {
Username string
EventType string
MailID string
From *time.Time
To *time.Time
PageSize int
Page int
}
// Logger is a PostgreSQL-backed, append-only audit log.
type Logger struct {
pool *pgxpool.Pool
logger *slog.Logger
}
// New connects to PostgreSQL using the given DSN and initialises the schema.
// logDir is reserved for future flat-file logging.
func New(dsn, logDir string, logger *slog.Logger) (*Logger, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("audit: connect: %w", err)
}
_, err = pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
mail_id VARCHAR(255) NOT NULL DEFAULT '',
success BOOLEAN NOT NULL DEFAULT true,
detail TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
pool.Close()
return nil, fmt.Errorf("audit: create schema: %w", err)
}
return &Logger{pool: pool, logger: logger}, nil
}
// Log appends an entry to the audit log. Errors are logged but not returned.
func (l *Logger) Log(entry Entry) {
ts := entry.Timestamp
if ts.IsZero() {
ts = time.Now().UTC()
}
ctx := context.Background()
_, err := l.pool.Exec(ctx,
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, query, mail_id, success, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
ts.UTC(),
entry.EventType,
entry.Username,
entry.IPAddress,
entry.Query,
entry.MailID,
entry.Success,
entry.Detail,
)
if err != nil {
l.logger.Error("audit: insert failed", "err", err)
}
}
// Query retrieves audit entries matching the given filter, returning the
// matched entries, the total count (ignoring pagination), and any error.
func (l *Logger) Query(filter QueryFilter) ([]Entry, int, error) {
pageSize := filter.PageSize
if pageSize <= 0 {
pageSize = 50
}
where, args := buildWhere(filter)
ctx := context.Background()
// Count total
countSQL := "SELECT COUNT(*) FROM audit_log" + where
var total int
if err := l.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("audit: count: %w", err)
}
offset := filter.Page * pageSize
// Append limit and offset as next positional args
limitArg := len(args) + 1
offsetArg := len(args) + 2
querySQL := fmt.Sprintf(
"SELECT id, timestamp, event_type, username, ip_address, query, mail_id, success, detail FROM audit_log%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d",
where, limitArg, offsetArg,
)
allArgs := append(args, pageSize, offset)
rows, err := l.pool.Query(ctx, querySQL, allArgs...)
if err != nil {
return nil, 0, fmt.Errorf("audit: query: %w", err)
}
defer rows.Close()
var entries []Entry
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Timestamp, &e.EventType, &e.Username, &e.IPAddress, &e.Query, &e.MailID, &e.Success, &e.Detail); err != nil {
return nil, 0, fmt.Errorf("audit: scan: %w", err)
}
entries = append(entries, e)
}
return entries, total, rows.Err()
}
// Close closes the audit connection pool.
func (l *Logger) Close() error {
l.pool.Close()
return nil
}
// buildWhere constructs a SQL WHERE clause from QueryFilter fields using
// positional parameters ($1, $2, ...) for PostgreSQL.
func buildWhere(f QueryFilter) (string, []interface{}) {
var clauses []string
var args []interface{}
n := 1
if f.Username != "" {
clauses = append(clauses, fmt.Sprintf("username = $%d", n))
args = append(args, f.Username)
n++
}
if f.EventType != "" {
clauses = append(clauses, fmt.Sprintf("event_type = $%d", n))
args = append(args, f.EventType)
n++
}
if f.MailID != "" {
clauses = append(clauses, fmt.Sprintf("mail_id = $%d", n))
args = append(args, f.MailID)
n++
}
if f.From != nil {
clauses = append(clauses, fmt.Sprintf("timestamp >= $%d", n))
args = append(args, f.From.UTC())
n++
}
if f.To != nil {
clauses = append(clauses, fmt.Sprintf("timestamp <= $%d", n))
args = append(args, f.To.UTC())
n++
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
+179
View File
@@ -0,0 +1,179 @@
package audit_test
import (
"context"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/archivmail/internal/audit"
)
func newTestAudit(t *testing.T) *audit.Logger {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
}
schema := "autest_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
// truncate schema name to 63 chars (PostgreSQL limit)
if len(schema) > 63 {
schema = schema[:63]
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema)
conn.Close(ctx)
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
schemaDSN := dsn + sep + "search_path=" + schema
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
l, err := audit.New(schemaDSN, t.TempDir(), logger)
if err != nil {
t.Fatalf("audit.New: %v", err)
}
t.Cleanup(func() {
l.Close()
conn2, _ := pgx.Connect(context.Background(), dsn)
if conn2 != nil {
conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
conn2.Close(context.Background())
}
})
return l
}
func TestLogAndQuery(t *testing.T) {
l := newTestAudit(t)
l.Log(audit.Entry{
EventType: audit.EventLogin,
Username: "alice",
IPAddress: "192.168.1.1",
Success: true,
})
l.Log(audit.Entry{
EventType: audit.EventSearch,
Username: "alice",
IPAddress: "192.168.1.1",
Query: "invoice",
Success: true,
})
l.Log(audit.Entry{
EventType: audit.EventLogin,
Username: "bob",
IPAddress: "10.0.0.1",
Success: false,
Detail: "wrong password",
})
all, total, err := l.Query(audit.QueryFilter{PageSize: 50})
if err != nil {
t.Fatalf("Query all: %v", err)
}
if total != 3 {
t.Errorf("expected 3 entries, got %d", total)
}
_ = all
}
func TestQueryByUsername(t *testing.T) {
l := newTestAudit(t)
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true})
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "bob", Success: true})
entries, total, _ := l.Query(audit.QueryFilter{Username: "alice", PageSize: 50})
if total != 2 {
t.Errorf("alice: expected 2 entries, got %d", total)
}
for _, e := range entries {
if e.Username != "alice" {
t.Errorf("got entry for user %q in alice filter", e.Username)
}
}
}
func TestQueryByEventType(t *testing.T) {
l := newTestAudit(t)
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true})
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "abc123", Success: true})
_, total, _ := l.Query(audit.QueryFilter{EventType: audit.EventSearch, PageSize: 50})
if total != 1 {
t.Errorf("search event filter: expected 1, got %d", total)
}
}
func TestQueryByMailID(t *testing.T) {
l := newTestAudit(t)
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "mail-001", Success: true})
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "bob", MailID: "mail-001", Success: true})
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "mail-002", Success: true})
_, total, _ := l.Query(audit.QueryFilter{MailID: "mail-001", PageSize: 50})
if total != 2 {
t.Errorf("mailID filter: expected 2, got %d", total)
}
}
func TestQueryDateRange(t *testing.T) {
l := newTestAudit(t)
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "bob", Success: true})
// Query with future date range — should return 0
future := time.Now().Add(24 * time.Hour)
futureEnd := time.Now().Add(48 * time.Hour)
_, total, _ := l.Query(audit.QueryFilter{From: &future, To: &futureEnd, PageSize: 50})
if total != 0 {
t.Errorf("future date range should return 0, got %d", total)
}
// Query with past-to-now range — should return all
past := time.Now().Add(-1 * time.Minute)
now := time.Now().Add(1 * time.Minute)
_, total, _ = l.Query(audit.QueryFilter{From: &past, To: &now, PageSize: 50})
if total != 2 {
t.Errorf("current date range should return 2, got %d", total)
}
}
func TestQueryPagination(t *testing.T) {
l := newTestAudit(t)
for i := 0; i < 10; i++ {
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true})
}
page0, total, _ := l.Query(audit.QueryFilter{PageSize: 4, Page: 0})
_, _, _ = l.Query(audit.QueryFilter{PageSize: 4, Page: 1})
page2, _, _ := l.Query(audit.QueryFilter{PageSize: 4, Page: 2})
if total != 10 {
t.Errorf("total = %d, want 10", total)
}
if len(page0) != 4 {
t.Errorf("page 0 len = %d, want 4", len(page0))
}
if len(page2) != 2 {
t.Errorf("page 2 len = %d, want 2", len(page2))
}
}
+156
View File
@@ -0,0 +1,156 @@
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/archivmail/internal/userstore"
)
// Session holds the claims extracted from a validated JWT.
type Session struct {
UserID int64
Username string
Role string
JTI string // unique JWT ID
}
// Manager handles login, token issuance, validation, and logout.
type Manager struct {
store *userstore.Store
ldap interface{} // placeholder for LDAP provider
jwtSecret []byte
}
// New creates a new auth Manager.
func New(store *userstore.Store, ldap interface{}, jwtSecret string) *Manager {
return &Manager{
store: store,
ldap: ldap,
jwtSecret: []byte(jwtSecret),
}
}
// Login verifies credentials and returns a signed JWT token.
func (m *Manager) Login(username, password string) (string, *userstore.User, error) {
user, err := m.store.VerifyPassword(username, password)
if err != nil {
return "", nil, fmt.Errorf("auth: login: %w", err)
}
jti := generateJTI()
now := time.Now()
claims := jwt.MapClaims{
"sub": user.Username,
"role": user.Role,
"uid": user.ID,
"jti": jti,
"iat": now.Unix(),
"exp": now.Add(8 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.jwtSecret)
if err != nil {
return "", nil, fmt.Errorf("auth: sign token: %w", err)
}
return signed, user, nil
}
// ValidateToken parses and validates the token, checking the blacklist.
func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
}
return m.jwtSecret, nil
})
if err != nil {
return nil, fmt.Errorf("auth: invalid token: %w", err)
}
if !token.Valid {
return nil, errors.New("auth: token not valid")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("auth: bad claims")
}
jti, _ := claims["jti"].(string)
blacklisted, err := m.store.IsBlacklisted(jti)
if err != nil {
return nil, fmt.Errorf("auth: blacklist check: %w", err)
}
if blacklisted {
return nil, errors.New("auth: token revoked")
}
username, _ := claims["sub"].(string)
role, _ := claims["role"].(string)
var userID int64
switch v := claims["uid"].(type) {
case float64:
userID = int64(v)
case int64:
userID = v
}
return &Session{
UserID: userID,
Username: username,
Role: role,
JTI: jti,
}, nil
}
// Logout revokes the token by adding its JTI to the blacklist.
func (m *Manager) Logout(tokenStr string) error {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method")
}
return m.jwtSecret, nil
})
if err != nil {
return fmt.Errorf("auth: logout parse: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return errors.New("auth: bad claims on logout")
}
jti, _ := claims["jti"].(string)
var exp time.Time
switch v := claims["exp"].(type) {
case float64:
exp = time.Unix(int64(v), 0)
case int64:
exp = time.Unix(v, 0)
default:
exp = time.Now().Add(8 * time.Hour)
}
return m.store.BlacklistToken(jti, exp)
}
// HasRole returns true when userRole satisfies the required role level.
// Hierarchy: admin > auditor > user
func HasRole(userRole, required string) bool {
levels := map[string]int{
userstore.RoleUser: 1,
userstore.RoleAuditor: 2,
userstore.RoleAdmin: 3,
}
return levels[userRole] >= levels[required]
}
// generateJTI returns a pseudo-unique identifier for a JWT.
func generateJTI() string {
return fmt.Sprintf("%d-%x", time.Now().UnixNano(), time.Now().UnixNano()^0xdeadbeef)
}
+161
View File
@@ -0,0 +1,161 @@
package auth_test
import (
"path/filepath"
"testing"
"github.com/archivmail/internal/auth"
"github.com/archivmail/internal/userstore"
)
func newTestAuth(t *testing.T) (*auth.Manager, *userstore.Store) {
t.Helper()
store, err := userstore.New(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatalf("userstore.New: %v", err)
}
t.Cleanup(func() { store.Close() })
// Seed a test user
store.Create(userstore.CreateUserRequest{
Username: "testadmin",
Email: "admin@example.com",
Password: "adminpass",
Role: userstore.RoleAdmin,
})
store.Create(userstore.CreateUserRequest{
Username: "regularuser",
Email: "user@example.com",
Password: "userpass",
Role: userstore.RoleUser,
})
mgr := auth.New(store, nil, "test-jwt-secret-32chars-long-enough")
return mgr, store
}
func TestLoginSuccess(t *testing.T) {
mgr, _ := newTestAuth(t)
token, user, err := mgr.Login("testadmin", "adminpass")
if err != nil {
t.Fatalf("Login: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
if user.Username != "testadmin" {
t.Errorf("Username = %q", user.Username)
}
if user.Role != userstore.RoleAdmin {
t.Errorf("Role = %q", user.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
mgr, _ := newTestAuth(t)
if _, _, err := mgr.Login("testadmin", "wrongpass"); err == nil {
t.Error("expected error for wrong password")
}
}
func TestLoginUnknownUser(t *testing.T) {
mgr, _ := newTestAuth(t)
if _, _, err := mgr.Login("nobody", "pw"); err == nil {
t.Error("expected error for unknown user")
}
}
func TestTokenValidation(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _ := mgr.Login("testadmin", "adminpass")
sess, err := mgr.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if sess.Username != "testadmin" {
t.Errorf("Session Username = %q", sess.Username)
}
if sess.Role != userstore.RoleAdmin {
t.Errorf("Session Role = %q", sess.Role)
}
if sess.JTI == "" {
t.Error("Session JTI should not be empty")
}
}
func TestTokenTampering(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _ := mgr.Login("testadmin", "adminpass")
tampered := token + "x"
if _, err := mgr.ValidateToken(tampered); err == nil {
t.Error("tampered token should fail validation")
}
}
func TestLogout(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _ := mgr.Login("testadmin", "adminpass")
// Token valid before logout
if _, err := mgr.ValidateToken(token); err != nil {
t.Fatalf("token should be valid before logout: %v", err)
}
if err := mgr.Logout(token); err != nil {
t.Fatalf("Logout: %v", err)
}
// Token invalid after logout
if _, err := mgr.ValidateToken(token); err == nil {
t.Error("token should be invalid after logout")
}
}
func TestHasRole(t *testing.T) {
tests := []struct {
userRole string
required string
want bool
}{
{userstore.RoleAdmin, userstore.RoleAdmin, true},
{userstore.RoleAdmin, userstore.RoleAuditor, true},
{userstore.RoleAdmin, userstore.RoleUser, true},
{userstore.RoleAuditor, userstore.RoleAdmin, false},
{userstore.RoleAuditor, userstore.RoleAuditor, true},
{userstore.RoleAuditor, userstore.RoleUser, true},
{userstore.RoleUser, userstore.RoleAdmin, false},
{userstore.RoleUser, userstore.RoleAuditor, false},
{userstore.RoleUser, userstore.RoleUser, true},
}
for _, tt := range tests {
got := auth.HasRole(tt.userRole, tt.required)
if got != tt.want {
t.Errorf("HasRole(%q, %q) = %v, want %v", tt.userRole, tt.required, got, tt.want)
}
}
}
func TestMultipleSessionsIndependent(t *testing.T) {
mgr, _ := newTestAuth(t)
token1, _, _ := mgr.Login("testadmin", "adminpass")
token2, _, _ := mgr.Login("testadmin", "adminpass")
if token1 == token2 {
t.Error("two logins should produce different tokens (different JTIs)")
}
// Logout session 1, session 2 should still work
mgr.Logout(token1)
if _, err := mgr.ValidateToken(token2); err != nil {
t.Errorf("session 2 should still be valid after session 1 logout: %v", err)
}
}
+99
View File
@@ -0,0 +1,99 @@
package imap
import (
"crypto/tls"
"fmt"
"strings"
imapv2 "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
// 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"`
}
// junkTrashNames lists well-known junk/trash folder names for fallback detection.
var junkTrashNames = []string{
"junk", "spam", "trash", "deleted items",
"deleted messages", "papierkorb", "gelöschte elemente",
}
// Connect establishes an IMAP client connection using the specified TLS mode.
func Connect(host string, port int, tlsMode string) (*imapclient.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
switch tlsMode {
case "ssl":
c, err := imapclient.DialTLS(addr, &imapclient.Options{
TLSConfig: &tls.Config{ServerName: host},
})
if err != nil {
return nil, fmt.Errorf("imap connect ssl: %w", err)
}
return c, nil
case "starttls":
c, err := imapclient.DialStartTLS(addr, &imapclient.Options{
TLSConfig: &tls.Config{ServerName: host},
})
if err != nil {
return nil, fmt.Errorf("imap connect starttls: %w", err)
}
return c, nil
case "none":
c, err := imapclient.DialInsecure(addr, nil)
if err != nil {
return nil, fmt.Errorf("imap connect plain: %w", err)
}
return c, nil
default:
return nil, fmt.Errorf("imap: unknown tls mode %q", tlsMode)
}
}
// ListFolders retrieves all mailbox folders and detects junk/trash folders.
func ListFolders(c *imapclient.Client) ([]FolderInfo, error) {
listCmd := c.List("", "*", nil)
mailboxes, err := listCmd.Collect()
if err != nil {
return nil, fmt.Errorf("imap list folders: %w", err)
}
var folders []FolderInfo
for _, mb := range mailboxes {
fi := FolderInfo{Name: mb.Mailbox}
// Check special-use attributes (RFC 6154)
for _, attr := range mb.Attrs {
if attr == imapv2.MailboxAttrJunk {
fi.Excluded = true
fi.Reason = "special_use"
break
}
if attr == imapv2.MailboxAttrTrash {
fi.Excluded = true
fi.Reason = "special_use"
break
}
}
// Fallback: case-insensitive name matching
if !fi.Excluded {
lower := strings.ToLower(mb.Mailbox)
for _, jt := range junkTrashNames {
if lower == jt {
fi.Excluded = true
fi.Reason = "name_match"
break
}
}
}
folders = append(folders, fi)
}
return folders, nil
}
+272
View File
@@ -0,0 +1,272 @@
package imap
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"time"
imapv2 "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/storage"
"github.com/archivmail/pkg/mailparser"
)
const batchSize = 50
// Importer runs background IMAP import jobs.
type Importer struct {
store *Store
mailStore *storage.Store
idx index.Indexer
logger *slog.Logger
}
// NewImporter creates a new Importer wired to the storage and index backends.
func NewImporter(store *Store, mailStore *storage.Store, idx index.Indexer, logger *slog.Logger) *Importer {
return &Importer{
store: store,
mailStore: mailStore,
idx: idx,
logger: logger,
}
}
// Run performs a full IMAP import for the given account. It is designed to be
// called as a goroutine: go imp.Run(context.Background(), accountID)
func (imp *Importer) Run(ctx context.Context, accountID int64) {
log := imp.logger.With("component", "imap-importer", "account_id", accountID)
acc, err := imp.store.Get(ctx, accountID)
if err != nil {
log.Error("failed to get account", "err", err)
return
}
password, err := imp.store.GetPassword(ctx, accountID)
if err != nil {
log.Error("failed to decrypt password", "err", err)
_ = imp.store.UpdateStatus(ctx, accountID, "error", "failed to decrypt password", 0, 0)
return
}
// Mark as running
if err := imp.store.UpdateStatus(ctx, accountID, "running", "", 0, 0); err != nil {
log.Error("failed to update status", "err", err)
return
}
imported, err := imp.doImport(ctx, acc, password, log)
if err != nil {
log.Error("import failed", "err", err)
_ = imp.store.UpdateStatus(ctx, accountID, "error", err.Error(), 0, 0)
return
}
if err := imp.store.UpdateDone(ctx, accountID, imported); err != nil {
log.Error("failed to update done", "err", err)
}
log.Info("import completed", "imported", imported)
}
// doImport handles the actual IMAP connection, folder iteration, and message fetching.
func (imp *Importer) doImport(ctx context.Context, acc *Account, password string, log *slog.Logger) (int, error) {
c, err := Connect(acc.Host, acc.Port, acc.TLS)
if err != nil {
return 0, fmt.Errorf("connect: %w", err)
}
defer c.Close()
// Login
if err := c.Login(acc.Username, password).Wait(); err != nil {
return 0, fmt.Errorf("login: %w", err)
}
// List all folders
folders, err := ListFolders(c)
if err != nil {
return 0, fmt.Errorf("list folders: %w", err)
}
// Build excluded set from account config
excluded := make(map[string]bool)
for _, f := range acc.ExcludedFolders {
excluded[f] = true
}
// Collect included folders
var includedFolders []string
for _, f := range folders {
if !excluded[f.Name] {
includedFolders = append(includedFolders, f.Name)
}
}
// Count total messages across all folders first
totalMsgs := 0
folderUIDs := make(map[string][]imapv2.UID)
for _, folder := range includedFolders {
selectData, err := c.Select(folder, nil).Wait()
if err != nil {
log.Warn("failed to select folder, skipping", "folder", folder, "err", err)
continue
}
_ = selectData
searchCmd := c.UIDSearch(&imapv2.SearchCriteria{}, nil)
searchData, err := searchCmd.Wait()
if err != nil {
log.Warn("failed to search folder, skipping", "folder", folder, "err", err)
continue
}
uids := searchData.AllUIDs()
folderUIDs[folder] = uids
totalMsgs += len(uids)
}
log.Info("starting import", "folders", len(includedFolders), "total_messages", totalMsgs)
_ = imp.store.UpdateStatus(ctx, acc.ID, "running", "", 0, totalMsgs)
imported := 0
processed := 0
for _, folder := range includedFolders {
uids, ok := folderUIDs[folder]
if !ok || len(uids) == 0 {
continue
}
// Need to re-select the folder before fetching
if _, err := c.Select(folder, nil).Wait(); err != nil {
log.Warn("failed to re-select folder", "folder", folder, "err", err)
continue
}
log.Info("importing folder", "folder", folder, "messages", len(uids))
// Process in batches
for i := 0; i < len(uids); i += batchSize {
end := i + batchSize
if end > len(uids) {
end = len(uids)
}
batch := uids[i:end]
count, err := imp.fetchBatch(ctx, c, batch, log)
if err != nil {
log.Error("batch fetch error", "folder", folder, "offset", i, "err", err)
// Continue with the next batch rather than aborting entirely
continue
}
imported += count
processed += len(batch)
_ = imp.store.UpdateStatus(ctx, acc.ID, "running", "", processed, totalMsgs)
}
}
return imported, nil
}
// 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) {
if len(uids) == 0 {
return 0, nil
}
fetchOptions := &imapv2.FetchOptions{
UID: true,
BodySection: []*imapv2.FetchItemBodySection{{}},
}
seqSet := imapv2.UIDSetNum(uids...)
fetchCmd := c.Fetch(seqSet, fetchOptions)
imported := 0
for {
msg := fetchCmd.Next()
if msg == nil {
break
}
// Collect body sections from this message
for {
item := msg.Next()
if item == nil {
break
}
switch body := item.(type) {
case imapclient.FetchItemDataBodySection:
raw, err := io.ReadAll(body.Literal)
if err != nil {
log.Warn("failed to read message body", "err", err)
continue
}
if err := imp.storeAndIndex(raw, log); err != nil {
log.Warn("failed to store/index message", "err", err)
continue
}
imported++
}
}
}
if err := fetchCmd.Close(); err != nil {
return imported, fmt.Errorf("fetch close: %w", err)
}
return imported, nil
}
// storeAndIndex saves a raw email to storage and indexes it.
func (imp *Importer) storeAndIndex(raw []byte, log *slog.Logger) error {
// Save to file storage (deduplicates by SHA256 automatically)
id, err := imp.mailStore.Save(raw, time.Now())
if err != nil {
return fmt.Errorf("save: %w", err)
}
// Parse for indexing
pm, err := mailparser.Parse(raw)
if err != nil {
log.Warn("failed to parse mail for indexing", "id", id, "err", err)
// Store succeeded, just skip indexing for unparseable mails
return nil
}
// Build attachment names string
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)),
}
if err := imp.idx.IndexSync(doc); err != nil {
log.Warn("failed to index mail", "id", id, "err", err)
// Non-fatal: mail is stored, just not searchable yet
}
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package imap
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Account represents an IMAP account configuration stored in the database.
type Account struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
Username string `json:"username"`
ExcludedFolders []string `json:"excluded_folders"`
Status string `json:"status"`
ErrorMsg string `json:"error_msg"`
LastImportAt *time.Time `json:"last_import_at,omitempty"`
LastImportCount int `json:"last_import_count"`
ProgressCurrent int `json:"progress_current"`
ProgressTotal int `json:"progress_total"`
CreatedAt time.Time `json:"created_at"`
}
// Store manages IMAP account persistence in PostgreSQL.
type Store struct {
pool *pgxpool.Pool
encKey [32]byte
}
const createTableSQL = `
CREATE TABLE IF NOT EXISTS imap_accounts (
id SERIAL PRIMARY KEY,
owner TEXT NOT NULL,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 993,
tls TEXT NOT NULL DEFAULT 'ssl',
username TEXT NOT NULL,
password_enc BYTEA NOT NULL,
excluded_folders TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'idle',
error_msg TEXT NOT NULL DEFAULT '',
last_import_at TIMESTAMPTZ,
last_import_count INTEGER NOT NULL DEFAULT 0,
progress_current INTEGER NOT NULL DEFAULT 0,
progress_total INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_imap_accounts_owner ON imap_accounts (owner);
`
// New creates a new Store, connects to PostgreSQL, and runs the migration.
func New(dsn, secret string) (*Store, error) {
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return nil, fmt.Errorf("imap store: connect: %w", err)
}
if _, err := pool.Exec(context.Background(), createTableSQL); err != nil {
pool.Close()
return nil, fmt.Errorf("imap store: migrate: %w", err)
}
key := sha256.Sum256([]byte(secret))
return &Store{pool: pool, encKey: key}, nil
}
// Close releases the database connection pool.
func (s *Store) Close() {
s.pool.Close()
}
// Create inserts a new IMAP account with an encrypted password.
func (s *Store) Create(ctx context.Context, acc Account, password string) (*Account, error) {
enc, err := encryptPassword(password, s.encKey)
if err != nil {
return nil, fmt.Errorf("imap store: encrypt password: %w", err)
}
row := s.pool.QueryRow(ctx, `
INSERT INTO imap_accounts (owner, name, host, port, tls, username, password_enc, excluded_folders)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at`,
acc.Owner, acc.Name, acc.Host, acc.Port, acc.TLS, acc.Username, enc, acc.ExcludedFolders,
)
if err := row.Scan(&acc.ID, &acc.CreatedAt); err != nil {
return nil, fmt.Errorf("imap store: create: %w", err)
}
acc.Status = "idle"
acc.ErrorMsg = ""
return &acc, nil
}
// List returns IMAP accounts. Admins see all accounts; regular users see only their own.
func (s *Store) List(ctx context.Context, owner string, isAdmin bool) ([]Account, error) {
var rows pgx.Rows
var err error
if isAdmin {
rows, err = s.pool.Query(ctx, `
SELECT id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at
FROM imap_accounts ORDER BY id`)
} else {
rows, err = s.pool.Query(ctx, `
SELECT id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at
FROM imap_accounts WHERE owner = $1 ORDER BY id`, owner)
}
if err != nil {
return nil, fmt.Errorf("imap store: list: %w", err)
}
defer rows.Close()
var accounts []Account
for rows.Next() {
var a Account
if err := rows.Scan(
&a.ID, &a.Owner, &a.Name, &a.Host, &a.Port, &a.TLS, &a.Username,
&a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt,
&a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt,
); err != nil {
return nil, fmt.Errorf("imap store: scan: %w", err)
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
// Get returns a single IMAP account by ID.
func (s *Store) Get(ctx context.Context, id int64) (*Account, error) {
var a Account
err := s.pool.QueryRow(ctx, `
SELECT id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at
FROM imap_accounts WHERE id = $1`, id,
).Scan(
&a.ID, &a.Owner, &a.Name, &a.Host, &a.Port, &a.TLS, &a.Username,
&a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt,
&a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("imap store: get %d: %w", id, err)
}
return &a, nil
}
// GetPassword retrieves and decrypts the stored password for an IMAP account.
func (s *Store) GetPassword(ctx context.Context, id int64) (string, error) {
var enc []byte
err := s.pool.QueryRow(ctx, `SELECT password_enc FROM imap_accounts WHERE id = $1`, id).Scan(&enc)
if err != nil {
return "", fmt.Errorf("imap store: get password: %w", err)
}
return decryptPassword(enc, s.encKey)
}
// Delete removes an IMAP account by ID.
func (s *Store) Delete(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM imap_accounts WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("imap store: delete: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("imap store: account %d not found", id)
}
return nil
}
// UpdateExcluded sets the list of excluded folders for an account.
func (s *Store) UpdateExcluded(ctx context.Context, id int64, excluded []string) error {
_, err := s.pool.Exec(ctx, `UPDATE imap_accounts SET excluded_folders = $1 WHERE id = $2`, excluded, id)
if err != nil {
return fmt.Errorf("imap store: update excluded: %w", err)
}
return nil
}
// UpdateStatus updates the import progress and status of an account.
func (s *Store) UpdateStatus(ctx context.Context, id int64, status, errMsg string, current, total int) error {
_, err := s.pool.Exec(ctx, `
UPDATE imap_accounts
SET status = $1, error_msg = $2, progress_current = $3, progress_total = $4
WHERE id = $5`, status, errMsg, current, total, id)
if err != nil {
return fmt.Errorf("imap store: update status: %w", err)
}
return nil
}
// UpdateDone marks an import as completed, setting status back to idle.
func (s *Store) UpdateDone(ctx context.Context, id int64, count int) error {
_, err := s.pool.Exec(ctx, `
UPDATE imap_accounts
SET status = 'idle', error_msg = '', last_import_at = now(),
last_import_count = $1, progress_current = 0, progress_total = 0
WHERE id = $2`, count, id)
if err != nil {
return fmt.Errorf("imap store: update done: %w", err)
}
return nil
}
// encryptPassword encrypts a plaintext password using AES-256-GCM.
func encryptPassword(plaintext string, key [32]byte) ([]byte, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
}
// decryptPassword decrypts a password previously encrypted with encryptPassword.
func decryptPassword(ciphertext []byte, key [32]byte) (string, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", fmt.Errorf("decrypt failed: %w", err)
}
return string(plaintext), nil
}
+61
View File
@@ -0,0 +1,61 @@
package index
import (
"fmt"
"time"
)
// MailDocument is the indexed representation of a stored email.
type MailDocument struct {
ID string
From string
To string
Subject string
Body string
AttachNames string
HasAttachment bool
Date time.Time
Size int64
}
// SearchRequest specifies search parameters.
type SearchRequest struct {
Query string
From string
To string
OwnEmail string
DateFrom *time.Time
DateTo *time.Time
PageSize int
Page int
}
// Hit is a single search result.
type Hit struct {
ID string `json:"id"`
Score float64 `json:"score"`
}
// SearchResult holds paginated search results.
type SearchResult struct {
Total int
Hits []Hit
}
// Indexer is the interface for full-text email indexing.
type Indexer interface {
IndexSync(doc MailDocument) error
Search(req SearchRequest) (*SearchResult, error)
Delete(id string) error
Close() error
}
// New creates an Indexer for the specified backend.
func New(dir string, batchSize int, backend string) (Indexer, error) {
switch backend {
case "xapian":
return newXapian(dir)
default:
return nil, fmt.Errorf("unknown index backend: %q (supported: xapian)", backend)
}
}
+192
View File
@@ -0,0 +1,192 @@
package index_test
import (
"testing"
"time"
"github.com/archivmail/internal/index"
)
// newXapianIndex creates a temporary Xapian index for testing.
func newXapianIndex(t *testing.T) index.Indexer {
t.Helper()
idx, err := index.New(t.TempDir(), 100, "xapian")
if err != nil {
t.Skip("xapian not available:", err)
}
t.Cleanup(func() { idx.Close() })
return idx
}
func seedDocs(t *testing.T, idx index.Indexer) {
t.Helper()
docs := []index.MailDocument{
{
ID: "aaa111",
From: "alice@example.com",
To: "bob@example.com",
Subject: "Invoice Q1-2026",
Body: "Please find attached the invoice for January.",
Date: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
Size: 1024,
},
{
ID: "bbb222",
From: "bob@example.com",
To: "alice@example.com charlie@example.com",
Subject: "Meeting Agenda",
Body: "Agenda for the quarterly review meeting.",
Date: time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC),
Size: 512,
},
{
ID: "ccc333",
From: "charlie@example.com",
To: "alice@example.com",
Subject: "Offer with attachment",
Body: "Please review the attached offer document.",
AttachNames: "offer.pdf",
HasAttachment: true,
Date: time.Date(2026, 3, 1, 14, 0, 0, 0, time.UTC),
Size: 8192,
},
}
for _, d := range docs {
if err := idx.IndexSync(d); err != nil {
t.Fatalf("IndexSync %s: %v", d.ID, err)
}
}
}
func TestIndexAndSearchFulltext(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
result, err := idx.Search(index.SearchRequest{Query: "invoice", PageSize: 10})
if err != nil {
t.Fatalf("Search: %v", err)
}
if result.Total == 0 {
t.Error("expected at least 1 hit for 'invoice'")
}
if result.Hits[0].ID != "aaa111" {
t.Errorf("top hit = %q, want aaa111", result.Hits[0].ID)
}
}
func TestSearchMatchAll(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
result, err := idx.Search(index.SearchRequest{PageSize: 25})
if err != nil {
t.Fatalf("Search all: %v", err)
}
if result.Total != 3 {
t.Errorf("expected 3 total hits, got %d", result.Total)
}
}
func TestSearchFromFilter(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
result, err := idx.Search(index.SearchRequest{
From: "alice@example.com",
PageSize: 25,
})
if err != nil {
t.Fatalf("Search from: %v", err)
}
if result.Total != 1 {
t.Errorf("expected 1 hit from alice, got %d", result.Total)
}
}
func TestSearchDateRange(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 2, 1, 23, 59, 59, 0, time.UTC)
result, err := idx.Search(index.SearchRequest{
DateFrom: &from,
DateTo: &to,
PageSize: 25,
})
if err != nil {
t.Fatalf("Search date range: %v", err)
}
if result.Total != 2 {
t.Errorf("expected 2 hits in Jan-Feb 2026, got %d", result.Total)
}
}
func TestSearchOwnEmail(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
// charlie@example.com sent 1 mail and received 1 mail = should see 2
result, err := idx.Search(index.SearchRequest{
OwnEmail: "charlie@example.com",
PageSize: 25,
})
if err != nil {
t.Fatalf("Search OwnEmail: %v", err)
}
if result.Total < 1 {
t.Errorf("charlie should see at least 1 mail, got %d", result.Total)
}
}
func TestSearchPagination(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
page0, _ := idx.Search(index.SearchRequest{PageSize: 2, Page: 0})
page1, _ := idx.Search(index.SearchRequest{PageSize: 2, Page: 1})
if len(page0.Hits) != 2 {
t.Errorf("page 0: expected 2 hits, got %d", len(page0.Hits))
}
if len(page1.Hits) != 1 {
t.Errorf("page 1: expected 1 hit, got %d", len(page1.Hits))
}
// No overlap
if page0.Hits[0].ID == page1.Hits[0].ID {
t.Error("pagination returned duplicate results")
}
}
func TestDelete(t *testing.T) {
idx := newXapianIndex(t)
seedDocs(t, idx)
if err := idx.Delete("aaa111"); err != nil {
t.Fatalf("Delete: %v", err)
}
result, _ := idx.Search(index.SearchRequest{Query: "invoice", PageSize: 10})
for _, h := range result.Hits {
if h.ID == "aaa111" {
t.Error("deleted document still in results")
}
}
}
func TestUnknownBackend(t *testing.T) {
_, err := index.New(t.TempDir(), 10, "elasticsearch")
if err == nil {
t.Error("expected error for unknown backend")
}
}
func TestXapianNotCompiledError(t *testing.T) {
_, err := index.New(t.TempDir(), 10, "xapian")
// Without -tags xapian this must return a helpful error
if err == nil {
t.Log("xapian compiled in — skipping stub error test")
} else {
t.Logf("xapian stub error (expected): %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
//go:build xapian
package index
/*
#cgo pkg-config: xapian-core
#cgo LDFLAGS: -lstdc++
#include "xapian_wrapper.h"
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"fmt"
"unsafe"
)
type xapianIndex struct {
db *C.XapianDB
}
func newXapian(dir string) (Indexer, error) {
cdir := C.CString(dir)
defer C.free(unsafe.Pointer(cdir))
var cerr *C.char
db := C.xapian_open(cdir, 1, &cerr)
if db == nil {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return nil, fmt.Errorf("xapian open: %s", msg)
}
return &xapianIndex{db: db}, nil
}
func (x *xapianIndex) IndexSync(doc MailDocument) error {
cid := C.CString(doc.ID)
defer C.free(unsafe.Pointer(cid))
cfrom := C.CString(doc.From)
defer C.free(unsafe.Pointer(cfrom))
cto := C.CString(doc.To)
defer C.free(unsafe.Pointer(cto))
csubj := C.CString(doc.Subject)
defer C.free(unsafe.Pointer(csubj))
cbody := C.CString(doc.Body)
defer C.free(unsafe.Pointer(cbody))
var cerr *C.char
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), &cerr)
if rc != 0 {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return fmt.Errorf("xapian index: %s", msg)
}
return nil
}
func (x *xapianIndex) Delete(id string) error {
cid := C.CString(id)
defer C.free(unsafe.Pointer(cid))
var cerr *C.char
rc := C.xapian_delete(x.db, cid, &cerr)
if rc != 0 {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return fmt.Errorf("xapian delete: %s", msg)
}
return nil
}
func (x *xapianIndex) Search(req SearchRequest) (*SearchResult, error) {
cquery := C.CString(req.Query)
defer C.free(unsafe.Pointer(cquery))
cfrom := C.CString(req.From)
defer C.free(unsafe.Pointer(cfrom))
cown := C.CString(req.OwnEmail)
defer C.free(unsafe.Pointer(cown))
cto := C.CString(req.To)
defer C.free(unsafe.Pointer(cto))
var dateFrom, dateTo C.longlong
if req.DateFrom != nil {
dateFrom = C.longlong(req.DateFrom.Unix())
}
if req.DateTo != nil {
dateTo = C.longlong(req.DateTo.Unix())
}
page := req.Page
if page < 1 {
page = 1
}
offset := C.int((page - 1) * req.PageSize)
limit := C.int(req.PageSize)
if limit <= 0 {
limit = 25
}
var cerr *C.char
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, &cerr)
if cresult == nil {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
return nil, fmt.Errorf("xapian search: %s", msg)
}
defer C.xapian_free_string(cresult)
jsonStr := C.GoString(cresult)
var raw struct {
Total int `json:"total"`
Hits []struct {
ID string `json:"id"`
Score float64 `json:"score"`
} `json:"hits"`
}
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return nil, fmt.Errorf("xapian parse result: %w", err)
}
hits := make([]Hit, len(raw.Hits))
for i, h := range raw.Hits {
hits[i] = Hit{ID: h.ID, Score: h.Score}
}
return &SearchResult{Total: raw.Total, Hits: hits}, nil
}
func (x *xapianIndex) Close() error {
C.xapian_close(x.db)
return nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !xapian
package index
import "errors"
func newXapian(dir string) (Indexer, error) {
return nil, errors.New("xapian: not compiled in — rebuild with: go build -tags xapian")
}
+199
View File
@@ -0,0 +1,199 @@
#include "xapian_wrapper.h"
#include <xapian.h>
#include <cstring>
#include <cstdlib>
#include <string>
#include <sstream>
#include <stdexcept>
struct XapianDB {
Xapian::WritableDatabase* wdb;
Xapian::Database* rdb;
bool writable;
};
static char* dup_error(const std::string& msg) {
char* s = (char*)malloc(msg.size() + 1);
if (s) memcpy(s, msg.c_str(), msg.size() + 1);
return s;
}
extern "C" {
XapianDB* xapian_open(const char* path, int writable, char** err) {
try {
XapianDB* db = new XapianDB{nullptr, nullptr, (bool)writable};
if (writable) {
db->wdb = new Xapian::WritableDatabase(path, Xapian::DB_CREATE_OR_OPEN);
} else {
db->rdb = new Xapian::Database(path);
}
return db;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return nullptr;
}
}
void xapian_close(XapianDB* db) {
if (!db) return;
if (db->wdb) { db->wdb->close(); delete db->wdb; }
if (db->rdb) { db->rdb->close(); delete db->rdb; }
delete db;
}
int xapian_index(XapianDB* db, const char* id, const char* from,
const char* to, const char* subject, const char* body,
long long timestamp, char** err) {
try {
Xapian::Document doc;
Xapian::TermGenerator gen;
gen.set_document(doc);
gen.set_stemmer(Xapian::Stem("en"));
// Prefix-indexed fields for filtering
gen.index_text(from, 1, "XF");
gen.index_text(to, 1, "XT");
gen.index_text(subject, 1, "XS");
// Free-text indexed fields
gen.index_text(subject);
gen.increase_termpos();
gen.index_text(body);
gen.increase_termpos();
gen.index_text(from);
gen.increase_termpos();
gen.index_text(to);
// Store timestamp for date range queries (value slot 0)
doc.add_value(0, Xapian::sortable_serialise((double)timestamp));
// Store ID as document data
doc.set_data(id);
doc.add_boolean_term(std::string("Q") + id);
db->wdb->replace_document(std::string("Q") + id, doc);
db->wdb->commit();
return 0;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return -1;
}
}
int xapian_delete(XapianDB* db, const char* id, char** err) {
try {
db->wdb->delete_document(std::string("Q") + id);
db->wdb->commit();
return 0;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return -1;
}
}
char* xapian_search(XapianDB* db, const char* query_str,
const char* from_filter, const char* own_email,
const char* to_filter,
long long date_from, long long date_to,
int offset, int limit, char** err) {
try {
Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb;
Xapian::Enquire enquire(xdb);
Xapian::Query main_query;
// Full-text query
if (query_str && query_str[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
qp.set_stemmer(Xapian::Stem("en"));
qp.set_stemming_strategy(Xapian::QueryParser::STEM_SOME);
qp.add_prefix("from", "XF");
qp.add_prefix("to", "XT");
qp.add_prefix("subject", "XS");
main_query = qp.parse_query(query_str,
Xapian::QueryParser::FLAG_DEFAULT |
Xapian::QueryParser::FLAG_PARTIAL);
} else {
main_query = Xapian::Query::MatchAll;
}
// From filter
if (from_filter && from_filter[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
Xapian::Query fq = qp.parse_query(from_filter,
Xapian::QueryParser::FLAG_DEFAULT, "XF");
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, fq);
}
// OwnEmail filter: (from=own OR to=own)
if (own_email && own_email[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
Xapian::Query fq = qp.parse_query(own_email,
Xapian::QueryParser::FLAG_DEFAULT, "XF");
Xapian::Query tq = qp.parse_query(own_email,
Xapian::QueryParser::FLAG_DEFAULT, "XT");
Xapian::Query owq(Xapian::Query::OP_OR, fq, tq);
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, owq);
}
// To filter
if (to_filter && to_filter[0] != '\0') {
Xapian::QueryParser qp;
qp.set_database(xdb);
Xapian::Query tq = qp.parse_query(to_filter,
Xapian::QueryParser::FLAG_DEFAULT, "XT");
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, tq);
}
// Date range
if (date_from > 0 || date_to > 0) {
double lo = date_from > 0 ? (double)date_from : 0.0;
double hi = date_to > 0 ? (double)date_to : 1e18;
Xapian::Query drq(Xapian::Query::OP_VALUE_RANGE, 0,
Xapian::sortable_serialise(lo),
Xapian::sortable_serialise(hi));
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, drq);
}
enquire.set_query(main_query);
enquire.set_sort_by_value(0, true); // sort by date desc
// Get total count
Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount());
int total = (int)all.get_matches_estimated();
// Get page
Xapian::MSet mset = enquire.get_mset(offset, limit);
std::ostringstream json;
json << "{\"total\":" << total << ",\"hits\":[";
bool first = true;
for (auto it = mset.begin(); it != mset.end(); ++it) {
if (!first) json << ",";
first = false;
std::string id = it.get_document().get_data();
double score = it.get_weight();
json << "{\"id\":\"" << id << "\",\"score\":" << score << "}";
}
json << "]}";
std::string result = json.str();
char* out = (char*)malloc(result.size() + 1);
memcpy(out, result.c_str(), result.size() + 1);
return out;
} catch (const std::exception& e) {
if (err) *err = dup_error(e.what());
return nullptr;
}
}
void xapian_free_string(char* s) {
free(s);
}
} // extern "C"
+32
View File
@@ -0,0 +1,32 @@
#ifndef XAPIAN_WRAPPER_H
#define XAPIAN_WRAPPER_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct XapianDB XapianDB;
XapianDB* xapian_open(const char* path, int writable, char** err);
void xapian_close(XapianDB* db);
int xapian_index(XapianDB* db, const char* id, const char* from,
const char* to, const char* subject, const char* body,
long long timestamp, char** err);
int xapian_delete(XapianDB* db, const char* id, char** err);
/* Returns JSON string: {"total":N,"hits":[{"id":"...","score":0.9},...]}
Returns NULL on error, sets *err. Caller must free with xapian_free_string. */
char* xapian_search(XapianDB* db, const char* query,
const char* from_filter, const char* own_email,
const char* to_filter,
long long date_from, long long date_to,
int offset, int limit, char** err);
void xapian_free_string(char* s);
#ifdef __cplusplus
}
#endif
#endif
+283
View File
@@ -0,0 +1,283 @@
// Package smtpd implements an embedded receive-only SMTP daemon for archivmail.
// It accepts incoming emails (e.g. from Postfix via always_bcc) and hands them
// off to the storage coordinator. No AUTH, no relay, no outbound mail.
package smtpd
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/emersion/go-smtp"
"github.com/archivmail/config"
"github.com/archivmail/internal/storage"
)
// Stats holds runtime statistics for the SMTP daemon.
type Stats struct {
Received atomic.Int64 // total emails successfully stored
Rejected atomic.Int64 // rejected (IP, size, etc.)
LastMailAt atomic.Value // time.Time of last accepted mail
}
// Daemon is the embedded receive-only SMTP server.
type Daemon struct {
cfg config.SMTPConfig
store *storage.Store
logger *slog.Logger
stats Stats
server *smtp.Server
mu sync.Mutex
running bool
}
// New creates a new SMTP Daemon. Call Start() to begin accepting connections.
func New(cfg config.SMTPConfig, store *storage.Store, logger *slog.Logger) *Daemon {
d := &Daemon{
cfg: cfg,
store: store,
logger: logger,
}
d.stats.LastMailAt.Store(time.Time{})
return d
}
// Start launches the SMTP daemon in a background goroutine.
// It returns immediately; use Stop() for graceful shutdown.
func (d *Daemon) Start() error {
if !d.cfg.Enabled {
d.logger.Info("SMTP daemon disabled via config")
return nil
}
bind := d.cfg.Bind
if bind == "" {
bind = ":2525"
}
domain := d.cfg.Domain
if domain == "" {
domain = "archivmail"
}
maxBytes := int64(d.cfg.MaxSizeMB) * 1024 * 1024
if maxBytes <= 0 {
maxBytes = 50 * 1024 * 1024 // 50 MB default
}
backend := &backend{daemon: d}
srv := smtp.NewServer(backend)
srv.Addr = bind
srv.Domain = domain
srv.MaxMessageBytes = maxBytes
srv.ReadTimeout = 5 * time.Minute
srv.WriteTimeout = 30 * time.Second
srv.AllowInsecureAuth = false // no AUTH offered at all
// TLS / STARTTLS
if d.cfg.TLSCert != "" && d.cfg.TLSKey != "" {
cert, err := tls.LoadX509KeyPair(d.cfg.TLSCert, d.cfg.TLSKey)
if err != nil {
return fmt.Errorf("smtpd: load TLS cert: %w", err)
}
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
}
d.mu.Lock()
d.server = srv
d.running = true
d.mu.Unlock()
go func() {
d.logger.Info("SMTP daemon starting", "addr", bind, "domain", domain,
"max_size_mb", d.cfg.MaxSizeMB, "tls", d.cfg.TLSCert != "")
if err := srv.ListenAndServe(); err != nil {
if !errors.Is(err, smtp.ErrServerClosed) {
d.logger.Error("SMTP daemon error", "err", err)
}
}
d.mu.Lock()
d.running = false
d.mu.Unlock()
}()
return nil
}
// Stop shuts down the SMTP daemon gracefully.
func (d *Daemon) Stop() {
d.mu.Lock()
srv := d.server
d.mu.Unlock()
if srv != nil {
srv.Close()
}
}
// Status returns a snapshot of the daemon's current state.
func (d *Daemon) Status() StatusResponse {
d.mu.Lock()
running := d.running
d.mu.Unlock()
lastMail, _ := d.stats.LastMailAt.Load().(time.Time)
var lastMailStr string
if !lastMail.IsZero() {
lastMailStr = lastMail.UTC().Format(time.RFC3339)
}
bind := d.cfg.Bind
if bind == "" {
bind = ":2525"
}
return StatusResponse{
Running: running,
Enabled: d.cfg.Enabled,
Bind: bind,
Domain: d.cfg.Domain,
TLS: d.cfg.TLSCert != "",
MaxSizeMB: d.cfg.MaxSizeMB,
AllowedIPs: d.cfg.AllowedIPs,
Received: d.stats.Received.Load(),
Rejected: d.stats.Rejected.Load(),
LastMailAt: lastMailStr,
}
}
// StatusResponse is returned by GET /api/admin/smtp/status.
type StatusResponse struct {
Running bool `json:"running"`
Enabled bool `json:"enabled"`
Bind string `json:"bind"`
Domain string `json:"domain"`
TLS bool `json:"tls"`
MaxSizeMB int `json:"max_size_mb"`
AllowedIPs []string `json:"allowed_ips"`
Received int64 `json:"received"`
Rejected int64 `json:"rejected"`
LastMailAt string `json:"last_mail_at,omitempty"`
}
// ── go-smtp Backend / Session ─────────────────────────────────────────────
type backend struct {
daemon *Daemon
}
func (b *backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
remoteIP := extractIP(c.Conn().RemoteAddr().String())
if !b.daemon.isAllowed(remoteIP) {
b.daemon.stats.Rejected.Add(1)
b.daemon.logger.Warn("SMTP: rejected connection from unlisted IP", "ip", remoteIP)
return nil, &smtp.SMTPError{
Code: 554,
EnhancedCode: smtp.EnhancedCode{5, 7, 1},
Message: "IP not in allowlist",
}
}
b.daemon.logger.Debug("SMTP: new session", "ip", remoteIP)
return &session{
daemon: b.daemon,
remoteIP: remoteIP,
}, nil
}
type session struct {
daemon *Daemon
remoteIP string
from string
rcpts []string
}
// AuthPlain never called because server doesn't advertise AUTH.
func (s *session) AuthPlain(_, _ string) error {
return smtp.ErrAuthUnsupported
}
func (s *session) Mail(from string, _ *smtp.MailOptions) error {
s.from = from
return nil
}
func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error {
s.rcpts = append(s.rcpts, to)
return nil
}
func (s *session) Data(r io.Reader) error {
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
s.daemon.stats.Rejected.Add(1)
return fmt.Errorf("smtpd: read data: %w", err)
}
raw := buf.Bytes()
id, err := s.daemon.store.Save(raw, time.Now())
if err != nil {
s.daemon.stats.Rejected.Add(1)
s.daemon.logger.Error("SMTP: storage failed", "from", s.from, "err", err)
return &smtp.SMTPError{
Code: 554,
EnhancedCode: smtp.EnhancedCode{4, 6, 0},
Message: "Storage failure, please retry",
}
}
s.daemon.stats.Received.Add(1)
s.daemon.stats.LastMailAt.Store(time.Now())
s.daemon.logger.Info("SMTP: mail stored", "id", id, "from", s.from,
"rcpts", strings.Join(s.rcpts, ","), "bytes", len(raw), "ip", s.remoteIP)
return nil
}
func (s *session) Reset() {
s.from = ""
s.rcpts = nil
}
func (s *session) Logout() error {
return nil
}
// ── Helpers ───────────────────────────────────────────────────────────────
// isAllowed returns true if the IP is in the allowlist, or if the allowlist
// is empty (allow-all mode for development).
func (d *Daemon) isAllowed(ip string) bool {
if len(d.cfg.AllowedIPs) == 0 {
return true // no restriction configured
}
for _, allowed := range d.cfg.AllowedIPs {
// Support CIDR notation (e.g. 192.168.1.0/24)
if strings.Contains(allowed, "/") {
_, network, err := net.ParseCIDR(allowed)
if err == nil && network.Contains(net.ParseIP(ip)) {
return true
}
continue
}
if allowed == ip {
return true
}
}
return false
}
// extractIP strips port from "ip:port" or "[::1]:port" strings.
func extractIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
+145
View File
@@ -0,0 +1,145 @@
package storage
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
)
// Store is a file-based email storage using SHA256 for deduplication.
type Store struct {
dir string
}
// StoreStats reports total mail count and size in bytes.
type StoreStats struct {
TotalMails int64
TotalBytes int64
}
// New initialises the storage directory, creating required subdirectories.
func New(dir string) (*Store, error) {
for _, sub := range []string{"store", "attachments", "meta"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
return nil, fmt.Errorf("storage: mkdir %s: %w", sub, err)
}
}
return &Store{dir: dir}, nil
}
// Save writes raw email bytes to storage. The ID is the hex-encoded SHA256 of
// the content. If the file already exists, Save is a no-op (deduplication).
func (s *Store) Save(raw []byte, _ time.Time) (string, error) {
sum := sha256.Sum256(raw)
id := fmt.Sprintf("%x", sum[:]) // 64 hex chars
path := s.filePath(id)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("storage: mkdir shard: %w", err)
}
// If file already exists, dedup: return same id without error.
if _, err := os.Stat(path); err == nil {
return id, nil
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
return "", fmt.Errorf("storage: write: %w", err)
}
return id, nil
}
// Load reads a stored email by its ID.
func (s *Store) Load(id string) ([]byte, error) {
path := s.filePath(id)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("storage: not found: %s", id)
}
return nil, fmt.Errorf("storage: read: %w", err)
}
return data, nil
}
// Delete removes a stored email by its ID.
func (s *Store) Delete(id string) error {
path := s.filePath(id)
if err := os.Remove(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("storage: not found: %s", id)
}
return fmt.Errorf("storage: delete: %w", err)
}
return nil
}
// Stats walks the store directory and returns aggregate statistics.
func (s *Store) Stats() (*StoreStats, error) {
var stats StoreStats
err := filepath.WalkDir(filepath.Join(s.dir, "store"), func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
stats.TotalMails++
stats.TotalBytes += info.Size()
return nil
})
if err != nil {
return nil, fmt.Errorf("storage: stats: %w", err)
}
return &stats, nil
}
// MailRef holds the ID and modification time of a stored mail.
type MailRef struct {
ID string
ModTime time.Time
}
// FirstAndLastMail walks the store and returns the oldest and newest mail by
// file modification time. Returns nil for either if the store is empty.
func (s *Store) FirstAndLastMail() (first, last *MailRef, err error) {
err = filepath.WalkDir(filepath.Join(s.dir, "store"), func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
ref := &MailRef{ID: d.Name(), ModTime: info.ModTime()}
if first == nil || ref.ModTime.Before(first.ModTime) {
first = ref
}
if last == nil || ref.ModTime.After(last.ModTime) {
last = ref
}
return nil
})
if err != nil {
return nil, nil, fmt.Errorf("storage: first/last: %w", err)
}
return first, last, nil
}
// filePath returns the on-disk path for a given mail ID.
// Uses 2-char prefix sharding: {dir}/store/{id[:2]}/{id}
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, "store", id[:2], id)
}
+126
View File
@@ -0,0 +1,126 @@
package storage_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
"github.com/archivmail/internal/storage"
)
func TestSaveAndLoad(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
raw := []byte("From: alice@example.com\r\nSubject: Test\r\n\r\nHello World")
id, err := store.Save(raw, time.Now())
if err != nil {
t.Fatalf("Save: %v", err)
}
if len(id) != 64 {
t.Errorf("expected 64-char SHA256 hex, got %d chars", len(id))
}
got, err := store.Load(id)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !bytes.Equal(raw, got) {
t.Errorf("loaded content mismatch")
}
}
func TestDeduplication(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatal(err)
}
raw := []byte("From: alice@example.com\r\n\r\nDuplicate test")
id1, err := store.Save(raw, time.Now())
if err != nil {
t.Fatal(err)
}
id2, err := store.Save(raw, time.Now())
if err != nil {
t.Fatal(err)
}
if id1 != id2 {
t.Errorf("duplicate mail produced different IDs: %s vs %s", id1, id2)
}
// Only one file should exist
count := 0
filepath.Walk(filepath.Join(dir, "store"), func(p string, info os.FileInfo, _ error) error {
if !info.IsDir() { count++ }
return nil
})
if count != 1 {
t.Errorf("expected 1 stored file after dedup, got %d", count)
}
}
func TestDelete(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatal(err)
}
raw := []byte("From: alice@example.com\r\n\r\nDelete me")
id, _ := store.Save(raw, time.Now())
if err := store.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.Load(id); err == nil {
t.Error("Load after Delete should return error")
}
}
func TestStats(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatal(err)
}
mails := [][]byte{
[]byte("From: a@x.com\r\n\r\nMail 1"),
[]byte("From: b@x.com\r\n\r\nMail 2"),
[]byte("From: c@x.com\r\n\r\nMail 3"),
}
for _, m := range mails {
store.Save(m, time.Now())
}
stats, err := store.Stats()
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.TotalMails != 3 {
t.Errorf("expected 3 mails, got %d", stats.TotalMails)
}
if stats.TotalBytes <= 0 {
t.Error("expected positive TotalBytes")
}
}
func TestStorageDirectoryCreation(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "path")
_, err := storage.New(dir)
if err != nil {
t.Fatalf("New with nested path: %v", err)
}
for _, sub := range []string{"store", "attachments", "meta"} {
if _, err := os.Stat(filepath.Join(dir, sub)); os.IsNotExist(err) {
t.Errorf("expected subdirectory %s to be created", sub)
}
}
}
+304
View File
@@ -0,0 +1,304 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
const (
RoleUser = "user"
RoleAdmin = "admin"
RoleAuditor = "auditor"
)
// User represents a user account in the system.
type User struct {
ID int64
Username string
Email string
Role string
Source string // "local" or "ldap"
Active bool
CreatedAt time.Time
}
// CreateUserRequest holds parameters for creating a new user.
type CreateUserRequest struct {
Username string
Email string
Password string
Role string
}
// UpdateUserRequest holds optional fields for updating a user.
type UpdateUserRequest struct {
Email *string
Role *string
Active *bool
Password *string
}
// Store is a PostgreSQL-backed user store.
type Store struct {
pool *pgxpool.Pool
}
// New connects to PostgreSQL using the given DSN and initialises the schema.
func New(dsn string) (*Store, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("userstore: connect: %w", err)
}
s := &Store{pool: pool}
if err := s.initSchema(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("userstore: init schema: %w", err)
}
return s, nil
}
func (s *Store) initSchema(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')),
source VARCHAR(20) NOT NULL DEFAULT 'local',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS token_blacklist (
jti VARCHAR(255) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL
);
`)
return err
}
// Close closes the underlying connection pool.
func (s *Store) Close() error {
s.pool.Close()
return nil
}
// Create inserts a new local user with a bcrypt-hashed password.
func (s *Store) Create(req CreateUserRequest) (*User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
}
ctx := context.Background()
var id int64
err = s.pool.QueryRow(ctx,
`INSERT INTO users (username, email, password_hash, role, source, active, created_at)
VALUES ($1, $2, $3, $4, 'local', true, NOW())
RETURNING id`,
req.Username, req.Email, string(hash), req.Role,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("userstore: create: %w", err)
}
return s.GetByID(id)
}
// GetByID retrieves a user by their numeric ID.
func (s *Store) GetByID(id int64) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users WHERE id = $1`, id,
)
return scanUser(row)
}
// GetByUsername retrieves a user by their username.
func (s *Store) GetByUsername(username string) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users WHERE username = $1`, username,
)
return scanUser(row)
}
// VerifyPassword checks credentials and returns the user, or an error if the
// password is wrong or the account is disabled.
func (s *Store) VerifyPassword(username, password string) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, password_hash FROM users WHERE username = $1`,
username,
)
var u User
var hash string
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("userstore: user not found")
}
if err != nil {
return nil, fmt.Errorf("userstore: scan: %w", err)
}
if !u.Active {
return nil, errors.New("userstore: account disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, errors.New("userstore: wrong password")
}
return &u, nil
}
// Update applies a partial update to a user record.
func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) {
ctx := context.Background()
if req.Email != nil {
if _, err := s.pool.Exec(ctx, `UPDATE users SET email = $1 WHERE id = $2`, *req.Email, id); err != nil {
return nil, fmt.Errorf("userstore: update email: %w", err)
}
}
if req.Role != nil {
if _, err := s.pool.Exec(ctx, `UPDATE users SET role = $1 WHERE id = $2`, *req.Role, id); err != nil {
return nil, fmt.Errorf("userstore: update role: %w", err)
}
}
if req.Active != nil {
if _, err := s.pool.Exec(ctx, `UPDATE users SET active = $1 WHERE id = $2`, *req.Active, id); err != nil {
return nil, fmt.Errorf("userstore: update active: %w", err)
}
}
if req.Password != nil {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
}
if _, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), id); err != nil {
return nil, fmt.Errorf("userstore: update password: %w", err)
}
}
return s.GetByID(id)
}
// Delete removes a user by ID. Returns an error if the user does not exist.
func (s *Store) Delete(id int64) error {
ctx := context.Background()
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("userstore: delete: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("userstore: user %d not found", id)
}
return nil
}
// List returns all users, optionally filtered by role. Pass role="" to list all.
func (s *Store) List(role string) ([]*User, error) {
ctx := context.Background()
var rows pgx.Rows
var err error
if role == "" {
rows, err = s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users ORDER BY id`)
} else {
rows, err = s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users WHERE role = $1 ORDER BY id`, role)
}
if err != nil {
return nil, fmt.Errorf("userstore: list: %w", err)
}
defer rows.Close()
var users []*User
for rows.Next() {
u, err := scanUserRow(rows)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// BlacklistToken adds a JWT ID to the token blacklist.
func (s *Store) BlacklistToken(jti string, expires time.Time) error {
ctx := context.Background()
_, err := s.pool.Exec(ctx,
`INSERT INTO token_blacklist (jti, expires_at) VALUES ($1, $2)
ON CONFLICT (jti) DO UPDATE SET expires_at = EXCLUDED.expires_at`,
jti, expires.UTC(),
)
return err
}
// IsBlacklisted returns true if the given JTI is in the blacklist.
func (s *Store) IsBlacklisted(jti string) (bool, error) {
ctx := context.Background()
var count int
err := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM token_blacklist WHERE jti = $1`, jti,
).Scan(&count)
return count > 0, err
}
// CleanExpiredTokens removes blacklist entries whose expiry has passed.
func (s *Store) CleanExpiredTokens() error {
ctx := context.Background()
_, err := s.pool.Exec(ctx, `DELETE FROM token_blacklist WHERE expires_at < NOW()`)
return err
}
// UpsertLDAPUser creates or updates an LDAP-sourced user.
func (s *Store) UpsertLDAPUser(username, email, role string) (*User, error) {
ctx := context.Background()
_, err := s.pool.Exec(ctx, `
INSERT INTO users (username, email, password_hash, role, source, active, created_at)
VALUES ($1, $2, '', $3, 'ldap', true, NOW())
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
role = EXCLUDED.role,
source = 'ldap'
`, username, email, role)
if err != nil {
return nil, fmt.Errorf("userstore: upsert ldap: %w", err)
}
return s.GetByUsername(username)
}
// --- helpers ---
func scanUser(row pgx.Row) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("userstore: not found")
}
if err != nil {
return nil, fmt.Errorf("userstore: scan: %w", err)
}
return &u, nil
}
func scanUserRow(rows pgx.Rows) (*User, error) {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("userstore: scan row: %w", err)
}
return &u, nil
}
+279
View File
@@ -0,0 +1,279 @@
package userstore_test
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/archivmail/internal/userstore"
)
func newTestStore(t *testing.T) *userstore.Store {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
}
// Use a unique schema per test to isolate
schema := "test_" + strings.ReplaceAll(t.Name(), "/", "_")
schema = strings.ToLower(schema)
// Append schema to DSN
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
schemaDSN := dsn + sep + "search_path=" + schema
// Create schema
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema)
conn.Close(ctx)
s, err := userstore.New(schemaDSN)
if err != nil {
t.Fatalf("userstore.New: %v", err)
}
t.Cleanup(func() {
s.Close()
conn2, _ := pgx.Connect(context.Background(), dsn)
if conn2 != nil {
conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
conn2.Close(context.Background())
}
})
return s
}
func TestCreateAndGetUser(t *testing.T) {
s := newTestStore(t)
u, err := s.Create(userstore.CreateUserRequest{
Username: "alice",
Email: "alice@example.com",
Password: "secret123",
Role: userstore.RoleAdmin,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if u.ID == 0 {
t.Error("expected non-zero ID")
}
if u.Username != "alice" {
t.Errorf("Username = %q", u.Username)
}
if u.Role != userstore.RoleAdmin {
t.Errorf("Role = %q", u.Role)
}
if u.Source != "local" {
t.Errorf("Source = %q, want local", u.Source)
}
got, err := s.GetByID(u.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Email != "alice@example.com" {
t.Errorf("Email = %q", got.Email)
}
}
func TestVerifyPassword(t *testing.T) {
s := newTestStore(t)
_, err := s.Create(userstore.CreateUserRequest{
Username: "bob", Email: "bob@example.com",
Password: "correcthorse", Role: userstore.RoleUser,
})
if err != nil {
t.Fatal(err)
}
// Correct password
u, err := s.VerifyPassword("bob", "correcthorse")
if err != nil {
t.Errorf("VerifyPassword correct: %v", err)
}
if u.Username != "bob" {
t.Errorf("Username = %q", u.Username)
}
// Wrong password
if _, err := s.VerifyPassword("bob", "wrongpassword"); err == nil {
t.Error("expected error for wrong password")
}
// Non-existent user
if _, err := s.VerifyPassword("nobody", "x"); err == nil {
t.Error("expected error for unknown user")
}
}
func TestUpdateUser(t *testing.T) {
s := newTestStore(t)
u, _ := s.Create(userstore.CreateUserRequest{
Username: "carol", Email: "carol@old.com",
Password: "pw", Role: userstore.RoleUser,
})
newEmail := "carol@new.com"
newRole := userstore.RoleAuditor
updated, err := s.Update(u.ID, userstore.UpdateUserRequest{
Email: &newEmail,
Role: &newRole,
})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Email != "carol@new.com" {
t.Errorf("Email after update = %q", updated.Email)
}
if updated.Role != userstore.RoleAuditor {
t.Errorf("Role after update = %q", updated.Role)
}
}
func TestDisableUser(t *testing.T) {
s := newTestStore(t)
u, _ := s.Create(userstore.CreateUserRequest{
Username: "dave", Email: "dave@x.com",
Password: "pw", Role: userstore.RoleUser,
})
active := false
s.Update(u.ID, userstore.UpdateUserRequest{Active: &active})
if _, err := s.VerifyPassword("dave", "pw"); err == nil {
t.Error("disabled user should not be able to login")
}
}
func TestDeleteUser(t *testing.T) {
s := newTestStore(t)
u, _ := s.Create(userstore.CreateUserRequest{
Username: "eve", Email: "eve@x.com",
Password: "pw", Role: userstore.RoleUser,
})
if err := s.Delete(u.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.GetByID(u.ID); err == nil {
t.Error("GetByID should error after delete")
}
// Delete non-existent should error
if err := s.Delete(u.ID); err == nil {
t.Error("second delete should return error")
}
}
func TestListUsers(t *testing.T) {
s := newTestStore(t)
users := []userstore.CreateUserRequest{
{Username: "u1", Email: "u1@x.com", Password: "pw", Role: userstore.RoleUser},
{Username: "u2", Email: "u2@x.com", Password: "pw", Role: userstore.RoleAdmin},
{Username: "u3", Email: "u3@x.com", Password: "pw", Role: userstore.RoleAuditor},
{Username: "u4", Email: "u4@x.com", Password: "pw", Role: userstore.RoleUser},
}
for _, req := range users {
s.Create(req)
}
all, err := s.List("")
if err != nil {
t.Fatalf("List all: %v", err)
}
if len(all) != 4 {
t.Errorf("List all: got %d, want 4", len(all))
}
admins, _ := s.List(userstore.RoleAdmin)
if len(admins) != 1 {
t.Errorf("List admin: got %d, want 1", len(admins))
}
regular, _ := s.List(userstore.RoleUser)
if len(regular) != 2 {
t.Errorf("List user: got %d, want 2", len(regular))
}
}
func TestTokenBlacklist(t *testing.T) {
s := newTestStore(t)
jti := "test-jti-12345"
expires := time.Now().Add(1 * time.Hour)
if err := s.BlacklistToken(jti, expires); err != nil {
t.Fatalf("BlacklistToken: %v", err)
}
blacklisted, err := s.IsBlacklisted(jti)
if err != nil {
t.Fatalf("IsBlacklisted: %v", err)
}
if !blacklisted {
t.Error("token should be blacklisted")
}
// Non-blacklisted token
bl2, _ := s.IsBlacklisted("other-jti")
if bl2 {
t.Error("unknown token should not be blacklisted")
}
}
func TestCleanExpiredTokens(t *testing.T) {
s := newTestStore(t)
// Add an already-expired token
s.BlacklistToken("expired-jti", time.Now().Add(-1*time.Hour))
// Add a valid token
s.BlacklistToken("valid-jti", time.Now().Add(1*time.Hour))
if err := s.CleanExpiredTokens(); err != nil {
t.Fatalf("CleanExpiredTokens: %v", err)
}
bl, _ := s.IsBlacklisted("expired-jti")
if bl {
t.Error("expired token should be cleaned up")
}
bl2, _ := s.IsBlacklisted("valid-jti")
if !bl2 {
t.Error("valid token should still be blacklisted")
}
}
func TestUpsertLDAPUser(t *testing.T) {
s := newTestStore(t)
u, err := s.UpsertLDAPUser("ldapuser", "ldap@corp.com", userstore.RoleAuditor)
if err != nil {
t.Fatalf("UpsertLDAPUser: %v", err)
}
if u.Source != "ldap" {
t.Errorf("Source = %q, want ldap", u.Source)
}
// Second upsert should update, not duplicate
u2, err := s.UpsertLDAPUser("ldapuser", "ldap@corp.com", userstore.RoleAuditor)
if err != nil {
t.Fatalf("UpsertLDAPUser second: %v", err)
}
if u2.ID != u.ID {
t.Error("second upsert should not create a new record")
}
all, _ := s.List("")
if len(all) != 1 {
t.Errorf("expected 1 user after double upsert, got %d", len(all))
}
}
+10 -1
View File
@@ -1,7 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
async rewrites() {
const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
return [
{
source: "/api/:path*",
destination: `${apiBase}/api/:path*`,
},
];
},
};
export default nextConfig;
-16
View File
@@ -99,7 +99,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3631,7 +3630,6 @@
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3642,7 +3640,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3701,7 +3698,6 @@
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.52.0",
"@typescript-eslint/types": "8.52.0",
@@ -4201,7 +4197,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4632,7 +4627,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5314,7 +5308,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5500,7 +5493,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6738,7 +6730,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7480,7 +7471,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7682,7 +7672,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7692,7 +7681,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7705,7 +7693,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -8603,7 +8590,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8773,7 +8759,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9127,7 +9112,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+208
View File
@@ -0,0 +1,208 @@
package mailparser
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"strings"
"time"
)
// Attachment represents a MIME attachment in a parsed email.
type Attachment struct {
Filename string
ContentType string
Data []byte
Size int
}
// ParsedMail holds the structured content of a parsed email message.
type ParsedMail struct {
From string
To []string
CC []string
Subject string
MessageID string
TextBody string
HTMLBody string
Date time.Time
Attachments []Attachment
Raw []byte
}
// Parse parses a raw RFC 2822 / MIME email and returns a ParsedMail.
func Parse(raw []byte) (*ParsedMail, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("mailparser: read message: %w", err)
}
pm := &ParsedMail{Raw: raw}
// From
if from := msg.Header.Get("From"); from != "" {
addrs, err := mail.ParseAddressList(from)
if err == nil && len(addrs) > 0 {
pm.From = addrs[0].Address
} else {
pm.From = from
}
}
// To
if to := msg.Header.Get("To"); to != "" {
addrs, err := mail.ParseAddressList(to)
if err == nil {
for _, a := range addrs {
pm.To = append(pm.To, a.Address)
}
}
}
// CC
if cc := msg.Header.Get("Cc"); cc != "" {
addrs, err := mail.ParseAddressList(cc)
if err == nil {
for _, a := range addrs {
pm.CC = append(pm.CC, a.Address)
}
}
}
// Subject - decode MIME encoded-words
pm.Subject = decodeMIMEHeader(msg.Header.Get("Subject"))
// Message-ID - strip angle brackets
msgID := msg.Header.Get("Message-Id")
pm.MessageID = strings.Trim(msgID, "<>")
// Date
if d, err := msg.Header.Date(); err == nil {
pm.Date = d
} else {
pm.Date = time.Now()
}
// Parse body / MIME parts
contentType := msg.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
// No content-type or parse error: treat as plain text
body, _ := io.ReadAll(msg.Body)
pm.TextBody = string(body)
return pm, nil
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if err := parseMultipart(pm, msg.Body, boundary); err != nil {
return nil, fmt.Errorf("mailparser: multipart: %w", err)
}
} else {
body, _ := io.ReadAll(msg.Body)
decoded := decodeBody(body, msg.Header.Get("Content-Transfer-Encoding"))
if strings.Contains(mediaType, "html") {
pm.HTMLBody = string(decoded)
} else {
pm.TextBody = string(decoded)
}
}
return pm, nil
}
// parseMultipart walks MIME parts and fills text, html, and attachments.
func parseMultipart(pm *ParsedMail, body io.Reader, boundary string) error {
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return err
}
ct := part.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
mediaType = "application/octet-stream"
params = map[string]string{}
}
data, _ := io.ReadAll(part)
cte := part.Header.Get("Content-Transfer-Encoding")
decoded := decodeBody(data, cte)
// Check disposition for attachment
disp := part.Header.Get("Content-Disposition")
dispType, dispParams, _ := mime.ParseMediaType(disp)
filename := dispParams["filename"]
if filename == "" {
filename = params["name"]
}
filename = decodeMIMEHeader(filename)
if strings.HasPrefix(dispType, "attachment") || filename != "" {
pm.Attachments = append(pm.Attachments, Attachment{
Filename: filename,
ContentType: mediaType,
Data: decoded,
Size: len(decoded),
})
continue
}
// Nested multipart
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(pm, bytes.NewReader(decoded), params["boundary"]); err != nil {
return err
}
continue
}
switch {
case strings.Contains(mediaType, "text/plain"):
pm.TextBody += string(decoded)
case strings.Contains(mediaType, "text/html"):
pm.HTMLBody += string(decoded)
}
}
return nil
}
// decodeBody decodes Content-Transfer-Encoding if needed.
func decodeBody(data []byte, cte string) []byte {
switch strings.ToLower(strings.TrimSpace(cte)) {
case "quoted-printable":
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data)))
if err == nil {
return decoded
}
case "base64":
clean := bytes.ReplaceAll(data, []byte("\r\n"), []byte{})
clean = bytes.ReplaceAll(clean, []byte("\n"), []byte{})
clean = bytes.ReplaceAll(clean, []byte("\r"), []byte{})
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(clean)))
n, err := base64.StdEncoding.Decode(decoded, clean)
if err == nil {
return decoded[:n]
}
}
return data
}
// decodeMIMEHeader decodes RFC 2047 encoded-word headers.
func decodeMIMEHeader(s string) string {
dec := new(mime.WordDecoder)
decoded, err := dec.DecodeHeader(s)
if err != nil {
return s
}
return decoded
}
+100
View File
@@ -0,0 +1,100 @@
package mailparser_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/archivmail/pkg/mailparser"
)
func readFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("readFixture %s: %v", name, err)
}
return data
}
func TestParseSimple(t *testing.T) {
raw := readFixture(t, "simple.eml")
p, err := mailparser.Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.From != "alice@example.com" {
t.Errorf("From = %q, want alice@example.com", p.From)
}
if len(p.To) != 2 {
t.Errorf("To: got %d recipients, want 2", len(p.To))
}
if len(p.CC) != 1 {
t.Errorf("CC: got %d, want 1", len(p.CC))
}
if p.Subject != "Test Invoice Q1-2026" {
t.Errorf("Subject = %q", p.Subject)
}
if p.MessageID != "test-001@example.com" {
t.Errorf("MessageID = %q", p.MessageID)
}
if !strings.Contains(p.TextBody, "invoice") {
t.Errorf("TextBody missing 'invoice': %q", p.TextBody)
}
if p.Date.IsZero() {
t.Error("Date is zero")
}
}
func TestParseMultipartWithAttachment(t *testing.T) {
raw := readFixture(t, "multipart.eml")
p, err := mailparser.Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.From != "sender@corp.de" {
t.Errorf("From = %q", p.From)
}
if len(p.Attachments) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(p.Attachments))
}
att := p.Attachments[0]
if att.Filename != "angebot.pdf" {
t.Errorf("Attachment filename = %q, want angebot.pdf", att.Filename)
}
if !strings.Contains(att.ContentType, "pdf") {
t.Errorf("ContentType = %q, want pdf", att.ContentType)
}
if att.Size == 0 {
t.Error("attachment size is 0")
}
}
func TestParseRawInline(t *testing.T) {
raw := []byte("From: test@example.com\r\nTo: dest@example.com\r\nSubject: Hello\r\n\r\nBody text here")
p, err := mailparser.Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.From != "test@example.com" {
t.Errorf("From = %q", p.From)
}
if len(p.Attachments) != 0 {
t.Errorf("expected 0 attachments, got %d", len(p.Attachments))
}
}
func TestParseMissingDate(t *testing.T) {
raw := []byte("From: test@example.com\r\nSubject: No Date\r\n\r\nNo date header")
p, err := mailparser.Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
// Should fall back to time.Now(), so should not be zero
if p.Date.IsZero() {
t.Error("Date should fall back to now, not zero")
}
}
+26
View File
@@ -0,0 +1,26 @@
From: sender@corp.de
To: empfaenger@example.com
Subject: Angebot fuer Dienstleistungen
Message-ID: <offer-001@corp.de>
Date: Fri, 13 Mar 2026 09:00:00 +0100
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="----=_Part_001_boundary"
------=_Part_001_boundary
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Bitte finden Sie anbei unser Angebot fuer die gewuenschten Dienstleistungen.
Mit freundlichen Gruessen,
Sender
------=_Part_001_boundary
Content-Type: application/pdf; name="angebot.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="angebot.pdf"
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0
ZURlY29kZT4+CnN0cmVhbQp4nCvkMlAwUDC1NNUzMlAwtrBQKEktLk4tSszMS1cozy/KSVEA
AAAA//8DAFBLAwQUAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAA
------=_Part_001_boundary--
+11
View File
@@ -0,0 +1,11 @@
From: alice@example.com
To: bob@example.com, carol@example.com
CC: dave@example.com
Subject: Test Invoice Q1-2026
Message-ID: <test-001@example.com>
Date: Thu, 12 Mar 2026 10:00:00 +0000
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Please find the invoice for Q1-2026 attached.
This is an invoice for services rendered in January.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# setup.sh — First-time build setup for mailarchive
# Run once on a machine with internet access.
# After this, the project builds and tests without internet.
set -e
echo "==> Checking Go version"
go version
GO_MINOR=$(go version | grep -oP 'go1\.\K[0-9]+')
if [ "$GO_MINOR" -lt 22 ]; then
echo "ERROR: Go 1.22+ required"
exit 1
fi
echo ""
echo "==> Downloading dependencies (go mod tidy)"
go mod tidy
echo ""
echo "==> Verifying modules"
go mod verify
echo ""
echo "==> Building (Bleve backend — default)"
make build
echo ""
echo "==> Binary sizes"
ls -lh bin/
echo ""
echo "==> Running tests"
make test
echo ""
echo "========================================"
echo " Build successful!"
echo ""
echo " To start the daemon:"
echo " sudo make install"
echo " sudo systemctl start mailarchive"
echo ""
echo " Or run directly:"
echo " ./bin/archivmail --config config/config.yml"
echo ""
echo " To build with Xapian (optional, needs libxapian-dev):"
echo " apt install libxapian-dev"
echo " make build-xapian"
echo "========================================"
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# test/smoke_test.sh — manual end-to-end smoke test
# Run AFTER the daemon is started:
# ./bin/mailarchived --config config/config.test.yml
#
# Requirements: curl, jq, swaks (apt install swaks)
set -e
BASE="http://localhost:8080"
PASS=0
FAIL=0
ok() { echo "$1"; ((PASS++)); }
fail() { echo "$1"; ((FAIL++)); }
sep() { echo ""; echo "--- $1 ---"; }
# ---- helper ----
get() { curl -sf -H "Authorization: Bearer $TOKEN" "$BASE$1"; }
post() { curl -sf -X POST -H "Content-Type: application/json" -d "$2" "$BASE$1"; }
postauth() { curl -sf -X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" -d "$2" "$BASE$1"; }
sep "Health"
if get /api/health | grep -q '"ok"'; then
ok "GET /api/health"
else
fail "GET /api/health"
fi
sep "Auth: Login"
RESP=$(post /api/auth/login '{"username":"admin","password":"adminpass"}')
TOKEN=$(echo "$RESP" | jq -r '.token')
if [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ]; then
ok "POST /api/auth/login → token received"
else
fail "POST /api/auth/login"
echo "Response: $RESP"
exit 1
fi
sep "Auth: Me"
ME=$(get /api/auth/me)
if echo "$ME" | grep -q '"admin"'; then
ok "GET /api/auth/me"
else
fail "GET /api/auth/me: $ME"
fi
sep "Auth: Reject wrong password"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"wrongpass"}' \
"$BASE/api/auth/login")
if [ "$CODE" = "401" ]; then
ok "Wrong password → 401"
else
fail "Wrong password → expected 401, got $CODE"
fi
sep "User management"
# Create user
NEW=$(postauth /api/users '{"username":"testuser","email":"test@x.com","password":"testpw","role":"user"}')
UID=$(echo "$NEW" | jq -r '.id')
if [ "$UID" != "null" ] && [ -n "$UID" ]; then
ok "POST /api/users → created id=$UID"
else
fail "POST /api/users: $NEW"
fi
# List users
USERS=$(get /api/users)
COUNT=$(echo "$USERS" | jq '. | length')
if [ "$COUNT" -ge 2 ]; then
ok "GET /api/users → $COUNT users"
else
fail "GET /api/users → expected ≥2, got $COUNT"
fi
sep "SMTP: Send test mail"
if command -v swaks &>/dev/null; then
swaks --to archive@localhost --from sender@localhost \
--server localhost:2525 \
--header "Subject: Smoke Test Invoice" \
--body "This is a smoke test mail." \
--silent 2 && ok "swaks SMTP send" || fail "swaks SMTP send"
sleep 1 # give indexer time
else
echo " ⚠️ swaks not installed, skipping SMTP test (apt install swaks)"
fi
sep "Search"
RESULT=$(get "/api/search?q=smoke")
TOTAL=$(echo "$RESULT" | jq -r '.total // 0')
if [ "$TOTAL" -ge 0 ]; then
ok "GET /api/search → total=$TOTAL"
else
fail "GET /api/search: $RESULT"
fi
sep "EML Import"
mkdir -p /tmp/test-eml
cat > /tmp/test-eml/test.eml << 'EML'
From: import@example.com
To: archive@example.com
Subject: Import Test Mail
Date: Thu, 12 Mar 2026 10:00:00 +0000
This mail was imported via CLI.
EML
./bin/archivmail-import --config config/config.test.yml /tmp/test-eml/ && \
ok "mailarchive-import" || fail "mailarchive-import"
sep "Export"
./bin/archivmail-export --config config/config.test.yml --format eml --out /tmp/test-export/ && \
ok "archivmail-export (EML)" || fail "archivmail-export (EML)"
sep "Audit Log"
AUDIT=$(get "/api/audit")
ATOTAL=$(echo "$AUDIT" | jq -r '.total // 0')
if [ "$ATOTAL" -gt 0 ]; then
ok "GET /api/audit → $ATOTAL entries"
else
fail "GET /api/audit → expected entries, got $ATOTAL"
fi
sep "Logout"
postauth /api/auth/logout '' > /dev/null && ok "POST /api/auth/logout"
# Token should now be rejected
CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" "$BASE/api/auth/me")
if [ "$CODE" = "401" ]; then
ok "Token invalid after logout → 401"
else
fail "Token should be invalid after logout, got $CODE"
fi
# ---- Summary ----
echo ""
echo "========================================"
echo " Smoke test complete"
echo " Passed: $PASS Failed: $FAIL"
echo "========================================"
[ "$FAIL" -eq 0 ]
+946
View File
@@ -0,0 +1,946 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import {
getUsers,
createUser,
getAuditLog,
getSMTPStatus,
getHealth,
getStorageStats,
getServices,
serviceAction,
getSystemStats,
type User,
type AuditEntry,
type SMTPStatus,
type StorageStats,
type ServiceStatus,
type SystemStats,
} from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
const AUDIT_PAGE_SIZE = 25;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export default function AdminPage() {
const { user, loading: authLoading } = useAuth(true);
// Dashboard state
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
const [countdown, setCountdown] = useState(30);
// Services state
const [services, setServices] = useState<ServiceStatus[]>([]);
const [servicesLoading, setServicesLoading] = useState(false);
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
const [serviceError, setServiceError] = useState("");
// Users state
const [users, setUsers] = useState<User[]>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState("");
// Create user dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState("user");
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState("");
// Audit state
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
const [auditPage, setAuditPage] = useState(1);
const [auditLoading, setAuditLoading] = useState(false);
const loadDashboard = useCallback(async () => {
setDashLoading(true);
try {
const [smtp, health, storage, sysStats] = await Promise.allSettled([
getSMTPStatus(),
getHealth(),
getStorageStats(),
getSystemStats(),
]);
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
}
}, []);
const loadUsers = useCallback(async () => {
setUsersLoading(true);
setUsersError("");
try {
const data = await getUsers();
setUsers(data || []);
} catch {
setUsersError("Benutzer konnten nicht geladen werden.");
} finally {
setUsersLoading(false);
}
}, []);
const loadAudit = useCallback(async (p: number) => {
setAuditLoading(true);
try {
const data = await getAuditLog({ page: p, page_size: AUDIT_PAGE_SIZE });
setAuditEntries(data.entries || []);
setAuditTotal(data.total);
setAuditPage(p);
} catch {
setAuditEntries([]);
} finally {
setAuditLoading(false);
}
}, []);
const loadServices = useCallback(async () => {
setServicesLoading(true);
setServiceError("");
try {
const data = await getServices();
setServices(data || []);
} catch {
setServiceError("Dienste konnten nicht abgerufen werden.");
} finally {
setServicesLoading(false);
}
}, []);
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
setServiceActionLoading(`${name}:${action}`);
setServiceError("");
try {
const updated = await serviceAction(name, action);
setServices((prev) => prev.map((s) => (s.name === updated.name ? updated : s)));
} catch (e: unknown) {
setServiceError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
} finally {
setServiceActionLoading(null);
}
}
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!user) return;
loadDashboard();
loadUsers();
loadAudit(1);
loadServices();
// Auto-Refresh Dashboard alle 30 Sekunden
setCountdown(30);
dashIntervalRef.current = setInterval(() => {
loadDashboard();
setCountdown(30);
}, 30_000);
// Countdown-Ticker
const ticker = setInterval(() => {
setCountdown((c) => (c > 0 ? c - 1 : 0));
}, 1_000);
return () => {
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
clearInterval(ticker);
};
}, [user, loadDashboard, loadUsers, loadAudit, loadServices]);
async function handleCreateUser(e: React.FormEvent) {
e.preventDefault();
setCreateLoading(true);
setCreateError("");
try {
await createUser({
username: newUsername,
email: newEmail,
password: newPassword,
role: newRole,
});
setDialogOpen(false);
setNewUsername("");
setNewEmail("");
setNewPassword("");
setNewRole("user");
loadUsers();
} catch {
setCreateError("Benutzer konnte nicht erstellt werden.");
} finally {
setCreateLoading(false);
}
}
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<main className="mx-auto max-w-7xl px-4 py-6">
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
<Tabs defaultValue="dashboard">
<TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<TabsTrigger value="services">Dienste</TabsTrigger>
<TabsTrigger value="users">Benutzer</TabsTrigger>
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
</TabsList>
{/* ── Dashboard ── */}
<TabsContent value="dashboard" className="mt-4 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemstatus</h2>
<div className="flex items-center gap-3">
{dashRefreshed && (
<span className="text-xs text-muted-foreground">
{dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s
</span>
)}
<Button variant="outline" size="sm" onClick={() => { loadDashboard(); setCountdown(30); }} disabled={dashLoading}>
{dashLoading ? "..." : "Jetzt aktualisieren"}
</Button>
</div>
</div>
{dashLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-lg" />
))}
</div>
) : (
<>
{/* Status-Kacheln */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* API */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">REST API</span>
<Badge variant={apiOnline ? "default" : "destructive"}>
{apiOnline ? "Online" : "Offline"}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">:8080</span>
<span className="text-muted-foreground">Protokoll</span>
<span>HTTP</span>
</div>
</CardContent>
</Card>
{/* SMTP */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span>
<Badge variant={smtpStatus?.running ? "default" : "destructive"}>
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
</Badge>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">{smtpStatus.bind}</span>
<span className="text-muted-foreground">Domain</span>
<span className="font-mono">{smtpStatus.domain || ""}</span>
<span className="text-muted-foreground">TLS</span>
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
<span className="text-muted-foreground">Max. Größe</span>
<span>{smtpStatus.max_size_mb > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
)}
</CardContent>
</Card>
{/* SMTP Statistik (nur live via SMTP-Daemon) */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span>
<span className="text-xs text-muted-foreground">seit letztem Start</span>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Empfangen</span>
<span className="font-semibold text-green-600">{smtpStatus.received}</span>
<span className="text-muted-foreground">Abgelehnt</span>
<span className="font-semibold text-red-500">{smtpStatus.rejected}</span>
<span className="text-muted-foreground">Letzte Mail</span>
<span className="text-xs">
{smtpStatus.last_mail_at
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
: ""}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
{/* Archiv-Speicher */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archiv gesamt</span>
{storageStats && (
<Badge variant="secondary" className="font-mono text-xs">
{storageStats.total_mails} Mails
</Badge>
)}
</div>
<Separator />
{storageStats ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">E-Mails</span>
<span className="font-semibold">{storageStats.total_mails.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{formatBytes(storageStats.total_bytes)}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</div>
{/* System Stats: CPU, RAM, Disks, Archivzeitraum */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
{!systemStats ? (
<Alert variant="destructive">
<AlertDescription>
Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt <code className="font-mono">/api/admin/system/stats</code> erreichbar ist.
</AlertDescription>
</Alert>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* CPU */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">CPU Load Average</span>
<Badge variant="secondary">{systemStats.cpu.num_cpu} CPU(s)</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">1 min</span>
<span className="font-semibold">{systemStats.cpu.load1.toFixed(2)}</span>
<span className="text-muted-foreground">5 min</span>
<span className="font-semibold">{systemStats.cpu.load5.toFixed(2)}</span>
<span className="text-muted-foreground">15 min</span>
<span className="font-semibold">{systemStats.cpu.load15.toFixed(2)}</span>
</div>
</CardContent>
</Card>
{/* RAM */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Arbeitsspeicher</span>
<Badge variant={systemStats.ram.used_pct > 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}>
{systemStats.ram.used_pct.toFixed(1)}%
</Badge>
</div>
<Separator />
<div className="space-y-2">
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${systemStats.ram.used_pct > 90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(systemStats.ram.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(systemStats.ram.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(systemStats.ram.free_bytes)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Archivzeitraum */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archivzeitraum</span>
</div>
<Separator />
{systemStats.archive.first_mail || systemStats.archive.last_mail ? (
<div className="space-y-2 text-sm">
{systemStats.archive.first_mail && (
<div>
<span className="text-xs text-muted-foreground block">Älteste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.first_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.first_mail.subject || "(kein Betreff)"}</span>
</div>
)}
{systemStats.archive.last_mail && (
<div className="pt-1 border-t">
<span className="text-xs text-muted-foreground block">Neueste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.last_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.last_mail.subject || "(kein Betreff)"}</span>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Archiv leer</p>
)}
</CardContent>
</Card>
</div>
{/* Festplatten */}
{systemStats.disks.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{systemStats.disks.map((disk) => (
<Card key={disk.mount}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">{disk.mount}</span>
<Badge variant={disk.used_pct > 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}>
{disk.used_pct.toFixed(1)}%
</Badge>
</div>
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${disk.used_pct > 90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(disk.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(disk.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(disk.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(disk.free_bytes)}</span>
<span className="text-muted-foreground">Dateisystem</span>
<span className="font-mono text-xs">{disk.fstype}</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
</>
)}
</div>
{/* IP-Allowlist */}
{smtpStatus && smtpStatus.allowed_ips?.length > 0 && (
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
<Separator />
<div className="flex flex-wrap gap-2 pt-1">
{smtpStatus.allowed_ips.map((ip) => (
<Badge key={ip} variant="outline" className="font-mono">
{ip}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Benutzerübersicht */}
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">Benutzer</span>
<Separator />
{usersLoading ? (
<Skeleton className="h-8 w-full" />
) : (
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<span>
<span className="font-semibold">{users.filter(u => u.active).length}</span>
<span className="text-muted-foreground ml-1">aktiv</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "admin").length}</span>
<span className="text-muted-foreground ml-1">Admin</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "auditor").length}</span>
<span className="text-muted-foreground ml-1">Auditor</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "user").length}</span>
<span className="text-muted-foreground ml-1">User</span>
</span>
</div>
)}
</CardContent>
</Card>
{!smtpStatus && (
<Alert variant="destructive">
<AlertDescription>
SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft.
</AlertDescription>
</Alert>
)}
</>
)}
</TabsContent>
{/* ── Dienste ── */}
<TabsContent value="services" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemdienste</h2>
<Button variant="outline" size="sm" onClick={loadServices} disabled={servicesLoading}>
{servicesLoading ? "..." : "Aktualisieren"}
</Button>
</div>
{serviceError && (
<Alert variant="destructive">
<AlertDescription>{serviceError}</AlertDescription>
</Alert>
)}
{servicesLoading && services.length === 0 ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Dienst</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-24">Autostart</TableHead>
<TableHead className="w-28">Externer Zugriff</TableHead>
<TableHead>Beschreibung</TableHead>
<TableHead className="w-72 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((svc) => {
const isActive = svc.active === "active";
const isFailed = svc.active === "failed";
const isEnabled = svc.enabled === "enabled" || svc.enabled === "static";
const busy = (key: string) => serviceActionLoading === `${svc.name}:${key}`;
const anyBusy = serviceActionLoading?.startsWith(`${svc.name}:`) ?? false;
return (
<TableRow key={svc.name}>
<TableCell className="font-mono text-sm font-medium">
{svc.name}
</TableCell>
<TableCell>
<Badge
variant={isActive ? "default" : isFailed ? "destructive" : "secondary"}
>
{svc.active === "active"
? `Aktiv (${svc.sub})`
: svc.active === "failed"
? "Fehler"
: svc.active === "inactive"
? "Gestoppt"
: svc.active}
</Badge>
</TableCell>
<TableCell>
<Badge variant={isEnabled ? "default" : "outline"}>
{svc.enabled === "enabled"
? "Aktiviert"
: svc.enabled === "disabled"
? "Deaktiviert"
: svc.enabled === "static"
? "Statisch"
: svc.enabled}
</Badge>
</TableCell>
<TableCell>
{svc.external_blocked !== undefined ? (
<Badge variant={svc.external_blocked ? "destructive" : "default"}>
{svc.external_blocked ? "Gesperrt" : "Offen"}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-xs">
{svc.description || ""}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1 flex-wrap">
{isActive ? (
<>
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "restart")}
>
{busy("restart") ? "..." : "Neustart"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "stop")}
>
{busy("stop") ? "..." : "Stop"}
</Button>
</>
) : (
<Button
size="sm"
variant="default"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "start")}
>
{busy("start") ? "..." : "Start"}
</Button>
)}
{svc.enabled === "enabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "disable")}
>
{busy("disable") ? "..." : "Deaktivieren"}
</Button>
) : svc.enabled === "disabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "enable")}
>
{busy("enable") ? "..." : "Aktivieren"}
</Button>
) : null}
{svc.external_blocked !== undefined && (
svc.external_blocked ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "allow_external")}
>
{busy("allow_external") ? "..." : "Extern freigeben"}
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => handleServiceAction(svc.name, "block_external")}
>
{busy("block_external") ? "..." : "Extern sperren"}
</Button>
)
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</TabsContent>
<TabsContent value="users" className="mt-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>Benutzer anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
<DialogDescription>
Erstellen Sie einen neuen Benutzer-Account.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-username">Benutzername</Label>
<Input
id="new-username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
aria-label="Neuer Benutzername"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-email">E-Mail</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
aria-label="Neue E-Mail-Adresse"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Passwort</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-role">Rolle</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger id="new-role" aria-label="Rolle auswaehlen">
<SelectValue placeholder="Rolle waehlen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
{createError && (
<p className="text-sm text-destructive" role="alert">
{createError}
</p>
)}
<DialogFooter>
<Button type="submit" disabled={createLoading}>
{createLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{usersLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : usersError ? (
<Card>
<CardContent className="p-8 text-center text-destructive">
{usersError}
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Benutzer vorhanden.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.username}>
<TableCell className="font-medium">
{u.username}
</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={u.active ? "default" : "destructive"}
>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</TabsContent>
<TabsContent value="audit" className="mt-4">
<h2 className="mb-4 text-lg font-semibold">Audit-Log</h2>
{auditLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : auditEntries.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Audit-Eintraege vorhanden.
</CardContent>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitstempel</TableHead>
<TableHead>Ereignis</TableHead>
<TableHead>Benutzer</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditEntries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap">
{new Date(entry.timestamp).toLocaleString("de-DE")}
</TableCell>
<TableCell>
<Badge variant="outline">
{entry.event_type}
</Badge>
</TableCell>
<TableCell>{entry.username}</TableCell>
<TableCell className="max-w-xs truncate">
{entry.detail}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{auditTotalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={auditPage <= 1}
onClick={() => loadAudit(auditPage - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {auditPage} von {auditTotalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={auditPage >= auditTotalPages}
onClick={() => loadAudit(auditPage + 1)}
>
Weiter
</Button>
</div>
)}
</>
)}
</TabsContent>
</Tabs>
</main>
</div>
);
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import {
getImapAccounts,
createImapAccount,
deleteImapAccount,
testImapConnection,
startImapImport,
getImapProgress,
type ImapAccount,
type ImapFolder,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardHeader,
CardContent,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
export default function ImapPage() {
const { user, loading: authLoading } = useAuth();
const [accounts, setAccounts] = useState<ImapAccount[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Form state
const [formName, setFormName] = useState("");
const [formHost, setFormHost] = useState("");
const [formPort, setFormPort] = useState("993");
const [formTls, setFormTls] = useState("ssl");
const [formUsername, setFormUsername] = useState("");
const [formPassword, setFormPassword] = useState("");
// Test state
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState("");
const [testFolders, setTestFolders] = useState<ImapFolder[] | null>(null);
const [excludedFolders, setExcludedFolders] = useState<Set<string>>(new Set());
// Saving state
const [saving, setSaving] = useState(false);
// Polling refs
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map());
const loadAccounts = useCallback(async () => {
try {
const data = await getImapAccounts();
setAccounts(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (user) loadAccounts();
}, [user, loadAccounts]);
// Start polling for running accounts
useEffect(() => {
for (const acc of accounts) {
if (acc.status === "running" && !pollingRefs.current.has(acc.id)) {
const interval = setInterval(async () => {
try {
const updated = await getImapProgress(acc.id);
setAccounts((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a))
);
if (updated.status !== "running") {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
}
} catch {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
}
}, 2000);
pollingRefs.current.set(acc.id, interval);
}
}
// Cleanup intervals for accounts that are no longer running
for (const [id, interval] of pollingRefs.current) {
const acc = accounts.find((a) => a.id === id);
if (!acc || acc.status !== "running") {
clearInterval(interval);
pollingRefs.current.delete(id);
}
}
}, [accounts]);
// Cleanup on unmount
useEffect(() => {
return () => {
for (const interval of pollingRefs.current.values()) {
clearInterval(interval);
}
};
}, []);
function resetForm() {
setFormName("");
setFormHost("");
setFormPort("993");
setFormTls("ssl");
setFormUsername("");
setFormPassword("");
setTestFolders(null);
setTestError("");
setExcludedFolders(new Set());
}
async function handleTest() {
setTesting(true);
setTestError("");
setTestFolders(null);
try {
const result = await testImapConnection({
host: formHost,
port: parseInt(formPort, 10) || 993,
tls: formTls,
username: formUsername,
password: formPassword,
});
if (result.ok && result.folders) {
setTestFolders(result.folders);
const excluded = new Set<string>();
for (const f of result.folders) {
if (f.excluded) excluded.add(f.name);
}
setExcludedFolders(excluded);
} else {
setTestError(result.error || "Verbindungstest fehlgeschlagen");
}
} catch (err) {
setTestError(err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen");
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
await createImapAccount({
name: formName,
host: formHost,
port: parseInt(formPort, 10) || 993,
tls: formTls,
username: formUsername,
password: formPassword,
excluded_folders: Array.from(excludedFolders),
});
setDialogOpen(false);
resetForm();
await loadAccounts();
} catch (err) {
setTestError(err instanceof Error ? err.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
}
async function handleStartImport(id: number) {
try {
const updated = await startImapImport(id);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch {
// ignore
}
}
async function handleDelete(id: number) {
try {
await deleteImapAccount(id);
setAccounts((prev) => prev.filter((a) => a.id !== id));
} catch {
// ignore
}
setDeleteConfirm(null);
}
function toggleExcluded(folderName: string) {
setExcludedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) {
next.delete(folderName);
} else {
next.add(folderName);
}
return next;
});
}
function statusBadge(status: string) {
switch (status) {
case "running":
return <Badge className="bg-blue-600 text-white">Importiert...</Badge>;
case "error":
return <Badge variant="destructive">Fehler</Badge>;
default:
return <Badge variant="secondary">Bereit</Badge>;
}
}
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">IMAP Import</h1>
<Button
onClick={() => {
resetForm();
setDialogOpen(true);
}}
>
Konto hinzufuegen
</Button>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : accounts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Noch keine IMAP-Konten konfiguriert. Klicken Sie auf &quot;Konto
hinzufuegen&quot;, um zu beginnen.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{accounts.map((acc) => (
<Card key={acc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<h3 className="font-semibold">{acc.name}</h3>
<p className="text-sm text-muted-foreground">
{acc.host}:{acc.port} ({acc.tls.toUpperCase()}) &middot; {acc.username}
</p>
</div>
{statusBadge(acc.status)}
</CardHeader>
<CardContent>
{acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1">
<Progress
value={
(acc.progress_current / acc.progress_total) * 100
}
/>
<p className="text-xs text-muted-foreground">
{acc.progress_current} von {acc.progress_total} E-Mails
</p>
</div>
)}
{acc.status === "error" && acc.error_msg && (
<p className="mb-3 text-sm text-destructive">
{acc.error_msg}
</p>
)}
{acc.last_import_at && (
<p className="text-sm text-muted-foreground mb-3">
Letzter Import:{" "}
{new Date(acc.last_import_at).toLocaleString("de-DE")} (
{acc.last_import_count} E-Mails)
</p>
)}
{acc.excluded_folders && acc.excluded_folders.length > 0 && (
<p className="text-xs text-muted-foreground mb-3">
Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")}
</p>
)}
<div className="flex gap-2">
<Button
size="sm"
disabled={acc.status === "running"}
onClick={() => handleStartImport(acc.id)}
>
Import starten
</Button>
<Button
size="sm"
variant="destructive"
disabled={acc.status === "running"}
onClick={() => setDeleteConfirm(acc.id)}
>
Loeschen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add Account Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>IMAP-Konto hinzufuegen</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="imap-name">Name</Label>
<Input
id="imap-name"
placeholder="z.B. Firmen-Mail"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="imap-host">Host</Label>
<Input
id="imap-host"
placeholder="imap.example.com"
value={formHost}
onChange={(e) => setFormHost(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="imap-port">Port</Label>
<Input
id="imap-port"
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label>Verschluesselung</Label>
<Select value={formTls} onValueChange={setFormTls}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="none">Unverschluesselt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="imap-user">Benutzername</Label>
<Input
id="imap-user"
placeholder="user@example.com"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="imap-pass">Passwort</Label>
<Input
id="imap-pass"
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={handleTest}
disabled={testing || !formHost || !formUsername || !formPassword}
className="w-full"
>
{testing ? "Teste Verbindung..." : "Verbindung testen"}
</Button>
{testError && (
<p className="text-sm text-destructive">{testError}</p>
)}
{testFolders && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">
Erkannte Ordner
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{testFolders.map((folder) => (
<div
key={folder.name}
className="flex items-center gap-2"
>
<Checkbox
id={`folder-${folder.name}`}
checked={!excludedFolders.has(folder.name)}
onCheckedChange={() =>
toggleExcluded(folder.name)
}
/>
<Label
htmlFor={`folder-${folder.name}`}
className="text-sm flex-1 cursor-pointer"
>
{folder.name}
</Label>
{folder.excluded && folder.reason && (
<span className="text-xs text-muted-foreground">
({folder.reason === "special_use"
? "IMAP-Flag"
: "Namens-Erkennung"})
</span>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
Deaktivierte Ordner werden nicht importiert.
</p>
</div>
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDialogOpen(false);
resetForm();
}}
>
Abbrechen
</Button>
<Button
onClick={handleSave}
disabled={
saving ||
!formName ||
!formHost ||
!formUsername ||
!formPassword
}
>
{saving ? "Speichert..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteConfirm !== null}
onOpenChange={() => setDeleteConfirm(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto loeschen?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Soll dieses IMAP-Konto wirklich entfernt werden? Bereits
importierte E-Mails bleiben im Archiv erhalten.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm !== null && handleDelete(deleteConfirm)}
>
Loeschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</div>
);
}
+4 -4
View File
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AI Coding Starter Kit",
description: "Built with AI Agent Team System",
title: "archivmail",
description: "E-Mail-Archiv",
};
export default function RootLayout({
@@ -12,8 +12,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">
<html lang="de">
<body className="antialiased min-h-screen bg-background text-foreground">
{children}
</body>
</html>
+352
View File
@@ -0,0 +1,352 @@
"use client";
import { use, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
getMail,
downloadMailAttachment,
downloadMailRaw,
type MailDetail,
type MailAttachment,
} from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function blockExternalSrcs(html: string): string {
// Replace src= in img/video/audio tags with data-src= to block loading
return html
.replace(/<(img|video|audio|source)(\s[^>]*?\s)src(\s*=\s*["']https?:)/gi,
"<$1$2data-src$3")
.replace(/<(img|video|audio|source)(\s)src(\s*=\s*["']https?:)/gi,
"<$1$2data-src$3");
}
// ── Sub-components ─────────────────────────────────────────────────────────
function MailHeaderGrid({ mail }: { mail: MailDetail }) {
const [showRaw, setShowRaw] = useState(false);
return (
<div className="space-y-3">
<div className="grid grid-cols-[6rem_1fr] gap-x-4 gap-y-1.5 text-sm">
<span className="font-medium text-muted-foreground">Von:</span>
<span className="break-all">{mail.from || ""}</span>
<span className="font-medium text-muted-foreground">An:</span>
<span className="break-all">{mail.to || ""}</span>
{mail.cc && (
<>
<span className="font-medium text-muted-foreground">CC:</span>
<span className="break-all">{mail.cc}</span>
</>
)}
<span className="font-medium text-muted-foreground">Datum:</span>
<span>{formatDate(mail.date)}</span>
<span className="font-medium text-muted-foreground">Betreff:</span>
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
<span className="font-medium text-muted-foreground">Größe:</span>
<span>{formatBytes(mail.size)}</span>
</div>
<button
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{showRaw ? "Header ausblenden" : "Original-Header anzeigen"}
</button>
{showRaw && (
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap break-all">
{mail.raw_headers}
</pre>
)}
</div>
);
}
function MailBodyView({ mail }: { mail: MailDetail }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [showExternal, setShowExternal] = useState(false);
const html = mail.body_html ?? null;
const plain = mail.body_plain ?? null;
// Adjust iframe height to content
function handleIframeLoad() {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const body = iframe.contentDocument?.body;
if (body) {
iframe.style.height = `${body.scrollHeight + 32}px`;
}
} catch {
iframe.style.height = "600px";
}
}
if (!html && !plain) {
return (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Kein Inhalt vorhanden.
</div>
);
}
if (html) {
const srcdoc = showExternal ? html : blockExternalSrcs(html);
return (
<div className="space-y-2">
{!showExternal && (
<Alert>
<AlertDescription className="flex items-center justify-between gap-4 text-sm">
<span>Externe Inhalte (Bilder, Tracker) sind blockiert.</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowExternal(true)}
>
Externe Inhalte laden
</Button>
</AlertDescription>
</Alert>
)}
<div className="overflow-hidden rounded-md border">
<iframe
ref={iframeRef}
srcDoc={srcdoc}
sandbox="allow-same-origin"
title="E-Mail-Inhalt"
className="w-full"
style={{ minHeight: "200px", height: "600px", border: "none" }}
onLoad={handleIframeLoad}
/>
</div>
</div>
);
}
// Plain-text fallback
return (
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted p-4 text-sm whitespace-pre-wrap break-words leading-relaxed">
{plain}
</pre>
);
}
function AttachmentRow({
mailId,
attachment,
}: {
mailId: string;
attachment: MailAttachment;
}) {
const [downloading, setDownloading] = useState(false);
async function handleDownload() {
setDownloading(true);
try {
const { blob, filename } = await downloadMailAttachment(
mailId,
attachment.index
);
triggerDownload(blob, filename || attachment.filename);
} catch (e) {
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setDownloading(false);
}
}
return (
<div className="flex items-center justify-between gap-4 rounded-md border px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<span className="block truncate font-medium">{attachment.filename}</span>
<span className="text-xs text-muted-foreground">
{attachment.content_type} · {formatBytes(attachment.size)}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? "..." : "Herunterladen"}
</Button>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function MailViewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { user, loading: authLoading } = useAuth();
const [mail, setMail] = useState<MailDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (!user) return;
getMail(id)
.then(setMail)
.catch((e) =>
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
)
.finally(() => setLoading(false));
}, [id, user]);
async function handleEmlDownload() {
setDownloading(true);
try {
const { blob, filename } = await downloadMailRaw(id);
triggerDownload(blob, filename);
} catch (e) {
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setDownloading(false);
}
}
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
{/* Back + Actions */}
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="outline" size="sm" asChild>
<Link href="/search"> Zurück zur Suche</Link>
</Button>
{mail && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{id}
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleEmlDownload}
disabled={downloading}
>
{downloading ? "..." : "Als .eml herunterladen"}
</Button>
</div>
)}
</div>
{/* Loading */}
{loading && (
<Card>
<CardHeader className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
)}
{/* Error */}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Mail content */}
{mail && (
<>
{/* Header */}
<Card>
<CardHeader>
<MailHeaderGrid mail={mail} />
</CardHeader>
</Card>
{/* Body */}
<Card>
<CardContent className="pt-6">
<MailBodyView mail={mail} />
</CardContent>
</Card>
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<span className="text-sm font-medium">
Anhänge ({mail.attachments.length})
</span>
</CardHeader>
<Separator />
<CardContent className="pt-4 space-y-2">
{mail.attachments.map((att) => (
<AttachmentRow
key={att.index}
mailId={id}
attachment={att}
/>
))}
</CardContent>
</Card>
)}
</>
)}
</main>
</div>
);
}
+87 -97
View File
@@ -1,101 +1,91 @@
import Image from 'next/image'
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
const token = localStorage.getItem("archivmail_token");
if (token) {
router.replace("/search");
}
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await login(username, password);
localStorage.setItem("archivmail_token", res.token);
router.push("/search");
} catch {
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
} finally {
setLoading(false);
}
}
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{' '}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">archivmail</CardTitle>
<p className="text-sm text-muted-foreground">
E-Mail-Archiv Anmeldung
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input
id="username"
type="text"
placeholder="Benutzername"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
aria-label="Benutzername"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
aria-label="Passwort"
/>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Anmelden..." : "Anmelden"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, type SearchHit } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
const PAGE_SIZE = 25;
export default function SearchPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [query, setQuery] = useState("");
const [fromFilter, setFromFilter] = useState("");
const [toFilter, setToFilter] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [results, setResults] = useState<SearchHit[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const doSearch = useCallback(
async (p: number) => {
setSearching(true);
try {
const res = await searchEmails({
q: query || undefined,
from: fromFilter || undefined,
to: toFilter || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
page: p,
page_size: PAGE_SIZE,
});
setResults(res.hits || []);
setTotal(res.total);
setPage(p);
setSearched(true);
} catch {
setResults([]);
setTotal(0);
} finally {
setSearching(false);
}
},
[query, fromFilter, toFilter, dateFrom, dateTo]
);
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
useEffect(() => {
if (!user) return;
setSearching(true);
searchEmails({ page: 1, page_size: PAGE_SIZE })
.then((res) => {
setResults(res.hits || []);
setTotal(res.total);
setPage(1);
setSearched(true);
})
.catch(() => {
setResults([]);
setTotal(0);
})
.finally(() => setSearching(false));
}, [user]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doSearch(1);
}
const totalPages = Math.ceil(total / PAGE_SIZE);
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-8 w-48" />
</div>
);
}
return (
<div className="min-h-screen">
<Navbar username={user.username} role={user.role} />
<main className="mx-auto max-w-7xl px-4 py-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Volltextsuche..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1"
aria-label="Suchbegriff"
/>
<Button type="submit" disabled={searching}>
{searching ? "Suche..." : "Suchen"}
</Button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="from-filter" className="text-xs">
Von (Absender)
</Label>
<Input
id="from-filter"
placeholder="absender@example.com"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
aria-label="Absender filtern"
/>
</div>
<div className="space-y-1">
<Label htmlFor="to-filter" className="text-xs">
An (Empfänger)
</Label>
<Input
id="to-filter"
placeholder="empfaenger@example.com"
value={toFilter}
onChange={(e) => setToFilter(e.target.value)}
aria-label="Empfänger filtern"
/>
</div>
<div className="space-y-1">
<Label htmlFor="date-from" className="text-xs">
Datum von
</Label>
<Input
id="date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
aria-label="Datum von"
/>
</div>
<div className="space-y-1">
<Label htmlFor="date-to" className="text-xs">
Datum bis
</Label>
<Input
id="date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
aria-label="Datum bis"
/>
</div>
</div>
</form>
<div className="mt-6">
{searching ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : searched && results.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine E-Mails gefunden.
</CardContent>
</Card>
) : results.length > 0 ? (
<>
<div className="mb-2 text-sm text-muted-foreground">
{query || fromFilter || toFilter || dateFrom || dateTo
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-32">Datum</TableHead>
<TableHead className="w-56">Von</TableHead>
<TableHead>Betreff</TableHead>
<TableHead className="w-48">An</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((hit) => (
<TableRow
key={hit.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/mail/${hit.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") router.push(`/mail/${hit.id}`);
}}
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{hit.date
? new Date(hit.date).toLocaleDateString("de-DE")
: "-"}
</TableCell>
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => doSearch(page - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => doSearch(page + 1)}
>
Weiter
</Button>
</div>
)}
</>
) : null}
</div>
</main>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { logout } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
interface NavbarProps {
username: string;
role: string;
}
export function Navbar({ username, role }: NavbarProps) {
const router = useRouter();
async function handleLogout() {
try {
await logout();
} catch {
// ignore logout errors
}
localStorage.removeItem("archivmail_token");
router.push("/");
}
return (
<nav
className="border-b bg-background"
aria-label="Hauptnavigation"
>
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link
href="/search"
className="text-lg font-bold tracking-tight"
>
archivmail
</Link>
<Link
href="/search"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Suche
</Link>
<Link
href="/imap"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
IMAP Import
</Link>
{role === "admin" && (
<Link
href="/admin"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Admin
</Link>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm">{username}</span>
<Badge variant="secondary">{role}</Badge>
<Button variant="outline" size="sm" onClick={handleLogout}>
Abmelden
</Button>
</div>
</div>
</nav>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { getMe, type MeResponse } from "@/lib/api";
interface AuthState {
user: MeResponse | null;
loading: boolean;
error: string | null;
}
export function useAuth(requireAdmin?: boolean) {
const router = useRouter();
const [state, setState] = useState<AuthState>({
user: null,
loading: true,
error: null,
});
const checkAuth = useCallback(async () => {
const token = localStorage.getItem("archivmail_token");
if (!token) {
router.replace("/");
return;
}
try {
const user = await getMe();
if (requireAdmin && user.role !== "admin") {
router.replace("/search");
return;
}
setState({ user, loading: false, error: null });
} catch {
localStorage.removeItem("archivmail_token");
router.replace("/");
}
}, [router, requireAdmin]);
useEffect(() => {
checkAuth();
}, [checkAuth]);
return state;
}
+393
View File
@@ -0,0 +1,393 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("archivmail_token");
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (res.status === 401) {
if (typeof window !== "undefined") {
localStorage.removeItem("archivmail_token");
window.location.href = "/";
}
throw new Error("Unauthorized");
}
if (!res.ok) {
const body = await res.text();
throw new Error(body || `Request failed: ${res.status}`);
}
if (res.status === 204) return {} as T;
return res.json();
}
// Types
export interface LoginResponse {
token: string;
username: string;
role: string;
}
export interface User {
username: string;
email: string;
role: string;
active: boolean;
}
export interface MeResponse {
username: string;
role: string;
email: string;
}
export interface SMTPStatus {
running: boolean;
enabled: boolean;
bind: string;
domain: string;
tls: boolean;
max_size_mb: number;
allowed_ips: string[];
received: number;
rejected: number;
last_mail_at?: string;
}
export interface HealthResponse {
status: string;
}
export interface SearchHit {
id: string;
score: number;
from?: string;
to?: string;
subject?: string;
date?: string;
}
export interface SearchResponse {
total: number;
hits: SearchHit[];
}
export interface MailAttachment {
index: number;
filename: string;
content_type: string;
size: number;
}
export interface MailDetail {
id: string;
from: string;
to: string;
cc?: string;
subject: string;
date: string;
size: number;
body_html?: string;
body_plain?: string;
raw_headers: string;
attachments: MailAttachment[];
}
export interface AuditEntry {
id: string;
timestamp: string;
event_type: string;
username: string;
detail: string;
}
export interface AuditResponse {
total: number;
entries: AuditEntry[];
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
role: string;
}
// API functions
export async function login(
username: string,
password: string
): Promise<LoginResponse> {
return request<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export async function getMe(): Promise<MeResponse> {
return request<MeResponse>("/api/auth/me");
}
export async function logout(): Promise<void> {
await request<void>("/api/auth/logout", { method: "POST" });
}
export async function searchEmails(params: {
q?: string;
from?: string;
to?: string;
date_from?: string;
date_to?: string;
page?: number;
page_size?: number;
}): Promise<SearchResponse> {
const sp = new URLSearchParams();
if (params.q) sp.set("q", params.q);
if (params.from) sp.set("from", params.from);
if (params.to) sp.set("to", params.to);
if (params.date_from) sp.set("date_from", params.date_from);
if (params.date_to) sp.set("date_to", params.date_to);
if (params.page) sp.set("page", String(params.page));
if (params.page_size) sp.set("page_size", String(params.page_size));
return request<SearchResponse>(`/api/search?${sp.toString()}`);
}
export async function getUsers(): Promise<User[]> {
return request<User[]>("/api/users");
}
export async function createUser(data: CreateUserRequest): Promise<User> {
return request<User>("/api/users", {
method: "POST",
body: JSON.stringify(data),
});
}
export interface StorageStats {
total_mails: number;
total_bytes: number;
}
export async function getStorageStats(): Promise<StorageStats> {
return request<StorageStats>("/api/admin/storage/stats");
}
export async function getSMTPStatus(): Promise<SMTPStatus> {
return request<SMTPStatus>("/api/admin/smtp/status");
}
export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>("/api/health");
}
export async function getMail(id: string): Promise<MailDetail> {
return request<MailDetail>(`/api/mails/${id}`);
}
export async function downloadMailAttachment(
id: string,
index: number
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
const disposition = res.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
const filename = match ? match[1].replace(/['"]/g, "") : `anhang-${index}`;
return { blob: await res.blob(), filename };
}
export async function downloadMailRaw(
id: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
return { blob: await res.blob(), filename: `${id}.eml` };
}
export interface ServiceStatus {
name: string;
display_name: string;
active: string; // active | inactive | failed | unknown
sub: string; // running | dead | exited | ...
enabled: string; // enabled | disabled | static | unknown
description: string;
external_blocked?: boolean; // only present for archivmail
}
export async function getServices(): Promise<ServiceStatus[]> {
return request<ServiceStatus[]>("/api/admin/services");
}
export async function serviceAction(
name: string,
action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external"
): Promise<ServiceStatus> {
return request<ServiceStatus>(`/api/admin/services/${encodeURIComponent(name)}/action`, {
method: "POST",
body: JSON.stringify({ action }),
});
}
export async function getAuditLog(params: {
page?: number;
page_size?: number;
username?: string;
event_type?: string;
}): Promise<AuditResponse> {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.page_size) sp.set("page_size", String(params.page_size));
if (params.username) sp.set("username", params.username);
if (params.event_type) sp.set("event_type", params.event_type);
return request<AuditResponse>(`/api/audit?${sp.toString()}`);
}
// ── IMAP ──────────────────────────────────────────────────────────────────
export interface ImapFolder {
name: string;
excluded: boolean;
reason?: string;
}
export interface ImapAccount {
id: number;
owner: string;
name: string;
host: string;
port: number;
tls: string;
username: string;
excluded_folders: string[];
status: string;
error_msg: string;
last_import_at?: string;
last_import_count: number;
progress_current: number;
progress_total: number;
created_at: string;
}
export interface ImapTestResult {
ok: boolean;
folders?: ImapFolder[];
error?: string;
}
export async function getImapAccounts(): Promise<ImapAccount[]> {
return request<ImapAccount[]>("/api/imap");
}
export async function createImapAccount(data: {
name: string;
host: string;
port: number;
tls: string;
username: string;
password: string;
excluded_folders: string[];
}): Promise<ImapAccount> {
return request<ImapAccount>("/api/imap", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function deleteImapAccount(id: number): Promise<void> {
await request<void>(`/api/imap/${id}`, { method: "DELETE" });
}
export async function testImapConnection(data: {
host: string;
port: number;
tls: string;
username: string;
password: string;
}): Promise<ImapTestResult> {
return request<ImapTestResult>("/api/imap/test", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function startImapImport(id: number): Promise<ImapAccount> {
return request<ImapAccount>(`/api/imap/${id}/import`, { method: "POST" });
}
export async function getImapProgress(id: number): Promise<ImapAccount> {
return request<ImapAccount>(`/api/imap/${id}/progress`);
}
// ── System Stats ──────────────────────────────────────────────────────────
export interface SystemStatsCPU {
load1: number;
load5: number;
load15: number;
num_cpu: number;
}
export interface SystemStatsRAM {
total_bytes: number;
used_bytes: number;
free_bytes: number;
used_pct: number;
}
export interface SystemStatsDisk {
mount: string;
total_bytes: number;
used_bytes: number;
free_bytes: number;
used_pct: number;
fstype: string;
}
export interface SystemStatsMailInfo {
id: string;
date: string;
from: string;
subject: string;
}
export interface SystemStats {
cpu: SystemStatsCPU;
ram: SystemStatsRAM;
disks: SystemStatsDisk[];
archive: {
first_mail: SystemStatsMailInfo | null;
last_mail: SystemStatsMailInfo | null;
};
}
export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>("/api/admin/system/stats");
}
Executable
+132
View File
@@ -0,0 +1,132 @@
#!/bin/bash
# archivmail Updater
# Zieht die neueste Version aus Gitea, baut Frontend + Backend und startet Dienste neu.
#
# Aufruf (auf dem Server als root):
# bash /opt/archivmail/update.sh
#
# Oder direkt von Gitea laden und ausführen:
# curl -fsSL https://gitea.perlbach24.de/scripte/archivmail/raw/branch/main/update.sh | bash
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${GREEN}[OK]${NC} $*"; }
info() { echo -e "${BLUE}[..]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
die() { echo -e "${RED}[ERR]${NC} $*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen: sudo bash update.sh"
REPO_URL="${REPO_URL:-https://gitea.perlbach24.de/scripte/archivmail.git}"
INSTALL_DIR="/opt/archivmail"
BUILD_DIR="/opt/archivmail/_build"
FRONTEND_DIR="/opt/archivmail/frontend"
BIN_DIR="/opt/archivmail/bin"
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ archivmail Updater ║"
echo " ╚══════════════════════════════════════╝"
echo ""
# ── Voraussetzungen prüfen ────────────────────────────────────────────────
command -v git >/dev/null || die "git nicht gefunden"
command -v node >/dev/null || die "node nicht gefunden"
command -v npm >/dev/null || die "npm nicht gefunden"
command -v go >/dev/null || die "go nicht gefunden"
# ── Quellcode holen ───────────────────────────────────────────────────────
if [[ -d "$BUILD_DIR/.git" ]]; then
info "Aktualisiere Quellcode aus Gitea..."
git -C "$BUILD_DIR" fetch origin
git -C "$BUILD_DIR" reset --hard origin/main
log "Quellcode aktualisiert ($(git -C "$BUILD_DIR" log -1 --format='%h %s'))"
else
info "Lade Quellcode von $REPO_URL ..."
mkdir -p "$BUILD_DIR"
git clone "$REPO_URL" "$BUILD_DIR"
log "Quellcode geladen"
fi
# ── Go Backend bauen ──────────────────────────────────────────────────────
info "Baue Go Backend..."
cd "$BUILD_DIR"
go build -o "$BUILD_DIR/archivmail-new" ./cmd/archivmail/
log "Go Backend gebaut"
# ── Next.js Frontend bauen ────────────────────────────────────────────────
info "Installiere Node-Abhängigkeiten..."
npm ci --prefer-offline 2>/dev/null || npm ci
log "Node-Abhängigkeiten installiert"
info "Baue Next.js Frontend..."
npm run build
log "Frontend gebaut"
# ── Dienste stoppen ───────────────────────────────────────────────────────
info "Stoppe Dienste..."
systemctl stop archivmail-frontend 2>/dev/null || warn "archivmail-frontend nicht aktiv"
systemctl stop archivmail 2>/dev/null || warn "archivmail nicht aktiv"
# ── Dateien einspielen ────────────────────────────────────────────────────
info "Spiele Backend ein..."
mkdir -p "$BIN_DIR"
cp "$BUILD_DIR/archivmail-new" "$BIN_DIR/archivmail"
chmod +x "$BIN_DIR/archivmail"
log "Backend eingespielt"
info "Spiele Frontend ein..."
mkdir -p "$FRONTEND_DIR"
rsync -a --delete \
"$BUILD_DIR/.next/" "$FRONTEND_DIR/.next/" \
2>/dev/null || cp -r "$BUILD_DIR/.next/." "$FRONTEND_DIR/.next/"
cp "$BUILD_DIR/package.json" "$FRONTEND_DIR/package.json"
cp "$BUILD_DIR/package-lock.json" "$FRONTEND_DIR/package-lock.json"
cp "$BUILD_DIR/next.config.ts" "$FRONTEND_DIR/next.config.ts"
cd "$FRONTEND_DIR"
npm ci --omit=dev --prefer-offline 2>/dev/null || npm ci --omit=dev
log "Frontend eingespielt"
# ── Datenbankmigrationen (falls vorhanden) ────────────────────────────────
if [[ -f "$BIN_DIR/archivmail" ]]; then
info "Führe Datenbankmigrationen durch..."
"$BIN_DIR/archivmail" migrate 2>/dev/null && log "Migrationen abgeschlossen" \
|| warn "Kein migrate-Befehl oder keine Migrationen nötig"
fi
# ── Dienste starten ───────────────────────────────────────────────────────
info "Starte Dienste..."
systemctl start archivmail
systemctl start archivmail-frontend
log "Dienste gestartet"
# ── Status prüfen ─────────────────────────────────────────────────────────
sleep 2
BACKEND_OK=0
FRONTEND_OK=0
systemctl is-active --quiet archivmail && BACKEND_OK=1
systemctl is-active --quiet archivmail-frontend && FRONTEND_OK=1
echo ""
echo " ┌──────────────────────────────────────┐"
[[ $BACKEND_OK -eq 1 ]] && echo " │ Backend ✓ läuft │" \
|| echo " │ Backend ✗ nicht aktiv │"
[[ $FRONTEND_OK -eq 1 ]] && echo " │ Frontend ✓ läuft │" \
|| echo " │ Frontend ✗ nicht aktiv │"
echo " └──────────────────────────────────────┘"
echo ""
[[ $BACKEND_OK -eq 1 && $FRONTEND_OK -eq 1 ]] && log "Update abgeschlossen." \
|| warn "Ein oder mehrere Dienste sind nicht aktiv. Prüfe: journalctl -u archivmail -u archivmail-frontend"