feat: add managed installation and system updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user