feat(mailbox): 支持用户自助申请邮箱

- 后端新增邮箱申请配置与接口,限制可申请域名并校验保留前缀。
- 管理后台系统设置增加自助申请邮箱开关、开放域名和禁止前缀配置。
- 个人中心新增申请邮箱入口,并在邮箱页优化当前邮箱切换与空列表处理。
- 补充相关测试与环境变量示例配置。
This commit is contained in:
LanQin
2026-06-16 00:51:41 +08:00
parent b9e43f211d
commit be8cd4be31
11 changed files with 587 additions and 153 deletions
+9 -5
View File
@@ -627,6 +627,14 @@ func defaultFolderDefs() []struct{ name, role string } {
} }
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) { func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, displayName, string(passwordHash), quotaMB, status)
}
func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainID, localPart, displayName, passwordHash string, quotaMB int, status string) (string, error) {
localPart = normalizeLocalPart(localPart) localPart = normalizeLocalPart(localPart)
if localPart == "" { if localPart == "" {
return "", errors.New("invalid local part") return "", errors.New("invalid local part")
@@ -642,10 +650,6 @@ func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, di
return "", err return "", err
} }
address := localPart + "@" + domain address := localPart + "@" + domain
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
if displayName == "" { if displayName == "" {
displayName = address displayName = address
} }
@@ -659,7 +663,7 @@ func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, di
id := newID("mbx") id := newID("mbx")
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at) _, err = tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, userID, domainID, localPart, address, displayName, string(passwordHash), quotaMB, status, now, now) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, userID, domainID, localPart, address, displayName, passwordHash, quotaMB, status, now, now)
if err != nil { if err != nil {
return "", err return "", err
} }
+91
View File
@@ -170,6 +170,33 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
return mailbox return mailbox
} }
func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{
"publicHostname": settings.PublicHostname,
"publicBaseUrl": settings.PublicBaseURL,
"smtpHost": settings.SMTPHost,
"smtpPort": settings.SMTPPort,
"smtpUsername": settings.SMTPUsername,
"smtpPassword": "",
"smtpRequireTls": settings.SMTPRequireTLS,
"maildirRoot": settings.MaildirRoot,
"maildirScanSeconds": settings.MaildirScanSeconds,
"sessionTtlHours": settings.SessionTTLHours,
"allowInsecureHttp": settings.AllowInsecureHTTP,
"openRegistration": settings.OpenRegistration,
"twoFactorEnabled": settings.TwoFactorEnabled,
"turnstileEnabled": settings.TurnstileEnabled,
"turnstileSiteKey": settings.TurnstileSiteKey,
"turnstileSecretKey": "",
"catchAllEnabled": settings.CatchAllEnabled,
"mailAutoRefresh": settings.MailAutoRefresh,
"mailRefreshSeconds": settings.MailRefreshSeconds,
"userMailboxApplyEnabled": settings.UserMailboxApplyEnabled,
"userMailboxDomainIds": settings.UserMailboxDomainIDs,
"reservedMailboxPrefixes": settings.ReservedMailboxPrefixes,
}
}
func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
@@ -305,6 +332,70 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
} }
} }
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(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 code=%d body=%v", code, login)
}
allowedDomain := createTestDomain(t, admin, "a.com")
blockedDomain := createTestDomain(t, admin, "b.com")
var created AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{"email": "person@example.net", "displayName": "Person", "role": "user", "password": "Password123!", "disabled": false}, &created); code != http.StatusCreated {
t.Fatalf("create user code=%d user=%+v", code, created)
}
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": "person@example.net", "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login code=%d", code)
}
var options MailboxApplyOptions
if code := userClient.do("GET", "/api/me/mailbox-apply-options", nil, &options); code != http.StatusOK || options.Enabled || len(options.Domains) != 0 {
t.Fatalf("disabled options code=%d options=%+v", code, options)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
update := systemSettingsPayload(settings)
update["userMailboxApplyEnabled"] = true
update["userMailboxDomainIds"] = []string{allowedDomain.ID}
update["reservedMailboxPrefixes"] = "admin\nroot"
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.UserMailboxApplyEnabled || len(settings.UserMailboxDomainIDs) != 1 {
t.Fatalf("enable apply code=%d settings=%+v", code, settings)
}
if code := userClient.do("GET", "/api/me/mailbox-apply-options", nil, &options); code != http.StatusOK || !options.Enabled || len(options.Domains) != 1 || options.Domains[0].ID != allowedDomain.ID {
t.Fatalf("enabled options code=%d options=%+v", code, options)
}
var errBody map[string]any
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "admin"}, &errBody); code != http.StatusForbidden {
t.Fatalf("reserved prefix code=%d body=%v", code, errBody)
}
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": blockedDomain.ID, "localPart": "alice"}, &errBody); code != http.StatusForbidden {
t.Fatalf("blocked domain code=%d body=%v", code, errBody)
}
var mailbox Mailbox
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "alice", "displayName": "Alice"}, &mailbox); code != http.StatusCreated || mailbox.Address != "alice@a.com" || mailbox.UserID != created.ID {
t.Fatalf("apply mailbox code=%d mailbox=%+v", code, mailbox)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].Address != "alice@a.com" {
t.Fatalf("mine code=%d items=%+v", code, mine.Items)
}
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "alice"}, &errBody); code != http.StatusConflict {
t.Fatalf("duplicate apply code=%d body=%v", code, errBody)
}
}
func TestUserCanSelectMultipleMailboxes(t *testing.T) { func TestUserCanSelectMultipleMailboxes(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
+56 -50
View File
@@ -8,61 +8,67 @@ import (
) )
type Config struct { type Config struct {
Addr string Addr string
DBPath string DBPath string
DataDir string DataDir string
CookieName string CookieName string
SessionTTLHours int SessionTTLHours int
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
PublicHostname string PublicHostname string
PublicBaseURL string PublicBaseURL string
SMTPHost string SMTPHost string
SMTPPort string SMTPPort string
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
SMTPRequireTLS bool SMTPRequireTLS bool
MaildirRoot string MaildirRoot string
MaildirScanSeconds int MaildirScanSeconds int
AllowInsecureHTTP bool AllowInsecureHTTP bool
OpenRegistration bool OpenRegistration bool
TwoFactorEnabled bool TwoFactorEnabled bool
TurnstileEnabled bool TurnstileEnabled bool
TurnstileSiteKey string TurnstileSiteKey string
TurnstileSecretKey string TurnstileSecretKey string
CatchAllEnabled bool CatchAllEnabled bool
MailAutoRefresh bool MailAutoRefresh bool
MailRefreshSeconds int MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
} }
func LoadConfig() Config { func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data") dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{ return Config{
Addr: getenv("LANQIN_ADDR", ":8080"), Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")), DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir, DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"), PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""), SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"), SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""), SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""), SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false), SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false), OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false), TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false), TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""), TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""), TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false), CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true), MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30), MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
} }
} }
+140
View File
@@ -12,6 +12,146 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type MailboxApplyOptions struct {
Enabled bool `json:"enabled"`
Domains []Domain `json:"domains"`
ReservedPrefixes []string `json:"reservedPrefixes,omitempty"`
}
func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request) {
domains, err := a.mailboxApplyDomains(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load domains")
return
}
respondJSON(w, http.StatusOK, MailboxApplyOptions{
Enabled: a.cfg.UserMailboxApplyEnabled,
Domains: domains,
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
})
}
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
if !a.cfg.UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "mailbox application is disabled")
return
}
user := currentUser(r)
var req struct {
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
DisplayName string `json:"displayName"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
domainID := strings.TrimSpace(req.DomainID)
if domainID == "" {
badRequest(w, errors.New("domainId is required"))
return
}
allowed, err := a.mailboxApplyDomainAllowed(r.Context(), domainID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to check domain")
return
}
if !allowed {
respondError(w, http.StatusForbidden, "domain is not available")
return
}
localPart := normalizeLocalPart(req.LocalPart)
if localPart == "" {
badRequest(w, errors.New("localPart is required"))
return
}
if len(localPart) > 64 {
badRequest(w, errors.New("localPart is too long"))
return
}
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[localPart] {
respondError(w, http.StatusForbidden, "localPart is reserved")
return
}
var exists int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=? AND local_part=?`, domainID, localPart).Scan(&exists); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
return
}
if exists > 0 {
respondError(w, http.StatusConflict, "mailbox already exists")
return
}
var passwordHash string
if err := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND disabled=0`, user.ID).Scan(&passwordHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
displayName := strings.TrimSpace(req.DisplayName)
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
mailboxID, err := a.createMailboxWithPasswordHash(r.Context(), user.ID, domainID, localPart, displayName, passwordHash, 1024, "active")
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "mailbox already exists")
return
}
badRequest(w, err)
return
}
mailbox, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
return
}
respondJSON(w, http.StatusCreated, mailbox)
}
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
if !a.cfg.UserMailboxApplyEnabled {
return []Domain{}, nil
}
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
if len(ids) == 0 {
return []Domain{}, nil
}
items := make([]Domain, 0, len(ids))
for _, id := range ids {
domain, err := a.domainByID(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return nil, err
}
if domain.Status == "active" {
items = append(items, *domain)
}
}
return items, nil
}
func (a *App) mailboxApplyDomainAllowed(ctx context.Context, domainID string) (bool, error) {
domains, err := a.mailboxApplyDomains(ctx)
if err != nil {
return false, err
}
for _, domain := range domains {
if domain.ID == domainID {
return true, nil
}
}
return false, nil
}
func (a *App) handleListContacts(w http.ResponseWriter, r *http.Request) { func (a *App) handleListContacts(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,name,email,note,created_at FROM contacts WHERE user_id=? ORDER BY name,email`, user.ID) rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,name,email,note,created_at FROM contacts WHERE user_id=? ORDER BY name,email`, user.ID)
+2
View File
@@ -37,6 +37,8 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth).Get("/me", a.handleMe) r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile) r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword) r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup) r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable) r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable) r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
+126 -76
View File
@@ -10,47 +10,53 @@ import (
) )
type SystemSettings struct { type SystemSettings struct {
PublicHostname string `json:"publicHostname"` PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"` PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"` SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"` SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"` SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"` SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"` SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"` MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"` MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"` SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"` AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"` OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"` TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"` TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"` TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"` TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"` CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"` MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"` MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
} }
type systemSettingsUpdate struct { type systemSettingsUpdate struct {
PublicHostname string `json:"publicHostname"` PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"` PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"` SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"` SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"` SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"` SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"` SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"` MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"` MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"` SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"` AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"` OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"` TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"` TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"` TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"` TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"` CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"` MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"` MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
} }
type PublicSettings struct { type PublicSettings struct {
@@ -132,6 +138,9 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
req.MailRefreshSeconds = 30 req.MailRefreshSeconds = 30
} }
next.MailRefreshSeconds = req.MailRefreshSeconds next.MailRefreshSeconds = req.MailRefreshSeconds
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",")
if err := a.saveSystemSettings(r.Context(), next); err != nil { if err := a.saveSystemSettings(r.Context(), next); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings") respondError(w, http.StatusInternalServerError, "failed to save settings")
@@ -215,25 +224,28 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
func (a *App) systemSettingsSnapshot() SystemSettings { func (a *App) systemSettingsSnapshot() SystemSettings {
return SystemSettings{ return SystemSettings{
PublicHostname: a.cfg.PublicHostname, PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL, PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost, SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort, SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername, SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "", SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS, SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot, MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds, MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours, SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP, AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration, OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled, TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled, TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey, TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "", TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled, CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh, MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds, MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
} }
} }
@@ -293,6 +305,12 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
if n, err := strconv.Atoi(value); err == nil && n > 0 { if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n a.cfg.MailRefreshSeconds = n
} }
case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
} }
} }
return rows.Err() return rows.Err()
@@ -300,25 +318,28 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error { func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
values := map[string]string{ values := map[string]string{
"publicHostname": cfg.PublicHostname, "publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL, "publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost, "smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort, "smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername, "smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword, "smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS), "smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot, "maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds), "maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours), "sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP), "allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration), "openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled), "twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled), "turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey, "turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey, "turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled), "catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh), "mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds), "mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
} }
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil) tx, err := a.db.BeginTx(ctx, nil)
@@ -335,6 +356,35 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
return tx.Commit() return tx.Commit()
} }
func cleanIDList(items []string) []string {
seen := map[string]bool{}
out := []string{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func parseReservedPrefixes(value string) []string {
value = strings.NewReplacer("\r", "\n", ",", "\n", ";", "\n", "", "\n", "", "\n").Replace(value)
seen := map[string]bool{}
out := []string{}
for _, item := range strings.Split(value, "\n") {
item = normalizeLocalPart(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func normalizeHostname(value string) string { func normalizeHostname(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
value = strings.TrimSuffix(value, ".") value = strings.TrimSuffix(value, ".")
+6
View File
@@ -22,6 +22,7 @@ export type MailRule = { id: string; mailboxId: string; name: string; matchMode:
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string } export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type SystemSettings = { export type SystemSettings = {
publicHostname: string publicHostname: string
publicBaseUrl: string publicBaseUrl: string
@@ -42,6 +43,9 @@ export type SystemSettings = {
catchAllEnabled: boolean catchAllEnabled: boolean
mailAutoRefresh: boolean mailAutoRefresh: boolean
mailRefreshSeconds: number mailRefreshSeconds: number
userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[]
reservedMailboxPrefixes: string
} }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string } export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number } export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
@@ -99,6 +103,8 @@ export const api = {
deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }), deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }),
mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }), cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
adminOverview: () => request<AdminOverview>("/api/admin/overview"), adminOverview: () => request<AdminOverview>("/api/admin/overview"),
users: () => request<ListResponse<AdminUser>>("/api/admin/users"), users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
+44 -3
View File
@@ -4,12 +4,13 @@ import { useSearchParams } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CheckCircle2, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react" import { CheckCircle2, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react"
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api" import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api"
import { formatBytes, formatDate } from "@/lib/utils" import { cn, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -79,7 +80,7 @@ export function AdminPage() {
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} />} {section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} />}
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />} {section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />} {section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} />} {section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
</main> </main>
</ScrollArea> </ScrollArea>
) )
@@ -371,7 +372,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
) )
} }
function SystemSettingsSection({ settings }: { settings?: SystemSettings }) { function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates }) const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates })
@@ -383,6 +384,8 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
const [turnstileEnabled, setTurnstileEnabled] = React.useState(false) const [turnstileEnabled, setTurnstileEnabled] = React.useState(false)
const [catchAllEnabled, setCatchAllEnabled] = React.useState(false) const [catchAllEnabled, setCatchAllEnabled] = React.useState(false)
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true) const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
React.useEffect(() => { React.useEffect(() => {
if (!settings) return if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls) setSmtpRequireTls(settings.smtpRequireTls)
@@ -392,6 +395,8 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
setTurnstileEnabled(settings.turnstileEnabled) setTurnstileEnabled(settings.turnstileEnabled)
setCatchAllEnabled(settings.catchAllEnabled) setCatchAllEnabled(settings.catchAllEnabled)
setMailAutoRefresh(settings.mailAutoRefresh) setMailAutoRefresh(settings.mailAutoRefresh)
setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled)
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
}, [settings]) }, [settings])
const save = useMutation({ const save = useMutation({
mutationFn: (form: FormData) => api.updateSystemSettings({ mutationFn: (form: FormData) => api.updateSystemSettings({
@@ -414,6 +419,9 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
catchAllEnabled, catchAllEnabled,
mailAutoRefresh, mailAutoRefresh,
mailRefreshSeconds: fieldNumber(form, "mailRefreshSeconds", settings?.mailRefreshSeconds || 30), mailRefreshSeconds: fieldNumber(form, "mailRefreshSeconds", settings?.mailRefreshSeconds || 30),
userMailboxApplyEnabled,
userMailboxDomainIds,
reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""),
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] }) qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -443,6 +451,9 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
settings.catchAllEnabled, settings.catchAllEnabled,
settings.mailAutoRefresh, settings.mailAutoRefresh,
settings.mailRefreshSeconds, settings.mailRefreshSeconds,
settings.userMailboxApplyEnabled,
(settings.userMailboxDomainIds || []).join(","),
settings.reservedMailboxPrefixes,
].join("|") : "loading" ].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [ const tabs: { key: typeof settingsTab; label: string }[] = [
{ key: "base", label: "基础" }, { key: "base", label: "基础" },
@@ -501,6 +512,36 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} /> <SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} />
<Separator /> <Separator />
<SwitchRow label="用户自助申请邮箱" checked={userMailboxApplyEnabled} onCheckedChange={setUserMailboxApplyEnabled} />
{userMailboxApplyEnabled && (
<div className="space-y-5 border-t pt-5">
<div className="space-y-3">
<Label></Label>
<div className="grid gap-2 md:grid-cols-2">
{domains.map((domain) => {
const checked = userMailboxDomainIds.includes(domain.id)
const disabled = domain.status !== "active"
return (
<label key={domain.id} className={cn("flex min-h-11 items-center gap-3 rounded-md border px-3 py-2", disabled && "cursor-not-allowed opacity-50")}>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(value) => setUserMailboxDomainIds((items) => value === true ? Array.from(new Set([...items, domain.id])) : items.filter((id) => id !== domain.id))}
/>
<span className="text-sm font-medium">{domain.name}</span>
</label>
)
})}
</div>
{domains.length === 0 && <Empty text="暂无域名" />}
</div>
<div className="space-y-2">
<Label></Label>
<Textarea name="reservedMailboxPrefixes" defaultValue={settings?.reservedMailboxPrefixes || ""} className="min-h-28 font-mono text-sm" />
</div>
</div>
)}
<Separator />
<SwitchRow label="自动刷新" checked={mailAutoRefresh} onCheckedChange={setMailAutoRefresh} /> <SwitchRow label="自动刷新" checked={mailAutoRefresh} onCheckedChange={setMailAutoRefresh} />
{mailAutoRefresh && ( {mailAutoRefresh && (
<div className="border-t pt-5"> <div className="border-t pt-5">
+20 -10
View File
@@ -88,17 +88,18 @@ export function MailPage() {
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes }) const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId }) const activeMailboxId = selectedMailbox?.id || ""
const labels = useQuery({ queryKey: ["labels", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId }) const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId })
const mailStats = useQuery({ queryKey: ["mail-stats", selectedMailboxId], queryFn: () => api.mailStats(selectedMailboxId), enabled: !!selectedMailboxId }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
const messages = useQuery({ const messages = useQuery({
queryKey: ["messages", selectedMailboxId, mailView, folder, selectedLabelId, query], queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query],
queryFn: () => { queryFn: () => {
if (mailView === "starred") return api.starredMessages(query, "", selectedMailboxId) if (mailView === "starred") return api.starredMessages(query, "", activeMailboxId)
if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", selectedMailboxId) if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", activeMailboxId)
return api.messages(folder, query, "", selectedMailboxId) return api.messages(folder, query, "", activeMailboxId)
}, },
enabled: !!selectedMailboxId && (mailView !== "label" || !!selectedLabelId), enabled: !!activeMailboxId && (mailView !== "label" || !!selectedLabelId),
}) })
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId })
function updateCachedMessage(id: string, patch: Partial<MailMessage>) { function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
@@ -175,15 +176,24 @@ export function MailPage() {
}) })
React.useEffect(() => { React.useEffect(() => {
if (!mailboxList.isSuccess) return
const items = mailboxList.data?.items || [] const items = mailboxList.data?.items || []
if (items.length === 0) return if (items.length === 0) {
if (selectedMailboxId) {
setSelectedMailboxId("")
setSelectedId(null)
}
localStorage.removeItem("lanqin:selected-mailbox")
return
}
if (!selectedMailboxId || !items.some((item) => item.id === selectedMailboxId)) { if (!selectedMailboxId || !items.some((item) => item.id === selectedMailboxId)) {
setSelectedMailboxId(items[0].id) setSelectedMailboxId(items[0].id)
} }
}, [mailboxList.data?.items, selectedMailboxId]) }, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
React.useEffect(() => { React.useEffect(() => {
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId) if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
else localStorage.removeItem("lanqin:selected-mailbox")
}, [selectedMailboxId]) }, [selectedMailboxId])
React.useEffect(() => { React.useEffect(() => {
+84 -9
View File
@@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react" import { QRCodeSVG } from "qrcode.react"
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailStats } from "@/lib/api" import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailStats } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils" import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode" import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
@@ -58,12 +58,14 @@ export function ProfilePage() {
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile" const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
const user = me.data?.user const user = me.data?.user
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes }) const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts }) const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules }) const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders }) const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
const ruleLabels = useQuery({ queryKey: ["labels", "rules", mailboxId], queryFn: () => api.labels(mailboxId), enabled: !!mailboxId })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const stats = useQuery({ queryKey: ["mail-stats", mailboxId], queryFn: () => api.mailStats(mailboxId), enabled: !!mailboxId }) const activeMailboxId = selectedMailbox?.id || ""
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
const profile = useMutation({ const profile = useMutation({
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }), mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
@@ -133,12 +135,28 @@ export function ProfilePage() {
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `已处理 ${res.affected} 封邮件` }) }, onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `已处理 ${res.affected} 封邮件` }) },
onError: (error) => toast({ title: "清理失败", description: error.message }), onError: (error) => toast({ title: "清理失败", description: error.message }),
}) })
const applyMailbox = useMutation({
mutationFn: api.applyMailbox,
onSuccess: (mailbox) => {
qc.invalidateQueries({ queryKey: ["mailboxes", "mine"] })
qc.invalidateQueries({ queryKey: ["mailbox-apply-options"] })
setMailboxId(mailbox.id)
toast({ title: "邮箱已申请" })
},
onError: (error) => toast({ title: "申请失败", description: error.message }),
})
React.useEffect(() => { React.useEffect(() => {
if (!mailboxes.isSuccess) return
const items = mailboxes.data?.items || [] const items = mailboxes.data?.items || []
if (items.length > 0 && (!mailboxId || !items.some((m) => m.id === mailboxId))) setMailboxId(items[0].id) if (items.length === 0) {
}, [mailboxId, mailboxes.data?.items]) if (mailboxId) setMailboxId("")
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId) }, [mailboxId]) localStorage.removeItem("lanqin:selected-mailbox")
return
}
if (!mailboxId || !items.some((m) => m.id === mailboxId)) setMailboxId(items[0].id)
}, [mailboxId, mailboxes.isSuccess, mailboxes.data?.items])
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId); else localStorage.removeItem("lanqin:selected-mailbox") }, [mailboxId])
React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode]) React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode])
async function logout() { await api.logout().catch(() => undefined); qc.clear(); navigate("/login", { replace: true }) } async function logout() { await api.logout().catch(() => undefined); qc.clear(); navigate("/login", { replace: true }) }
@@ -190,7 +208,7 @@ export function ProfilePage() {
) )
function renderTab() { function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} /> if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} /> if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
@@ -353,8 +371,65 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
) )
} }
function MailboxManagement({ mailboxes, selectedMailboxId, onSelect, onCopy, onOpen }: { mailboxes: Mailbox[]; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void }) { function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMailboxId, onSelect, onCopy, onOpen, onApply }: { mailboxes: Mailbox[]; applyOptions?: MailboxApplyOptions; applyPending: boolean; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void> }) {
return <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)}{mailboxes.length === 0 && <EmptyState text="暂无邮箱账号" />}</div> const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
return (
<div className="space-y-4">
<div className="flex justify-end">
{canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)}
{mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
</div>
</div>
)
}
function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApplyOptions; pending: boolean; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void> }) {
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState(options.domains[0]?.id || "")
React.useEffect(() => {
if (!open) return
setDomainId((current) => options.domains.some((domain) => domain.id === current) ? current : options.domains[0]?.id || "")
}, [open, options.domains])
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = new FormData(event.currentTarget)
try {
await onApply({
domainId,
localPart: String(form.get("localPart") || ""),
displayName: String(form.get("displayName") || ""),
})
event.currentTarget.reset()
setOpen(false)
} catch {}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" onClick={() => setOpen(true)}><Plus className="h-4 w-4" /></Button>
<DialogContent className="sm:max-w-lg">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<Field label="邮箱前缀"><Input name="localPart" autoFocus required placeholder="your-name" /></Field>
<Field label="域名后缀">
<Select value={domainId} onValueChange={setDomainId}>
<SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger>
<SelectContent>{options.domains.map((domain) => <SelectItem key={domain.id} value={domain.id}>@{domain.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field label="显示名称"><Input name="displayName" placeholder="可选" /></Field>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}></Button>
<Button disabled={pending || !domainId}>{pending ? "申请中..." : "申请"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
} }
function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) { function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) {
+9
View File
@@ -102,6 +102,15 @@ LANQIN_RSPAMD_DKIM_SYNC_SECONDS=60
# 无人收件开关。false:未注册邮箱默认拒收;true:未注册收件地址进入全部邮件。 # 无人收件开关。false:未注册邮箱默认拒收;true:未注册收件地址进入全部邮件。
LANQIN_CATCH_ALL_ENABLED=false LANQIN_CATCH_ALL_ENABLED=false
# 用户是否可以在个人中心自助申请邮箱。
LANQIN_USER_MAILBOX_APPLY_ENABLED=false
# 允许自助申请的域名 ID,多个用英文逗号分隔。建议在后台“系统设置 > 邮件”里配置。
LANQIN_USER_MAILBOX_DOMAIN_IDS=
# 禁止自助申请的邮箱前缀,多个用英文逗号分隔。
LANQIN_RESERVED_MAILBOX_PREFIXES=admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon
# Webmail 是否自动轮询刷新邮件。 # Webmail 是否自动轮询刷新邮件。
LANQIN_MAIL_AUTO_REFRESH=true LANQIN_MAIL_AUTO_REFRESH=true