feat: add managed installation and system updates

This commit is contained in:
zxyszx
2026-08-03 16:16:20 +08:00
parent fbbd126ef4
commit ea7d58b610
43 changed files with 2766 additions and 624 deletions
+28 -13
View File
@@ -94,14 +94,14 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -183,11 +183,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current := currentUser(r)
var req struct {
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -779,6 +779,13 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
user := currentUser(r)
isSystemAdmin := user != nil && user.Role == "admin"
wantsUnregistered := mailboxID == "unregistered" || strings.EqualFold(folder, "Unregistered")
if wantsUnregistered && !isSystemAdmin {
respondError(w, http.StatusForbidden, "system admin required")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 {
offset = 0
@@ -787,6 +794,9 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
where := []string{"1=1"}
args := []any{}
if !isSystemAdmin {
where = append(where, "m.mailbox_id IS NOT NULL")
}
if mailboxID == "unregistered" {
where = append(where, "m.mailbox_id IS NULL")
} else if mailboxID != "" && mailboxID != "all" {
@@ -844,6 +854,11 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "message not found")
return
}
user := currentUser(r)
if msg.MailboxID == "" && (user == nil || user.Role != "admin") {
respondError(w, http.StatusForbidden, "system admin required")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'')
FROM messages m
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
+108
View File
@@ -1075,6 +1075,73 @@ func TestMailRulesMailboxIsolation(t *testing.T) {
}
}
func TestMailRuleManagementActions(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mailbox := defaultAdminUserAndMailbox(t, a)
create := func(name, value string) MailRule {
t.Helper()
var rule MailRule
if code := client.do("POST", "/api/me/rules", map[string]any{
"mailboxId": mailbox.ID,
"name": name,
"matchMode": "all",
"conditions": []map[string]string{{
"field": "subject", "operator": "contains", "value": value,
}},
"actions": []map[string]string{{"type": "archive"}},
"enabled": true,
}, &rule); code != http.StatusCreated {
t.Fatalf("create %s code=%d rule=%+v", name, code, rule)
}
return rule
}
first := create("first", "欢迎使用 NewSzxcn 邮箱")
second := create("second", "two")
var updated MailRule
if code := client.do("POST", "/api/me/rules/"+first.ID, map[string]any{"name": "first updated", "enabled": false}, &updated); code != http.StatusOK {
t.Fatalf("update code=%d rule=%+v", code, updated)
}
if updated.Name != "first updated" || updated.Enabled {
t.Fatalf("updated rule=%+v", updated)
}
var applied struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if code := client.do("POST", "/api/me/rules/"+first.ID+"/apply", nil, &applied); code != http.StatusOK || !applied.OK || applied.Affected != 1 {
t.Fatalf("apply code=%d body=%+v", code, applied)
}
var moved map[string]any
if code := client.do("POST", "/api/me/rules/"+second.ID+"/move", map[string]string{"direction": "down"}, &moved); code != http.StatusOK {
t.Fatalf("move code=%d body=%+v", code, moved)
}
var listed struct {
Items []MailRule `json:"items"`
}
if code := client.do("GET", "/api/me/rules", nil, &listed); code != http.StatusOK || len(listed.Items) != 2 {
t.Fatalf("list code=%d items=%+v", code, listed.Items)
}
if listed.Items[0].ID != first.ID || listed.Items[1].ID != second.ID {
t.Fatalf("unexpected order after move: %+v", listed.Items)
}
var missing map[string]any
if code := client.do("POST", "/api/me/rules/missing/apply", nil, &missing); code != http.StatusNotFound {
t.Fatalf("missing apply code=%d body=%+v", code, missing)
}
}
func TestBlockedSenderMovesInboundToSpamAndIsolatesUsers(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -1769,6 +1836,47 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
if got := list.Items[0].RecipientAddr; got != "ghost@lanqin.local" {
t.Fatalf("recipientAddress=%q", got)
}
unregisteredMessageID := list.Items[0].ID
var auditGroup PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
"name": "Catch-all Message Auditors",
"description": "Test group for registered-message audit access",
"permissions": []string{PermissionMessagesView, PermissionMessagesRead, PermissionMessagesAttachment},
}, &auditGroup); code != http.StatusCreated {
t.Fatalf("create message audit group code=%d group=%+v", code, auditGroup)
}
var auditor AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "message-auditor@lanqin.local",
"displayName": "Message Auditor",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{auditGroup.ID},
}, &auditor); code != http.StatusCreated {
t.Fatalf("create message auditor code=%d user=%+v", code, auditor)
}
auditorClient := &testClient{t: t, server: ts}
if code := auditorClient.do("POST", "/api/auth/login", map[string]string{"email": "message-auditor@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("auditor login code=%d body=%v", code, login)
}
var errBody map[string]any
if code := auditorClient.do("GET", "/api/admin/messages?mailboxId=unregistered", nil, &errBody); code != http.StatusForbidden {
t.Fatalf("message auditor unregistered list code=%d body=%v", code, errBody)
}
list.Items = nil
if code := auditorClient.do("GET", "/api/admin/messages?q=stored%20for%20admin", nil, &list); code != http.StatusOK {
t.Fatalf("message auditor all-mail query code=%d items=%+v", code, list.Items)
}
for _, item := range list.Items {
if item.MailboxID == "" {
t.Fatalf("message auditor all-mail query exposed unregistered mail: %+v", item)
}
}
if code := auditorClient.do("GET", "/api/admin/messages/"+unregisteredMessageID, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("message auditor unregistered detail code=%d body=%v", code, errBody)
}
}
func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
+8
View File
@@ -9,6 +9,7 @@ import (
type Config struct {
Addr string
AppVersion string
DBPath string
DataDir string
CookieName string
@@ -55,12 +56,16 @@ type Config struct {
StatusWebhookURL string
StatusWebhookSecret string
StatusWebhookAllowPrivateHosts bool
ReleaseAPIURL string
UpdateServiceURL string
UpdateServiceToken string
}
func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{
Addr: getenv("LANQIN_ADDR", ":8080"),
AppVersion: getenv("LANQIN_APP_VERSION", BuildVersion),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
@@ -107,6 +112,9 @@ func LoadConfig() Config {
StatusWebhookURL: getenv("LANQIN_STATUS_WEBHOOK_URL", ""),
StatusWebhookSecret: getenv("LANQIN_STATUS_WEBHOOK_SECRET", ""),
StatusWebhookAllowPrivateHosts: getenvBool("LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS", false),
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
}
}
+3 -3
View File
@@ -34,10 +34,10 @@ type MailboxForwardingRule struct {
}
type ForwardingSettings struct {
VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"`
AccountTargetEmail string `json:"accountTargetEmail"`
VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"`
AccountTargetEmail string `json:"accountTargetEmail"`
AccountTargetEmails []string `json:"accountTargetEmails"`
MailboxRules []MailboxForwardingRule `json:"mailboxRules"`
MailboxRules []MailboxForwardingRule `json:"mailboxRules"`
}
func (a *App) handleForwardingSettings(w http.ResponseWriter, r *http.Request) {
+8 -3
View File
@@ -2126,13 +2126,18 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) {
attID := chi.URLParam(r, "id")
row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID)
var filename, contentType, path string
row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path,COALESCE(m.mailbox_id,'') FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?`, attID)
var filename, contentType, path, mailboxID string
var size int64
if err := row.Scan(&filename, &contentType, &size, &path); err != nil {
if err := row.Scan(&filename, &contentType, &size, &path, &mailboxID); err != nil {
respondError(w, http.StatusNotFound, "attachment not found")
return
}
user := currentUser(r)
if mailboxID == "" && (user == nil || user.Role != "admin") {
respondError(w, http.StatusForbidden, "system admin required")
return
}
f, err := os.Open(path)
if err != nil {
respondError(w, http.StatusNotFound, "attachment file missing")
@@ -0,0 +1,351 @@
package app
import (
"archive/zip"
"bufio"
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
const maxMailImportBytes int64 = 256 << 20
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
ids, err := a.exportMessageIDs(r)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "mailbox or label not found")
return
}
if errors.Is(err, errSystemAdminRequired) {
respondError(w, http.StatusForbidden, "system admin required")
return
}
badRequest(w, err)
return
}
filename := fmt.Sprintf("mail-export-%s.zip", a.now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Cache-Control", "no-store")
zw := zip.NewWriter(w)
usedNames := make(map[string]int, len(ids))
for index, id := range ids {
raw, subject, err := a.rawMessageForExport(r.Context(), id)
if err != nil {
_ = zw.Close()
return
}
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
if err != nil {
_ = zw.Close()
return
}
if _, err := entry.Write(raw); err != nil {
_ = zw.Close()
return
}
}
_ = zw.Close()
}
var errSystemAdminRequired = errors.New("system admin required")
func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
user := currentUser(r)
if user == nil {
return nil, errors.New("no user")
}
view := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
where := []string{}
args := []any{}
if view == "unknown" {
if user.Role != "admin" {
return nil, errSystemAdminRequired
}
where = append(where, "m.mailbox_id IS NULL")
} else {
where = append(where, "EXISTS (SELECT 1 FROM mailboxes owner_mb WHERE owner_mb.id=m.mailbox_id AND owner_mb.user_id=? AND owner_mb.status='active')")
args = append(args, user.ID)
if mailboxID != "" && !isAllMailboxID(mailboxID) {
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
return nil, err
}
where = append(where, "m.mailbox_id=?")
args = append(args, mailboxID)
}
switch view {
case "", "folder":
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
if folder == "" {
folder = "Inbox"
}
normalized, err := normalizeFolderNameForUser(folder)
if err != nil {
return nil, err
}
where = append(where, "f.name=?")
args = append(args, normalized)
case "starred":
where = append(where, "m.is_starred=1")
case "label":
labelID := strings.TrimSpace(r.URL.Query().Get("labelId"))
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
return nil, sql.ErrNoRows
}
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
args = append(args, labelID)
default:
return nil, errors.New("unsupported mail view")
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
msg, err := a.storedMessageByID(ctx, id)
if err != nil {
return nil, "", err
}
if msg.RawPath != "" {
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
return raw, msg.Subject, nil
}
}
}
attachments, err := a.attachmentInputsForMessage(ctx, id)
if err != nil {
return nil, "", err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
Date: messageDate(msg), Attachments: attachments,
})
return raw, msg.Subject, err
}
func exportMessageFilename(subject, id string, index int) string {
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
name = strings.Trim(name, ".-_")
if name == "" {
name = "message"
}
if len(name) > 80 {
name = name[:80]
}
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
}
func uniqueExportFilename(name string, used map[string]int) string {
used[name]++
if used[name] == 1 {
return name
}
base := strings.TrimSuffix(name, filepath.Ext(name))
return fmt.Sprintf("%s-%d%s", base, used[name], filepath.Ext(name))
}
func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxMailImportBytes)
if err := r.ParseMultipartForm(maxMailImportBytes); err != nil {
respondError(w, http.StatusRequestEntityTooLarge, "import is too large")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
mb, err := a.mailboxForCurrentUserWithID(r, r.FormValue("mailboxId"))
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderName := strings.TrimSpace(r.FormValue("folder"))
if folderName == "" {
folderName = "Inbox"
}
folderName, err = normalizeFolderNameForUser(folderName)
if err != nil {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), mb.ID, folderName)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
files = r.MultipartForm.File["file"]
}
if len(files) == 0 {
badRequest(w, errors.New("at least one EML or MBOX file is required"))
return
}
imported, skipped := 0, 0
problems := []string{}
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024
}
for _, header := range files {
messages, fileErr := readImportFile(header, maxMessageBytes)
if fileErr != nil {
skipped++
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, fileErr))
continue
}
for _, raw := range messages {
if err := a.importRawMessage(r.Context(), mb, folderID, raw); err != nil {
skipped++
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, err))
continue
}
imported++
}
}
if imported == 0 && len(problems) > 0 {
badRequest(w, errors.New(problems[0]))
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported, "skipped": skipped, "errors": problems})
}
func readImportFile(header *multipart.FileHeader, maxMessageBytes int64) ([][]byte, error) {
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".eml" && ext != ".mbox" {
return nil, errors.New("only .eml and .mbox files are supported")
}
file, err := header.Open()
if err != nil {
return nil, err
}
defer file.Close()
if ext == ".eml" {
raw, err := io.ReadAll(io.LimitReader(file, maxMessageBytes+1))
if err != nil {
return nil, err
}
if int64(len(raw)) > maxMessageBytes {
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
}
if len(bytes.TrimSpace(raw)) == 0 {
return nil, errors.New("message is empty")
}
return [][]byte{raw}, nil
}
return parseMBOX(file, maxMessageBytes)
}
func parseMBOX(reader io.Reader, maxMessageBytes int64) ([][]byte, error) {
scanner := bufio.NewScanner(reader)
bufferSize := int(maxMessageBytes + 1024)
if bufferSize < 64*1024 {
bufferSize = 64 * 1024
}
scanner.Buffer(make([]byte, 64*1024), bufferSize)
var current bytes.Buffer
messages := [][]byte{}
seenSeparator := false
flush := func() error {
raw := bytes.TrimSpace(current.Bytes())
current.Reset()
if len(raw) == 0 {
return nil
}
if int64(len(raw)) > maxMessageBytes {
return fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
}
messages = append(messages, append([]byte(nil), raw...))
return nil
}
for scanner.Scan() {
line := scanner.Bytes()
if bytes.HasPrefix(line, []byte("From ")) {
if seenSeparator {
if err := flush(); err != nil {
return nil, err
}
}
seenSeparator = true
continue
}
if bytes.HasPrefix(line, []byte(">From ")) {
line = line[1:]
}
current.Write(line)
current.WriteString("\r\n")
if int64(current.Len()) > maxMessageBytes {
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if err := flush(); err != nil {
return nil, err
}
if len(messages) == 0 {
return nil, errors.New("MBOX contains no messages")
}
return messages, nil
}
func (a *App) importRawMessage(ctx context.Context, mb *Mailbox, folderID string, raw []byte) error {
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
if err != nil {
return fmt.Errorf("invalid message: %w", err)
}
msg.MailboxID = mb.ID
msg.FolderID = folderID
msg.RecipientAddr = mb.Address
msg.RawPath = ""
id, err := a.insertMessage(ctx, msg, attachments)
if err != nil {
return err
}
if err := a.writeRawMessageToMaildir(ctx, id, raw, false); err != nil {
a.deleteMessage(ctx, id)
return err
}
return nil
}
func appendImportProblem(items []string, problem string) []string {
if len(items) >= 5 {
return items
}
return append(items, problem)
}
@@ -0,0 +1,174 @@
package app
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseMBOXMultipleMessages(t *testing.T) {
raw := strings.Join([]string{
"From sender@example.com Mon Jan 1 00:00:00 2024",
"From: sender@example.com",
"To: first@example.com",
"Subject: first",
"",
"first body",
">From escaped body line",
"From sender@example.com Tue Jan 2 00:00:00 2024",
"From: sender@example.com",
"To: second@example.com",
"Subject: second",
"",
"second body",
}, "\n")
messages, err := parseMBOX(strings.NewReader(raw), 1<<20)
if err != nil {
t.Fatal(err)
}
if len(messages) != 2 {
t.Fatalf("messages=%d", len(messages))
}
if !bytes.Contains(messages[0], []byte("Subject: first")) || !bytes.Contains(messages[0], []byte("From escaped body line")) {
t.Fatalf("first message=%q", messages[0])
}
if !bytes.Contains(messages[1], []byte("Subject: second")) {
t.Fatalf("second message=%q", messages[1])
}
}
func TestMailImportExportAndOwnership(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login=%d", code)
}
var domains struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-owner", "Transfer Owner", "Password123!", nil)
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-other", "Transfer Other", "Password123!", nil)
owner := &testClient{t: t, server: ts}
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("owner login=%d", code)
}
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
var imported struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
t.Fatalf("import code=%d response=%+v", code, imported)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
t.Fatalf("list code=%d items=%+v", code, list.Items)
}
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
if status != http.StatusOK {
t.Fatalf("export status=%d body=%q", status, archive)
}
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
t.Fatal(err)
}
if len(zr.File) != 1 {
t.Fatalf("zip entries=%d", len(zr.File))
}
entry, err := zr.File[0].Open()
if err != nil {
t.Fatal(err)
}
exported, err := io.ReadAll(entry)
entry.Close()
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
t.Fatalf("exported message err=%v raw=%q", err, exported)
}
var denied map[string]any
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
t.Fatalf("cross-mailbox import code=%d", code)
}
status, _ = getMailExport(t, owner, "/api/mail/export?view=unknown")
if status != http.StatusForbidden {
t.Fatalf("unknown export status=%d", status)
}
}
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
_ = writer.WriteField("mailboxId", mailboxID)
_ = writer.WriteField("folder", folder)
for name, content := range files {
part, err := writer.CreateFormFile("files", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, client.server.URL+"/api/mail/import", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if client.cookie != nil {
req.AddCookie(client.cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
t.Fatalf("decode import response: %v", err)
}
}
return resp.StatusCode
}
func getMailExport(t *testing.T, client *testClient, path string) (int, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, client.server.URL+path, nil)
if err != nil {
t.Fatal(err)
}
if client.cookie != nil {
req.AddCookie(client.cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, body
}
+35 -35
View File
@@ -137,12 +137,12 @@ type PermissionGroup struct {
}
type PermissionLimits struct {
MaxAttachmentMB int `json:"maxAttachmentMb"`
MaxMailboxCount int `json:"maxMailboxCount"`
SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"`
POP3MinuteLimit int `json:"pop3MinuteLimit"`
MaxAttachmentMB int `json:"maxAttachmentMb"`
MaxMailboxCount int `json:"maxMailboxCount"`
SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"`
POP3MinuteLimit int `json:"pop3MinuteLimit"`
}
func defaultPermissionLimits() PermissionLimits {
@@ -213,12 +213,12 @@ func encodePermissionLimits(limits PermissionLimits) string {
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
}
}
@@ -233,8 +233,8 @@ func mergeLimitValue(left, right int) int {
}
func minimalLimits() PermissionLimits {
// minimalLimits sets every field to 1 so that mergePermissionLimits
// (which takes the max of each field) produces correct aggregation
// minimalLimits sets every field to 1 so that mergePermissionLimits
// (which takes the max of each field) produces correct aggregation
// when no group has a limit set for a given field.
return PermissionLimits{
MaxAttachmentMB: 1,
@@ -311,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
{Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
{Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
{Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
{Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
{Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
{Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
{Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
{Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
{Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
@@ -334,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
{Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
{Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
{Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
{Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
{Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
{Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
{Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
{Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
@@ -454,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup {
return []PermissionGroup{
{
ID: PermissionGroupSuperAdmin,
Name: "管理员",
Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
Name: "管理员",
Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
Permissions: allPermissionKeys(),
Limits: PermissionLimits{},
System: true,
+207 -6
View File
@@ -565,6 +565,201 @@ func (a *App) handleDeleteRule(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleUpdateRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := chi.URLParam(r, "id")
item, err := a.ruleByID(r.Context(), user.ID, id)
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "rule not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
var req struct {
MailboxID *string `json:"mailboxId"`
Name *string `json:"name"`
MatchMode *string `json:"matchMode"`
Conditions *[]MailRuleCondition `json:"conditions"`
Actions *[]MailRuleAction `json:"actions"`
ApplyExisting *bool `json:"applyToExisting"`
StopProcessing *bool `json:"stopProcessing"`
Enabled *bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if req.MailboxID != nil {
mailboxID, ok := a.optionalMailboxIDForUser(r, *req.MailboxID)
if !ok {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
item.MailboxID = mailboxID
}
if req.Name != nil {
item.Name = strings.TrimSpace(*req.Name)
if item.Name == "" {
item.Name = "收件规则"
}
}
if req.MatchMode != nil {
raw := strings.ToLower(strings.TrimSpace(*req.MatchMode))
if raw != "all" && raw != "and" && raw != "any" && raw != "or" {
badRequest(w, errors.New("invalid match mode"))
return
}
item.MatchMode = normalizeRuleMatchMode(raw)
}
if req.Conditions != nil {
item.Conditions = normalizeRuleConditions(*req.Conditions, "", "")
if len(item.Conditions) == 0 {
badRequest(w, errors.New("rule condition is required"))
return
}
}
if req.Actions != nil {
item.Actions = normalizeRuleActions(*req.Actions, "")
item.Actions, err = a.cleanRuleActions(r.Context(), user.ID, item.Actions)
if err != nil || len(item.Actions) == 0 {
if err == nil {
err = errors.New("rule action is required")
}
badRequest(w, err)
return
}
}
if req.ApplyExisting != nil {
item.ApplyToExisting = *req.ApplyExisting
}
if req.StopProcessing != nil {
item.StopProcessing = *req.StopProcessing
}
if req.Enabled != nil {
item.Enabled = *req.Enabled
}
conditionsJSON, err := json.Marshal(item.Conditions)
if err != nil {
badRequest(w, err)
return
}
actionsJSON, err := json.Marshal(item.Actions)
if err != nil {
badRequest(w, err)
return
}
item.FromContains = legacyConditionValue(item.Conditions, "from")
item.SubjectContains = legacyConditionValue(item.Conditions, "subject")
item.Action = item.Actions[0].Type
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `UPDATE mail_rules SET mailbox_id=?,name=?,match_mode=?,conditions_json=?,actions_json=?,from_contains=?,subject_contains=?,action=?,apply_to_existing=?,stop_processing=?,enabled=?,updated_at=? WHERE id=? AND user_id=?`, item.MailboxID, item.Name, item.MatchMode, string(conditionsJSON), string(actionsJSON), item.FromContains, item.SubjectContains, item.Action, boolInt(item.ApplyToExisting), boolInt(item.StopProcessing), boolInt(item.Enabled), now, id, user.ID)
if err != nil {
badRequest(w, err)
return
}
updated, err := a.ruleByID(r.Context(), user.ID, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
respondJSON(w, http.StatusOK, updated)
}
func (a *App) handleMoveRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
Direction string `json:"direction"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if req.Direction != "up" && req.Direction != "down" {
badRequest(w, errors.New("invalid direction"))
return
}
type orderedRule struct{ id, createdAt string }
rows, err := a.db.QueryContext(r.Context(), `SELECT id,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rules")
return
}
items := []orderedRule{}
for rows.Next() {
var item orderedRule
if err := rows.Scan(&item.id, &item.createdAt); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan rules")
return
}
items = append(items, item)
}
rows.Close()
index := -1
for i := range items {
if items[i].id == chi.URLParam(r, "id") {
index = i
break
}
}
if index < 0 {
respondError(w, http.StatusNotFound, "rule not found")
return
}
target := index - 1
if req.Direction == "down" {
target = index + 1
}
if target < 0 || target >= len(items) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[target].createdAt, items[index].id, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[index].createdAt, items[target].id, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move rule")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleApplyRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
item, err := a.ruleByID(r.Context(), user.ID, chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "rule not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
affected, err := a.applyRuleToExistingMessages(r.Context(), user.ID, item.MailboxID, item)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to apply rule")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affected": affected})
}
func (a *App) ruleByID(ctx context.Context, userID, id string) (MailRule, error) {
return scanRule(a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=? AND user_id=?`, id, userID))
}
func (a *App) handleListBlockedSenders(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? ORDER BY created_at DESC`, user.ID)
@@ -1114,7 +1309,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at DESC`, userID, mailboxID)
if err != nil {
return
}
@@ -1620,13 +1815,23 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
if err != nil {
return 0, err
}
messages := []ruleMessage{}
messageIDs := []string{}
var count int64
for rows.Next() {
var messageID string
if err := rows.Scan(&messageID); err != nil {
rows.Close()
return count, err
}
messageIDs = append(messageIDs, messageID)
}
if err := rows.Err(); err != nil {
rows.Close()
return count, err
}
rows.Close()
messages := []ruleMessage{}
for _, messageID := range messageIDs {
msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
continue
@@ -1636,10 +1841,6 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
}
messages = append(messages, msg)
}
if err := rows.Err(); err != nil {
return count, err
}
rows.Close()
for _, msg := range messages {
if err := a.applyRuleActions(ctx, msg.MailboxID, msg.ID, rule.Actions); err != nil {
return count, err
+7
View File
@@ -65,6 +65,9 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}", a.handleUpdateRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/move", a.handleMoveRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/apply", a.handleApplyRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
@@ -99,6 +102,8 @@ func (a *App) Router() http.Handler {
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/export", a.handleExportMail)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/import", a.handleImportMail)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
r.With(a.requirePermission(PermissionMailRead)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts)
@@ -131,6 +136,8 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Use(a.requireAdminAccess)
r.Get("/admin/system/version", a.handleSystemVersion)
r.Post("/admin/system/update", a.handleSystemUpdate)
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
@@ -0,0 +1,282 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
var (
BuildVersion = "dev"
BuildCommit = ""
BuildDate = ""
)
type systemVersionInfo struct {
CurrentVersion string `json:"currentVersion"`
CurrentCommit string `json:"currentCommit,omitempty"`
BuildDate string `json:"buildDate,omitempty"`
LatestVersion string `json:"latestVersion,omitempty"`
LatestName string `json:"latestName,omitempty"`
ReleaseURL string `json:"releaseUrl,omitempty"`
ReleaseNotes string `json:"releaseNotes,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
UpdateAvailable bool `json:"updateAvailable"`
UpdateEnabled bool `json:"updateEnabled"`
CheckError string `json:"checkError,omitempty"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
PublishedAt time.Time `json:"published_at"`
}
func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
info, err := a.systemVersion(r.Context())
if err != nil {
info.CheckError = "暂时无法连接版本服务"
a.log.Warn("check system version", "error", err)
}
respondJSON(w, http.StatusOK, info)
}
func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "system administrator required")
return
}
if !a.updateEnabled() {
respondError(w, http.StatusServiceUnavailable, "online update is not configured")
return
}
info, err := a.systemVersion(r.Context())
if err != nil {
respondError(w, http.StatusBadGateway, "failed to check latest release")
return
}
if !info.UpdateAvailable {
respondError(w, http.StatusConflict, "already on the latest version")
return
}
backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
if err != nil {
a.log.Error("backup database before update", "error", err)
respondError(w, http.StatusInternalServerError, "failed to back up database")
return
}
if err := a.triggerUpdateService(r.Context()); err != nil {
a.log.Error("trigger system update", "error", err)
respondError(w, http.StatusBadGateway, "failed to start update")
return
}
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
respondJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"currentVersion": info.CurrentVersion,
"targetVersion": info.LatestVersion,
"message": "更新已启动,服务会在完成后自动恢复",
})
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
current := strings.TrimSpace(a.cfg.AppVersion)
if current == "" {
current = BuildVersion
}
info := systemVersionInfo{
CurrentVersion: current,
CurrentCommit: strings.TrimSpace(BuildCommit),
BuildDate: strings.TrimSpace(BuildDate),
UpdateEnabled: a.updateEnabled(),
}
release, err := a.fetchLatestRelease(ctx)
if err != nil {
return info, err
}
info.LatestVersion = strings.TrimSpace(release.TagName)
info.LatestName = strings.TrimSpace(release.Name)
info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
info.ReleaseNotes = strings.TrimSpace(release.Body)
if !release.PublishedAt.IsZero() {
info.PublishedAt = &release.PublishedAt
}
info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
return info, nil
}
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
client := &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return githubRelease{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
}
var release githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
return githubRelease{}, err
}
if strings.TrimSpace(release.TagName) == "" {
return githubRelease{}, errors.New("release API returned an empty tag")
}
return release, nil
}
func (a *App) updateEnabled() bool {
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
}
func (a *App) triggerUpdateService(ctx context.Context) error {
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("update service returned %s", resp.Status)
}
return nil
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.cfg.DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err
}
backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
quotedPath := strings.ReplaceAll(backupPath, "'", "''")
if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
return "", err
}
if err := pruneUpdateBackups(backupDir, 5); err != nil {
a.log.Warn("prune update backups", "error", err)
}
return backupPath, nil
}
func pruneUpdateBackups(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
type backupFile struct {
path string
modTime time.Time
}
backups := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
}
sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
if keep < 0 {
keep = 0
}
if len(backups) <= keep {
return nil
}
for _, backup := range backups[keep:] {
if err := os.Remove(backup.path); err != nil {
return err
}
}
return nil
}
var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
func versionIsNewer(latest, current string) bool {
latestParts, latestPrerelease, latestOK := parseVersion(latest)
currentParts, currentPrerelease, currentOK := parseVersion(current)
if !latestOK {
return false
}
if !currentOK {
return true
}
for i := 0; i < len(latestParts); i++ {
if latestParts[i] != currentParts[i] {
return latestParts[i] > currentParts[i]
}
}
return currentPrerelease != "" && latestPrerelease == ""
}
func parseVersion(value string) ([3]int, string, bool) {
match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
if match == nil {
return [3]int{}, "", false
}
var parts [3]int
for i := 0; i < 3; i++ {
if match[i+1] == "" {
continue
}
part, err := strconv.Atoi(match[i+1])
if err != nil {
return [3]int{}, "", false
}
parts[i] = part
}
return parts, match[4], true
}
@@ -0,0 +1,164 @@
package app
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
func TestSystemVersionAndUpdate(t *testing.T) {
releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"tag_name":"v0.2.0","name":"Version 0.2.0","html_url":"https://example.test/releases/v0.2.0","body":"Release notes","published_at":"2026-08-03T00:00:00Z"}`)
}))
defer releaseServer.Close()
var updateRequests atomic.Int32
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("update method = %s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer update-secret" {
t.Errorf("authorization = %q", got)
}
updateRequests.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer updateServer.Close()
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
AppVersion: "v0.1.0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: dir,
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ReleaseAPIURL: releaseServer.URL,
UpdateServiceURL: updateServer.URL,
UpdateServiceToken: "update-secret",
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
var version systemVersionInfo
if code := admin.do("GET", "/api/admin/system/version", nil, &version); code != http.StatusOK {
t.Fatalf("version code=%d", code)
}
if version.CurrentVersion != "v0.1.0" || version.LatestVersion != "v0.2.0" || !version.UpdateAvailable || !version.UpdateEnabled {
t.Fatalf("unexpected version response: %+v", version)
}
var update map[string]any
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
t.Fatalf("update code=%d response=%v", code, update)
}
if updateRequests.Load() != 1 {
t.Fatalf("update requests=%d", updateRequests.Load())
}
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
if err != nil || len(backups) != 1 {
t.Fatalf("backups=%v err=%v", backups, err)
}
if info, err := os.Stat(backups[0]); err != nil || info.Size() == 0 {
t.Fatalf("backup stat=%v err=%v", info, err)
}
}
func TestSystemUpdateRequiresSystemAdministrator(t *testing.T) {
a := newTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/api/admin/system/update", nil)
req = req.WithContext(context.WithValue(req.Context(), userContextKey, &User{ID: "operator", Role: "user"}))
recorder := httptest.NewRecorder()
a.handleSystemUpdate(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("code=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestSystemVersionHandlesReleaseFailure(t *testing.T) {
dir := t.TempDir()
a, err := New(Config{
Addr: ":0",
AppVersion: "v0.1.0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: dir,
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
ReleaseAPIURL: "http://127.0.0.1:1/releases/latest",
AllowInsecureHTTP: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
defer a.Close()
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/version", nil)
recorder := httptest.NewRecorder()
a.handleSystemVersion(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("code=%d", recorder.Code)
}
var info systemVersionInfo
if err := json.Unmarshal(recorder.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
if !strings.Contains(info.CheckError, "版本服务") || info.CurrentVersion != "v0.1.0" {
t.Fatalf("unexpected response: %+v", info)
}
}
func TestVersionIsNewer(t *testing.T) {
tests := []struct {
latest string
current string
want bool
}{
{"v0.2.0", "v0.1.9", true},
{"v1.0.0", "v0.99.99", true},
{"v1.0.0", "v1.0.0", false},
{"v1.0.0-beta.1", "v1.0.0", false},
{"v1.0.0", "v1.0.0-beta.1", true},
{"v1.0.0+build.2", "v1.0.0+build.1", false},
{"v1.0.0", "dev", true},
}
for _, tt := range tests {
if got := versionIsNewer(tt.latest, tt.current); got != tt.want {
t.Errorf("versionIsNewer(%q, %q)=%v want %v", tt.latest, tt.current, got, tt.want)
}
}
}
func TestPruneUpdateBackupsWithFewerFilesThanLimit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pre-update-one.db")
if err := os.WriteFile(path, []byte("backup"), 0o600); err != nil {
t.Fatal(err)
}
if err := pruneUpdateBackups(dir, 5); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("backup should be retained: %v", err)
}
}