feat(email): 初始化自建邮箱 MVP

- 新增 Go + SQLite 后端,支持登录、域名、邮箱、别名、联系人、规则、统计与邮件收发。
- 新增 Webmail 前端,支持多邮箱切换、邮件列表/阅读/写信、附件、搜索、主题切换与个人中心。
- 新增 Docker Compose 部署骨架,补充 Postfix、Dovecot、OpenDKIM、Nginx 配置与部署说明。
This commit is contained in:
LanQin
2026-06-14 01:07:48 +08:00
commit 3ef8caa319
85 changed files with 13649 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"lanqin-email-api/internal/app"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg := app.LoadConfig()
svc, err := app.New(cfg, logger)
if err != nil {
logger.Error("failed to initialize app", "error", err)
os.Exit(1)
}
defer svc.Close()
server := &http.Server{
Addr: cfg.Addr,
Handler: svc.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("LanQin API listening", "addr", cfg.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("server shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
+29
View File
@@ -0,0 +1,29 @@
module lanqin-email-api
go 1.22
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/microcosm-cc/bluemonday v1.0.27
golang.org/x/crypto v0.26.0
modernc.org/sqlite v1.31.1
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sys v0.23.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+61
View File
@@ -0,0 +1,61 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs=
modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+309
View File
@@ -0,0 +1,309 @@
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
}
+398
View File
@@ -0,0 +1,398 @@
package app
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
type App struct {
cfg Config
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
if logger == nil {
logger = slog.Default()
}
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "attachments"), 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy()}
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.migrate(context.Background()); err != nil {
db.Close()
return nil, err
}
if err := a.seed(context.Background()); err != nil {
db.Close()
return nil, err
}
if strings.TrimSpace(cfg.MaildirRoot) != "" {
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
go a.maildirWorker(workerCtx)
}
return a, nil
}
func (a *App) Close() error {
if a == nil || a.db == nil {
return nil
}
if a.workerCancel != nil {
a.workerCancel()
}
return a.db.Close()
}
func (a *App) configureSQLite(ctx context.Context) error {
pragmas := []string{
"PRAGMA foreign_keys = ON",
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
}
for _, q := range pragmas {
if _, err := a.db.ExecContext(ctx, q); err != nil {
return err
}
}
return nil
}
func (a *App) migrate(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','user')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
dkim_selector TEXT NOT NULL,
dkim_public_key TEXT NOT NULL,
dkim_private_key TEXT NOT NULL,
dns_status TEXT NOT NULL DEFAULT 'unchecked',
dns_checked_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS mailboxes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
local_part TEXT NOT NULL,
address TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
quota_mb INTEGER NOT NULL DEFAULT 1024,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(domain_id, local_part)
)`,
`CREATE TABLE IF NOT EXISTS aliases (
id TEXT PRIMARY KEY,
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
source TEXT NOT NULL UNIQUE,
destination TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(mailbox_id, name)
)`,
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
message_uid TEXT NOT NULL,
message_id TEXT NOT NULL,
subject TEXT NOT NULL,
from_addr TEXT NOT NULL,
to_addrs TEXT NOT NULL,
cc_addrs TEXT NOT NULL DEFAULT '[]',
bcc_addrs TEXT NOT NULL DEFAULT '[]',
sent_at TEXT NOT NULL,
received_at TEXT NOT NULL,
snippet TEXT NOT NULL,
body_text TEXT NOT NULL,
body_html TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
has_attachments INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0,
raw_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> ''`,
`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
storage_path TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, email)
)`,
`CREATE TABLE IF NOT EXISTS mail_rules (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
from_contains TEXT NOT NULL DEFAULT '',
subject_contains TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS blocked_senders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, mailbox_id, email)
)`,
`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id, email)`,
`CREATE INDEX IF NOT EXISTS idx_mail_rules_user_mailbox ON mail_rules(user_id, mailbox_id, enabled)`,
`CREATE INDEX IF NOT EXISTS idx_blocked_senders_user_mailbox ON blocked_senders(user_id, mailbox_id, email)`,
}
for _, stmt := range stmts {
if _, err := a.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
return nil
}
func (a *App) seed(ctx context.Context) error {
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
domainName := strings.Split(a.cfg.AdminEmail, "@")[1]
domainID, err := a.createDomainTx(ctx, nil, domainName)
if err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
_, err = a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now)
if err != nil {
return err
}
local := strings.Split(a.cfg.AdminEmail, "@")[0]
mailboxID, err := a.createMailbox(ctx, userID, domainID, local, "LanQin Admin", a.cfg.AdminPassword, 2048, "active")
if err != nil {
return err
}
if err := a.seedWelcomeMessage(ctx, mailboxID); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", a.cfg.AdminEmail)
return nil
}
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
name = normalizeDomain(name)
if name == "" || !strings.Contains(name, ".") {
return "", errors.New("invalid domain")
}
selector := "lanqin"
publicKey, privateKey, err := generateDKIMMaterial()
if err != nil {
return "", err
}
id := newID("dom")
now := a.now().UTC().Format(time.RFC3339Nano)
query := `INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`
args := []any{id, name, "active", selector, publicKey, privateKey, "unchecked", now, now}
if tx != nil {
_, err = tx.ExecContext(ctx, query, args...)
} else {
_, err = a.db.ExecContext(ctx, query, args...)
}
if err != nil {
return "", err
}
return id, nil
}
func generateDKIMMaterial() (string, string, error) {
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
return "", "", err
}
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return "", "", err
}
privDER := x509.MarshalPKCS1PrivateKey(key)
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
return base64.StdEncoding.EncodeToString(pubDER), base64.StdEncoding.EncodeToString(privPEM), nil
}
func defaultFolderDefs() []struct{ name, role string } {
return []struct{ name, role string }{
{"Inbox", "inbox"},
{"Sent", "sent"},
{"Drafts", "drafts"},
{"Archive", "archive"},
{"Spam", "spam"},
{"Trash", "trash"},
}
}
func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, displayName, password string, quotaMB int, status string) (string, error) {
localPart = normalizeLocalPart(localPart)
if localPart == "" {
return "", errors.New("invalid local part")
}
if quotaMB <= 0 {
quotaMB = 1024
}
if status == "" {
status = "active"
}
var domain string
if err := a.db.QueryRowContext(ctx, `SELECT name FROM domains WHERE id=?`, domainID).Scan(&domain); err != nil {
return "", err
}
address := localPart + "@" + domain
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
if displayName == "" {
displayName = address
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
id := newID("mbx")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, userID, domainID, localPart, address, displayName, string(passwordHash), quotaMB, status, now, now)
if err != nil {
return "", err
}
for _, f := range defaultFolderDefs() {
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, now)
if err != nil {
return "", err
}
}
if err := tx.Commit(); err != nil {
return "", err
}
return id, nil
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil {
return err
}
now := a.now().UTC()
msg := storedMessage{
MailboxID: mailboxID,
FolderID: folderID,
MessageUID: newID("uid"),
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
Subject: "欢迎使用 LanQin Email",
From: "system@lanqin.local",
To: []string{a.cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
Snippet: "你的自建邮箱 Webmail 已经初始化完成。",
BodyText: "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
IsRead: false,
}
_, err = a.insertMessage(ctx, msg, nil)
return err
}
+352
View File
@@ -0,0 +1,352 @@
package app
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestApp(t *testing.T) *App {
t.Helper()
dir := t.TempDir()
cfg := Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.Close() })
return a
}
type testClient struct {
t *testing.T
server *httptest.Server
cookie *http.Cookie
}
func (c *testClient) do(method, path string, body any, out any) int {
c.t.Helper()
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.server.URL+path, reader)
if err != nil {
c.t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.cookie != nil {
req.AddCookie(c.cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer resp.Body.Close()
for _, cookie := range resp.Cookies() {
if strings.Contains(cookie.Name, "lanqin") && cookie.Value != "" {
c.cookie = cookie
}
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
c.t.Fatalf("decode %s %s: %v", method, path, err)
}
} else {
_, _ = io.Copy(io.Discard, resp.Body)
}
return resp.StatusCode
}
func TestAuthAdminAndLocalDeliveryFlow(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("login code=%d body=%v", code, login)
}
var domains struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
domainID := domains.Items[0].ID
var mb1 Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "alice", "displayName": "Alice", "password": "Password123!"}, &mb1); code != http.StatusCreated {
t.Fatalf("create alice code=%d mailbox=%+v", code, mb1)
}
var mb2 Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "bob", "displayName": "Bob", "password": "Password123!"}, &mb2); code != http.StatusCreated {
t.Fatalf("create bob code=%d mailbox=%+v", code, mb2)
}
var alias Alias
if code := admin.do("POST", "/api/admin/aliases", map[string]any{"domainId": domainID, "source": "sales", "destination": mb1.Address}, &alias); code != http.StatusCreated {
t.Fatalf("alias code=%d alias=%+v", code, alias)
}
alice := &testClient{t: t, server: ts}
if code := alice.do("POST", "/api/auth/login", map[string]string{"email": mb1.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("alice login=%d", code)
}
payload := map[string]any{
"to": []string{mb2.Address},
"subject": "hello bob",
"html": "<p>Hello <strong>Bob</strong></p><script>alert(1)</script>",
"attachments": []map[string]string{{"filename": "note.txt", "contentType": "text/plain", "contentBase64": base64.StdEncoding.EncodeToString([]byte("hi"))}},
}
var sent MailMessage
if code := alice.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated || !sent.HasAttachments {
t.Fatalf("send code=%d msg=%+v", code, sent)
}
bob := &testClient{t: t, server: ts}
if code := bob.do("POST", "/api/auth/login", map[string]string{"email": mb2.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("bob login=%d", code)
}
var list struct {
Items []MailMessage `json:"items"`
NextCursor string `json:"nextCursor"`
}
if code := bob.do("GET", "/api/mail/messages?folder=Inbox", nil, &list); code != http.StatusOK || len(list.Items) != 1 {
t.Fatalf("bob inbox code=%d items=%d", code, len(list.Items))
}
if strings.Contains(list.Items[0].Snippet, "script") {
t.Fatalf("message was not sanitized: %q", list.Items[0].Snippet)
}
var detail MailMessage
if code := bob.do("GET", "/api/mail/messages/"+list.Items[0].ID, nil, &detail); code != http.StatusOK || len(detail.Attachments) != 1 || !detail.IsRead {
t.Fatalf("detail code=%d detail=%+v", code, detail)
}
if strings.Contains(detail.BodyHTML, "script") {
t.Fatalf("html was not sanitized: %s", detail.BodyHTML)
}
var ok map[string]any
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/star", map[string]bool{"starred": true}, &ok); code != http.StatusOK {
t.Fatalf("star code=%d", code)
}
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/move", map[string]string{"folder": "Archive"}, &ok); code != http.StatusOK {
t.Fatalf("move code=%d", code)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete code=%d", code)
}
}
func TestUserCanSelectMultipleMailboxes(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("login code=%d body=%v", code, login)
}
var domains struct {
Items []Domain `json:"items"`
}
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
}
domainID := domains.Items[0].ID
var primary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi", "displayName": "Multi", "password": "Password123!"}, &primary); code != http.StatusCreated {
t.Fatalf("create primary code=%d mailbox=%+v", code, primary)
}
var secondary Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", map[string]any{"domainId": domainID, "localPart": "multi-work", "displayName": "Multi Work", "password": "Password456!", "ownerEmail": primary.Address}, &secondary); code != http.StatusCreated {
t.Fatalf("create secondary code=%d mailbox=%+v", code, secondary)
}
if primary.UserID != secondary.UserID {
t.Fatalf("mailboxes were not bound to one user: primary=%s secondary=%s", primary.UserID, secondary.UserID)
}
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login=%d", code)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 {
t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items))
}
if code := userClient.do("GET", "/api/mail/folders?mailboxId="+secondary.ID, nil, nil); code != http.StatusOK {
t.Fatalf("folders for selected mailbox code=%d", code)
}
var sent MailMessage
payload := map[string]any{
"mailboxId": secondary.ID,
"to": []string{"admin@lanqin.local"},
"subject": "selected mailbox sender",
"text": "hello from selected mailbox",
}
if code := userClient.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated || sent.From != secondary.Address {
t.Fatalf("send with selected mailbox code=%d from=%q want=%q", code, sent.From, secondary.Address)
}
var adminInbox struct {
Items []MailMessage `json:"items"`
}
if code := admin.do("GET", "/api/mail/messages?folder=Inbox&q=selected%20mailbox%20sender", nil, &adminInbox); code != http.StatusOK || len(adminInbox.Items) != 1 || adminInbox.Items[0].From != secondary.Address {
t.Fatalf("admin inbox code=%d items=%d first=%+v", code, len(adminInbox.Items), adminInbox.Items)
}
}
func TestProfileAndPasswordUpdate(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var profile struct {
User User `json:"user"`
}
if code := client.do("POST", "/api/me/profile", map[string]string{"displayName": "蓝钦管理员"}, &profile); code != http.StatusOK || profile.User.DisplayName != "蓝钦管理员" {
t.Fatalf("profile code=%d user=%+v", code, profile.User)
}
var ok map[string]any
if code := client.do("POST", "/api/me/password", map[string]string{"currentPassword": "wrong", "newPassword": "NewPassword123!"}, &ok); code != http.StatusUnauthorized {
t.Fatalf("wrong password change code=%d", code)
}
if code := client.do("POST", "/api/me/password", map[string]string{"currentPassword": "ChangeMe123!", "newPassword": "NewPassword123!"}, &ok); code != http.StatusOK {
t.Fatalf("password change code=%d body=%v", code, ok)
}
fresh := &testClient{t: t, server: ts}
if code := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("old password login code=%d", code)
}
if code := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "NewPassword123!"}, &login); code != http.StatusOK {
t.Fatalf("new password login code=%d", code)
}
}
func TestDNSRecords(t *testing.T) {
a := newTestApp(t)
d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a))
if err != nil {
t.Fatal(err)
}
records := a.dnsRecordsFor(d)
if len(records) != 4 {
t.Fatalf("records=%d", len(records))
}
if records[0].Type != "MX" || !strings.Contains(records[2].Value, "v=DKIM1") {
t.Fatalf("unexpected records: %+v", records)
}
}
func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
var admin maildirMailbox
for _, mb := range mailboxes {
if mb.Address == "admin@lanqin.local" {
admin = mb
break
}
}
if admin.ID == "" {
t.Fatal("admin mailbox not found")
}
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", "new")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw := strings.Join([]string{
"From: sender@example.test",
"To: admin@lanqin.local",
"Subject: Maildir import test",
"Message-Id: <maildir-import@example.test>",
"Date: Sat, 13 Jun 2026 13:00:00 +0000",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"hello from maildir",
}, "\r\n")
if err := os.WriteFile(filepath.Join(dir, "1749819600.M1P1.test"), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
count, err := a.syncMaildirOnce(ctx)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("imported=%d, want 1", count)
}
count, err = a.syncMaildirOnce(ctx)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("second import=%d, want duplicate skip", count)
}
var subject, body string
err = a.db.QueryRow(`SELECT subject, body_text FROM messages WHERE mailbox_id=? AND message_id='<maildir-import@example.test>'`, admin.ID).Scan(&subject, &body)
if err != nil {
t.Fatal(err)
}
if subject != "Maildir import test" || !strings.Contains(body, "hello from maildir") {
t.Fatalf("unexpected imported message subject=%q body=%q", subject, body)
}
}
func mustDefaultDomainID(t *testing.T, a *App) string {
t.Helper()
var id string
if err := a.db.QueryRow(`SELECT id FROM domains LIMIT 1`).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type Config struct {
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
}
func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
}
}
func getenv(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func getenvBool(key string, fallback bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if v == "" {
return fallback
}
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func getenvInt(key string, fallback int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
var n int
_, err := fmt.Sscanf(v, "%d", &n)
if err != nil || n <= 0 {
return fallback
}
return n
}
+103
View File
@@ -0,0 +1,103 @@
package app
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func (a *App) handleDNSRecords(w http.ResponseWriter, r *http.Request) {
domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"items": a.dnsRecordsFor(domain)})
}
func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "domain not found")
return
}
result := a.checkDNS(r.Context(), domain)
now := a.now().UTC().Format(time.RFC3339Nano)
_, _ = a.db.ExecContext(r.Context(), `UPDATE domains SET dns_status=?, dns_checked_at=?, updated_at=? WHERE id=?`, result.Status, now, now, domain.ID)
respondJSON(w, http.StatusOK, result)
}
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
name := strings.TrimSuffix(d.Name, ".")
return []DNSRecord{
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
{Type: "TXT", Name: d.DKIMSelector + "._domainkey." + name, Value: "v=DKIM1; k=rsa; p=" + d.DKIMPublicKey, TTL: 300},
{Type: "TXT", Name: "_dmarc." + name, Value: "v=DMARC1; p=quarantine; rua=mailto:postmaster@" + name, TTL: 300},
}
}
func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resolver := net.DefaultResolver
checks := map[string]DNSCheckStatus{}
mx, err := resolver.LookupMX(ctx, d.Name)
if err != nil || len(mx) == 0 {
checks["mx"] = DNSCheckStatus{OK: false, Message: "未找到 MX 记录"}
} else {
found := make([]string, 0, len(mx))
ok := false
for _, item := range mx {
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
found = append(found, entry)
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
ok = true
}
}
checks["mx"] = DNSCheckStatus{OK: ok, Message: boolMessage(ok, "MX 指向正确", "MX 未指向当前邮件主机"), Found: found}
}
rootTXT, _ := resolver.LookupTXT(ctx, d.Name)
checks["spf"] = txtContains(rootTXT, "v=spf1", "SPF 记录存在", "未找到 SPF 记录")
dkimName := d.DKIMSelector + "._domainkey." + d.Name
dkimTXT, _ := resolver.LookupTXT(ctx, dkimName)
checks["dkim"] = txtContains(dkimTXT, "v=DKIM1", "DKIM 记录存在", "未找到 DKIM 记录")
dmarcTXT, _ := resolver.LookupTXT(ctx, "_dmarc."+d.Name)
checks["dmarc"] = txtContains(dmarcTXT, "v=DMARC1", "DMARC 记录存在", "未找到 DMARC 记录")
status := "ok"
for _, c := range checks {
if !c.OK {
status = "error"
break
}
}
return DNSCheckResult{Domain: d.Name, Status: status, Checks: checks}
}
func txtContains(records []string, needle, okMsg, failMsg string) DNSCheckStatus {
found := append([]string{}, records...)
for _, item := range records {
if strings.Contains(strings.ToLower(item), strings.ToLower(needle)) {
return DNSCheckStatus{OK: true, Message: okMsg, Found: found}
}
}
return DNSCheckStatus{OK: false, Message: failMsg, Found: found}
}
func boolMessage(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
+575
View File
@@ -0,0 +1,575 @@
package app
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type AttachmentInput struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
ContentBase64 string `json:"contentBase64"`
}
type storedMessage struct {
MailboxID string
FolderID string
MessageUID string
MessageID string
Subject string
From string
To []string
CC []string
BCC []string
SentAt time.Time
ReceivedAt time.Time
Snippet string
BodyText string
BodyHTML string
IsRead bool
IsStarred bool
RawPath string
}
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `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 address`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load 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.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
m.UserEmail = user.Email
m.CreatedAt = parseTime(created)
items = append(items, m)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role
ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END, f.name`, mb.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folders")
return
}
defer rows.Close()
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
items = append(items, f)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folder := r.URL.Query().Get("folder")
if folder == "" {
folder = "Inbox"
}
folderID, err := a.ensureFolder(r.Context(), mb.ID, folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 {
offset = 0
}
limit := 30
args := []any{mb.ID, folderID}
where := `mailbox_id=? AND folder_id=?`
if q != "" {
where += ` AND (subject LIKE ? OR from_addr LIKE ? OR snippet LIKE ? OR body_text LIKE ?)`
like := "%" + q + "%"
args = append(args, like, like, like, like)
}
args = append(args, limit+1, offset)
query := `SELECT id,mailbox_id,folder_id,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,is_read,is_starred,has_attachments,size_bytes
FROM messages WHERE ` + where + ` ORDER BY received_at DESC LIMIT ? OFFSET ?`
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load messages")
return
}
defer rows.Close()
items := []MailMessage{}
for rows.Next() {
msg, err := scanMessageSummary(rows, folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan messages")
return
}
items = append(items, msg)
}
next := ""
if len(items) > limit {
items = items[:limit]
next = strconv.Itoa(offset + limit)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
msg.IsRead = true
respondJSON(w, http.StatusOK, msg)
}
func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
var req struct {
MailboxID string `json:"mailboxId"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments []AttachmentInput `json:"attachments"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC)
allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...)
if len(allRecipients) == 0 {
badRequest(w, errors.New("at least one recipient is required"))
return
}
if strings.TrimSpace(req.Subject) == "" {
req.Subject = "(no subject)"
}
req.HTML = a.policy.Sanitize(req.HTML)
if strings.TrimSpace(req.Text) == "" {
req.Text = stripTags(req.HTML)
}
if strings.TrimSpace(req.HTML) == "" {
req.HTML = "<p>" + htmlEscape(req.Text) + "</p>"
}
now := a.now().UTC()
messageID := fmt.Sprintf("<%s@%s>", newID("msg"), strings.Split(mb.Address, "@")[1])
mimeBytes, err := BuildMIME(MIMEMessage{
From: mb.Address, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, MessageID: messageID, Date: now, Attachments: req.Attachments,
})
if err != nil {
badRequest(w, err)
return
}
if a.cfg.SMTPHost != "" {
if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil {
a.log.Warn("smtp delivery failed; keeping local sent copy", "error", err)
}
}
sentFolderID, err := a.ensureFolder(r.Context(), mb.ID, "Sent")
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load sent folder")
return
}
base := storedMessage{MailboxID: mb.ID, FolderID: sentFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: req.Subject, From: mb.Address, To: req.To, CC: req.CC, BCC: req.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(req.Text, req.HTML), BodyText: req.Text, BodyHTML: req.HTML, IsRead: true}
sentID, err := a.insertMessage(r.Context(), base, req.Attachments)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to store sent message")
return
}
// Development/local-domain delivery: if a recipient exists as a local mailbox, write an Inbox copy.
localRecipients := append(req.To, req.CC...)
localRecipients = append(localRecipients, req.BCC...)
for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt)
if err != nil {
continue
}
inboxID, err := a.ensureFolder(r.Context(), rcptMailbox.ID, "Inbox")
if err != nil {
continue
}
copyMsg := base
copyMsg.MailboxID = rcptMailbox.ID
copyMsg.FolderID = inboxID
copyMsg.MessageUID = newID("uid")
copyMsg.IsRead = false
if inboxMsgID, err := a.insertMessage(r.Context(), copyMsg, req.Attachments); err == nil {
a.applyInboundControls(r.Context(), inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
}
}
msg, _ := a.messageByID(r.Context(), sentID, true)
respondJSON(w, http.StatusCreated, msg)
}
func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
var req struct {
Read *bool `json:"read"`
}
_ = decodeJSON(r, &req)
read := true
if req.Read != nil {
read = *req.Read
}
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, updated_at=? WHERE id=?`, boolInt(read), a.now().UTC().Format(time.RFC3339Nano), msg.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "read": read})
}
func (a *App) handleStar(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
var req struct {
Starred *bool `json:"starred"`
}
_ = decodeJSON(r, &req)
starred := !msg.IsStarred
if req.Starred != nil {
starred = *req.Starred
}
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, updated_at=? WHERE id=?`, boolInt(starred), a.now().UTC().Format(time.RFC3339Nano), msg.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "starred": starred})
}
func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
var req struct {
Folder string `json:"folder"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, req.Folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to move message")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
if strings.EqualFold(msg.Folder, "Trash") {
a.deleteMessageFiles(r.Context(), msg.ID)
_, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID)
} else {
trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
if e != nil {
err = e
} else {
_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, trashID, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
}
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete message")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
attID := chi.URLParam(r, "id")
row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path
FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id
WHERE a.id=? AND mb.user_id=?`, attID, user.ID)
var filename, contentType, path string
var size int64
if err := row.Scan(&filename, &contentType, &size, &path); err != nil {
respondError(w, http.StatusNotFound, "attachment not found")
return
}
f, err := os.Open(path)
if err != nil {
respondError(w, http.StatusNotFound, "attachment file missing")
return
}
defer f.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
_, _ = io.Copy(w, f)
}
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, _ := w.(http.Flusher)
fmt.Fprintf(w, "event: sync\ndata: {\"status\":\"connected\"}\n\n")
if flusher != nil {
flusher.Flush()
}
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case t := <-ticker.C:
fmt.Fprintf(w, "event: heartbeat\ndata: {\"time\":\"%s\"}\n\n", t.UTC().Format(time.RFC3339))
if flusher != nil {
flusher.Flush()
}
}
}
}
func (a *App) mailboxForCurrentUser(r *http.Request) (*Mailbox, error) {
return a.mailboxForCurrentUserWithID(r, r.URL.Query().Get("mailboxId"))
}
func (a *App) mailboxForCurrentUserWithID(r *http.Request, mailboxID string) (*Mailbox, error) {
user := currentUser(r)
if user == nil {
return nil, errors.New("no user")
}
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID != "" {
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at
FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, user.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.UserEmail = user.Email
m.CreatedAt = parseTime(created)
return &m, nil
}
return a.mailboxForUser(r.Context(), user.ID)
}
func (a *App) mailboxByAddress(ctx context.Context, address 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 address=? AND status='active'`, normalizeEmail(address))
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) loadMessageForRequest(r *http.Request, id string, includeBody bool) (*MailMessage, error) {
user := currentUser(r)
row := a.db.QueryRowContext(r.Context(), `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE m.id=? AND mb.user_id=?`, id, user.ID)
var messageID string
if err := row.Scan(&messageID); err != nil {
return nil, err
}
return a.messageByID(r.Context(), messageID, includeBody)
}
func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
row := a.db.QueryRowContext(ctx, `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody)
if err != nil {
return nil, err
}
if includeBody {
atts, err := a.attachmentsForMessage(ctx, id)
if err != nil {
return nil, err
}
msg.Attachments = atts
}
return &msg, nil
}
func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, error) {
id := newID("mail")
now := a.now().UTC().Format(time.RFC3339Nano)
hasAttachments := len(attachments) > 0
size := int64(len(msg.BodyText) + len(msg.BodyHTML))
for _, att := range attachments {
if decoded, err := base64.StdEncoding.DecodeString(att.ContentBase64); err == nil {
size += int64(len(decoded))
}
}
_, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, msg.MailboxID, msg.FolderID, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now)
if err != nil {
return "", err
}
for _, att := range attachments {
if err := a.storeAttachment(ctx, id, att); err != nil {
return "", err
}
}
return id, nil
}
func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
filename := filepath.Base(strings.TrimSpace(input.Filename))
if filename == "." || filename == "" {
filename = "attachment.bin"
}
contentType := input.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
data, err := base64.StdEncoding.DecodeString(input.ContentBase64)
if err != nil {
return err
}
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
id := newID("att")
path := filepath.Join(dir, id+"_"+filename)
if err := os.WriteFile(path, data, 0o600); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `INSERT INTO attachments(id,message_id,filename,content_type,size_bytes,storage_path,created_at) VALUES(?,?,?,?,?,?,?)`, id, messageID, filename, contentType, len(data), path, a.now().UTC().Format(time.RFC3339Nano))
return err
}
func (a *App) attachmentsForMessage(ctx context.Context, messageID string) ([]Attachment, error) {
rows, err := a.db.QueryContext(ctx, `SELECT id,message_id,filename,content_type,size_bytes,created_at FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Attachment{}
for rows.Next() {
var item Attachment
var created string
if err := rows.Scan(&item.ID, &item.MessageID, &item.Filename, &item.ContentType, &item.SizeBytes, &created); err != nil {
return nil, err
}
item.CreatedAt = parseTime(created)
items = append(items, item)
}
return items, nil
}
func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
rows, err := a.db.QueryContext(ctx, `SELECT storage_path FROM attachments WHERE message_id=?`, messageID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var p string
if rows.Scan(&p) == nil {
_ = os.Remove(p)
}
}
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
}
type messageSummaryScanner interface{ Scan(dest ...any) error }
func scanMessageSummary(row messageSummaryScanner, folder string) (MailMessage, error) {
var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil {
return msg, err
}
msg.Folder = folder
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
return msg, nil
}
func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int
var bodyText, bodyHTML string
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil {
return msg, err
}
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
if includeBody {
msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
}
return msg, nil
}
+379
View File
@@ -0,0 +1,379 @@
package app
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
netmail "net/mail"
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
)
type maildirMailbox struct {
ID string
Address string
LocalPart string
Domain string
}
type maildirFolder struct {
ID string
Name string
Role string
}
type parsedMail struct {
Text string
HTML string
Attachments []AttachmentInput
}
func (a *App) maildirWorker(ctx context.Context) {
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
if n, err := a.syncMaildirOnce(ctx); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n > 0 {
a.log.Info("initial maildir sync imported messages", "count", n)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.log.Info("maildir sync worker stopped")
return
case <-ticker.C:
n, err := a.syncMaildirOnce(ctx)
if err != nil {
a.log.Warn("maildir sync failed", "error", err)
continue
}
if n > 0 {
a.log.Info("maildir sync imported messages", "count", n)
}
}
}
}
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
if root == "" {
return 0, nil
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
return 0, err
}
imported := 0
for _, mb := range mailboxes {
folders, err := a.maildirFolders(ctx, mb.ID)
if err != nil {
return imported, err
}
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
for _, folder := range folders {
folderBase := maildirFolderPath(base, folder.Name)
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return imported, ctx.Err()
default:
}
dir := filepath.Join(folderBase, sub)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return imported, err
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
if err != nil {
a.log.Warn("maildir file import failed", "path", path, "error", err)
continue
}
if ok {
imported++
}
}
}
}
}
return imported, nil
}
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.status='active' AND d.status='active' ORDER BY m.address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []maildirMailbox
for rows.Next() {
var mb maildirMailbox
if err := rows.Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain); err != nil {
return nil, err
}
out = append(out, mb)
}
return out, rows.Err()
}
func (a *App) maildirFolders(ctx context.Context, mailboxID string) ([]maildirFolder, error) {
rows, err := a.db.QueryContext(ctx, `SELECT id,name,role FROM folders WHERE mailbox_id=?`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []maildirFolder
for rows.Next() {
var f maildirFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func maildirFolderPath(base, folder string) string {
if strings.EqualFold(folder, "Inbox") {
return base
}
folder = strings.TrimSpace(folder)
folder = strings.TrimPrefix(folder, ".")
return filepath.Join(base, "."+folder)
}
func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder maildirFolder, path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
return false, err
}
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
if err != nil {
return false, err
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
msg.RawPath = path
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
}
if msg.MessageID == "" {
msg.MessageID = fmt.Sprintf("<%s@lanqin.local>", newID("msg"))
}
if msg.ReceivedAt.IsZero() {
msg.ReceivedAt = a.now().UTC()
}
if msg.SentAt.IsZero() {
msg.SentAt = msg.ReceivedAt
}
if msg.Snippet == "" {
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
}
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
return false, err
} else if exists {
return false, nil
}
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
}
return err == nil, err
}
func (a *App) maildirMessageExists(ctx context.Context, mailboxID, folderID, rawPath, messageID string) (bool, error) {
var count int
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM messages WHERE mailbox_id=? AND (raw_path=? OR (folder_id=? AND message_id=? AND message_id <> ''))`, mailboxID, rawPath, folderID, messageID).Scan(&count)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
return count > 0, nil
}
func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage, []AttachmentInput, error) {
m, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return storedMessage{}, nil, err
}
decoder := new(mime.WordDecoder)
subject, _ := decoder.DecodeHeader(m.Header.Get("Subject"))
if strings.TrimSpace(subject) == "" {
subject = "(no subject)"
}
from := firstAddress(m.Header.Get("From"))
to := addressList(m.Header.Get("To"))
cc := addressList(m.Header.Get("Cc"))
if len(to) == 0 {
to = []string{fallbackTo}
}
sentAt := parseMailDate(m.Header.Get("Date"))
parsed := &parsedMail{}
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
return storedMessage{}, nil, err
}
bodyHTML := a.policy.Sanitize(parsed.HTML)
bodyText := parsed.Text
if strings.TrimSpace(bodyText) == "" {
bodyText = stripTags(bodyHTML)
}
if strings.TrimSpace(bodyHTML) == "" && strings.TrimSpace(bodyText) != "" {
bodyHTML = "<p>" + htmlEscape(bodyText) + "</p>"
}
receivedAt := a.now().UTC()
if !sentAt.IsZero() {
receivedAt = sentAt
}
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject,
From: from,
To: to,
CC: cc,
SentAt: sentAt,
ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
}, parsed.Attachments, nil
}
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
contentType := header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
mediaType = "text/plain"
}
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if err := parseMailPart(part.Header, part, parsed); err != nil {
return err
}
}
return nil
}
decoded, err := io.ReadAll(transferReader(header.Get("Content-Transfer-Encoding"), body))
if err != nil {
return err
}
filename := partFilename(header)
if filename != "" || (!strings.HasPrefix(strings.ToLower(mediaType), "text/") && len(decoded) > 0) {
if filename == "" {
filename = "attachment.bin"
}
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
return nil
}
switch strings.ToLower(mediaType) {
case "text/html":
if parsed.HTML == "" {
parsed.HTML = string(decoded)
}
case "text/plain":
if parsed.Text == "" {
parsed.Text = string(decoded)
}
default:
// Ignore unsupported inline parts for now.
}
return nil
}
func transferReader(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
func partFilename(header textproto.MIMEHeader) string {
decoder := new(mime.WordDecoder)
if _, params, err := mime.ParseMediaType(header.Get("Content-Disposition")); err == nil {
if name := strings.TrimSpace(params["filename"]); name != "" {
decoded, _ := decoder.DecodeHeader(name)
if decoded != "" {
name = decoded
}
return filepath.Base(name)
}
}
if _, params, err := mime.ParseMediaType(header.Get("Content-Type")); err == nil {
if name := strings.TrimSpace(params["name"]); name != "" {
decoded, _ := decoder.DecodeHeader(name)
if decoded != "" {
name = decoded
}
return filepath.Base(name)
}
}
return ""
}
func firstAddress(value string) string {
items := addressList(value)
if len(items) == 0 {
return strings.TrimSpace(value)
}
return items[0]
}
func addressList(value string) []string {
items, err := netmail.ParseAddressList(value)
if err != nil {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
out = append(out, normalizeEmail(item.Address))
}
return out
}
func parseMailDate(value string) time.Time {
if strings.TrimSpace(value) == "" {
return time.Time{}
}
if t, err := netmail.ParseDate(value); err == nil {
return t.UTC()
}
return time.Time{}
}
+176
View File
@@ -0,0 +1,176 @@
package app
import (
"bytes"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/smtp"
"net/textproto"
"strings"
"time"
)
type MIMEMessage struct {
From string
To []string
CC []string
BCC []string
Subject string
Text string
HTML string
MessageID string
Date time.Time
Attachments []AttachmentInput
}
func BuildMIME(m MIMEMessage) ([]byte, error) {
var buf bytes.Buffer
writeHeader := func(k, v string) {
if strings.TrimSpace(v) != "" {
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
}
}
writeHeader("From", m.From)
writeHeader("To", strings.Join(m.To, ", "))
writeHeader("Cc", strings.Join(m.CC, ", "))
writeHeader("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
writeHeader("Message-ID", m.MessageID)
writeHeader("Date", m.Date.Format(time.RFC1123Z))
writeHeader("MIME-Version", "1.0")
mixed := multipart.NewWriter(&buf)
writeHeader("Content-Type", `multipart/mixed; boundary="`+mixed.Boundary()+`"`)
buf.WriteString("\r\n")
var altBuf bytes.Buffer
alt := multipart.NewWriter(&altBuf)
textHeader := textprotoMIMEHeader(map[string]string{"Content-Type": `text/plain; charset="utf-8"`, "Content-Transfer-Encoding": "base64"})
textPart, err := alt.CreatePart(textHeader)
if err != nil {
return nil, err
}
writeBase64(textPart, []byte(m.Text))
htmlHeader := textprotoMIMEHeader(map[string]string{"Content-Type": `text/html; charset="utf-8"`, "Content-Transfer-Encoding": "base64"})
htmlPart, err := alt.CreatePart(htmlHeader)
if err != nil {
return nil, err
}
writeBase64(htmlPart, []byte(m.HTML))
if err := alt.Close(); err != nil {
return nil, err
}
altMixedHeader := textprotoMIMEHeader(map[string]string{"Content-Type": `multipart/alternative; boundary="` + alt.Boundary() + `"`})
altMixedPart, err := mixed.CreatePart(altMixedHeader)
if err != nil {
return nil, err
}
if _, err := altMixedPart.Write(altBuf.Bytes()); err != nil {
return nil, err
}
for _, att := range m.Attachments {
data, err := base64.StdEncoding.DecodeString(att.ContentBase64)
if err != nil {
return nil, err
}
contentType := att.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
filename := mime.QEncoding.Encode("utf-8", att.Filename)
h := textprotoMIMEHeader(map[string]string{
"Content-Type": contentType + `; name="` + filename + `"`,
"Content-Disposition": `attachment; filename="` + filename + `"`,
"Content-Transfer-Encoding": "base64",
})
part, err := mixed.CreatePart(h)
if err != nil {
return nil, err
}
writeBase64(part, data)
}
if err := mixed.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func textprotoMIMEHeader(values map[string]string) textproto.MIMEHeader {
h := textproto.MIMEHeader{}
for k, v := range values {
h.Set(k, v)
}
return h
}
func writeBase64(w io.Writer, data []byte) {
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
base64.StdEncoding.Encode(encoded, data)
for len(encoded) > 76 {
_, _ = w.Write(encoded[:76])
_, _ = w.Write([]byte("\r\n"))
encoded = encoded[76:]
}
_, _ = w.Write(encoded)
_, _ = w.Write([]byte("\r\n"))
}
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
addr := net.JoinHostPort(a.cfg.SMTPHost, a.cfg.SMTPPort)
var auth smtp.Auth
if a.cfg.SMTPUsername != "" {
auth = smtp.PlainAuth("", a.cfg.SMTPUsername, a.cfg.SMTPPassword, a.cfg.SMTPHost)
}
if !a.cfg.SMTPRequireTLS {
return smtp.SendMail(addr, auth, from, recipients, mimeBytes)
}
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: a.cfg.SMTPHost, MinVersion: tls.VersionTLS12})
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, a.cfg.SMTPHost)
if err != nil {
return err
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, rcpt := range recipients {
if err := client.Rcpt(rcpt); err != nil {
return err
}
}
wc, err := client.Data()
if err != nil {
return err
}
if _, err := wc.Write(mimeBytes); err != nil {
_ = wc.Close()
return err
}
if err := wc.Close(); err != nil {
return err
}
return client.Quit()
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\n", "<br>")
return s
}
+453
View File
@@ -0,0 +1,453 @@
package app
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func (a *App) handleListContacts(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,name,email,note,created_at FROM contacts WHERE user_id=? ORDER BY name,email`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load contacts")
return
}
defer rows.Close()
items := []Contact{}
for rows.Next() {
item, err := scanContact(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan contacts")
return
}
items = append(items, item)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateContact(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Note string `json:"note"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("invalid email"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = email
}
id := newID("ctc")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err := a.db.ExecContext(r.Context(), `INSERT INTO contacts(id,user_id,name,email,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id,email) DO UPDATE SET name=excluded.name,note=excluded.note,updated_at=excluded.updated_at`,
id, user.ID, name, email, strings.TrimSpace(req.Note), now, now)
if err != nil {
badRequest(w, err)
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,name,email,note,created_at FROM contacts WHERE user_id=? AND email=?`, user.ID, email)
item, err := scanContact(row)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load contact")
return
}
respondJSON(w, http.StatusCreated, item)
}
func (a *App) handleDeleteContact(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
res, err := a.db.ExecContext(r.Context(), `DELETE FROM contacts WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
if n, _ := res.RowsAffected(); n == 0 {
respondError(w, http.StatusNotFound, "contact not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleListRules(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,from_contains,subject_contains,action,enabled,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rules")
return
}
defer rows.Close()
items := []MailRule{}
for rows.Next() {
item, err := scanRule(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan rules")
return
}
items = append(items, item)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
Enabled *bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
mailboxID, ok := a.optionalMailboxIDForUser(r, req.MailboxID)
if !ok {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
action := strings.TrimSpace(req.Action)
if action != "archive" && action != "trash" && action != "star" && action != "mark-read" {
badRequest(w, errors.New("invalid rule action"))
return
}
fromContains := strings.TrimSpace(req.FromContains)
subjectContains := strings.TrimSpace(req.SubjectContains)
if fromContains == "" && subjectContains == "" {
badRequest(w, errors.New("rule condition is required"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "收件规则"
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
id := newID("rule")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err := a.db.ExecContext(r.Context(), `INSERT INTO mail_rules(id,user_id,mailbox_id,name,from_contains,subject_contains,action,enabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, user.ID, mailboxID, name, fromContains, subjectContains, action, boolInt(enabled), now, now)
if err != nil {
badRequest(w, err)
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,name,from_contains,subject_contains,action,enabled,created_at FROM mail_rules WHERE id=?`, id)
item, err := scanRule(row)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load rule")
return
}
respondJSON(w, http.StatusCreated, item)
}
func (a *App) handleDeleteRule(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mail_rules WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete rule")
return
}
if n, _ := res.RowsAffected(); n == 0 {
respondError(w, http.StatusNotFound, "rule not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleListBlockedSenders(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load blocked senders")
return
}
defer rows.Close()
items := []BlockedSender{}
for rows.Next() {
item, err := scanBlockedSender(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan blocked senders")
return
}
items = append(items, item)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateBlockedSender(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
MailboxID string `json:"mailboxId"`
Email string `json:"email"`
Reason string `json:"reason"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
mailboxID, ok := a.optionalMailboxIDForUser(r, req.MailboxID)
if !ok {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("invalid email"))
return
}
id := newID("blk")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err := a.db.ExecContext(r.Context(), `INSERT INTO blocked_senders(id,user_id,mailbox_id,email,reason,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id,mailbox_id,email) DO UPDATE SET reason=excluded.reason,updated_at=excluded.updated_at`,
id, user.ID, mailboxID, email, strings.TrimSpace(req.Reason), now, now)
if err != nil {
badRequest(w, err)
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? AND mailbox_id=? AND email=?`, user.ID, mailboxID, email)
item, err := scanBlockedSender(row)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load blocked sender")
return
}
respondJSON(w, http.StatusCreated, item)
}
func (a *App) handleDeleteBlockedSender(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
res, err := a.db.ExecContext(r.Context(), `DELETE FROM blocked_senders WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete blocked sender")
return
}
if n, _ := res.RowsAffected(); n == 0 {
respondError(w, http.StatusNotFound, "blocked sender not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
args := []any{user.ID}
where := `mb.user_id=?`
if mailboxID != "" {
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
where += ` AND mb.id=?`
args = append(args, mailboxID)
}
stats := MailStats{ByFolder: []MailStatsFolderCount{}}
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb LEFT JOIN messages m ON m.mailbox_id=mb.id WHERE `+where, args...)
if err := row.Scan(&stats.TotalMessages, &stats.UnreadMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load stats")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder stats")
return
}
defer rows.Close()
for rows.Next() {
var item MailStatsFolderCount
if err := rows.Scan(&item.Folder, &item.Role, &item.Count, &item.Unread, &item.Bytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folder stats")
return
}
stats.ByFolder = append(stats.ByFolder, item)
}
respondJSON(w, http.StatusOK, stats)
}
func (a *App) handleMailCleanup(w http.ResponseWriter, r *http.Request) {
var req struct {
MailboxID string `json:"mailboxId"`
Target string `json:"target"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
target := strings.TrimSpace(req.Target)
affected := int64(0)
switch target {
case "empty-trash":
affected, err = a.deleteMessagesInFolder(r.Context(), mb.ID, "Trash")
case "empty-spam":
affected, err = a.deleteMessagesInFolder(r.Context(), mb.ID, "Spam")
case "archive-read-inbox":
affected, err = a.archiveReadInbox(r.Context(), mb.ID)
default:
badRequest(w, errors.New("invalid cleanup target"))
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to cleanup messages")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affected": affected})
}
func (a *App) optionalMailboxIDForUser(r *http.Request, mailboxID string) (string, bool) {
mailboxID = strings.TrimSpace(mailboxID)
if mailboxID == "" || mailboxID == "all" {
return "", true
}
_, err := a.mailboxForCurrentUserWithID(r, mailboxID)
return mailboxID, err == nil
}
func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder string) (int64, error) {
folderID, err := a.ensureFolder(ctx, mailboxID, folder)
if err != nil {
return 0, err
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=?`, mailboxID, folderID)
if err != nil {
return 0, err
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return 0, err
}
ids = append(ids, id)
}
for _, id := range ids {
a.deleteMessageFiles(ctx, id)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
return 0, err
}
}
return int64(len(ids)), nil
}
func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, error) {
inboxID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil {
return 0, err
}
archiveID, err := a.ensureFolder(ctx, mailboxID, "Archive")
if err != nil {
return 0, err
}
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`,
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
func scanContact(row messageSummaryScanner) (Contact, error) {
var item Contact
var created string
err := row.Scan(&item.ID, &item.UserID, &item.Name, &item.Email, &item.Note, &created)
item.CreatedAt = parseTime(created)
return item, err
}
func scanRule(row messageSummaryScanner) (MailRule, error) {
var item MailRule
var enabled int
var created string
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.FromContains, &item.SubjectContains, &item.Action, &enabled, &created)
item.Enabled = intBool(enabled)
item.CreatedAt = parseTime(created)
return item, err
}
func scanBlockedSender(row messageSummaryScanner) (BlockedSender, error) {
var item BlockedSender
var created string
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Email, &item.Reason, &created)
item.CreatedAt = parseTime(created)
return item, err
}
func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, from, subject string) {
var userID string
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
return
}
from = normalizeEmail(from)
var blocked int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
if blocked > 0 {
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
return
}
rows, err := a.db.QueryContext(ctx, `SELECT from_contains,subject_contains,action FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
if err != nil {
return
}
defer rows.Close()
lowerFrom := strings.ToLower(from)
lowerSubject := strings.ToLower(subject)
for rows.Next() {
var fromContains, subjectContains, action string
if rows.Scan(&fromContains, &subjectContains, &action) != nil {
continue
}
if fromContains != "" && !strings.Contains(lowerFrom, strings.ToLower(fromContains)) {
continue
}
if subjectContains != "" && !strings.Contains(lowerSubject, strings.ToLower(subjectContains)) {
continue
}
switch action {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
case "star":
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), messageID)
case "mark-read":
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
}
}
+316
View File
@@ -0,0 +1,316 @@
package app
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"golang.org/x/crypto/bcrypt"
)
type contextKey string
const userContextKey contextKey = "user"
func (a *App) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(a.corsMiddleware)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
})
r.Route("/api", func(r chi.Router) {
r.Post("/auth/login", a.handleLogin)
r.Post("/auth/logout", a.handleLogout)
r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/mail/mailboxes", a.handleMyMailboxes)
r.Get("/mail/folders", a.handleMailFolders)
r.Get("/mail/messages", a.handleMailMessages)
r.Get("/mail/messages/{id}", a.handleMailMessage)
r.Post("/mail/send", a.handleMailSend)
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.Post("/mail/messages/{id}/star", a.handleStar)
r.Post("/mail/messages/{id}/move", a.handleMove)
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.Get("/mail/attachments/{id}", a.handleAttachment)
})
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Use(a.requireAdmin)
r.Get("/admin/domains", a.handleListDomains)
r.Post("/admin/domains", a.handleCreateDomain)
r.Get("/admin/mailboxes", a.handleListMailboxes)
r.Post("/admin/mailboxes", a.handleCreateMailbox)
r.Get("/admin/aliases", a.handleListAliases)
r.Post("/admin/aliases", a.handleCreateAlias)
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
})
})
return r
}
func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
email := normalizeEmail(req.Email)
user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "invalid email or password")
return
}
token := randomToken()
sessionID := newID("ses")
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
_, err = a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
sessionID, user.ID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create session")
return
}
http.SetCookie(w, &http.Cookie{
Name: a.cfg.CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !a.cfg.AllowInsecureHTTP,
})
respondJSON(w, http.StatusOK, map[string]any{"user": user})
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
}
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"user": currentUser(r)})
}
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
DisplayName string `json:"displayName"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
badRequest(w, errors.New("displayName is required"))
return
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, updated_at=? WHERE id=?`,
displayName, a.now().UTC().Format(time.RFC3339Nano), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update profile")
return
}
updated, err := a.userByID(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load profile")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
}
func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if len(req.NewPassword) < 8 {
badRequest(w, errors.New("newPassword must be at least 8 characters"))
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
var currentHash string
if err := row.Scan(&currentHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil {
respondError(w, http.StatusUnauthorized, "current password is incorrect")
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, string(newHash), now, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update password")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?, updated_at=? WHERE user_id=?`, string(newHash), now, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox password")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save password")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.authenticateRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
})
}
func (a *App) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
func currentUser(r *http.Request) *User {
user, _ := r.Context().Value(userContextKey).(*User)
return user
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
cookie, err := r.Cookie(a.cfg.CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.created_at
FROM sessions s JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
var u User
var disabled int
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &created); err != nil {
return nil, err
}
u.Disabled = intBool(disabled)
u.CreatedAt = parseTime(created)
if u.Disabled {
return nil, errors.New("disabled")
}
return &u, nil
}
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,created_at FROM users WHERE email=?`, email)
var u User
var passwordHash string
var disabled int
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound
}
return nil, "", err
}
u.Disabled = intBool(disabled)
u.CreatedAt = parseTime(created)
return &u, passwordHash, nil
}
func (a *App) userByID(ctx context.Context, id string) (*User, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,created_at FROM users WHERE id=?`, id)
var u User
var disabled int
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errNotFound
}
return nil, err
}
u.Disabled = intBool(disabled)
u.CreatedAt = parseTime(created)
return &u, nil
}
+152
View File
@@ -0,0 +1,152 @@
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"`
CreatedAt time.Time `json:"createdAt"`
}
type Domain struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
DKIMSelector string `json:"dkimSelector"`
DKIMPublicKey string `json:"dkimPublicKey,omitempty"`
DNSStatus string `json:"dnsStatus"`
DNSCheckedAt *time.Time `json:"dnsCheckedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Mailbox struct {
ID string `json:"id"`
UserID string `json:"userId"`
UserEmail string `json:"userEmail,omitempty"`
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
Address string `json:"address"`
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
type Alias struct {
ID string `json:"id"`
DomainID string `json:"domainId"`
Source string `json:"source"`
Destination string `json:"destination"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
}
type MailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
}
type MailMessage struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Attachments []Attachment `json:"attachments,omitempty"`
}
type Attachment struct {
ID string `json:"id"`
MessageID string `json:"messageId"`
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
CreatedAt time.Time `json:"createdAt"`
}
type DNSRecord struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
type DNSCheckResult struct {
Domain string `json:"domain"`
Status string `json:"status"`
Checks map[string]DNSCheckStatus `json:"checks"`
}
type DNSCheckStatus struct {
OK bool `json:"ok"`
Message string `json:"message"`
Found []string `json:"found,omitempty"`
}
type Contact struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
Name string `json:"name"`
Email string `json:"email"`
Note string `json:"note"`
CreatedAt time.Time `json:"createdAt"`
}
type MailRule struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
FromContains string `json:"fromContains"`
SubjectContains string `json:"subjectContains"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
}
type BlockedSender struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Email string `json:"email"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"createdAt"`
}
type MailStats struct {
TotalMessages int64 `json:"totalMessages"`
UnreadMessages int64 `json:"unreadMessages"`
StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"`
StorageBytes int64 `json:"storageBytes"`
ByFolder []MailStatsFolderCount `json:"byFolder"`
}
type MailStatsFolderCount struct {
Folder string `json:"folder"`
Role string `json:"role"`
Count int64 `json:"count"`
Unread int64 `json:"unread"`
Bytes int64 `json:"bytes"`
}
+199
View File
@@ -0,0 +1,199 @@
package app
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"unicode"
"github.com/microcosm-cc/bluemonday"
)
type HTMLPolicy struct{ policy *bluemonday.Policy }
func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy()
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th")
return &HTMLPolicy{policy: p}
}
func (p *HTMLPolicy) Sanitize(s string) string {
if p == nil || p.policy == nil {
return s
}
return p.policy.Sanitize(s)
}
func newID(prefix string) string {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
return prefix + "_" + base64.RawURLEncoding.EncodeToString(buf)
}
func randomToken() string {
buf := make([]byte, 32)
_, _ = rand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func normalizeDomain(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.TrimSuffix(s, ".")
return s
}
var localPartRe = regexp.MustCompile(`[^a-z0-9._%+\-]`)
func normalizeLocalPart(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = localPartRe.ReplaceAllString(s, "")
s = strings.Trim(s, ".")
return s
}
func normalizeEmail(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if !strings.Contains(s, "@") {
return s
}
parts := strings.SplitN(s, "@", 2)
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
}
func dedupeEmails(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))
for _, item := range items {
email := normalizeEmail(item)
if email == "" || !strings.Contains(email, "@") || seen[email] {
continue
}
seen[email] = true
out = append(out, email)
}
return out
}
func jsonEncode(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func jsonDecodeSlice(s string) []string {
if s == "" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil
}
return out
}
func respondJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func respondError(w http.ResponseWriter, status int, msg string) {
respondJSON(w, status, map[string]any{"error": msg})
}
func decodeJSON(r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
return nil
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func intBool(v int) bool { return v != 0 }
func parseTime(v string) time.Time {
t, _ := time.Parse(time.RFC3339Nano, v)
return t
}
func nullableTime(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
t := parseTime(v.String)
return &t
}
func snippetFrom(text, html string) string {
s := text
if strings.TrimSpace(s) == "" {
s = stripTags(html)
}
s = strings.Join(strings.Fields(s), " ")
if len([]rune(s)) > 160 {
r := []rune(s)
s = string(r[:160]) + "…"
}
return s
}
func stripTags(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch r {
case '<':
inTag = true
case '>':
inTag = false
default:
if !inTag {
if unicode.IsSpace(r) {
b.WriteRune(' ')
} else {
b.WriteRune(r)
}
}
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func badRequest(w http.ResponseWriter, err error) {
msg := "bad request"
if err != nil {
msg = err.Error()
}
respondError(w, http.StatusBadRequest, msg)
}
func requireString(name, value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
return nil
}
var errNotFound = errors.New("not found")