feat(mailbox): 支持用户自助申请邮箱
- 后端新增邮箱申请配置与接口,限制可申请域名并校验保留前缀。 - 管理后台系统设置增加自助申请邮箱开关、开放域名和禁止前缀配置。 - 个人中心新增申请邮箱入口,并在邮箱页优化当前邮箱切换与空列表处理。 - 补充相关测试与环境变量示例配置。
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, ".")
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user