3ef8caa319
- 新增 Go + SQLite 后端,支持登录、域名、邮箱、别名、联系人、规则、统计与邮件收发。 - 新增 Webmail 前端,支持多邮箱切换、邮件列表/阅读/写信、附件、搜索、主题切换与个人中心。 - 新增 Docker Compose 部署骨架,补充 Postfix、Dovecot、OpenDKIM、Nginx 配置与部署说明。
310 lines
10 KiB
Go
310 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (a *App) handleListDomains(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains ORDER BY name`)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []Domain{}
|
|
for rows.Next() {
|
|
var d Domain
|
|
var checked sql.NullString
|
|
var created string
|
|
if err := rows.Scan(&d.ID, &d.Name, &d.Status, &d.DKIMSelector, &d.DKIMPublicKey, &d.DNSStatus, &checked, &created); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to scan domains")
|
|
return
|
|
}
|
|
d.DNSCheckedAt = nullableTime(checked)
|
|
d.CreatedAt = parseTime(created)
|
|
items = append(items, d)
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func (a *App) handleCreateDomain(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
id, err := a.createDomainTx(r.Context(), nil, req.Name)
|
|
if err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
d, err := a.domainByID(r.Context(), id)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load domain")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, d)
|
|
}
|
|
|
|
func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
|
FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []Mailbox{}
|
|
for rows.Next() {
|
|
var m Mailbox
|
|
var created string
|
|
if err := rows.Scan(&m.ID, &m.UserID, &m.UserEmail, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
|
return
|
|
}
|
|
m.CreatedAt = parseTime(created)
|
|
items = append(items, m)
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
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"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
if err := requireString("domainId", req.DomainID); err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
if err := requireString("localPart", req.LocalPart); err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
if len(req.Password) < 8 {
|
|
badRequest(w, errors.New("password must be at least 8 characters"))
|
|
return
|
|
}
|
|
role := req.Role
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
if role != "user" && role != "admin" {
|
|
badRequest(w, errors.New("invalid role"))
|
|
return
|
|
}
|
|
|
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
|
if err != nil {
|
|
respondError(w, http.StatusNotFound, "domain not found")
|
|
return
|
|
}
|
|
local := normalizeLocalPart(req.LocalPart)
|
|
address := local + "@" + domain.Name
|
|
|
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
|
userID := strings.TrimSpace(req.UserID)
|
|
displayName := req.DisplayName
|
|
if displayName == "" {
|
|
displayName = address
|
|
}
|
|
if userID != "" {
|
|
var disabled int
|
|
if err := tx.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
respondError(w, http.StatusNotFound, "owner user not found")
|
|
} else {
|
|
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
|
}
|
|
return
|
|
}
|
|
if intBool(disabled) {
|
|
badRequest(w, errors.New("owner user is disabled"))
|
|
return
|
|
}
|
|
} else {
|
|
ownerEmail := normalizeEmail(req.OwnerEmail)
|
|
if ownerEmail == "" {
|
|
ownerEmail = address
|
|
}
|
|
if !strings.Contains(ownerEmail, "@") {
|
|
badRequest(w, errors.New("invalid owner email"))
|
|
return
|
|
}
|
|
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
|
return
|
|
}
|
|
userID = newID("usr")
|
|
ownerDisplayName := displayName
|
|
if !strings.EqualFold(ownerEmail, address) {
|
|
ownerDisplayName = ownerEmail
|
|
}
|
|
_, 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)
|
|
if err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
} else if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to prepare owner user")
|
|
return
|
|
}
|
|
|
|
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, local, displayName, req.Password, req.QuotaMB, "active")
|
|
if err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
m, err := a.mailboxByID(r.Context(), mailboxID)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, m)
|
|
}
|
|
|
|
func (a *App) handleListAliases(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases ORDER BY source`)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to list aliases")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []Alias{}
|
|
for rows.Next() {
|
|
var item Alias
|
|
var enabled int
|
|
var created string
|
|
if err := rows.Scan(&item.ID, &item.DomainID, &item.Source, &item.Destination, &enabled, &created); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to scan aliases")
|
|
return
|
|
}
|
|
item.Enabled = intBool(enabled)
|
|
item.CreatedAt = parseTime(created)
|
|
items = append(items, item)
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DomainID string `json:"domainId"`
|
|
Source string `json:"source"`
|
|
Destination string `json:"destination"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
domain, err := a.domainByID(r.Context(), req.DomainID)
|
|
if err != nil {
|
|
respondError(w, http.StatusNotFound, "domain not found")
|
|
return
|
|
}
|
|
source := normalizeEmail(req.Source)
|
|
if !strings.Contains(source, "@") {
|
|
source = normalizeLocalPart(source) + "@" + domain.Name
|
|
}
|
|
destination := normalizeEmail(req.Destination)
|
|
if source == "" || destination == "" || !strings.Contains(destination, "@") {
|
|
badRequest(w, errors.New("invalid alias"))
|
|
return
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
id := newID("als")
|
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
|
_, err = a.db.ExecContext(r.Context(), `INSERT INTO aliases(id,domain_id,source,destination,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`,
|
|
id, req.DomainID, source, destination, boolInt(enabled), now, now)
|
|
if err != nil {
|
|
badRequest(w, err)
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, Alias{ID: id, DomainID: req.DomainID, Source: source, Destination: destination, Enabled: enabled, CreatedAt: parseTime(now)})
|
|
}
|
|
|
|
func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
|
row := a.db.QueryRowContext(ctx, `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains WHERE id=?`, id)
|
|
var d Domain
|
|
var checked sql.NullString
|
|
var created string
|
|
if err := row.Scan(&d.ID, &d.Name, &d.Status, &d.DKIMSelector, &d.DKIMPublicKey, &d.DNSStatus, &checked, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
d.DNSCheckedAt = nullableTime(checked)
|
|
d.CreatedAt = parseTime(created)
|
|
return &d, nil
|
|
}
|
|
|
|
func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
|
|
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE id=?`, id)
|
|
var m Mailbox
|
|
var created string
|
|
if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
m.CreatedAt = parseTime(created)
|
|
return &m, nil
|
|
}
|
|
|
|
func (a *App) mailboxForUser(ctx context.Context, userID string) (*Mailbox, error) {
|
|
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at LIMIT 1`, userID)
|
|
var m Mailbox
|
|
var created string
|
|
if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
m.CreatedAt = parseTime(created)
|
|
return &m, nil
|
|
}
|
|
|
|
func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (string, error) {
|
|
var id string
|
|
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, folder).Scan(&id); err == nil {
|
|
return id, nil
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return "", err
|
|
}
|
|
role := strings.ToLower(folder)
|
|
id = newID("fld")
|
|
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano))
|
|
return id, err
|
|
}
|