From 3ef8caa31988c7e2525813a75b7137fe49d242c5 Mon Sep 17 00:00:00 2001 From: LanQin Date: Sun, 14 Jun 2026 01:07:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(email):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E8=87=AA=E5=BB=BA=E9=82=AE=E7=AE=B1=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Go + SQLite 后端,支持登录、域名、邮箱、别名、联系人、规则、统计与邮件收发。 - 新增 Webmail 前端,支持多邮箱切换、邮件列表/阅读/写信、附件、搜索、主题切换与个人中心。 - 新增 Docker Compose 部署骨架,补充 Postfix、Dovecot、OpenDKIM、Nginx 配置与部署说明。 --- .gitignore | 63 + README.md | 62 + apps/api/cmd/server/main.go | 52 + apps/api/go.mod | 29 + apps/api/go.sum | 61 + apps/api/internal/app/admin_handlers.go | 309 + apps/api/internal/app/app.go | 398 ++ apps/api/internal/app/app_test.go | 352 ++ apps/api/internal/app/config.go | 79 + apps/api/internal/app/dns_handlers.go | 103 + apps/api/internal/app/mail_handlers.go | 575 ++ apps/api/internal/app/maildir_sync.go | 379 ++ apps/api/internal/app/mime.go | 176 + apps/api/internal/app/personal_handlers.go | 453 ++ apps/api/internal/app/router_auth.go | 316 ++ apps/api/internal/app/types.go | 152 + apps/api/internal/app/util.go | 199 + apps/web/SHADCN_RULES.md | 23 + apps/web/components.json | 21 + apps/web/index.html | 12 + apps/web/package-lock.json | 5347 ++++++++++++++++++ apps/web/package.json | 46 + apps/web/postcss.config.cjs | 6 + apps/web/scripts/check-shadcn.mjs | 91 + apps/web/src/components/protected-layout.tsx | 132 + apps/web/src/components/ui/avatar.tsx | 50 + apps/web/src/components/ui/badge.tsx | 36 + apps/web/src/components/ui/button.tsx | 57 + apps/web/src/components/ui/card.tsx | 76 + apps/web/src/components/ui/dialog.tsx | 120 + apps/web/src/components/ui/dropdown-menu.tsx | 199 + apps/web/src/components/ui/input.tsx | 22 + apps/web/src/components/ui/label.tsx | 24 + apps/web/src/components/ui/resizable.tsx | 43 + apps/web/src/components/ui/scroll-area.tsx | 46 + apps/web/src/components/ui/select.tsx | 157 + apps/web/src/components/ui/separator.tsx | 31 + apps/web/src/components/ui/sheet.tsx | 138 + apps/web/src/components/ui/sidebar.tsx | 771 +++ apps/web/src/components/ui/skeleton.tsx | 15 + apps/web/src/components/ui/table.tsx | 120 + apps/web/src/components/ui/textarea.tsx | 22 + apps/web/src/components/ui/toast.tsx | 129 + apps/web/src/components/ui/toaster.tsx | 35 + apps/web/src/components/ui/tooltip.tsx | 30 + apps/web/src/hooks/use-me.ts | 6 + apps/web/src/hooks/use-mobile.tsx | 19 + apps/web/src/hooks/use-toast.ts | 191 + apps/web/src/index.css | 151 + apps/web/src/lib/api.ts | 67 + apps/web/src/lib/theme.ts | 35 + apps/web/src/lib/utils.ts | 21 + apps/web/src/main.tsx | 31 + apps/web/src/pages/admin.tsx | 332 ++ apps/web/src/pages/login.tsx | 42 + apps/web/src/pages/mail.tsx | 434 ++ apps/web/src/pages/profile.tsx | 278 + apps/web/src/types.d.ts | 4 + apps/web/src/vite-env.d.ts | 1 + apps/web/tailwind.config.cjs | 60 + apps/web/tsconfig.json | 23 + apps/web/tsconfig.node.json | 4 + apps/web/vite.config.ts | 19 + deploy/.env.example | 14 + deploy/README.md | 35 + deploy/api.Dockerfile | 13 + deploy/docker-compose.yml | 73 + deploy/dovecot/Dockerfile | 10 + deploy/dovecot/dovecot-sql.conf.ext | 5 + deploy/dovecot/dovecot.conf | 48 + deploy/dovecot/entrypoint.sh | 7 + deploy/nginx/default.conf | 22 + deploy/nginx/web.conf | 7 + deploy/opendkim/Dockerfile | 9 + deploy/opendkim/TrustedHosts | 5 + deploy/opendkim/entrypoint.sh | 19 + deploy/opendkim/opendkim.conf | 12 + deploy/postfix/Dockerfile | 10 + deploy/postfix/entrypoint.sh | 9 + deploy/postfix/main.cf | 30 + deploy/postfix/master.cf | 29 + deploy/postfix/sqlite-aliases.cf | 2 + deploy/postfix/sqlite-domains.cf | 2 + deploy/postfix/sqlite-mailboxes.cf | 2 + deploy/web.Dockerfile | 11 + 85 files changed, 13649 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/cmd/server/main.go create mode 100644 apps/api/go.mod create mode 100644 apps/api/go.sum create mode 100644 apps/api/internal/app/admin_handlers.go create mode 100644 apps/api/internal/app/app.go create mode 100644 apps/api/internal/app/app_test.go create mode 100644 apps/api/internal/app/config.go create mode 100644 apps/api/internal/app/dns_handlers.go create mode 100644 apps/api/internal/app/mail_handlers.go create mode 100644 apps/api/internal/app/maildir_sync.go create mode 100644 apps/api/internal/app/mime.go create mode 100644 apps/api/internal/app/personal_handlers.go create mode 100644 apps/api/internal/app/router_auth.go create mode 100644 apps/api/internal/app/types.go create mode 100644 apps/api/internal/app/util.go create mode 100644 apps/web/SHADCN_RULES.md create mode 100644 apps/web/components.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.cjs create mode 100644 apps/web/scripts/check-shadcn.mjs create mode 100644 apps/web/src/components/protected-layout.tsx create mode 100644 apps/web/src/components/ui/avatar.tsx create mode 100644 apps/web/src/components/ui/badge.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/components/ui/resizable.tsx create mode 100644 apps/web/src/components/ui/scroll-area.tsx create mode 100644 apps/web/src/components/ui/select.tsx create mode 100644 apps/web/src/components/ui/separator.tsx create mode 100644 apps/web/src/components/ui/sheet.tsx create mode 100644 apps/web/src/components/ui/sidebar.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/components/ui/table.tsx create mode 100644 apps/web/src/components/ui/textarea.tsx create mode 100644 apps/web/src/components/ui/toast.tsx create mode 100644 apps/web/src/components/ui/toaster.tsx create mode 100644 apps/web/src/components/ui/tooltip.tsx create mode 100644 apps/web/src/hooks/use-me.ts create mode 100644 apps/web/src/hooks/use-mobile.tsx create mode 100644 apps/web/src/hooks/use-toast.ts create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/theme.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/admin.tsx create mode 100644 apps/web/src/pages/login.tsx create mode 100644 apps/web/src/pages/mail.tsx create mode 100644 apps/web/src/pages/profile.tsx create mode 100644 apps/web/src/types.d.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tailwind.config.cjs create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts create mode 100644 deploy/.env.example create mode 100644 deploy/README.md create mode 100644 deploy/api.Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/dovecot/Dockerfile create mode 100644 deploy/dovecot/dovecot-sql.conf.ext create mode 100644 deploy/dovecot/dovecot.conf create mode 100644 deploy/dovecot/entrypoint.sh create mode 100644 deploy/nginx/default.conf create mode 100644 deploy/nginx/web.conf create mode 100644 deploy/opendkim/Dockerfile create mode 100644 deploy/opendkim/TrustedHosts create mode 100644 deploy/opendkim/entrypoint.sh create mode 100644 deploy/opendkim/opendkim.conf create mode 100644 deploy/postfix/Dockerfile create mode 100644 deploy/postfix/entrypoint.sh create mode 100644 deploy/postfix/main.cf create mode 100644 deploy/postfix/master.cf create mode 100644 deploy/postfix/sqlite-aliases.cf create mode 100644 deploy/postfix/sqlite-domains.cf create mode 100644 deploy/postfix/sqlite-mailboxes.cf create mode 100644 deploy/web.Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dc030a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b1b044 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# LanQin Email + +LanQin Email 是一个自建邮箱 Webmail MVP:React/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 +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`。 diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go new file mode 100644 index 0000000..049491d --- /dev/null +++ b/apps/api/cmd/server/main.go @@ -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") +} diff --git a/apps/api/go.mod b/apps/api/go.mod new file mode 100644 index 0000000..844d823 --- /dev/null +++ b/apps/api/go.mod @@ -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 +) diff --git a/apps/api/go.sum b/apps/api/go.sum new file mode 100644 index 0000000..428dc1b --- /dev/null +++ b/apps/api/go.sum @@ -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= diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go new file mode 100644 index 0000000..5415051 --- /dev/null +++ b/apps/api/internal/app/admin_handlers.go @@ -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 +} diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go new file mode 100644 index 0000000..f24d1b8 --- /dev/null +++ b/apps/api/internal/app/app.go @@ -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: "

你的自建邮箱 Webmail 已经初始化完成。

请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。

", + IsRead: false, + } + _, err = a.insertMessage(ctx, msg, nil) + return err +} diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go new file mode 100644 index 0000000..1f3fecf --- /dev/null +++ b/apps/api/internal/app/app_test.go @@ -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": "

Hello Bob

", + "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: ", + "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=''`, 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 +} diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go new file mode 100644 index 0000000..14c737b --- /dev/null +++ b/apps/api/internal/app/config.go @@ -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 +} diff --git a/apps/api/internal/app/dns_handlers.go b/apps/api/internal/app/dns_handlers.go new file mode 100644 index 0000000..12c8b5c --- /dev/null +++ b/apps/api/internal/app/dns_handlers.go @@ -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 +} diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go new file mode 100644 index 0000000..07bad72 --- /dev/null +++ b/apps/api/internal/app/mail_handlers.go @@ -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 = "

" + htmlEscape(req.Text) + "

" + } + + 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 +} diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go new file mode 100644 index 0000000..2e225e5 --- /dev/null +++ b/apps/api/internal/app/maildir_sync.go @@ -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 = "

" + htmlEscape(bodyText) + "

" + } + 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{} +} diff --git a/apps/api/internal/app/mime.go b/apps/api/internal/app/mime.go new file mode 100644 index 0000000..b1403a3 --- /dev/null +++ b/apps/api/internal/app/mime.go @@ -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, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\n", "
") + return s +} diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go new file mode 100644 index 0000000..964eddb --- /dev/null +++ b/apps/api/internal/app/personal_handlers.go @@ -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) + } + } +} diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go new file mode 100644 index 0000000..e170afe --- /dev/null +++ b/apps/api/internal/app/router_auth.go @@ -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(¤tHash); 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 +} diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go new file mode 100644 index 0000000..61fe9e0 --- /dev/null +++ b/apps/api/internal/app/types.go @@ -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"` +} diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go new file mode 100644 index 0000000..d4f5b72 --- /dev/null +++ b/apps/api/internal/app/util.go @@ -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") diff --git a/apps/web/SHADCN_RULES.md b/apps/web/SHADCN_RULES.md new file mode 100644 index 0000000..ff56e3a --- /dev/null +++ b/apps/web/SHADCN_RULES.md @@ -0,0 +1,23 @@ +# Web shadcn/ui 规则 + +`apps/web` 的业务页面和业务组件必须优先并完整使用官方 shadcn/ui 组件源码。 + +## 规则 + +- 所有 UI primitive 必须来自 `@/components/ui/*`。 +- 新增 UI 能力时,先执行 `npx shadcn@latest add ` 添加官方组件源码。 +- 业务 TSX 禁止直接写原生 `