diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go
index 5249de2..04adf28 100644
--- a/apps/api/internal/app/admin_handlers.go
+++ b/apps/api/internal/app/admin_handlers.go
@@ -94,6 +94,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
+ LoginName string `json:"loginName"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
@@ -107,14 +108,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
actor := currentUser(r)
- email := normalizeEmail(req.Email)
- if email == "" || !strings.Contains(email, "@") {
- badRequest(w, errors.New("invalid email"))
+ loginName, err := cleanLoginName(req.LoginName, req.Email)
+ if err != nil {
+ badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
- displayName = email
+ displayName = loginName
}
role := strings.TrimSpace(req.Role)
if role == "" {
@@ -154,7 +155,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
+ VALUES(?,?,?,?,?,?,?,?,?)`, id, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
badRequest(w, err)
return
}
@@ -515,14 +516,15 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
var req struct {
- DomainID string `json:"domainId"`
- LocalPart string `json:"localPart"`
- DisplayName string `json:"displayName"`
- Password string `json:"password"`
- QuotaMB int `json:"quotaMb"`
- Role string `json:"role"`
- OwnerEmail string `json:"ownerEmail"`
- UserID string `json:"userId"`
+ DomainID string `json:"domainId"`
+ LocalPart string `json:"localPart"`
+ DisplayName string `json:"displayName"`
+ Password string `json:"password"`
+ QuotaMB int `json:"quotaMb"`
+ Role string `json:"role"`
+ OwnerLoginName string `json:"ownerLoginName"`
+ OwnerEmail string `json:"ownerEmail"`
+ UserID string `json:"userId"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -591,15 +593,12 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
return
}
} else {
- ownerEmail := normalizeEmail(req.OwnerEmail)
- if ownerEmail == "" {
- ownerEmail = address
- }
- if !strings.Contains(ownerEmail, "@") {
- badRequest(w, errors.New("invalid owner email"))
+ ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
+ if err != nil {
+ badRequest(w, err)
return
}
- err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
+ err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerLoginName).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
@@ -608,11 +607,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
}
userID = newID("usr")
ownerDisplayName := displayName
- if !strings.EqualFold(ownerEmail, address) {
- ownerDisplayName = ownerEmail
+ if !strings.EqualFold(ownerLoginName, address) {
+ ownerDisplayName = ownerLoginName
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
+ VALUES(?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
if err != nil {
badRequest(w, err)
return
diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go
index 4848e2f..e3e4829 100644
--- a/apps/api/internal/app/auth_handlers.go
+++ b/apps/api/internal/app/auth_handlers.go
@@ -11,6 +11,7 @@ import (
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
+ LoginName string `json:"loginName"`
Email string `json:"email"`
Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"`
@@ -49,14 +50,18 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
- email := normalizeEmail(req.Email)
- user, passwordHash, err := a.userByEmail(r.Context(), email)
+ loginName, err := cleanLoginName(req.LoginName, req.Email)
+ if err != nil {
+ respondError(w, http.StatusUnauthorized, "账号或密码错误")
+ return
+ }
+ user, passwordHash, err := a.userByEmail(r.Context(), loginName)
if err != nil || user.Disabled {
- respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
+ respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
- respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
+ respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go
index 2f81449..82140ba 100644
--- a/apps/api/internal/app/permissions.go
+++ b/apps/api/internal/app/permissions.go
@@ -616,6 +616,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
if u == nil {
return nil
}
+ if u.LoginName == "" {
+ u.LoginName = u.Email
+ }
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
if err != nil {
return err
diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go
index f77b990..005ec12 100644
--- a/apps/api/internal/app/types.go
+++ b/apps/api/internal/app/types.go
@@ -3,19 +3,20 @@ package app
import "time"
type User struct {
- ID string `json:"id"`
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
- Role string `json:"role"`
- Disabled bool `json:"disabled"`
- Protected bool `json:"protected"`
- TwoFactorEnabled bool `json:"twoFactorEnabled"`
- MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
- Permissions []string `json:"permissions"`
- Limits PermissionLimits `json:"limits"`
- PermissionGroupIDs []string `json:"permissionGroupIds"`
- PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
- CreatedAt time.Time `json:"createdAt"`
+ ID string `json:"id"`
+ LoginName string `json:"loginName"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ Role string `json:"role"`
+ Disabled bool `json:"disabled"`
+ Protected bool `json:"protected"`
+ TwoFactorEnabled bool `json:"twoFactorEnabled"`
+ MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
+ Permissions []string `json:"permissions"`
+ Limits PermissionLimits `json:"limits"`
+ PermissionGroupIDs []string `json:"permissionGroupIds"`
+ PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
+ CreatedAt time.Time `json:"createdAt"`
}
type AdminUser struct {
diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go
index 038ccd1..2aff503 100644
--- a/apps/api/internal/app/util.go
+++ b/apps/api/internal/app/util.go
@@ -149,6 +149,44 @@ func normalizeEmail(s string) string {
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
}
+func normalizeLoginName(s string) string {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if s == "" {
+ return ""
+ }
+ if strings.Contains(s, "@") {
+ return normalizeEmail(s)
+ }
+ return normalizeLocalPart(s)
+}
+
+func cleanLoginName(value string, fallbacks ...string) (string, error) {
+ loginName := normalizeLoginName(value)
+ for _, fallback := range fallbacks {
+ if loginName != "" {
+ break
+ }
+ loginName = normalizeLoginName(fallback)
+ }
+ if loginName == "" {
+ return "", errors.New("登录名不能为空")
+ }
+ if len([]rune(loginName)) > 80 {
+ return "", errors.New("登录名不能超过 80 个字符")
+ }
+ if strings.Contains(loginName, "@") {
+ parts := strings.SplitN(loginName, "@", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return "", errors.New("登录名格式无效")
+ }
+ return loginName, nil
+ }
+ if len([]rune(loginName)) < 2 {
+ return "", errors.New("登录名至少需要 2 个字符")
+ }
+ return loginName, nil
+}
+
func dedupeEmails(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))
diff --git a/apps/web/package.json b/apps/web/package.json
index fade335..8e92ffb 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,5 +1,5 @@
{
- "name": "lanqin-email-web",
+ "name": "newszxcn-email-web",
"version": "0.1.0",
"private": true,
"type": "module",
diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx
index cf7d37e..513673f 100644
--- a/apps/web/src/components/protected-layout.tsx
+++ b/apps/web/src/components/protected-layout.tsx
@@ -104,7 +104,7 @@ function ProtectedContent() {
{user.displayName}
- {user.email}
+ {user.loginName || user.email}
{user.role === "admin" ? "管理员" : "普通用户"}
diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts
index a087dca..9118e8d 100644
--- a/apps/web/src/lib/api-types.ts
+++ b/apps/web/src/lib/api-types.ts
@@ -50,7 +50,7 @@ export type PermissionInfo = { key: PermissionKey; label: string; description: s
export type PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
-export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
+export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
@@ -199,6 +199,6 @@ export type SystemSettings = {
export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
-export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
+export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index c11a8a2..51c4ad3 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -125,7 +125,7 @@ export const api = {
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
- createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
+ createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
@@ -134,7 +134,7 @@ export const api = {
updateDomain: (id: string, payload: { status: string }) => request(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request>("/api/admin/mailboxes"),
- createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string; userId?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
+ createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request>("/api/admin/aliases"),
diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx
index 508147c..a3558d0 100644
--- a/apps/web/src/pages/admin.tsx
+++ b/apps/web/src/pages/admin.tsx
@@ -58,6 +58,7 @@ const projectTag = import.meta.env.VITE_APP_VERSION || ""
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9
+const accountLoginName = (user: Pick) => user.loginName || user.email
export function AdminPage() {
const me = useMe()
@@ -221,7 +222,8 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
const canDelete = hasPermission(user, "admin.users.delete")
const filteredUsers = users.filter((user) => {
const keyword = query.trim().toLowerCase()
- const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
+ const loginName = accountLoginName(user)
+ const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
const matchesRole = roleFilter === "all" || user.role === roleFilter
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
return matchesKeyword && matchesRole && matchesStatus
@@ -264,9 +266,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
{user.displayName}
-
{user.email}
+
{accountLoginName(user)}
-
setPendingConfirm({ title: "删除账号?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
+ setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
@@ -286,14 +288,14 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
{user.displayName}
- {user.email}
+ {accountLoginName(user)}
{user.disabled ? "停用" : "正常"}
{new Date(user.createdAt).toLocaleDateString()}
- setPendingConfirm({ title: "删除账号?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
+ setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
))}
@@ -1714,7 +1716,7 @@ function invalidateAdmin(qc: ReturnType
) { qc.invalidateQ
function UserMailboxCell({ user }: { user: AdminUser }) {
const { toast } = useToast()
- const loginAddress = user.email
+ const loginAddress = accountLoginName(user)
const mailboxes = user.mailboxes || []
const [mailboxQuery, setMailboxQuery] = React.useState("")
const normalizedQuery = mailboxQuery.trim().toLowerCase()
@@ -1880,7 +1882,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
const [permissionGroupIds, setPermissionGroupIds] = React.useState([])
const create = useMutation({
mutationFn: (form: FormData) => api.createUser({
- email: String(form.get("email") || ""),
+ loginName: String(form.get("loginName") || ""),
displayName: String(form.get("displayName") || ""),
password: String(form.get("password") || ""),
role,
@@ -1897,7 +1899,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
创建账号