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
+63
View File
@@ -0,0 +1,63 @@
# Dependencies
node_modules/
.pnpm-store/
.yarn/
# Build outputs
/dist/
dist/
build/
.vite/
.cache/
coverage/
*.tsbuildinfo
# Environment files
.env
.env.*
!.env.example
!**/.env.example
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime data / local databases
/data/
/tmp/
tmp/
apps/api/data/
apps/api/tmp/
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# Mail/runtime generated artifacts
maildata/
dkim-keys/
*.pem
*.key
*.crt
# Go generated binaries / test artifacts
*.exe
*.test
*.out
coverage.out
# OS / editor local files
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
.cursor/
.claude/
*.swp
*.swo
+62
View File
@@ -0,0 +1,62 @@
# LanQin Email
LanQin Email 是一个自建邮箱 Webmail MVPReact/Vite + shadcn 风格组件前端,Go + SQLite 后端,部署层预留 Postfix/Dovecot/OpenDKIM 集成。
## 快速开发
### 后端
```bash
cd apps/api
go mod tidy
go test ./...
go run ./cmd/server
```
默认管理员:
- 邮箱:`admin@lanqin.local`
- 密码:`ChangeMe123!`
生产环境请通过 `LANQIN_ADMIN_PASSWORD` 覆盖。
### 前端
```bash
cd apps/web
npm install
npm run dev
```
前端默认代理 `/api``http://localhost:8080`
### Web UI 规则
`apps/web` 的业务页面和业务组件必须使用官方 shadcn/ui 组件源码。新增 UI primitive 前先执行:
```bash
cd apps/web
npx shadcn@latest add <component>
npm run check:shadcn
```
详细规则见 `apps/web/SHADCN_RULES.md``npm run check:shadcn` 是提交前的实际检查入口。
## Docker 部署
`deploy/docker-compose.yml` 提供 Linux 单机部署骨架:API、Web、Postfix、Dovecot、OpenDKIM、Nginx。真实公网收发前需要正确配置 MX/SPF/DKIM/DMARC,并确认云厂商开放 25/587/993 端口。
## V1 能力
- 管理员/普通用户登录
- 多域名、邮箱账号、别名管理
- DNS 记录展示和检测
- Webmail:文件夹、邮件列表、阅读、写信、附件、搜索、已读、星标、移动、删除
- 开发环境本地投递:给系统内邮箱发送会直接写入对方 Inbox,便于无公网邮件栈验证
## 当前收发说明
- 本地开发:系统内邮箱互发可直接使用;未配置 `LANQIN_SMTP_HOST` 时,外部收件人不会真正投递到公网。
- 服务器部署:`deploy/.env.example` 默认使用 `LANQIN_SMTP_HOST=postfix`,发件会交给 Postfix。
- 收件同步:Postfix/Dovecot 收到的 Maildir 邮件会由 API 的 Maildir worker 同步到 SQLite 后展示在 Webmail。
- Maildir worker 通过 `LANQIN_MAILDIR_ROOT``LANQIN_MAILDIR_SCAN_SECONDS` 控制,默认服务器路径为 `/var/mail/vhosts`
+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")
+23
View File
@@ -0,0 +1,23 @@
# Web shadcn/ui 规则
`apps/web` 的业务页面和业务组件必须优先并完整使用官方 shadcn/ui 组件源码。
## 规则
- 所有 UI primitive 必须来自 `@/components/ui/*`
- 新增 UI 能力时,先执行 `npx shadcn@latest add <component>` 添加官方组件源码。
- 业务 TSX 禁止直接写原生 `<button>``<input>``<textarea>``<select>``<table>``<dialog>``<aside>` 等控件。
- 业务页面不要在标题下方添加说明性小字/副标题文案,例如“管理当前用户资料和密码”“管理当前用户拥有的多个邮箱”这类内容。
- 业务 TSX 禁止使用 `CardDescription``DialogDescription``SheetDescription`;需要说明时改为清晰标题、表单 Label、按钮或 Badge。
- 视觉风格必须保持 shadcn `new-york + neutral`:白底、黑色主按钮、细边框、克制留白。
- 禁止在业务 TSX 使用蓝色品牌色、渐变背景、重阴影/营销感样式,例如 `bg-gradient-*``*-blue-*``shadow-xl``shadow-2xl``shadow-primary*`
- `src/components/ui/**` 是官方 shadcn 组件源码,允许内部使用原生标签。
- 允许语义/布局标签:`div``span``form``header``main``section``p`、标题、`a`
## 检查
```bash
cd apps/web
npm run check:shadcn
npm run build
```
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LanQin Email</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5347
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "lanqin-email-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"check:shadcn": "node scripts/check-shadcn.mjs",
"check": "npm run check:shadcn && npm run build"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.1.7",
"lucide-react": "^0.468.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "6.28.0",
"tailwind-merge": "2.5.4"
},
"devDependencies": {
"@types/node": "22.10.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.3",
"autoprefixer": "10.4.20",
"postcss": "8.4.49",
"tailwindcss": "3.4.15",
"typescript": "5.6.3",
"vite": "5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+91
View File
@@ -0,0 +1,91 @@
import fs from "node:fs"
import path from "node:path"
const root = process.cwd()
const srcDir = path.join(root, "src")
const ignoredSegments = [
`${path.sep}components${path.sep}ui${path.sep}`,
`${path.sep}dist${path.sep}`,
]
// Business code must use shadcn/ui wrappers for visible UI primitives.
// Semantic/layout tags such as div, span, form, header, main, section and a are allowed.
const forbidden = [
{ tag: "button", replacement: "@/components/ui/button Button" },
{ tag: "input", replacement: "@/components/ui/input Input" },
{ tag: "textarea", replacement: "@/components/ui/textarea Textarea" },
{ tag: "select", replacement: "@/components/ui/select Select" },
{ tag: "table", replacement: "@/components/ui/table Table" },
{ tag: "thead", replacement: "@/components/ui/table TableHeader" },
{ tag: "tbody", replacement: "@/components/ui/table TableBody" },
{ tag: "tr", replacement: "@/components/ui/table TableRow" },
{ tag: "th", replacement: "@/components/ui/table TableHead" },
{ tag: "td", replacement: "@/components/ui/table TableCell" },
{ tag: "dialog", replacement: "@/components/ui/dialog Dialog" },
{ tag: "aside", replacement: "@/components/ui/sidebar Sidebar" },
]
// Product UI rule: do not add small explanatory subtitle text under titles.
// Keep screens clean: use titles, labels, badges and actionable controls only.
const forbiddenComponents = [
{ name: "CardDescription", reason: "不要在卡片标题下添加说明性小字" },
{ name: "DialogDescription", reason: "不要在弹窗标题下添加说明性小字" },
{ name: "SheetDescription", reason: "不要在抽屉标题下添加说明性小字" },
]
const forbiddenClassPatterns = [
{ pattern: /\bbg-gradient-[^\s"`'}]+/, reason: "不要使用渐变背景,保持 shadcn neutral 极简风格" },
{ pattern: /\b(?:from|via|to)-blue-[^\s"`'}]+/, reason: "不要使用蓝色渐变色阶,保持 neutral/black 视觉" },
{ pattern: /\b(?:bg|text|border|ring)-blue-[^\s"`'}]+/, reason: "不要使用蓝色品牌色,保持 neutral/black 视觉" },
{ pattern: /\bshadow-(?:lg|xl|2xl|primary[^\s"`'}]*)/, reason: "不要使用重阴影或营销感阴影,保持 shadcn 默认克制风格" },
]
function walk(dir) {
const out = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) out.push(...walk(full))
else if (/\.(tsx|jsx)$/.test(entry.name)) out.push(full)
}
return out
}
const violations = []
for (const file of walk(srcDir)) {
if (ignoredSegments.some((segment) => file.includes(segment))) continue
const rel = path.relative(root, file)
const content = fs.readFileSync(file, "utf8")
const lines = content.split(/\r?\n/)
lines.forEach((line, index) => {
for (const rule of forbidden) {
const re = new RegExp(`<${rule.tag}(\\s|>|/)`)
if (re.test(line)) {
violations.push({ file: rel, line: index + 1, tag: rule.tag, replacement: rule.replacement, code: line.trim() })
}
}
for (const rule of forbiddenComponents) {
const re = new RegExp(`\\b${rule.name}\\b`)
if (re.test(line)) {
violations.push({ file: rel, line: index + 1, tag: rule.name, replacement: rule.reason, code: line.trim() })
}
}
for (const rule of forbiddenClassPatterns) {
if (rule.pattern.test(line)) {
violations.push({ file: rel, line: index + 1, tag: "visual-style", replacement: rule.reason, code: line.trim() })
}
}
})
}
if (violations.length > 0) {
console.error("\nshadcn/ui rule failed: business TSX must not use native UI primitives.\n")
for (const v of violations) {
console.error(`${v.file}:${v.line} <${v.tag}> -> use ${v.replacement}`)
console.error(` ${v.code}`)
}
console.error("\nAllowed: native semantic/layout tags like div, span, form, header, main, section, a. Official shadcn components under src/components/ui are exempt.\n")
process.exit(1)
}
console.log("shadcn/ui rule passed: no native UI primitives found in business TSX.")
@@ -0,0 +1,132 @@
import * as React from "react"
import { Navigate, Outlet, Link, useLocation, useNavigate } from "react-router-dom"
import { Inbox, LogOut, Mail, Settings } from "lucide-react"
import { useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar"
export function ProtectedLayout() {
const me = useMe()
const location = useLocation()
const navigate = useNavigate()
const qc = useQueryClient()
if (me.isLoading) return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
const user = me.data.user
const isMailRoute = location.pathname.startsWith("/mail")
const isProfileRoute = location.pathname.startsWith("/profile")
async function logout() {
await api.logout().catch(() => undefined)
qc.clear()
navigate("/login", { replace: true })
}
if (isMailRoute || isProfileRoute) {
return <Outlet />
}
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link to="/mail">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Mail className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">LanQin Email</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<NavItem to="/mail" icon={<Inbox />} label="Webmail" />
{user.role === "admin" && <NavItem to="/admin" icon={<Settings />} label="系统管理" />}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="group-data-[collapsible=icon]:!p-0" asChild>
<Link to="/profile">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg bg-muted text-foreground">
{user.displayName.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "用户"}
</Badge>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className="p-2">
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />退
</Button>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<div className="flex min-h-svh flex-col bg-muted/20">
<div className="flex h-12 items-center border-b bg-white px-3 md:hidden">
<SidebarTrigger />
</div>
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
)
}
function NavItem({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
const location = useLocation()
const active = location.pathname.startsWith(to)
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={active} tooltip={label}>
<Link to={to}>
{icon}
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+43
View File
@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+157
View File
@@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+771
View File
@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+6
View File
@@ -0,0 +1,6 @@
import { useQuery } from "@tanstack/react-query"
import { api } from "@/lib/api"
export function useMe() {
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: false })
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+191
View File
@@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+151
View File
@@ -0,0 +1,151 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* { @apply border-border; }
body { @apply bg-background text-foreground antialiased; }
html, body, #root { min-height: 100%; }
html { color-scheme: light; }
html.dark { color-scheme: dark; }
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 0 0% 98%;
--sidebar-primary-foreground: 240 5.9% 10%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
html.theme-transition body {
transition: background-color 180ms ease, color 180ms ease;
}
html.theme-transition::before {
content: "";
position: fixed;
inset: 0;
z-index: 2147483647;
pointer-events: none;
background: var(--theme-fade-bg, hsl(var(--background)));
animation: theme-fade-out 180ms ease-out both;
will-change: opacity;
}
@keyframes theme-fade-out {
from { opacity: 0.28; }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
html.theme-transition body {
transition-duration: 0ms !important;
}
html.theme-transition::before {
animation-duration: 0ms !important;
}
}
/* ===== 邮件 HTML 内容渲染 ===== */
.mail-html {
line-height: 1.75;
color: hsl(var(--foreground));
}
.mail-html a { color: hsl(var(--primary)); text-decoration: underline; }
.mail-html a:hover { opacity: 0.85; }
.mail-html blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1rem;
color: hsl(var(--muted-foreground));
margin: 1rem 0;
}
.mail-html table { border-collapse: collapse; max-width: 100%; width: 100%; }
.mail-html td, .mail-html th {
border: 1px solid hsl(var(--border));
padding: 0.5rem 0.75rem;
text-align: left;
}
.mail-html th { background-color: hsl(var(--muted)); font-weight: 600; }
.mail-html img { max-width: 100%; height: auto; border-radius: 0.5rem; }
.mail-html p { margin: 0.75rem 0; }
.mail-html ul, .mail-html ol { padding-left: 1.5rem; margin: 0.75rem 0; }
.mail-html h1, .mail-html h2, .mail-html h3, .mail-html h4 { margin: 1.25rem 0 0.5rem; font-weight: 600; }
.mail-html h1 { font-size: 1.5rem; }
.mail-html h2 { font-size: 1.25rem; }
.mail-html h3 { font-size: 1.125rem; }
.mail-html pre {
background-color: hsl(var(--muted));
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
font-size: 0.875rem;
line-height: 1.5;
}
.mail-html hr { border: none; border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
/* ===== 滚动条美化 ===== */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
/* ===== 选中文本 ===== */
::selection {
background-color: hsl(var(--primary) / 0.15);
color: hsl(var(--foreground));
}
+67
View File
@@ -0,0 +1,67 @@
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; createdAt: string }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailMessage = {
id: string; mailboxId?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
export type ListResponse<T> = { items: T[]; nextCursor?: string }
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailRule = { id: string; mailboxId: string; name: string; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read"; enabled: boolean; createdAt: string }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers || {}) }, ...init })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
}
return res.json() as Promise<T>
}
export const api = {
login: (email: string, password: string) => request<{ user: User }>("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
me: () => request<{ user: User }>("/api/me"),
updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }),
changePassword: (payload: { currentPassword: string; newPassword: string }) => request<{ ok: boolean }>("/api/me/password", { method: "POST", body: JSON.stringify(payload) }),
contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }),
rules: () => request<ListResponse<MailRule>>("/api/me/rules"),
createRule: (payload: { mailboxId: string; name: string; fromContains: string; subjectContains: string; action: string; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }),
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }),
deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }),
mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
createDomain: (name: string) => request<Domain>("/api/admin/domains", { method: "POST", body: JSON.stringify({ name }) }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
createAlias: (payload: { domainId: string; source: string; destination: string; enabled: boolean }) => request<Alias>("/api/admin/aliases", { method: "POST", body: JSON.stringify(payload) }),
dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`),
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ folder, q, cursor })
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
},
message: (id: string) => request<MailMessage>(`/api/mail/messages/${id}`),
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload) }),
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
star: (id: string, starred: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/star`, { method: "POST", body: JSON.stringify({ starred }) }),
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
}
+35
View File
@@ -0,0 +1,35 @@
const THEME_TRANSITION_MS = 180
let themeTimer: number | undefined
export function getInitialTheme() {
return localStorage.getItem("lanqin:theme") === "dark" || document.documentElement.classList.contains("dark")
}
export function applyTheme(dark: boolean, animated = false) {
const root = document.documentElement
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
const updateTheme = () => {
root.classList.toggle("dark", dark)
root.style.colorScheme = dark ? "dark" : "light"
localStorage.setItem("lanqin:theme", dark ? "dark" : "light")
}
if (!animated || reduceMotion) {
root.classList.remove("theme-transition")
root.style.removeProperty("--theme-fade-bg")
updateTheme()
return
}
if (themeTimer) window.clearTimeout(themeTimer)
root.style.setProperty("--theme-fade-bg", getComputedStyle(document.body).backgroundColor)
root.classList.add("theme-transition")
requestAnimationFrame(() => {
updateTheme()
themeTimer = window.setTimeout(() => {
root.classList.remove("theme-transition")
root.style.removeProperty("--theme-fade-bg")
themeTimer = undefined
}, THEME_TRANSITION_MS)
})
}
+21
View File
@@ -0,0 +1,21 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(date)
}
export function formatBytes(bytes: number) {
if (!bytes) return "0 B"
const units = ["B", "KB", "MB", "GB"]
let size = bytes
let idx = 0
while (size >= 1024 && idx < units.length - 1) { size /= 1024; idx++ }
return `${size.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`
}
+31
View File
@@ -0,0 +1,31 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster"
import { ProtectedLayout } from "@/components/protected-layout"
import { LoginPage } from "@/pages/login"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
import "./index.css"
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/", element: <ProtectedLayout />, children: [
{ index: true, element: <Navigate to="/mail" replace /> },
{ path: "mail", element: <MailPage /> },
{ path: "profile", element: <ProfilePage /> },
{ path: "admin", element: <AdminPage /> },
] },
])
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</React.StrictMode>,
)
+332
View File
@@ -0,0 +1,332 @@
import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CheckCircle2, Copy, Globe2, Mailbox, Plus, RefreshCcw, ShieldCheck, Users } from "lucide-react"
import { api, DNSRecord, Domain } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/hooks/use-toast"
export function AdminPage() {
const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains })
const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes })
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases })
const [selectedDomain, setSelectedDomain] = React.useState<string | null>(null)
React.useEffect(() => {
if (!selectedDomain && domains.data?.items?.[0]) setSelectedDomain(domains.data.items[0].id)
}, [domains.data, selectedDomain])
const domain = domains.data?.items.find((d) => d.id === selectedDomain)
return (
<ScrollArea className="h-svh">
<div className="p-6">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
</div>
<div className="flex flex-wrap gap-2">
<CreateDomainDialog />
<CreateMailboxDialog domains={domains.data?.items || []} />
<CreateAliasDialog domains={domains.data?.items || []} />
</div>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<Stat icon={<Globe2 />} label="域名" value={domains.data?.items.length || 0} />
<Stat icon={<Mailbox />} label="邮箱账号" value={mailboxes.data?.items.length || 0} />
<Stat icon={<Users />} label="别名" value={aliases.data?.items.length || 0} />
<Stat icon={<ShieldCheck />} label="DNS 正常" value={(domains.data?.items || []).filter((d) => d.dnsStatus === "ok").length} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_520px]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{domains.data?.items.map((d) => (
<Button key={d.id} type="button" variant={selectedDomain === d.id ? "secondary" : "outline"} onClick={() => setSelectedDomain(d.id)} className="h-auto w-full justify-between p-4 text-left">
<div>
<div className="font-medium">{d.name}</div>
<div className="text-xs text-muted-foreground">selector: {d.dkimSelector}</div>
</div>
<Badge variant={d.dnsStatus === "ok" ? "default" : "secondary"}>{d.dnsStatus === "ok" ? "正常" : d.dnsStatus}</Badge>
</Button>
))}
</div>
</CardContent>
</Card>
<DNSPanel domain={domain} />
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mailboxes.data?.items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.address}</TableCell>
<TableCell className="text-muted-foreground">{m.userEmail || m.userId}</TableCell>
<TableCell>{m.displayName}</TableCell>
<TableCell>{m.quotaMb} MB</TableCell>
<TableCell><Badge variant="secondary">{m.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>/</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{aliases.data?.items.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.source}</TableCell>
<TableCell>{a.destination}</TableCell>
<TableCell><Badge variant={a.enabled ? "default" : "secondary"}>{a.enabled ? "启用" : "停用"}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</ScrollArea>
)
}
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
return (
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-muted text-foreground">{icon}</div>
<div>
<div className="text-2xl font-semibold tracking-tight">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
)
}
function DNSPanel({ domain }: { domain?: Domain }) {
const { toast } = useToast()
const qc = useQueryClient()
const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
const check = useMutation({
mutationFn: () => api.checkDns(domain!.id),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["admin", "domains"] })
toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join("") })
},
})
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground"></CardContent></Card>
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>DNS </CardTitle>
</div>
<Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
{check.data && (
<>
<Separator className="my-4" />
<div className="space-y-2">
{Object.entries(check.data.checks).map(([k, v]) => (
<div key={k} className="flex items-center gap-2 text-sm">
<CheckCircle2 className={`h-4 w-4 ${v.ok ? "text-green-600" : "text-destructive"}`} />
<span className="font-medium">{k.toUpperCase()}:</span> {v.message}
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
)
}
function DNSRecordRow({ record }: { record: DNSRecord }) {
const { toast } = useToast()
const text = `${record.type} ${record.name} ${record.value}`
return (
<div className="rounded-lg border bg-card p-3">
<div className="mb-2 flex items-center justify-between">
<Badge variant="outline" className="font-mono">{record.type}</Badge>
<Button size="sm" variant="ghost" className="h-7 gap-1 text-xs" onClick={() => { navigator.clipboard.writeText(text); toast({ title: "已复制" }) }}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<div className="break-all font-mono text-xs text-muted-foreground">
<div>Name: {record.name}</div>
<div>Value: {record.value}</div>
<div>TTL: {record.ttl}s</div>
</div>
</div>
)
}
function CreateDomainDialog() {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const mut = useMutation({
mutationFn: (form: FormData) => api.createDomain(String(form.get("name"))),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); setOpen(false); toast({ title: "域名已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline"><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<div className="space-y-2"><Label></Label><Input name="name" placeholder="example.com" required /></div>
<DialogFooter><Button disabled={mut.isPending}></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function CreateMailboxDialog({ domains }: { domains: Domain[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
const [role, setRole] = React.useState("user")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
const mut = useMutation({
mutationFn: (form: FormData) => api.createMailbox({
domainId,
localPart: String(form.get("localPart")),
displayName: String(form.get("displayName")),
password: String(form.get("password")),
quotaMb: Number(form.get("quotaMb") || 1024),
role: role as "admin" | "user",
ownerEmail: String(form.get("ownerEmail") || ""),
}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "mailboxes"] }); setOpen(false); toast({ title: "邮箱已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<div className="grid grid-cols-2 gap-3">
<Field name="localPart" label="账号" placeholder="alice" />
<Field name="displayName" label="显示名" placeholder="Alice" />
</div>
<Field name="ownerEmail" label="归属用户邮箱(可选)" placeholder="留空则使用新邮箱创建用户;填已有账号则追加邮箱" required={false} />
<div className="grid grid-cols-2 gap-3">
<Field name="password" label="密码" type="password" placeholder="至少 8 位" />
<Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" />
</div>
<div className="space-y-2"><Label></Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
const qc = useQueryClient()
const { toast } = useToast()
const [open, setOpen] = React.useState(false)
const [domainId, setDomainId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
const mut = useMutation({
mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "aliases"] }); setOpen(false); toast({ title: "别名已创建" }) },
onError: (e) => toast({ title: "创建失败", description: e.message }),
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline"><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>/</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
<DomainSelect domains={domains} value={domainId} onChange={setDomainId} />
<Field name="source" label="来源" placeholder="sales 或 sales@example.com" />
<Field name="destination" label="目标邮箱" placeholder="alice@example.com" />
<DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div>
}
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) {
return <div className="space-y-2"><Label></Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger>
<SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent>
</Select>
</div>
}
+42
View File
@@ -0,0 +1,42 @@
import { Navigate } from "react-router-dom"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast"
export function LoginPage() {
const me = useMe()
const qc = useQueryClient()
const { toast } = useToast()
const login = useMutation({
mutationFn: (form: FormData) => api.login(String(form.get("email")), String(form.get("password"))),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["me"] }) },
onError: (e) => toast({ title: "登录失败", description: e.message }),
})
if (me.data?.user) return <Navigate to="/mail" replace />
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-[360px]">
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight">LanQin Email</h1>
</div>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); login.mutate(new FormData(e.currentTarget)) }}>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
</div>
<Button className="h-11 w-full text-base" disabled={login.isPending}>
{login.isPending ? "登录中..." : "登录"}
</Button>
</form>
</div>
</div>
)
}
+434
View File
@@ -0,0 +1,434 @@
import * as React from "react"
import DOMPurify from "dompurify"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate, useSearchParams } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { Archive, Check, ChevronsUpDown, Copy, Forward, Inbox, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Sun, Trash2 } from "lucide-react"
import { api, Mailbox, MailMessage } from "@/lib/api"
import { cn, formatBytes, formatDate } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar"
import { useMe } from "@/hooks/use-me"
import { useToast } from "@/hooks/use-toast"
const folderIcons: Record<string, React.ReactNode> = { inbox: <Inbox className="h-4 w-4" />, sent: <Send className="h-4 w-4" />, archive: <Archive className="h-4 w-4" />, trash: <Trash2 className="h-4 w-4" /> }
const folderLabels: Record<string, string> = {
Inbox: "收件箱",
Sent: "已发送",
Drafts: "草稿箱",
Archive: "归档",
Spam: "垃圾邮件",
Trash: "回收站",
}
type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string }
type MailFilter = "all" | "unread" | "starred" | "attachments"
const filterLabels: Record<MailFilter, string> = {
all: "全部邮件",
unread: "未读邮件",
starred: "星标邮件",
attachments: "有附件",
}
export function MailPage() {
const qc = useQueryClient()
const { toast } = useToast()
const navigate = useNavigate()
const me = useMe()
const [searchParams, setSearchParams] = useSearchParams()
const [folder, setFolder] = React.useState(() => searchParams.get("folder") || "Inbox")
const [query, setQuery] = React.useState("")
const [selectedId, setSelectedId] = React.useState<string | null>(null)
const [composeOpen, setComposeOpen] = React.useState(false)
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const themeMountedRef = React.useRef(false)
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId })
const messages = useQuery({ queryKey: ["messages", selectedMailboxId, folder, query], queryFn: () => api.messages(folder, query, "", selectedMailboxId), enabled: !!selectedMailboxId })
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!), enabled: !!selectedId })
const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), onSuccess: () => qc.invalidateQueries({ queryKey: ["messages"] }) })
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); toast({ title: "已删除" }) } })
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); toast({ title: "已移动" }) } })
const markAllRead = useMutation({
mutationFn: async (items: MailMessage[]) => {
const unread = items.filter((message) => !message.isRead)
await Promise.all(unread.map((message) => api.markRead(message.id, true)))
return unread.length
},
onSuccess: async (count) => {
await qc.invalidateQueries({ queryKey: ["messages"] })
await qc.invalidateQueries({ queryKey: ["folders"] })
toast({ title: count > 0 ? `已标记 ${count} 封邮件为已读` : "当前没有未读邮件" })
},
onError: (error) => toast({ title: "操作失败", description: error.message }),
})
React.useEffect(() => {
const items = mailboxList.data?.items || []
if (items.length === 0) return
if (!selectedMailboxId || !items.some((item) => item.id === selectedMailboxId)) {
setSelectedMailboxId(items[0].id)
}
}, [mailboxList.data?.items, selectedMailboxId])
React.useEffect(() => {
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
}, [selectedMailboxId])
React.useEffect(() => {
const nextFolder = searchParams.get("folder") || "Inbox"
if (nextFolder !== folder) {
setFolder(nextFolder)
setSelectedId(null)
}
}, [folder, searchParams])
React.useEffect(() => {
applyTheme(darkMode, themeMountedRef.current)
themeMountedRef.current = true
}, [darkMode])
React.useEffect(() => {
const events = new EventSource("/api/events", { withCredentials: true })
events.addEventListener("sync", () => qc.invalidateQueries({ queryKey: ["folders"] }))
return () => events.close()
}, [qc])
const selected = detail.data
const allMessages = messages.data?.items || []
const visibleMessages = allMessages.filter((message) => {
if (mailFilter === "unread") return !message.isRead
if (mailFilter === "starred") return message.isStarred
if (mailFilter === "attachments") return message.hasAttachments
return true
})
const unreadCount = allMessages.filter((message) => !message.isRead).length
function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
function switchMailbox(mailboxId: string) {
setSelectedMailboxId(mailboxId)
setFolder("Inbox")
setSearchParams({})
setSelectedId(null)
setMailFilter("all")
}
async function copyCurrentMailbox() {
if (!selectedMailbox?.address) return
await navigator.clipboard.writeText(selectedMailbox.address)
toast({ title: "邮箱地址已复制" })
}
function openSettings() {
navigate("/profile")
}
function toggleSidebar() {
if (sidebarCollapsed) {
sidebarPanelRef.current?.expand(14)
setSidebarCollapsed(false)
} else {
sidebarPanelRef.current?.collapse()
setSidebarCollapsed(true)
}
}
return (
<div className="h-svh bg-background">
<SidebarProvider className="h-full min-h-0 w-full">
<ResizablePanelGroup direction="horizontal" className="h-full min-h-0 w-full">
<ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}>
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar">
<SidebarHeader className={cn("border-b py-3", sidebarCollapsed ? "px-2" : "px-3")}>
<AccountHeader
collapsed={sidebarCollapsed}
name={me.data?.user.displayName || selectedMailbox?.address || "LanQin"}
email={me.data?.user.email || selectedMailbox?.address}
darkMode={darkMode}
onToggleTheme={() => setDarkMode((value) => !value)}
onSettings={openSettings}
/>
<div className={cn("mt-2 flex gap-2", sidebarCollapsed && "justify-center")}>
<MailboxSwitcher
collapsed={sidebarCollapsed}
mailboxes={mailboxList.data?.items || []}
selectedMailbox={selectedMailbox}
onSelect={switchMailbox}
/>
{!sidebarCollapsed && (
<Button type="button" variant="outline" size="icon" className="h-9 w-9 shrink-0 rounded-md" onClick={copyCurrentMailbox} disabled={!selectedMailbox}>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
<Button className={cn("mt-2 h-10 w-full rounded-md text-sm", sidebarCollapsed && "px-0")} size={sidebarCollapsed ? "icon" : "default"} onClick={() => openCompose()}>
<PencilLine className="h-4 w-4" />
{!sidebarCollapsed && <span></span>}
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{(folders.data?.items || []).map((f) => (
<SidebarMenuItem key={f.id}>
<SidebarMenuButton isActive={folder === f.name} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => { setFolder(f.name); setSearchParams(f.name === "Inbox" ? {} : { folder: f.name }); setSelectedId(null) }}>
{folderIcons[f.role] || <Inbox className="h-4 w-4" />}
{!sidebarCollapsed && <span>{folderLabels[f.name] || f.name}</span>}
{!sidebarCollapsed && f.unreadCount > 0 && <Badge variant="secondary" className="ml-auto">{f.unreadCount}</Badge>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
{folders.isLoading && <FolderSkeleton />}
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
{!sidebarCollapsed && <span></span>}
</Button>
</div>
</Sidebar>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={85} minSize={60}>
<section className="flex h-full min-h-0 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }) }}><RefreshCcw className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" disabled={markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm"><SlidersHorizontal className="h-4 w-4" />{filterLabels[mailFilter]}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.keys(filterLabels) as MailFilter[]).map((value) => (
<DropdownMenuItem key={value} onSelect={() => setMailFilter(value)}>
{filterLabels[value]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative w-full max-w-md">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="搜索邮件" className="pl-9" />
</div>
</header>
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
<ResizablePanel defaultSize={32} minSize={24} maxSize={44}>
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-5">
<div>
<div className="text-sm font-semibold">{folderLabels[folder] || folder}</div>
<div className="text-xs text-muted-foreground">{visibleMessages.length} / {allMessages.length} </div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
{messages.isLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} onClick={() => setSelectedId(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{allMessages.length === 0 ? "当前文件夹没有邮件" : "当前筛选条件下没有邮件"}</div>}
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={68} minSize={44}>
<section className="h-full min-h-0">
{!selectedId && <div className="grid h-full place-items-center text-muted-foreground"></div>}
{detail.isLoading && <div className="space-y-4 p-6"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-40 w-full" /></div>}
{selected && <div className="flex h-full min-h-0 flex-col">
<div className="border-b p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">{selected.subject}</h2>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" /></Button>
{selected.folder === "Archive" ? (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}></Button>
) : (
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Archive" })}></Button>
)}
<Button variant="destructive" size="sm" onClick={() => del.mutate(selected.id)}></Button>
</div>
</div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDate(selected.receivedAt)}</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-6">
<div className="mail-html prose max-w-none text-sm leading-7" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(selected.bodyHtml || `<pre>${selected.bodyText || ""}</pre>`) }} />
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium"></div><div className="space-y-2">{selected.attachments.map((a) => <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={`/api/mail/attachments/${a.id}`} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a>)}</div></div>}
</div>
</ScrollArea>
</div>}
</section>
</ResizablePanel>
</ResizablePanelGroup>
</section>
</ResizablePanel>
</ResizablePanelGroup>
</SidebarProvider>
<ComposeSheet mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }) }} />
</div>
)
}
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> }
function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onSettings: () => void }) {
const displayName = cleanAccountName(name, email)
if (collapsed) {
return (
<div className="flex justify-center">
<Avatar className="size-8 rounded-full">
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback>
</Avatar>
</div>
)
}
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-8 rounded-full">
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-sm">
<div className="truncate text-sm font-semibold leading-5">{displayName}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="size-8 rounded-md text-muted-foreground" onClick={onToggleTheme}>
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Button type="button" variant="ghost" size="icon" className="size-8 rounded-md text-muted-foreground" onClick={onSettings}>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function MailboxSwitcher({ collapsed, mailboxes, selectedMailbox, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailbox?: Mailbox; onSelect: (mailboxId: string) => void }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn("h-9 min-w-0 flex-1 justify-start gap-2 rounded-md bg-background px-2 text-left font-normal", collapsed && "w-8 flex-none justify-center px-0")}>
<Mail className="h-4 w-4 shrink-0 text-muted-foreground" />
{!collapsed && (
<>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{selectedMailbox?.address || "选择邮箱"}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{mailboxes.length === 0 && <DropdownMenuItem disabled></DropdownMenuItem>}
{mailboxes.map((mailbox) => (
<DropdownMenuItem key={mailbox.id} onSelect={() => onSelect(mailbox.id)} className="gap-2">
<Check className={cn("h-4 w-4", selectedMailbox?.id === mailbox.id ? "opacity-100" : "opacity-0")} />
<span className="min-w-0 flex-1 truncate font-medium">{mailbox.address}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function cleanAccountName(name: string, email?: string) {
const value = name.trim()
if (!value || (email && value.toLowerCase() === email.toLowerCase())) return email?.split("@")[0] || "用户"
return value
}
function accountInitial(name: string, email?: string) {
const source = cleanAccountName(name, email)
const first = Array.from(source.trim())[0]
return (first || "蓝").toUpperCase()
}
function MessageRow({ message, active, onClick, onStar }: { message: MailMessage; active: boolean; onClick: () => void; onStar: () => void }) {
return <div onClick={onClick} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<div className="mb-1 flex items-center justify-between gap-2"><div className="truncate text-sm">{message.from}</div><div className="shrink-0 text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div></div>
<div className="mb-1 flex items-center gap-2"><Button type="button" variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-yellow-500" onClick={(e) => { e.stopPropagation(); onStar() }}><Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} /></Button><span className="truncate text-sm">{message.subject}</span>{message.hasAttachments && <Paperclip className="h-3 w-3 text-muted-foreground" />}</div>
<div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
</div>
}
function ComposeSheet({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) {
const { toast } = useToast()
const [files, setFiles] = React.useState<File[]>([])
const send = useMutation({ mutationFn: api.send, onSuccess: () => { toast({ title: "发送成功" }); setFiles([]); onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }) })
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!mailbox) {
toast({ title: "请选择发件邮箱" })
return
}
const form = new FormData(e.currentTarget)
const attachments = await Promise.all(files.map(fileToAttachment))
const text = String(form.get("text") || "")
send.mutate({ mailboxId: mailbox.id, to: splitEmails(String(form.get("to") || "")), cc: splitEmails(String(form.get("cc") || "")), bcc: splitEmails(String(form.get("bcc") || "")), subject: String(form.get("subject") || ""), text, html: text.replace(/\n/g, "<br>"), attachments })
}
return <Sheet open={open} onOpenChange={onOpenChange}><SheetContent className="overflow-y-auto sm:max-w-2xl"><SheetHeader><SheetTitle></SheetTitle></SheetHeader><form key={draft?.key || "new"} className="mt-5 space-y-4" onSubmit={submit}>
<div className="space-y-2"><Label></Label><Input value={mailbox?.address || "未选择"} readOnly /></div>
<div className="space-y-2"><Label></Label><Input name="to" placeholder="user@example.com, other@example.com" defaultValue={draft?.to || ""} required /></div>
<div className="grid grid-cols-2 gap-3"><div className="space-y-2"><Label></Label><Input name="cc" defaultValue={draft?.cc || ""} /></div><div className="space-y-2"><Label></Label><Input name="bcc" defaultValue={draft?.bcc || ""} /></div></div>
<div className="space-y-2"><Label></Label><Input name="subject" defaultValue={draft?.subject || ""} /></div>
<div className="space-y-2"><Label></Label><Textarea name="text" className="min-h-[220px]" defaultValue={draft?.text || ""} /></div>
<div className="space-y-2"><Label></Label><Input type="file" multiple onChange={(e) => setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 && <div className="text-xs text-muted-foreground">{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join("")}</div>}</div>
<SheetFooter><Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button><Button disabled={send.isPending || !mailbox}>{send.isPending ? "发送中..." : "发送"}</Button></SheetFooter>
</form></SheetContent></Sheet>
}
function splitEmails(s: string) { return s.split(/[;,\s]+/).map((v) => v.trim()).filter(Boolean) }
function withPrefix(subject: string, prefix: string) { return subject.toLowerCase().startsWith(prefix.toLowerCase()) ? subject : `${prefix} ${subject}` }
function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
return `\n\n----- 原始邮件 -----\nFrom: ${message.from}\nTo: ${message.to.join(", ")}\nDate: ${formatDate(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
}
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
async function fileToAttachment(file: File) {
const buffer = await file.arrayBuffer()
let binary = ""
const bytes = new Uint8Array(buffer)
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
return { filename: file.name, contentType: file.type || "application/octet-stream", contentBase64: btoa(binary) }
}
+278
View File
@@ -0,0 +1,278 @@
import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react"
import { api, Mailbox, MailStats } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "@/components/ui/sidebar"
import { useToast } from "@/hooks/use-toast"
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "rules" | "blocked" | "stats"
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
contacts: { label: "联系人管理", icon: <Contact className="h-4 w-4" /> },
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
rules: { label: "收件规则", icon: <SlidersHorizontal className="h-4 w-4" /> },
blocked: { label: "被拦截邮件", icon: <Ban className="h-4 w-4" /> },
stats: { label: "数据统计", icon: <BarChart3 className="h-4 w-4" /> },
}
const tabKeys = Object.keys(tabs) as Tab[]
const actionLabels: Record<string, string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读" }
export function ProfilePage() {
const me = useMe()
const qc = useQueryClient()
const navigate = useNavigate()
const [params, setParams] = useSearchParams()
const { toast } = useToast()
const passwordFormRef = React.useRef<HTMLFormElement>(null)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
const [ruleMailboxId, setRuleMailboxId] = React.useState("all")
const [ruleAction, setRuleAction] = React.useState("archive")
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
const themeMountedRef = React.useRef(false)
const rawTab = params.get("tab") as Tab | null
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
const user = me.data?.user
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const stats = useQuery({ queryKey: ["mail-stats", mailboxId], queryFn: () => api.mailStats(mailboxId), enabled: !!mailboxId })
const profile = useMutation({
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
onSuccess: (data) => { qc.setQueryData(["me"], data); toast({ title: "个人资料已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const password = useMutation({
mutationFn: (form: FormData) => {
const newPassword = String(form.get("newPassword") || "")
if (newPassword !== String(form.get("confirmPassword") || "")) throw new Error("两次输入的新密码不一致")
return api.changePassword({ currentPassword: String(form.get("currentPassword") || ""), newPassword })
},
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
onError: (error) => toast({ title: "修改失败", description: error.message }),
})
const createContact = useMutation({
mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
const createRule = useMutation({
mutationFn: (form: FormData) => api.createRule({ mailboxId: ruleMailboxId === "all" ? "" : ruleMailboxId, name: String(form.get("name") || ""), fromContains: String(form.get("fromContains") || ""), subjectContains: String(form.get("subjectContains") || ""), action: ruleAction, enabled: true }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "收件规则已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
const createBlocked = useMutation({
mutationFn: (form: FormData) => api.createBlockedSender({ mailboxId: blockedMailboxId === "all" ? "" : blockedMailboxId, email: String(form.get("email") || ""), reason: String(form.get("reason") || "") }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } })
const cleanup = useMutation({
mutationFn: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => api.cleanupMail({ mailboxId, target }),
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `已处理 ${res.affected} 封邮件` }) },
onError: (error) => toast({ title: "清理失败", description: error.message }),
})
React.useEffect(() => {
const items = mailboxes.data?.items || []
if (items.length > 0 && (!mailboxId || !items.some((m) => m.id === mailboxId))) setMailboxId(items[0].id)
}, [mailboxId, mailboxes.data?.items])
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId) }, [mailboxId])
React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode])
async function logout() { await api.logout().catch(() => undefined); qc.clear(); navigate("/login", { replace: true }) }
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) }
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
if (!user) return <div className="grid h-svh place-items-center text-muted-foreground">...</div>
return (
<div className="h-svh bg-background">
<SidebarProvider className="h-full min-h-0 w-full">
<ResizablePanelGroup direction="horizontal" className="h-full min-h-0 w-full">
<ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}>
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar">
<SidebarHeader className={cn("border-b py-4", sidebarCollapsed ? "px-2" : "px-4")}>
<AccountHeader collapsed={sidebarCollapsed} name={user.displayName || selectedMailbox?.address || "LanQin"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/mail")} />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex flex-col items-center" : "")}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn("text-muted-foreground", !sidebarCollapsed && "w-full justify-start")} onClick={logout}>
<LogOut className="h-4 w-4" />
{!sidebarCollapsed && <span>退</span>}
</Button>
<Separator className="my-2" />
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}>{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}{!sidebarCollapsed && <span></span>}</Button>
</div>
</Sidebar>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={85} minSize={60}>
<section className="flex h-full min-h-0 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
<div className="text-sm font-semibold">{tabs[tab].label}</div>
</header>
<ScrollArea className="min-h-0 flex-1"><main className="mx-auto w-full max-w-6xl p-6">{renderTab()}</main></ScrollArea>
</section>
</ResizablePanel>
</ResizablePanelGroup>
</SidebarProvider>
</div>
)
function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/mail") }} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} />
}
}
function ProfileOverview({ user, profile, password, passwordFormRef, stats }: { user: { email: string; displayName: string; role: string; disabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats }) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); profile.mutate(new FormData(e.currentTarget)) }}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="用户名">
<Input value={user.email} readOnly />
</Field>
<Field label="显示名称">
<Input name="displayName" defaultValue={user.displayName} required />
</Field>
</div>
<div className="flex justify-end">
<Button disabled={profile.isPending}>{profile.isPending ? "保存中..." : "保存资料"}</Button>
</div>
</form>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2 text-sm">
<ShieldCheck className="h-4 w-4" />
</div>
<Badge>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span></span>
<Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "已停用" : "正常"}</Badge>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span></span>
<span>{new Date(user.createdAt).toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
<Field label="当前密码">
<Input name="currentPassword" type="password" required />
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field label="新密码">
<Input name="newPassword" type="password" minLength={8} required />
</Field>
<Field label="确认新密码">
<Input name="confirmPassword" type="password" minLength={8} required />
</Field>
</div>
<div className="flex justify-end">
<Button disabled={password.isPending}>{password.isPending ? "更新中..." : "更新密码"}</Button>
</div>
</form>
</CardContent>
</Card>
<StatsSummary stats={stats} />
</div>
)
}
function MailboxManagement({ mailboxes, selectedMailboxId, onSelect, onCopy, onOpen }: { mailboxes: Mailbox[]; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void }) {
return <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)}{mailboxes.length === 0 && <EmptyState text="暂无邮箱账号" />}</div>
}
function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) {
return <div className="grid gap-6 lg:grid-cols-[380px_minmax(0,1fr)]"><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="姓名"><Input name="name" placeholder="张三" /></Field><Field label="邮箱"><Input name="email" type="email" required /></Field><Field label="备注"><Input name="note" /></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存联系人"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.name}</div><div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div></div><div className="flex shrink-0 gap-1"><Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div></div>)}{!loading && items.length === 0 && <EmptyState text="暂无联系人" />}</CardContent></Card></div>
}
function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
return <div className="space-y-6"><StatsSummary stats={stats} /><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="grid gap-3 md:grid-cols-3"><CleanupButton icon={<MailCheck className="h-4 w-4" />} title="归档已读收件箱" disabled={!mailbox || pending} onClick={() => onCleanup("archive-read-inbox")} /><CleanupButton icon={<MailX className="h-4 w-4" />} title="清空垃圾邮件" disabled={!mailbox || pending} onClick={() => onCleanup("empty-spam")} /><CleanupButton icon={<Trash2 className="h-4 w-4" />} title="清空回收站" disabled={!mailbox || pending} onClick={() => onCleanup("empty-trash")} /></CardContent></Card></div>
}
function RulesSection({ items, mailboxes, mailboxId, action, onMailboxChange, onActionChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; action: string; onMailboxChange: (value: string) => void; onActionChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
return <div className="grid gap-6 lg:grid-cols-[420px_minmax(0,1fr)]"><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="规则名称"><Input name="name" /></Field><Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field><div className="grid gap-3 md:grid-cols-2"><Field label="发件人包含"><Input name="fromContains" /></Field><Field label="主题包含"><Input name="subjectContains" /></Field></div><Field label="执行动作"><Select value={action} onValueChange={onActionChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="archive"></SelectItem><SelectItem value="trash"></SelectItem><SelectItem value="star"></SelectItem><SelectItem value="mark-read"></SelectItem></SelectContent></Select></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "保存规则"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.name}<Badge variant="outline" className="ml-2">{actionLabels[item.action]}</Badge></div><div className="truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"} · {item.fromContains ? `发件人包含 ${item.fromContains}` : ""} {item.subjectContains ? `主题包含 ${item.subjectContains}` : ""}</div></div><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div>)}{items.length === 0 && <EmptyState text="暂无收件规则" />}</CardContent></Card></div>
}
function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChange, onCreate, onDelete, pending }: { items: any[]; mailboxes: Mailbox[]; mailboxId: string; spamCount: number; onMailboxChange: (value: string) => void; onCreate: (form: FormData) => void; onDelete: (id: string) => void; pending: boolean }) {
return <div className="grid gap-6 lg:grid-cols-[420px_minmax(0,1fr)]"><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); onCreate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field label="适用邮箱"><MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={onMailboxChange} /></Field><Field label="发件人邮箱"><Input name="email" type="email" required /></Field><Field label="原因"><Input name="reason" /></Field><Button className="w-full" disabled={pending}>{pending ? "保存中..." : "加入拦截"}</Button></form></CardContent></Card><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{items.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border p-3"><div className="min-w-0"><div className="truncate text-sm font-medium">{item.email}</div><div className="truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div></div><Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => onDelete(item.id)}><Trash2 className="h-4 w-4" /></Button></div>)}{items.length === 0 && <EmptyState text="暂无拦截发件人" />}</CardContent></Card></div>
}
function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; onRefresh: () => void }) {
return <div className="space-y-6"><div className="flex items-center justify-between"><div className="text-sm text-muted-foreground">{mailbox?.address || "未选择邮箱"}</div><Button variant="outline" onClick={onRefresh}><RefreshCcw className="h-4 w-4" /></Button></div><StatsSummary stats={stats} /><Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent className="space-y-2">{(stats?.byFolder || []).map((f) => <div key={f.folder} className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 rounded-lg border p-3 text-sm"><div className="font-medium">{folderLabel(f.folder)}</div><Badge variant="secondary">{f.count} </Badge><span className="text-muted-foreground"> {f.unread}</span><span className="text-muted-foreground">{formatBytes(f.bytes)}</span></div>)}</CardContent></Card></div>
}
function StatsSummary({ stats }: { stats?: MailStats }) {
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
}
function CleanupButton({ icon, title, disabled, onClick }: { icon: React.ReactNode; title: string; disabled: boolean; onClick: () => void }) { return <Button variant="outline" className="h-auto justify-start p-4 text-left" disabled={disabled} onClick={onClick}><div className="mr-3 rounded-lg bg-muted p-2">{icon}</div><div className="font-medium">{title}</div></Button> }
function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxes: Mailbox[]; onChange: (value: string) => void }) { return <Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all"></SelectItem>{mailboxes.map((m) => <SelectItem key={m.id} value={m.id}>{m.address}</SelectItem>)}</SelectContent></Select> }
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
function EmptyState({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
function folderLabel(folder: string) { return ({ Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站" } as Record<string, string>)[folder] || folder }
function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onBack }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onBack: () => void }) {
const displayName = cleanAccountName(name, email)
if (collapsed) return <div className="flex justify-center"><Avatar className="size-9 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback></Avatar></div>
return <div className="flex items-center justify-between gap-3"><div className="flex min-w-0 items-center gap-3"><Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback></Avatar><div className="min-w-0 text-sm"><div className="truncate text-base font-semibold leading-5">{displayName}</div></div></div><div className="flex shrink-0 items-center gap-1"><Button type="button" variant="ghost" size="icon" className="size-9 rounded-lg text-muted-foreground" onClick={onToggleTheme}>{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}</Button><Button type="button" variant="ghost" size="icon" className="size-9 rounded-lg text-muted-foreground" onClick={onBack}><ArrowLeft className="h-4 w-4" /></Button></div></div>
}
function cleanAccountName(name: string, email?: string) { const value = name.trim(); if (!value || (email && value.toLowerCase() === email.toLowerCase())) return email?.split("@")[0] || "用户"; return value }
function accountInitial(name: string, email?: string) { const source = cleanAccountName(name, email); const first = Array.from(source.trim())[0]; return (first || "蓝").toUpperCase() }
+4
View File
@@ -0,0 +1,4 @@
declare module "dompurify" {
const DOMPurify: { sanitize: (source: string) => string }
export default DOMPurify
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+60
View File
@@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [],
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src", "vite.config.ts"],
"references": []
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.json" }]
}
+19
View File
@@ -0,0 +1,19 @@
import path from "node:path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
"/healthz": "http://localhost:8080",
},
},
})
+14
View File
@@ -0,0 +1,14 @@
LANQIN_PUBLIC_HOSTNAME=mail.example.com
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
LANQIN_ADMIN_EMAIL=admin@example.com
LANQIN_ADMIN_PASSWORD=ChangeMe123!
LANQIN_DATA_DIR=/data
LANQIN_DB_PATH=/data/lanqin.db
LANQIN_ADDR=:8080
LANQIN_ALLOW_INSECURE_HTTP=false
LANQIN_SMTP_HOST=postfix
LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false
LANQIN_MAILDIR_ROOT=/var/mail/vhosts
LANQIN_MAILDIR_SCAN_SECONDS=30
TZ=Asia/Shanghai
+35
View File
@@ -0,0 +1,35 @@
# LanQin Email Docker 部署说明
## 启动
```bash
cd deploy
cp .env.example .env
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
docker compose up -d --build
```
## DNS
进入 Web 管理后台后,在“DNS 记录”面板查看每个域名需要配置的:
- MX
- SPF TXT
- DKIM TXT
- DMARC TXT
配置完成后点击“检测”。
## 邮件服务边界
- Postfix 读取 `/data/lanqin.db` 中的 `domains``mailboxes``aliases`
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
- OpenDKIM 启动时从 SQLite 导出域名 DKIM 私钥到容器内 `/etc/opendkim/keys`
- Go API 是 Webmail 和管理后台唯一入口;浏览器不直接连接 SMTP/IMAP。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
## 生产注意
- 替换 Dovecot 示例自签证书,建议在 Nginx 或边缘负载均衡终止 HTTPS。
- 云厂商通常默认封禁 25 端口,需要单独申请解封。
- SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。
+13
View File
@@ -0,0 +1,13 @@
FROM golang:1.22-bookworm AS build
WORKDIR /src/apps/api
COPY apps/api/go.mod apps/api/go.sum ./
RUN go mod download
COPY apps/api ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/lanqin-api ./cmd/server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
EXPOSE 8080
CMD ["lanqin-api"]
+73
View File
@@ -0,0 +1,73 @@
version: "3.9"
services:
api:
build:
context: ..
dockerfile: deploy/api.Dockerfile
env_file: .env
volumes:
- lanqin-data:/data
- maildata:/var/mail/vhosts:ro
depends_on:
- dovecot
- postfix
restart: unless-stopped
web:
build:
context: ..
dockerfile: deploy/web.Dockerfile
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
- "443:443"
depends_on:
- api
- web
restart: unless-stopped
postfix:
build:
context: ./postfix
env_file: .env
volumes:
- lanqin-data:/data
- maildata:/var/mail/vhosts
ports:
- "25:25"
- "587:587"
depends_on:
- dovecot
- opendkim
restart: unless-stopped
dovecot:
build:
context: ./dovecot
env_file: .env
volumes:
- lanqin-data:/data
- maildata:/var/mail/vhosts
ports:
- "993:993"
restart: unless-stopped
opendkim:
build:
context: ./opendkim
env_file: .env
volumes:
- lanqin-data:/data:ro
- dkim-keys:/etc/opendkim/keys
restart: unless-stopped
volumes:
lanqin-data:
maildata:
dkim-keys:
+10
View File
@@ -0,0 +1,10 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite ssl-cert ca-certificates && rm -rf /var/lib/apt/lists/*
COPY dovecot.conf /etc/dovecot/dovecot.conf
COPY dovecot-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 993 24 12345
CMD ["/entrypoint.sh"]
+5
View File
@@ -0,0 +1,5 @@
driver = sqlite
connect = /data/lanqin.db
default_pass_scheme = BLF-CRYPT
password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active'
user_query = SELECT '/var/mail/vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part AS home, 'maildir:/var/mail/vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes WHERE address = '%u' AND status = 'active'
+48
View File
@@ -0,0 +1,48 @@
protocols = imap lmtp
listen = *
base_dir = /var/run/dovecot/
log_path = /dev/stderr
info_log_path = /dev/stdout
ssl = yes
ssl_cert = </etc/ssl/certs/ssl-cert-snakeoil.pem
ssl_key = </etc/ssl/private/ssl-cert-snakeoil.key
mail_home = /var/mail/vhosts/%d/%n
namespace inbox {
inbox = yes
mailbox Drafts { special_use = \Drafts }
mailbox Sent { special_use = \Sent }
mailbox Trash { special_use = \Trash }
mailbox Archive { special_use = \Archive }
mailbox Spam { special_use = \Junk }
}
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
service imap-login {
inet_listener imaps { port = 993 ssl = yes }
}
service lmtp {
inet_listener lmtp { address = 0.0.0.0 port = 24 }
}
service auth {
inet_listener postfix-auth {
address = 0.0.0.0
port = 12345
}
}
protocol lmtp {
postmaster_address = postmaster@localhost
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
mkdir -p /var/mail/vhosts
chown -R 5000:5000 /var/mail/vhosts
exec dovecot -F
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://api:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /healthz {
proxy_pass http://api:8080/healthz;
}
location / {
proxy_pass http://web:80;
proxy_set_header Host $host;
}
}
+7
View File
@@ -0,0 +1,7 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ /index.html; }
}
+9
View File
@@ -0,0 +1,9 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends opendkim opendkim-tools sqlite3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY opendkim.conf /etc/opendkim.conf
COPY TrustedHosts /etc/opendkim/TrustedHosts
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8891
CMD ["/entrypoint.sh"]
+5
View File
@@ -0,0 +1,5 @@
127.0.0.1
localhost
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -eu
mkdir -p /etc/opendkim/keys
: > /etc/opendkim/KeyTable
: > /etc/opendkim/SigningTable
if [ -f /data/lanqin.db ]; then
sqlite3 -separator '|' /data/lanqin.db "SELECT name, dkim_selector, dkim_private_key FROM domains WHERE status='active';" | while IFS='|' read -r domain selector private_key; do
[ -n "$domain" ] || continue
dir="/etc/opendkim/keys/$domain"
mkdir -p "$dir"
keyfile="$dir/$selector.private"
printf '%s' "$private_key" | base64 -d > "$keyfile"
chmod 600 "$keyfile"
echo "$selector._domainkey.$domain $domain:$selector:$keyfile" >> /etc/opendkim/KeyTable
echo "*@${domain} ${selector}._domainkey.${domain}" >> /etc/opendkim/SigningTable
done
fi
chown -R opendkim:opendkim /etc/opendkim
exec opendkim -f -x /etc/opendkim.conf
+12
View File
@@ -0,0 +1,12 @@
Syslog yes
UMask 002
Mode sv
Canonicalization relaxed/simple
SubDomains no
OversignHeaders From
Socket inet:8891@0.0.0.0
UserID opendkim:opendkim
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
+10
View File
@@ -0,0 +1,10 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends postfix postfix-sqlite ca-certificates rsyslog && rm -rf /var/lib/apt/lists/*
COPY main.cf /etc/postfix/main.cf
COPY master.cf /etc/postfix/master.cf
COPY sqlite-*.cf /etc/postfix/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 25 587
CMD ["/entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
: "${LANQIN_PUBLIC_HOSTNAME:=mail.example.com}"
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "smtpd_milters = inet:opendkim:8891"
postconf -e "non_smtpd_milters = inet:opendkim:8891"
postfix check
exec postfix start-fg
+30
View File
@@ -0,0 +1,30 @@
compatibility_level = 3.6
myhostname = mail.example.com
myorigin = $myhostname
mydestination = localhost
inet_interfaces = all
inet_protocols = all
# SQLite maps read the same LanQin DB created by the Go API.
virtual_mailbox_domains = sqlite:/etc/postfix/sqlite-domains.cf
virtual_mailbox_maps = sqlite:/etc/postfix/sqlite-mailboxes.cf
virtual_alias_maps = sqlite:/etc/postfix/sqlite-aliases.cf
virtual_transport = lmtp:inet:dovecot:24
virtual_mailbox_base = /var/mail/vhosts
smtpd_banner = $myhostname ESMTP LanQin Email
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
# Submission auth via Dovecot.
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:dovecot:12345
smtpd_sasl_auth_enable = yes
smtpd_tls_security_level = may
smtp_tls_security_level = may
# DKIM milter.
milter_protocol = 6
milter_default_action = accept
smtpd_milters = inet:opendkim:8891
non_smtpd_milters = inet:opendkim:8891
+29
View File
@@ -0,0 +1,29 @@
smtp inet n - y - - smtpd
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=may
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
tlsmgr unix - - y 1000? 1 tlsmgr
rewrite unix - - y - - trivial-rewrite
bounce unix - - y - 0 bounce
defer unix - - y - 0 bounce
trace unix - - y - 0 bounce
verify unix - - y - 1 verify
flush unix n - y 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
smtp unix - - y - - smtp
relay unix - - y - - smtp
showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
scache unix - - y - 1 scache
+2
View File
@@ -0,0 +1,2 @@
dbpath = /data/lanqin.db
query = SELECT destination FROM aliases WHERE source='%s' AND enabled=1 UNION SELECT address FROM mailboxes WHERE address='%s' AND status='active'
+2
View File
@@ -0,0 +1,2 @@
dbpath = /data/lanqin.db
query = SELECT 1 FROM domains WHERE name='%s' AND status='active'
+2
View File
@@ -0,0 +1,2 @@
dbpath = /data/lanqin.db
query = SELECT 'vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part || '/Maildir/' FROM mailboxes WHERE address='%s' AND status='active'
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-bookworm-slim AS build
WORKDIR /src/apps/web
COPY apps/web/package.json apps/web/package-lock.json* ./
RUN npm ci || npm install
COPY apps/web ./
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /src/apps/web/dist /usr/share/nginx/html
COPY deploy/nginx/web.conf /etc/nginx/conf.d/default.conf
EXPOSE 80