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) {
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)
if localPart == "" {
return "", errors.New("invalid local part")
@@ -642,10 +650,6 @@ func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, di
return "", err
}
address := localPart + "@" + domain
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
if displayName == "" {
displayName = address
}
@@ -659,7 +663,7 @@ func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, di
id := newID("mbx")
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)
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 {
return "", err
}
+91
View File
@@ -170,6 +170,33 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
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) {
a := newTestApp(t)
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) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
+56 -50
View File
@@ -8,61 +8,67 @@ import (
)
type Config struct {
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
}
func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
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"
)
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) {
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)
+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).Post("/me/profile", a.handleUpdateProfile)
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/enable", a.handleTwoFactorEnable)
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
+126 -76
View File
@@ -10,47 +10,53 @@ import (
)
type SystemSettings struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
}
type systemSettingsUpdate struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
}
type PublicSettings struct {
@@ -132,6 +138,9 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
req.MailRefreshSeconds = 30
}
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 {
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 {
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
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 {
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()
@@ -300,25 +318,28 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"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)
tx, err := a.db.BeginTx(ctx, nil)
@@ -335,6 +356,35 @@ func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
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 {
value = strings.ToLower(strings.TrimSpace(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 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 MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
@@ -42,6 +43,9 @@ export type SystemSettings = {
catchAllEnabled: boolean
mailAutoRefresh: boolean
mailRefreshSeconds: number
userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[]
reservedMailboxPrefixes: 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 }
@@ -99,6 +103,8 @@ export const api = {
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)}` : ""}`),
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"),
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) }),
+44 -3
View File
@@ -4,12 +4,13 @@ import { useSearchParams } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
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 { formatBytes, formatDate } from "@/lib/utils"
import { cn, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -79,7 +80,7 @@ export function AdminPage() {
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} />}
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
</main>
</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 { toast } = useToast()
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 [catchAllEnabled, setCatchAllEnabled] = React.useState(false)
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
React.useEffect(() => {
if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls)
@@ -392,6 +395,8 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
setTurnstileEnabled(settings.turnstileEnabled)
setCatchAllEnabled(settings.catchAllEnabled)
setMailAutoRefresh(settings.mailAutoRefresh)
setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled)
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
}, [settings])
const save = useMutation({
mutationFn: (form: FormData) => api.updateSystemSettings({
@@ -414,6 +419,9 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
catchAllEnabled,
mailAutoRefresh,
mailRefreshSeconds: fieldNumber(form, "mailRefreshSeconds", settings?.mailRefreshSeconds || 30),
userMailboxApplyEnabled,
userMailboxDomainIds,
reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -443,6 +451,9 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
settings.catchAllEnabled,
settings.mailAutoRefresh,
settings.mailRefreshSeconds,
settings.userMailboxApplyEnabled,
(settings.userMailboxDomainIds || []).join(","),
settings.reservedMailboxPrefixes,
].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [
{ key: "base", label: "基础" },
@@ -501,6 +512,36 @@ function SystemSettingsSection({ settings }: { settings?: SystemSettings }) {
<CardContent className="space-y-5">
<SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} />
<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} />
{mailAutoRefresh && (
<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 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 folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId })
const labels = useQuery({ queryKey: ["labels", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
const mailStats = useQuery({ queryKey: ["mail-stats", selectedMailboxId], queryFn: () => api.mailStats(selectedMailboxId), enabled: !!selectedMailboxId })
const activeMailboxId = selectedMailbox?.id || ""
const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId })
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({
queryKey: ["messages", selectedMailboxId, mailView, folder, selectedLabelId, query],
queryKey: ["messages", activeMailboxId, mailView, folder, selectedLabelId, query],
queryFn: () => {
if (mailView === "starred") return api.starredMessages(query, "", selectedMailboxId)
if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", selectedMailboxId)
return api.messages(folder, query, "", selectedMailboxId)
if (mailView === "starred") return api.starredMessages(query, "", activeMailboxId)
if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", activeMailboxId)
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 })
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
@@ -175,15 +176,24 @@ export function MailPage() {
})
React.useEffect(() => {
if (!mailboxList.isSuccess) return
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)) {
setSelectedMailboxId(items[0].id)
}
}, [mailboxList.data?.items, selectedMailboxId])
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
React.useEffect(() => {
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
else localStorage.removeItem("lanqin:selected-mailbox")
}, [selectedMailboxId])
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 { 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 { 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 { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
@@ -58,12 +58,14 @@ export function ProfilePage() {
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
const user = me.data?.user
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 rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
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 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({
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} 封邮件` }) },
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(() => {
if (!mailboxes.isSuccess) return
const items = mailboxes.data?.items || []
if (items.length > 0 && (!mailboxId || !items.some((m) => m.id === mailboxId))) setMailboxId(items[0].id)
}, [mailboxId, mailboxes.data?.items])
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId) }, [mailboxId])
if (items.length === 0) {
if (mailboxId) setMailboxId("")
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])
async function logout() { await api.logout().catch(() => undefined); qc.clear(); navigate("/login", { replace: true }) }
@@ -190,7 +208,7 @@ export function ProfilePage() {
)
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 === "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} />
@@ -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 }) {
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>
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> }) {
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 }) {
+9
View File
@@ -102,6 +102,15 @@ LANQIN_RSPAMD_DKIM_SYNC_SECONDS=60
# 无人收件开关。false:未注册邮箱默认拒收;true:未注册收件地址进入全部邮件。
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 是否自动轮询刷新邮件。
LANQIN_MAIL_AUTO_REFRESH=true