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