Dateien nach "/" hochladen
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
APP = mailarchived
|
||||||
|
IMPORTER= mailarchive-import
|
||||||
|
VERSION ?= 0.1.0
|
||||||
|
LDFLAGS = -X main.Version=$(VERSION) -s -w
|
||||||
|
|
||||||
|
.PHONY: all build build-xapian clean install
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# Default build — Bleve index (pure Go, no system deps)
|
||||||
|
build:
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/$(APP) ./cmd/mailarchived
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/$(IMPORTER) ./cmd/importer
|
||||||
|
|
||||||
|
# Xapian build — faster at scale, requires: apt install libxapian-dev
|
||||||
|
build-xapian:
|
||||||
|
go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(APP)-xapian ./cmd/mailarchived
|
||||||
|
go build -tags xapian -ldflags "$(LDFLAGS)" -o bin/$(IMPORTER) ./cmd/importer
|
||||||
|
|
||||||
|
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/mailarchive-test/{store,index,logs}
|
||||||
|
./bin/mailarchived --config config/config.test.yml &
|
||||||
|
sleep 2
|
||||||
|
bash test/smoke_test.sh
|
||||||
|
pkill mailarchived || 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 644 config/config.yml /etc/mailarchive/config.yml
|
||||||
|
install -D -m 644 debian/mailarchive.service /lib/systemd/system/mailarchive.service
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin mailarchive 2>/dev/null || true
|
||||||
|
mkdir -p /var/lib/mailarchive/{store,index,attachments,meta}
|
||||||
|
mkdir -p /var/log/mailarchive
|
||||||
|
chown -R mailarchive:mailarchive /var/lib/mailarchive /var/log/mailarchive
|
||||||
|
systemctl daemon-reload
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mailarchive/config"
|
||||||
|
"github.com/mailarchive/internal/api"
|
||||||
|
"github.com/mailarchive/internal/audit"
|
||||||
|
"github.com/mailarchive/internal/auth"
|
||||||
|
"github.com/mailarchive/internal/index"
|
||||||
|
"github.com/mailarchive/internal/storage"
|
||||||
|
"github.com/mailarchive/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, "bleve")
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
|
||||||
|
users, err := userstore.New(filepath.Join(dir, "users.db"))
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
|
||||||
|
audlog, err := audit.New(filepath.Join(dir, "audit.db"), dir, logger)
|
||||||
|
if err != nil { t.Fatal(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()
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# config/config.test.yml
|
||||||
|
# Local test configuration — uses /tmp, no system paths, no root required.
|
||||||
|
# Usage: ./bin/mailarchived --config config/config.test.yml
|
||||||
|
|
||||||
|
storage:
|
||||||
|
path: /tmp/mailarchive-test/store
|
||||||
|
users_db: /tmp/mailarchive-test/users.db
|
||||||
|
audit_db: /tmp/mailarchive-test/audit.db
|
||||||
|
max_size_mb: 100
|
||||||
|
compression: false
|
||||||
|
|
||||||
|
smtp:
|
||||||
|
enabled: true
|
||||||
|
bind: ":2525"
|
||||||
|
domain: "localhost"
|
||||||
|
tls_cert: ""
|
||||||
|
tls_key: ""
|
||||||
|
max_size_mb: 10
|
||||||
|
|
||||||
|
# No IMAP for local testing
|
||||||
|
imap: []
|
||||||
|
|
||||||
|
api:
|
||||||
|
bind: ":8080"
|
||||||
|
secret: "dev-secret-change-in-production-min32"
|
||||||
|
tls: false
|
||||||
|
|
||||||
|
index:
|
||||||
|
path: /tmp/mailarchive-test/index
|
||||||
|
backend: bleve
|
||||||
|
batch_size: 10
|
||||||
|
async_queue_size: 100
|
||||||
|
|
||||||
|
auth:
|
||||||
|
ldap: null # disabled for local testing
|
||||||
|
|
||||||
|
logging:
|
||||||
|
path: /tmp/mailarchive-test/logs
|
||||||
|
level: debug
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Build and run the mailarchive daemon
|
||||||
|
mailarchive:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: mailarchive
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # Web UI + REST API
|
||||||
|
- "2525:2525" # SMTP gateway
|
||||||
|
volumes:
|
||||||
|
- maildata:/var/lib/mailarchive
|
||||||
|
- maillogs:/var/log/mailarchive
|
||||||
|
- ./config/config.yml:/etc/mailarchive/config.yml:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8080/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Send a test mail to the SMTP gateway
|
||||||
|
mailtest:
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on:
|
||||||
|
mailarchive:
|
||||||
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
apk add --no-cache swaks &&
|
||||||
|
swaks
|
||||||
|
--to archive@example.com
|
||||||
|
--from sender@example.com
|
||||||
|
--server mailarchive:2525
|
||||||
|
--header 'Subject: Test Invoice 001'
|
||||||
|
--body 'Please find attached the invoice.' &&
|
||||||
|
echo 'Test mail sent!'
|
||||||
|
"
|
||||||
|
profiles: ["test"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
maildata:
|
||||||
|
maillogs:
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package mailparser_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mailarchive/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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user