Compare commits

...

5 Commits

Author SHA1 Message Date
zxyszx ccd5c4efd5 chore: prepare v1.2.4
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 22:44:01 +08:00
zxyszx da888234b9 feat: add guided mail server installer 2026-08-03 22:39:51 +08:00
zxyszx 2f7494e5e6 ui: rebalance system version dialog
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-03 20:41:50 +08:00
zxyszx 1350611908 ci: authenticate GHCR releases with package token 2026-08-03 20:12:57 +08:00
zxyszx 9a489992ed fix: harden runtime and remove placeholder features 2026-08-03 19:51:27 +08:00
56 changed files with 1145 additions and 1329 deletions
+8
View File
@@ -40,6 +40,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
+10 -2
View File
@@ -27,6 +27,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -136,8 +144,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
- name: Prepare image name
id: image
+5 -1
View File
@@ -23,6 +23,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
## Update
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
@@ -39,10 +41,12 @@ Useful commands:
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`.
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
## Required ports
+16 -4
View File
@@ -29,10 +29,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员用户名和密码
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
@@ -67,10 +76,12 @@ sudo newszxcn-email rollback
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 移除容器不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
`uninstall` 移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
## DNS 与端口
@@ -104,10 +115,11 @@ sudo newszxcn-email uninstall
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|-- data/ # SQLite、附件和更新前备份
|-- mail/ # Maildir 邮件原文
`-- dkim/ # DKIM 私钥
|-- dkim/ # DKIM 私钥
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``.env`
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
+16 -4
View File
@@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员邮箱和密码
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 移除容器不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
`uninstall` 移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
## DNS 与端口
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
├── docker-compose.yml # 邮箱主服务与内部更新服务
├── data/ # SQLite、附件和更新前备份
├── mail/ # Maildir 邮件原文
── dkim/ # DKIM 私钥
── dkim/ # DKIM 私钥
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``.env`
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
+1 -1
View File
@@ -1 +1 @@
1.2.1
1.2.4
+8 -8
View File
@@ -3,9 +3,14 @@ module lanqin-email-api
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/go-chi/chi/v5 v5.3.0
github.com/microcosm-cc/bluemonday v1.0.27
golang.org/x/crypto v0.26.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0
modernc.org/sqlite v1.31.1
)
@@ -13,19 +18,14 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // 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/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/sys v0.45.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
+8 -8
View File
@@ -10,8 +10,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
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/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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=
@@ -33,8 +33,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
@@ -43,8 +43,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -59,8 +59,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+6 -6
View File
@@ -143,8 +143,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -344,8 +344,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -562,8 +562,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
role := req.Role
+34 -12
View File
@@ -24,6 +24,7 @@ import (
type App struct {
cfg Config
cfgMu sync.RWMutex
db *sql.DB
log *slog.Logger
now func() time.Time
@@ -34,6 +35,24 @@ type App struct {
externalIMAP externalIMAPClientFactory
}
func (a *App) config() Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
return a.cfg
}
func (a *App) setConfig(cfg Config) {
a.cfgMu.Lock()
a.cfg = cfg
a.cfgMu.Unlock()
}
func (a *App) updateConfig(update func(*Config)) {
a.cfgMu.Lock()
defer a.cfgMu.Unlock()
update(&a.cfg)
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
if logger == nil {
logger = slog.Default()
@@ -76,7 +95,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
if strings.TrimSpace(a.config().MaildirRoot) != "" {
a.startWorker(func() { a.maildirWorker(workerCtx) })
}
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
@@ -925,7 +944,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
// Current seed() creates mailboxes with display_name = admin email, so this migration
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(a.config().AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
@@ -1387,6 +1406,7 @@ func messageIndexes() []string {
}
func (a *App) seed(ctx context.Context) error {
cfg := a.config()
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
@@ -1395,7 +1415,7 @@ func (a *App) seed(ctx context.Context) error {
return a.ensureConfiguredAdminSuperAdmin(ctx)
}
adminPassword := a.cfg.AdminPassword
adminPassword := cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
@@ -1410,8 +1430,8 @@ func (a *App) seed(ctx context.Context) error {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if strings.TrimSpace(a.cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(a.cfg.AdminUsername)
if strings.TrimSpace(cfg.AdminUsername) != "" {
adminUsername, err := cleanUsername(cfg.AdminUsername)
if err != nil {
return fmt.Errorf("invalid admin username: %w", err)
}
@@ -1422,7 +1442,7 @@ func (a *App) seed(ctx context.Context) error {
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
return nil
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
@@ -1463,12 +1483,13 @@ func (a *App) seed(ctx context.Context) error {
}
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
cfg := a.config()
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
return err
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
@@ -1589,6 +1610,7 @@ func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, u
}
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
cfg := a.config()
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
if err != nil {
return err
@@ -1599,10 +1621,10 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
rendered := renderMailTemplate(tpl, templateRenderData{
To: a.cfg.AdminEmail,
To: cfg.AdminEmail,
From: "system@lanqin.local",
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
Time: now,
})
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
@@ -1615,7 +1637,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
Subject: subject,
From: "system@lanqin.local",
FromName: "NewSzxcn 邮箱",
To: []string{a.cfg.AdminEmail},
To: []string{cfg.AdminEmail},
SentAt: now,
ReceivedAt: now,
Snippet: snippetFrom(bodyText, bodyHTML),
+77 -77
View File
@@ -650,7 +650,7 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
if settings.ExternalIMAPGmailClientID != "gmail-client" || settings.ExternalIMAPOutlookClientID != "outlook-client" {
t.Fatalf("oauth client ids not saved: %+v", settings)
}
if a.cfg.ExternalIMAPSecretKey != "test-secret" || a.cfg.ExternalIMAPGmailClientSecret != "gmail-secret" || a.cfg.ExternalIMAPOutlookClientSecret != "outlook-secret" {
if a.config().ExternalIMAPSecretKey != "test-secret" || a.config().ExternalIMAPGmailClientSecret != "gmail-secret" || a.config().ExternalIMAPOutlookClientSecret != "outlook-secret" {
t.Fatalf("secret settings not persisted in config")
}
if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.ExternalIMAPEnabled {
@@ -660,8 +660,8 @@ func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t)
a.cfg.ExternalIMAPEnabled = true
a.cfg.ExternalIMAPSecretKey = "test-secret"
a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPEnabled = true })
a.updateConfig(func(cfg *Config) { cfg.ExternalIMAPSecretKey = "test-secret" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -958,7 +958,7 @@ func TestMailRulesConditionGroupsAndActions(t *testing.T) {
func TestMailRulesForwardingAction(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -1344,7 +1344,7 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
t.Fatalf("closed registration code=%d body=%v", code, out)
}
a.cfg.OpenRegistration = true
a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true })
var registered struct {
User User `json:"user"`
}
@@ -1968,8 +1968,8 @@ func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2010,8 +2010,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 8)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2187,8 +2187,8 @@ func TestMailSendRejectsUnauthorizedFrom(t *testing.T) {
func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "postfix"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "postfix" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
user, mb := defaultAdminUserAndMailbox(t, a)
if _, err := a.db.ExecContext(context.Background(), `DROP TABLE send_queue`); err != nil {
t.Fatal(err)
@@ -2387,8 +2387,8 @@ func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2496,9 +2496,9 @@ func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.cfg.DeliveryWebhookSecret = "delivery-test-secret"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
a.updateConfig(func(cfg *Config) { cfg.DeliveryWebhookSecret = "delivery-test-secret" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -2580,7 +2580,7 @@ func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
}{Events: []deliveryWebhookEvent{{ID: "provider-event-1", Provider: "test-provider", MessageID: first.MessageID, Recipient: recipient.Address, Status: "bounced", Reason: "550 mailbox unavailable", OccurredAt: a.now().UTC().Format(time.RFC3339Nano)}}}
body, _ := json.Marshal(eventPayload)
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
mac := hmac.New(sha256.New, []byte(a.config().DeliveryWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
webhookHeaders := map[string]string{"X-LanQin-Timestamp": timestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(mac.Sum(nil))}
@@ -2589,7 +2589,7 @@ func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
t.Fatalf("invalid delivery webhook signature code=%d", code)
}
oldTimestamp := strconv.FormatInt(a.now().UTC().Add(-10*time.Minute).Unix(), 10)
oldMAC := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
oldMAC := hmac.New(sha256.New, []byte(a.config().DeliveryWebhookSecret))
_, _ = oldMAC.Write([]byte(oldTimestamp + "."))
_, _ = oldMAC.Write(body)
oldHeaders := map[string]string{"X-LanQin-Timestamp": oldTimestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(oldMAC.Sum(nil))}
@@ -2769,9 +2769,9 @@ func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
}))
defer receiver.Close()
a.cfg.StatusWebhookURL = receiver.URL
a.cfg.StatusWebhookSecret = "outbound-test-secret"
a.cfg.StatusWebhookAllowPrivateHosts = true
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = receiver.URL })
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookSecret = "outbound-test-secret" })
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = true })
user, mb := defaultAdminUserAndMailbox(t, a)
a.recordSendAudit(context.Background(), sendAuditFailed, sendQueueStatusFailed, sendAuditInput{QueueID: "snd_test", UserID: user.ID, MailboxID: mb.ID, SentMessageID: "mail_test", Source: sendSourceOpenAPI, MailFrom: mb.Address, Recipients: []string{"recipient@example.test"}, Error: "test failure"})
@@ -2807,8 +2807,8 @@ func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
privateTLS := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer privateTLS.Close()
a.cfg.StatusWebhookURL = privateTLS.URL
a.cfg.StatusWebhookAllowPrivateHosts = false
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookURL = privateTLS.URL })
a.updateConfig(func(cfg *Config) { cfg.StatusWebhookAllowPrivateHosts = false })
if _, err := a.validatedStatusWebhookURL(context.Background()); err == nil || !strings.Contains(err.Error(), "private or local") {
t.Fatalf("private webhook target should be rejected, err=%v", err)
}
@@ -2818,8 +2818,8 @@ func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: stale\r\n\r\nbody")
@@ -2868,8 +2868,8 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
mimeBytes := []byte("From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: marker\r\n\r\nbody")
@@ -2922,8 +2922,8 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
func TestSendQueueAPIPermissionIsolation(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -2997,8 +2997,8 @@ func TestSendQueueAPIPermissionIsolation(t *testing.T) {
func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "25" })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -3129,8 +3129,8 @@ func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -3384,8 +3384,8 @@ func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
func TestSubmissionSendsRelayAndStoresSentCopy(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
raw := strings.Join([]string{
"From: Admin <admin@lanqin.local>",
"To: person@example.com",
@@ -3481,8 +3481,8 @@ func TestSerializeMessageUsesStableHeaderOrder(t *testing.T) {
func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3513,8 +3513,8 @@ func TestSubmissionRelayFailureKeepsSentCopyAndRetries(t *testing.T) {
func TestSubmissionSentCopyDedupesByMessageID(t *testing.T) {
a := newTestApp(t)
host, port, _ := startCapturingSMTP(t, 4)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3587,8 +3587,8 @@ func TestInsertSentMessageOnceFailsWhenDedupeKeyHasNoMessage(t *testing.T) {
func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = "127.0.0.1" })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = "1" })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3602,8 +3602,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
}
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
@@ -3628,8 +3628,8 @@ func TestSubmissionRequeuesTerminalFailedDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3670,8 +3670,8 @@ func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
@@ -3821,9 +3821,9 @@ func TestSendQueueMessageIDMigrationDropsDuplicatesBeforeUniqueIndex(t *testing.
func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
a := newTestApp(t)
a.cfg.SubmissionAddr = ":587"
a.cfg.SubmissionTLSAddr = ":465"
if _, err := LoadServerTLSConfig(a.cfg); err == nil {
a.updateConfig(func(cfg *Config) { cfg.SubmissionAddr = ":587" })
a.updateConfig(func(cfg *Config) { cfg.SubmissionTLSAddr = ":465" })
if _, err := LoadServerTLSConfig(a.config()); err == nil {
t.Fatal("submission TLS config should require certificate files")
}
}
@@ -3831,9 +3831,9 @@ func TestSubmissionTLSConfigRequiresCertificateFiles(t *testing.T) {
func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
a := newTestApp(t)
certPath, keyPath := writeTestCertificateFiles(t, "first.example.test")
a.cfg.TLSCertFile = certPath
a.cfg.TLSKeyFile = keyPath
tlsConfig, err := LoadServerTLSConfig(a.cfg)
a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil {
t.Fatal(err)
}
@@ -3876,12 +3876,12 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
certPath, keyPath := writeTestCertificateFiles(t, "mail.example.test")
a.cfg.TLSCertFile = certPath
a.cfg.TLSKeyFile = keyPath
tlsConfig, err := LoadServerTLSConfig(a.cfg)
a.updateConfig(func(cfg *Config) { cfg.TLSCertFile = certPath })
a.updateConfig(func(cfg *Config) { cfg.TLSKeyFile = keyPath })
tlsConfig, err := LoadServerTLSConfig(a.config())
if err != nil {
t.Fatal(err)
}
@@ -3952,8 +3952,8 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
func TestAdminSMTPTestEndpoint(t *testing.T) {
a := newTestApp(t)
host, port, received := startFakeSMTP(t)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
a.updateConfig(func(cfg *Config) { cfg.SMTPHost = host })
a.updateConfig(func(cfg *Config) { cfg.SMTPPort = port })
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
@@ -4102,7 +4102,7 @@ func TestUserMailSignaturesDefaultResolution(t *testing.T) {
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
a := newTestApp(t)
a.cfg.TwoFactorEnabled = true
a.updateConfig(func(cfg *Config) { cfg.TwoFactorEnabled = true })
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
@@ -4569,7 +4569,7 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
@@ -4650,7 +4650,7 @@ func TestMaildirImportStoresAuthenticationResults(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
ts := httptest.NewServer(a.Router())
defer ts.Close()
@@ -4753,8 +4753,8 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.cfg.MaildirScanSeconds = 45
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
a.updateConfig(func(cfg *Config) { cfg.MaildirScanSeconds = 45 })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
@@ -4806,7 +4806,7 @@ func TestMaildirSyncHealthAfterTrackedSync(t *testing.T) {
if counts.Imported != 1 || counts.FilesScanned != 1 {
t.Fatalf("counts=%+v, want imported=1 filesScanned=1", counts)
}
health := a.maildirHealth.snapshot(a.cfg)
health := a.maildirHealth.snapshot(a.config())
if !health.Configured || !health.Enabled {
t.Fatalf("configured health=%+v, want enabled", health)
}
@@ -4828,7 +4828,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = root })
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
@@ -4901,7 +4901,7 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) {
func TestWebmailSentWritesMaildirSent(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4941,7 +4941,7 @@ func TestWebmailSentWritesMaildirSent(t *testing.T) {
func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -4984,7 +4984,7 @@ func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) {
func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
a := newTestApp(t)
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5037,7 +5037,7 @@ func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) {
func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5090,7 +5090,7 @@ func TestMoveAndDeleteMessageUpdateMaildir(t *testing.T) {
func TestMessageFlagsUpdateMaildir(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5136,7 +5136,7 @@ func TestMessageFlagsUpdateMaildir(t *testing.T) {
func TestIMAPUIDAndModSeqProgression(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
srv := httptest.NewServer(a.Router())
defer srv.Close()
client := &testClient{t: t, server: srv}
@@ -5229,7 +5229,7 @@ func TestIMAPUIDAndModSeqProgression(t *testing.T) {
func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5275,7 +5275,7 @@ func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5304,7 +5304,7 @@ func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
@@ -5345,7 +5345,7 @@ func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
a.cfg.MaildirRoot = t.TempDir()
a.updateConfig(func(cfg *Config) { cfg.MaildirRoot = t.TempDir() })
user, mb := defaultAdminUserAndMailbox(t, a)
clearMailboxMessagesForTest(t, a, mb.ID)
+9 -9
View File
@@ -70,7 +70,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
@@ -87,7 +87,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if !a.cfg.OpenRegistration {
if !a.config().OpenRegistration {
respondError(w, http.StatusForbidden, "当前未开放注册")
return
}
@@ -112,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("邮箱地址无效"))
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("密码至少需要 6 个字符"))
return
}
displayName := strings.TrimSpace(req.DisplayName)
@@ -176,7 +176,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
@@ -192,10 +192,10 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
if cookie, err := r.Cookie(a.config().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})
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -245,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.NewPassword) < 8 {
badRequest(w, errors.New("新密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.NewPassword) {
badRequest(w, errors.New("新密码至少需要 6 个字符"))
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
+2 -2
View File
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
name := strings.TrimSuffix(d.Name, ".")
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
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},
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
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, ".")) {
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
ok = true
}
}
+13 -22
View File
@@ -131,7 +131,7 @@ type externalIMAPOAuthState struct {
}
func (a *App) externalIMAPWorker(ctx context.Context) {
interval := time.Duration(a.cfg.ExternalIMAPSyncSeconds) * time.Second
interval := time.Duration(a.config().ExternalIMAPSyncSeconds) * time.Second
if interval <= 0 {
interval = 5 * time.Minute
}
@@ -148,7 +148,7 @@ func (a *App) externalIMAPWorker(ctx context.Context) {
}
func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM external_imap_accounts WHERE enabled=1 AND storage_mode=? ORDER BY COALESCE(last_sync_at, created_at) ASC LIMIT 10`, externalIMAPStorageLocal)
@@ -170,7 +170,7 @@ func (a *App) syncDueExternalIMAPAccounts(ctx context.Context) {
func (a *App) requireExternalIMAPEnabled(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !a.cfg.ExternalIMAPEnabled {
if !a.config().ExternalIMAPEnabled {
respondError(w, http.StatusForbidden, "external imap is disabled")
return
}
@@ -540,7 +540,7 @@ func (a *App) handleExternalIMAPOAuthCallback(w http.ResponseWriter, r *http.Req
respondError(w, http.StatusInternalServerError, "failed to save oauth account")
return
}
http.Redirect(w, r, strings.TrimRight(a.cfg.PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
http.Redirect(w, r, strings.TrimRight(a.config().PublicBaseURL, "/")+"/profile?tab=mailboxes", http.StatusFound)
}
func (a *App) handleMailExternalAccounts(w http.ResponseWriter, r *http.Request) {
@@ -789,7 +789,7 @@ func (a *App) normalizeExternalIMAPPayload(ctx context.Context, req externalIMAP
}
func (a *App) validateExternalIMAPHost(ctx context.Context, host string) error {
if a.cfg.ExternalIMAPAllowPrivateHosts {
if a.config().ExternalIMAPAllowPrivateHosts {
return nil
}
if strings.EqualFold(host, "localhost") {
@@ -868,7 +868,7 @@ func (a *App) decryptExternalIMAPPassword(ciphertext string) (string, error) {
}
func (a *App) externalIMAPKey() ([]byte, error) {
secret := strings.TrimSpace(a.cfg.ExternalIMAPSecretKey)
secret := strings.TrimSpace(a.config().ExternalIMAPSecretKey)
if secret == "" {
return nil, errors.New("LANQIN_EXTERNAL_IMAP_SECRET_KEY is required")
}
@@ -883,15 +883,15 @@ type externalIMAPOAuthProvider struct {
}
func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, externalIMAPOAuthProvider, error) {
callback := strings.TrimRight(a.cfg.PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
callback := strings.TrimRight(a.config().PublicBaseURL, "/") + "/api/external-imap-oauth/" + provider + "/callback"
switch provider {
case externalIMAPOAuthGmail:
if a.cfg.ExternalIMAPGmailClientID == "" || a.cfg.ExternalIMAPGmailClientSecret == "" {
if a.config().ExternalIMAPGmailClientID == "" || a.config().ExternalIMAPGmailClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("gmail oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPGmailClientID,
ClientSecret: a.cfg.ExternalIMAPGmailClientSecret,
ClientID: a.config().ExternalIMAPGmailClientID,
ClientSecret: a.config().ExternalIMAPGmailClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "https://mail.google.com/"},
Endpoint: oauth2.Endpoint{
@@ -900,12 +900,12 @@ func (a *App) externalIMAPOAuthConfig(provider string) (*oauth2.Config, external
},
}, externalIMAPOAuthProvider{Name: "Gmail", Host: "imap.gmail.com", Port: 993}, nil
case externalIMAPOAuthOutlook:
if a.cfg.ExternalIMAPOutlookClientID == "" || a.cfg.ExternalIMAPOutlookClientSecret == "" {
if a.config().ExternalIMAPOutlookClientID == "" || a.config().ExternalIMAPOutlookClientSecret == "" {
return nil, externalIMAPOAuthProvider{}, errors.New("outlook oauth is not configured")
}
return &oauth2.Config{
ClientID: a.cfg.ExternalIMAPOutlookClientID,
ClientSecret: a.cfg.ExternalIMAPOutlookClientSecret,
ClientID: a.config().ExternalIMAPOutlookClientID,
ClientSecret: a.config().ExternalIMAPOutlookClientSecret,
RedirectURL: callback,
Scopes: []string{"openid", "email", "profile", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All"},
Endpoint: oauth2.Endpoint{
@@ -1376,15 +1376,6 @@ func safeExternalEMLFilename(subject string) string {
return name + ".eml"
}
func externalIMAPAttachmentsFromBodyStructure(body imap.BodyStructure) []Attachment {
parts := externalIMAPAttachmentPartsFromBodyStructure(body)
items := make([]Attachment, 0, len(parts))
for _, part := range parts {
items = append(items, part.Attachment)
}
return items
}
func externalIMAPAttachmentPartsFromBodyStructure(body imap.BodyStructure) []externalIMAPAttachmentPart {
now := time.Now().UTC()
items := []externalIMAPAttachmentPart{}
+2 -2
View File
@@ -45,7 +45,7 @@ func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
@@ -101,7 +101,7 @@ func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID st
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
return nil
}
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
var rfcMessageID string
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
if strings.TrimSpace(rfcMessageID) == "" {
+3 -3
View File
@@ -389,7 +389,7 @@ func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email
}
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", errors.New("SMTP 未配置,无法发送验证邮件")
}
mb, err := a.primaryMailboxForUser(ctx, userID)
@@ -428,9 +428,9 @@ func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targe
}
func (a *App) forwardingVerificationURL(token string) string {
base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
base := strings.TrimRight(strings.TrimSpace(a.config().PublicBaseURL), "/")
if base == "" {
base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/")
base = "https://" + strings.Trim(strings.TrimSpace(a.config().PublicHostname), "/")
}
return base + "/api/verify-email?token=" + url.QueryEscape(token)
}
-19
View File
@@ -232,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
return next, nil
}
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
var folderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
return err
}
if !folderID.Valid || folderID.String == "" {
return nil
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
if err != nil {
return err
}
if modSeq == 0 {
return nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return err
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
+4 -4
View File
@@ -971,7 +971,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
if err != nil {
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
continue
}
copyMsg := base
@@ -986,7 +986,7 @@ func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, r
continue
}
if rcptMailbox.Status != "active" {
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
copyMsg := base
copyMsg.MailboxID = ""
copyMsg.FolderID = ""
@@ -2345,7 +2345,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
if err != nil {
return err
}
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
dir := filepath.Join(a.config().DataDir, "attachments", messageID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -2550,7 +2550,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
_ = os.Remove(p)
}
}
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
}
func (a *App) deleteMessage(ctx context.Context, messageID string) {
@@ -216,7 +216,7 @@ func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
imported, skipped := 0, 0
problems := []string{}
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
maxMessageBytes := int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
if maxMessageBytes <= 0 {
maxMessageBytes = 35 * 1024 * 1024
}
+4 -4
View File
@@ -32,7 +32,7 @@ type translateMailMessageResponse struct {
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -59,7 +59,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
@@ -78,7 +78,7 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
if !a.config().MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
@@ -126,7 +126,7 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
maxChars := a.config().MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
+1 -1
View File
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
}
+6 -21
View File
@@ -45,13 +45,13 @@ type parsedMail struct {
}
func (a *App) maildirWorker(ctx context.Context) {
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n := counts.total(); n > 0 {
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" {
return maildirSyncCounts{}, nil
}
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
if err := rows.Err(); err != nil {
return nil, err
}
if a.cfg.CatchAllEnabled {
if a.config().CatchAllEnabled {
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil {
return nil, err
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
return out, nil
}
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
return counts.Imported, err
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
@@ -394,16 +389,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
@@ -514,7 +499,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
+9 -9
View File
@@ -14,7 +14,7 @@ import (
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -114,7 +114,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
@@ -166,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
@@ -215,7 +215,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
@@ -284,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
@@ -354,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
@@ -472,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
root := strings.TrimSpace(a.config().MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
+1 -1
View File
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
}
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
return sendSMTPWithConfig(a.config(), from, recipients, mimeBytes)
}
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
+1 -1
View File
@@ -31,7 +31,7 @@ type deliveryWebhookEvent struct {
}
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
secret := strings.TrimSpace(a.config().DeliveryWebhookSecret)
if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return
+4 -16
View File
@@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -371,8 +371,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
var userID string
@@ -923,15 +923,3 @@ func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
}
return limit
}
func parseOpenAPIOffset(r *http.Request) int {
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
if cursor == "" {
return 0
}
offset, err := strconv.Atoi(cursor)
if err != nil || offset < 0 {
return 0
}
return offset
}
+23
View File
@@ -0,0 +1,23 @@
package app
import "testing"
func TestHasMinimumPasswordLength(t *testing.T) {
tests := []struct {
name string
password string
want bool
}{
{name: "five ASCII characters", password: "abc12", want: false},
{name: "six ASCII characters", password: "abc123", want: true},
{name: "six Unicode characters", password: "密码测试六位", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
}
})
}
}
+2 -25
View File
@@ -490,21 +490,6 @@ func regularUserDefaultPermissions() []string {
}
}
func fixedPermissionGroupIDs() map[string]bool {
out := map[string]bool{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = true
}
return out
}
func assignablePermissionGroupIDs() map[string]bool {
out := fixedPermissionGroupIDs()
delete(out, PermissionGroupSuperAdmin)
delete(out, PermissionGroupRegular)
return out
}
func isAssignablePermissionGroupID(groupID string) bool {
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
}
@@ -517,14 +502,6 @@ func permissionGroupOrder() map[string]int {
return out
}
func permissionGroupNames() map[string]string {
out := map[string]string{}
for _, group := range defaultPermissionGroups() {
out[group.ID] = group.Name
}
return out
}
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range defaultPermissionGroups() {
@@ -1057,10 +1034,10 @@ func (a *App) isDefaultAdminUser(u *User) bool {
if u == nil {
return false
}
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
}
adminEmail := normalizeEmail(a.cfg.AdminEmail)
adminEmail := normalizeEmail(a.config().AdminEmail)
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
}
+6 -6
View File
@@ -27,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
return
}
respondJSON(w, http.StatusOK, MailboxApplyOptions{
Enabled: a.cfg.UserMailboxApplyEnabled,
Enabled: a.config().UserMailboxApplyEnabled,
Domains: domains,
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
})
}
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
return
}
@@ -73,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
return
}
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[localPart] {
@@ -129,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
}
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
if !a.cfg.UserMailboxApplyEnabled {
if !a.config().UserMailboxApplyEnabled {
return []Domain{}, nil
}
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
if len(ids) == 0 {
return []Domain{}, nil
}
+2 -2
View File
@@ -215,7 +215,7 @@ func (a *App) registerOpenAPIRoutes(r chi.Router) {
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) {
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -273,7 +273,7 @@ func currentUser(r *http.Request) *User {
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
cookie, err := r.Cookie(a.cfg.CookieName)
cookie, err := r.Cookie(a.config().CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
+3 -3
View File
@@ -67,7 +67,7 @@ type sendQueueItem struct {
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
@@ -149,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
}
func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
if strings.TrimSpace(a.config().SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
@@ -396,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
if safeID == "" || safeID == "." {
safeID = "unknown"
}
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
+3 -3
View File
@@ -8,20 +8,20 @@ import (
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
token := randomToken()
sessionID := newID("ses")
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: a.cfg.CookieName,
Name: a.config().CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !a.cfg.AllowInsecureHTTP,
Secure: !a.config().AllowInsecureHTTP,
})
return nil
}
+75 -68
View File
@@ -100,15 +100,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
refreshSeconds := a.cfg.MailRefreshSeconds
cfg := a.config()
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
refreshSeconds := cfg.MailRefreshSeconds
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration {
if cfg.OpenRegistration {
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil {
defer rows.Close()
@@ -131,7 +132,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
next := a.cfg
next := a.config()
next.PublicHostname = normalizeHostname(req.PublicHostname)
if next.PublicHostname == "" {
badRequest(w, errors.New("publicHostname is required"))
@@ -208,7 +209,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusInternalServerError, "failed to save settings")
return
}
a.cfg = next
a.setConfig(next)
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
}
@@ -218,7 +219,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
cfg := a.cfg
cfg := a.config()
if strings.TrimSpace(cfg.SMTPHost) == "" {
badRequest(w, errors.New("SMTP 主机未设置"))
return
@@ -285,41 +286,43 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemSettingsSnapshot() SystemSettings {
cfg := a.config()
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
PublicHostname: cfg.PublicHostname,
PublicBaseURL: cfg.PublicBaseURL,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
SMTPRequireTLS: cfg.SMTPRequireTLS,
MaildirRoot: cfg.MaildirRoot,
MaildirScanSeconds: cfg.MaildirScanSeconds,
SessionTTLHours: cfg.SessionTTLHours,
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
OpenRegistration: cfg.OpenRegistration,
TwoFactorEnabled: cfg.TwoFactorEnabled,
TurnstileEnabled: cfg.TurnstileEnabled,
TurnstileSiteKey: cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
CatchAllEnabled: cfg.CatchAllEnabled,
MailAutoRefresh: cfg.MailAutoRefresh,
MailRefreshSeconds: cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
}
}
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
cfg := a.config()
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
if err != nil {
return err
@@ -332,76 +335,80 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
}
switch key {
case "publicHostname":
a.cfg.PublicHostname = value
cfg.PublicHostname = value
case "publicBaseUrl":
a.cfg.PublicBaseURL = value
cfg.PublicBaseURL = value
case "smtpHost":
a.cfg.SMTPHost = value
cfg.SMTPHost = value
case "smtpPort":
a.cfg.SMTPPort = value
cfg.SMTPPort = value
case "smtpUsername":
a.cfg.SMTPUsername = value
cfg.SMTPUsername = value
case "smtpPassword":
a.cfg.SMTPPassword = value
cfg.SMTPPassword = value
case "smtpRequireTls":
a.cfg.SMTPRequireTLS = value == "true"
cfg.SMTPRequireTLS = value == "true"
case "maildirRoot":
a.cfg.MaildirRoot = value
cfg.MaildirRoot = value
case "maildirScanSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MaildirScanSeconds = n
cfg.MaildirScanSeconds = n
}
case "sessionTtlHours":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.SessionTTLHours = n
cfg.SessionTTLHours = n
}
case "allowInsecureHttp":
a.cfg.AllowInsecureHTTP = value == "true"
cfg.AllowInsecureHTTP = value == "true"
case "openRegistration":
a.cfg.OpenRegistration = value == "true"
cfg.OpenRegistration = value == "true"
case "twoFactorEnabled":
a.cfg.TwoFactorEnabled = value == "true"
cfg.TwoFactorEnabled = value == "true"
case "turnstileEnabled":
a.cfg.TurnstileEnabled = value == "true"
cfg.TurnstileEnabled = value == "true"
case "turnstileSiteKey":
a.cfg.TurnstileSiteKey = value
cfg.TurnstileSiteKey = value
case "turnstileSecretKey":
a.cfg.TurnstileSecretKey = value
cfg.TurnstileSecretKey = value
case "catchAllEnabled":
a.cfg.CatchAllEnabled = value == "true"
cfg.CatchAllEnabled = value == "true"
case "mailAutoRefresh":
a.cfg.MailAutoRefresh = value == "true"
cfg.MailAutoRefresh = value == "true"
case "mailRefreshSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.MailRefreshSeconds = n
cfg.MailRefreshSeconds = n
}
case "userMailboxApplyEnabled":
a.cfg.UserMailboxApplyEnabled = value == "true"
cfg.UserMailboxApplyEnabled = value == "true"
case "userMailboxDomainIds":
a.cfg.UserMailboxDomainIDs = value
cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
a.cfg.ExternalIMAPEnabled = value == "true"
cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value
cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n
cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value
cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value
cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value
cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
a.cfg.ExternalIMAPOutlookClientSecret = value
cfg.ExternalIMAPOutlookClientSecret = value
}
}
return rows.Err()
if err := rows.Err(); err != nil {
return err
}
a.setConfig(cfg)
return nil
}
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
+9 -9
View File
@@ -27,7 +27,7 @@ type statusWebhookEnvelope struct {
}
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
now := a.now().UTC()
@@ -39,7 +39,7 @@ func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey,
}
func (a *App) statusWebhookWorker(ctx context.Context) {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return
}
a.log.Info("status webhook worker started")
@@ -59,7 +59,7 @@ func (a *App) statusWebhookWorker(ctx context.Context) {
}
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
if strings.TrimSpace(a.config().StatusWebhookURL) == "" {
return nil
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
@@ -104,7 +104,7 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
return err
}
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
mac := hmac.New(sha256.New, []byte(a.config().StatusWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
@@ -134,17 +134,17 @@ func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload
}
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
if strings.TrimSpace(a.config().StatusWebhookSecret) == "" {
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
}
target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL")
}
if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
return nil, errors.New("status webhook URL must use HTTPS")
}
if !a.cfg.StatusWebhookAllowPrivateHosts {
if !a.config().StatusWebhookAllowPrivateHosts {
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err
}
@@ -157,7 +157,7 @@ func (a *App) statusWebhookDialContext(ctx context.Context, network, address str
if err != nil {
return nil, err
}
if a.cfg.StatusWebhookAllowPrivateHosts {
if a.config().StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+4 -4
View File
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
}
}
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.cfg.PublicHostname
s.Domain = a.config().PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
@@ -97,7 +97,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
current := strings.TrimSpace(a.cfg.AppVersion)
current := strings.TrimSpace(a.config().AppVersion)
if current == "" {
current = BuildVersion
}
@@ -124,7 +124,7 @@ func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
}
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return githubRelease{}, errors.New("invalid release API URL")
@@ -134,7 +134,7 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
client := &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -161,11 +161,11 @@ func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
}
func (a *App) updateEnabled() bool {
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
}
func (a *App) triggerUpdateService(ctx context.Context) error {
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("invalid update service URL")
}
@@ -173,7 +173,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -193,7 +193,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.cfg.DataDir, "backups")
backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return "", err
}
+2 -2
View File
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
}
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
if !a.cfg.TurnstileEnabled {
if !a.config().TurnstileEnabled {
return nil
}
token = strings.TrimSpace(token)
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
if secret == "" || token == "" {
return errors.New("turnstile verification required")
}
+2 -2
View File
@@ -144,7 +144,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
}
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
@@ -179,7 +179,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if !a.cfg.TwoFactorEnabled {
if !a.config().TwoFactorEnabled {
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
return
}
+7
View File
@@ -14,12 +14,19 @@ import (
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/microcosm-cc/bluemonday"
)
type HTMLPolicy struct{ policy *bluemonday.Policy }
const minimumPasswordLength = 6
func hasMinimumPasswordLength(password string) bool {
return utf8.RuneCountInString(password) >= minimumPasswordLength
}
func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy()
p.AllowElements("html", "head", "body", "center", "font")
+3 -8
View File
@@ -26,27 +26,22 @@
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16",
"@tiptap/core": "^3.27.0",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-font-family": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",
"@tiptap/extension-link": "^3.27.0",
"@tiptap/extension-placeholder": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0",
"@tiptap/extension-underline": "^3.27.0",
"@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.10",
"dompurify": "3.4.12",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "6.30.4",
"react-router-dom": "7.18.2",
"tailwind-merge": "2.5.4"
},
"devDependencies": {
@@ -55,7 +50,7 @@
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20",
"postcss": "8.5.15",
"postcss": "8.5.25",
"tailwindcss": "3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.6.3",
@@ -1,4 +1,3 @@
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -69,11 +69,11 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
</div>
</DialogHeader>
<div className="space-y-4">
<div className="border-b pb-4 text-center">
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-4xl font-semibold tabular-nums">{currentVersion}</div>
{version.data?.latestVersion && <div className="mt-2 text-sm text-muted-foreground">{version.data.latestVersion}</div>}
<div className="space-y-3">
<div className="border-b pb-3 text-center">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
{version.data?.latestVersion && <div className="mt-1 text-xs text-muted-foreground">{version.data.latestVersion}</div>}
</div>
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
@@ -91,7 +91,7 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
{version.data?.releaseNotes && (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
<div className="h-[clamp(12rem,30svh,18rem)] overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6 text-foreground/80">
{version.data.releaseNotes}
</div>
</div>
-43
View File
@@ -1,43 +0,0 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
+10 -7
View File
@@ -6,14 +6,15 @@ import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
import { RegisterPage } from "@/pages/register"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
import { NotFoundPage } from "@/pages/not-found"
import "./index.css"
const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
@@ -31,7 +32,9 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">...</div>}>
<RouterProvider router={router} />
</React.Suspense>
<Toaster />
<LanguageDomSync />
</QueryClientProvider>
+6 -11
View File
@@ -422,13 +422,13 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
const defaultLimitsQuery = useQuery({ queryKey: ["admin", "permission-limits", "defaults"], queryFn: api.defaultPermissionLimits, enabled: dialogOpen })
const defaultLimits = defaultLimitsQuery.data || defaultPermissionLimits
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultLimits)
React.useEffect(() => {
if (dialogOpen) {
setPermissions(group?.permissions || [])
setLimits(group?.limits || defaultPermissionLimits)
setLimits(group?.limits || defaultLimits)
}
}, [dialogOpen, group])
}, [defaultLimits, dialogOpen, group])
const mutation = useMutation({
mutationFn: (form: FormData) => {
const payload = {
@@ -1858,7 +1858,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
<Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={8} />
<Field name="password" label="初始密码" type="password" minLength={6} />
<div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
@@ -1928,7 +1928,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
}
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
@@ -1948,7 +1948,7 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 6 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
}
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
@@ -1979,11 +1979,6 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
}
const dnsDescriptions: Record<string, string> = {
MX: "指定收件服务器。把邮件投递到该地址指向的服务器。",
TXT: "", // 具体含义根据内容区分
}
function dnsDescription(record: DNSRecord): string {
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
+3 -21
View File
@@ -11,8 +11,8 @@ import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Ban, Bold, Calendar, Check, ChevronDown, Clock3, Code2, Copy, Download, Ellipsis, Eraser, Eye, FileText, Folder, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, MailQuestion, Moon, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
import { api, ExternalImapAccount, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, MailSearchParams, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { useDisplayMode } from "@/lib/display-mode"
@@ -140,7 +140,7 @@ export function MailPage() {
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [exportingMail, setExportingMail] = React.useState(false)
const [importingMail, setImportingMail] = React.useState(false)
const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
@@ -4728,24 +4728,6 @@ function toDateTimeLocalValue(date: Date) {
function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft {
return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() }
}
function scheduleToHtml(schedule: ScheduleDraft) {
const start = parseScheduleStart(schedule)
const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000)
const rows = [
["时间", schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`],
["持续", schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes)],
["提醒", reminderLabel(schedule.reminderMinutes)],
["重复", repeatLabel(schedule.repeat)],
schedule.location ? ["位置", schedule.location] : undefined,
schedule.description ? ["描述", schedule.description] : undefined,
].filter(Boolean) as string[][]
return DOMPurify.sanitize(`
<div style="border:1px solid #d4d4d8;border-radius:8px;padding:14px 16px;margin:16px 0;background:#fafafa;">
<div style="font-weight:600;font-size:16px;margin-bottom:10px;">${escapeHtml(schedule.title)}</div>
${rows.map(([label, value]) => `<div style="margin:6px 0;"><span style="color:#71717a;">${label}</span>${escapeHtml(value)}</div>`).join("")}
</div>
`)
}
function scheduleToFile(schedule: ScheduleDraft) {
const ics = scheduleToIcs(schedule)
const filename = `${safeFilename(schedule.title || "schedule")}.ics`
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -113,11 +113,11 @@ export function RegisterPage() {
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium"></Label>
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
+2
View File
@@ -8,6 +8,8 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+5 -2
View File
@@ -20,9 +20,12 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
# 手动部署可执行:openssl rand -hex 24
LANQIN_UPDATE_TOKEN=
# 可选端口绑定。使用宿主机反向代理时,可将 HTTP 设为 127.0.0.1:8088
# 一键安装器记录的部署方式。手动部署时可以留空
LANQIN_INSTALL_FIREWALL_MODE=
LANQIN_INSTALL_WEB_MODE=
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
LANQIN_HTTP_BIND=80
LANQIN_HTTPS_BIND=443
LANQIN_SMTP_BIND=25
LANQIN_SMTPS_BIND=465
LANQIN_SUBMISSION_BIND=587
+4 -3
View File
@@ -19,6 +19,8 @@ sudo newszxcn-email rollback
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
## 最简单部署:单容器镜像版
服务器上不需要源码构建,只要 `docker-compose.yml``.env` 即可。
@@ -158,10 +160,9 @@ Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/
```dotenv
LANQIN_HTTP_BIND=127.0.0.1:8088
LANQIN_HTTPS_BIND=127.0.0.1:8443
```
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`不使用宿主机反向代理时保留默认的 `80``443` 即可
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
@@ -182,7 +183,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
```
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
+1 -3
View File
@@ -7,7 +7,6 @@ services:
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
ports:
- "${LANQIN_HTTP_BIND:-80}:80"
- "${LANQIN_HTTPS_BIND:-443}:443"
- "${LANQIN_SMTP_BIND:-25}:25"
- "${LANQIN_SMTPS_BIND:-465}:465"
- "${LANQIN_SUBMISSION_BIND:-587}:587"
@@ -17,8 +16,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
# - /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
com.centurylinklabs.watchtower.scope: "newszxcn-email"
+454 -29
View File
@@ -6,6 +6,9 @@ RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
COMMAND="${1:-install}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf"
ACME_WEBROOT="/var/www/newszxcn-acme"
CERT_DIR="${INSTALL_DIR}/certs"
log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; }
success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; }
@@ -22,6 +25,8 @@ NewSzxcn Email 管理命令
update 备份数据库并更新到最新版
status 查看容器与健康状态
logs 持续查看运行日志
restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次命令行更新前的镜像
uninstall 停止并移除容器,保留邮件与配置
EOF
@@ -37,6 +42,19 @@ require_curl() {
command -v curl >/dev/null 2>&1 || fail "系统缺少 curl,请先安装 curl。"
}
install_packages() {
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y "$@"
elif command -v yum >/dev/null 2>&1; then
yum install -y "$@"
else
fail "暂不支持当前系统的软件包管理器,请使用 Ubuntu、Debian、CentOS、Rocky Linux 或 AlmaLinux。"
fi
}
ensure_docker() {
if ! command -v docker >/dev/null 2>&1; then
log "未检测到 Docker,正在安装 Docker Engine..."
@@ -57,10 +75,13 @@ script_dir() {
}
refresh_assets() {
local source_dir
local source_dir local_source="false"
source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then
local_source="true"
fi
install -d -m 0755 "${INSTALL_DIR}"
if [[ -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then
if [[ "${local_source}" == "true" && -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then
install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example"
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email
@@ -81,6 +102,16 @@ random_secret() {
fi
}
random_admin_password() {
local value
if command -v openssl >/dev/null 2>&1; then
value="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')"
else
value="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
fi
printf '%.12s' "${value}"
}
set_env() {
local key="$1" value="$2" file="${INSTALL_DIR}/.env" tmp
tmp="$(mktemp)"
@@ -102,9 +133,9 @@ env_value() {
prompt_value() {
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
local value="${!variable:-}"
if [[ -z "${value}" && -r /dev/tty ]]; then
if [[ -z "${value}" ]] && has_tty; then
if [[ "${secret}" == "true" ]]; then
read -r -s -p "${prompt}${default_value:+ [自动生成]}: " value </dev/tty
read -r -s -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
printf '\n' >/dev/tty
else
read -r -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
@@ -114,30 +145,124 @@ prompt_value() {
printf '%s' "${value}"
}
prompt_choice() {
local variable="$1" prompt="$2" default_value="$3" value
value="${!variable:-}"
while true; do
if [[ -z "${value}" ]] && has_tty; then
read -r -p "${prompt}" value </dev/tty
fi
value="${value:-${default_value}}"
if [[ "${value}" =~ ^[123]$ ]]; then
printf '%s' "${value}"
return
fi
prompt_text "[提示] 请输入 1、2 或 3。\n"
value=""
has_tty || fail "${variable} 必须设置为 1、2 或 3。"
done
}
has_tty() {
[[ -e /dev/tty ]] && (: </dev/tty) 2>/dev/null
}
prompt_text() {
if has_tty; then
printf '%b' "$1" >/dev/tty
else
printf '%b' "$1" >&2
fi
}
valid_hostname() {
local hostname="$1" label tld
local -a labels
[[ ${#hostname} -le 253 && "${hostname}" == *.* ]] || return 1
IFS='.' read -r -a labels <<<"${hostname}"
for label in "${labels[@]}"; do
[[ ${#label} -ge 1 && ${#label} -le 63 ]] || return 1
[[ "${label}" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$ ]] || return 1
done
tld="${labels[${#labels[@]}-1]}"
[[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]]
}
prompt_admin_password() {
local password="${LANQIN_ADMIN_PASSWORD:-}" confirm=""
local safe_password_re='^[A-Za-z0-9][A-Za-z0-9._!@#%+,=:;?*/()^-]*$'
if [[ -n "${password}" ]]; then
[[ ${#password} -ge 6 ]] || fail "管理员密码至少需要 6 个字符。"
[[ "${password}" =~ ${safe_password_re} ]] || fail "管理员密码包含安装配置不支持的字符。"
printf '%s' "${password}"
return
fi
if ! has_tty; then
password="$(random_admin_password)"
prompt_text "[提示] 已自动生成管理员密码:${password}\n"
printf '%s' "${password}"
return
fi
while true; do
read -r -s -p "管理员密码(回车自动生成 12 位,或输入至少 6 位): " password </dev/tty
printf '\n' >/dev/tty
if [[ -z "${password}" ]]; then
password="$(random_admin_password)"
prompt_text "[提示] 已自动生成管理员密码:${password}\n"
printf '%s' "${password}"
return
fi
if [[ ${#password} -lt 6 ]]; then
prompt_text "[提示] 管理员密码至少需要 6 个字符。\n"
continue
fi
if [[ ! "${password}" =~ ${safe_password_re} ]]; then
prompt_text "[提示] 密码必须以字母或数字开头,只能使用字母、数字和常用符号。\n"
continue
fi
read -r -s -p "再次输入管理员密码: " confirm </dev/tty
printf '\n' >/dev/tty
if [[ "${password}" != "${confirm}" ]]; then
prompt_text "[提示] 两次输入的密码不一致,请重新输入。\n"
continue
fi
printf '%s' "${password}"
return
done
}
configure_first_install() {
if [[ -f "${INSTALL_DIR}/.env" ]]; then
return
fi
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
local hostname public_url admin_username admin_password update_token
local firewall_mode hostname admin_username admin_password web_mode public_url update_token
prompt_text '\n防火墙配置 [1]:\n1. 仅开放邮局必要端口(推荐)\n2. 保留现有防火墙,由用户自行配置\n3. 开放全部端口(不推荐)\n'
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
[[ "${hostname}" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || fail "邮件服务器域名格式不正确。"
public_url="$(prompt_value LANQIN_PUBLIC_BASE_URL "Webmail 访问地址" "https://${hostname}")"
admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "初始管理员用户名" "admin")"
[[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名格式不正确,需为 2-80 位且不能包含 @。"
admin_password="$(prompt_value LANQIN_ADMIN_PASSWORD "初始管理员密码" "" true)"
if [[ -z "${admin_password}" ]]; then
admin_password="$(random_secret)"
warn "已自动生成管理员密码:${admin_password}"
fi
[[ ${#admin_password} -ge 10 ]] || fail "管理员密码至少需要 10 个字符。"
update_token="$(random_secret)"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "管理员用户名" "admin")"
[[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名需为 2-80 位且不能包含 @。"
admin_password="$(prompt_admin_password)"
prompt_text '\nWeb 部署方式 [1]\n1. 自动配置 Nginx + SSL\n2. 宝塔/已有 Nginx 反代\n3. 仅 HTTP 测试\n'
web_mode="$(prompt_choice LANQIN_INSTALL_WEB_MODE "请选择 [1]: " "1")"
if [[ "${web_mode}" == "3" ]]; then
public_url="http://${hostname}"
else
public_url="https://${hostname}"
fi
update_token="$(random_secret)"
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
set_env LANQIN_INSTALL_FIREWALL_MODE "${firewall_mode}"
set_env LANQIN_PUBLIC_HOSTNAME "${hostname}"
set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
set_env LANQIN_ADMIN_USERNAME "${admin_username}"
set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
set_env LANQIN_INSTALL_WEB_MODE "${web_mode}"
set_env LANQIN_UPDATE_TOKEN "${update_token}"
chmod 0600 "${INSTALL_DIR}/.env"
}
@@ -152,10 +277,98 @@ ensure_update_token() {
}
prepare_directories() {
install -d -m 0755 "${INSTALL_DIR}/data" "${INSTALL_DIR}/mail" "${INSTALL_DIR}/dkim"
install -d -m 0755 "${INSTALL_DIR}/data" "${INSTALL_DIR}/mail" "${INSTALL_DIR}/dkim" "${CERT_DIR}"
install -d -m 0700 "${INSTALL_DIR}/data/backups"
}
configure_runtime_bindings() {
local web_mode
web_mode="$(env_value LANQIN_INSTALL_WEB_MODE || true)"
case "${web_mode}" in
1|2)
set_env LANQIN_HTTP_BIND "127.0.0.1:8088"
set_env LANQIN_ALLOW_INSECURE_HTTP "false"
;;
3)
set_env LANQIN_HTTP_BIND "80"
set_env LANQIN_ALLOW_INSECURE_HTTP "true"
;;
"")
warn "这是旧版安装配置,保留现有 Web 端口和反向代理设置。"
;;
esac
}
detect_ssh_ports() {
local ports=""
if command -v sshd >/dev/null 2>&1; then
ports="$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | sort -nu || true)"
fi
if [[ -z "${ports}" ]] && command -v ss >/dev/null 2>&1; then
ports="$(ss -lntp 2>/dev/null | awk '/sshd/ {sub(/.*:/, "", $4); print $4}' | sort -nu || true)"
fi
printf '%s\n' "${ports:-22}"
}
configure_restricted_firewall() {
local ports=(25 80 443 465 587 993 995) ssh_port
while IFS= read -r ssh_port; do
[[ "${ssh_port}" =~ ^[0-9]+$ ]] && ports+=("${ssh_port}")
done < <(detect_ssh_ports)
if command -v firewall-cmd >/dev/null 2>&1; then
systemctl enable --now firewalld >/dev/null 2>&1 || fail "firewalld 启动失败。"
for ssh_port in "${ports[@]}"; do
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
done
firewall-cmd --reload >/dev/null
success "firewalld 已仅开放 SSH 和邮局必要端口。"
return
fi
if ! command -v ufw >/dev/null 2>&1; then
install_packages ufw
fi
if command -v ufw >/dev/null 2>&1; then
for ssh_port in "${ports[@]}"; do
ufw allow "${ssh_port}/tcp" >/dev/null
done
ufw --force enable >/dev/null
success "UFW 已开放 SSH 和邮局必要端口。"
return
fi
fail "没有找到可管理的 UFW 或 firewalld。"
}
configure_open_firewall() {
warn "正在按选择开放全部端口,请同时检查云厂商安全组。"
if command -v ufw >/dev/null 2>&1; then
ufw --force disable >/dev/null 2>&1 || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now firewalld >/dev/null 2>&1 || true
fi
if command -v iptables >/dev/null 2>&1; then
iptables -P INPUT ACCEPT
iptables -F INPUT
fi
if command -v ip6tables >/dev/null 2>&1; then
ip6tables -P INPUT ACCEPT
ip6tables -F INPUT
fi
success "主机防火墙已调整为开放入站;云厂商安全组仍需单独配置。"
}
configure_firewall() {
case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in
1) configure_restricted_firewall ;;
2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;;
3) configure_open_firewall ;;
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
*) fail "防火墙模式配置无效。" ;;
esac
}
wait_for_health() {
local attempts="${1:-60}" bind port
bind="$(env_value LANQIN_HTTP_BIND || true)"
@@ -170,6 +383,166 @@ wait_for_health() {
return 1
}
ensure_nginx() {
if ! command -v nginx >/dev/null 2>&1; then
log "正在安装宿主机 Nginx..."
install_packages nginx
fi
install -d -m 0755 "$(dirname "${NGINX_CONFIG}")" "${ACME_WEBROOT}/.well-known/acme-challenge"
if command -v getenforce >/dev/null 2>&1 && [[ "$(getenforce)" == "Enforcing" ]] && command -v setsebool >/dev/null 2>&1; then
setsebool -P httpd_can_network_connect 1
fi
}
write_nginx_http_config() {
local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
tmp="$(mktemp)"
cat >"${tmp}" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${hostname};
location ^~ /.well-known/acme-challenge/ {
root ${ACME_WEBROOT};
default_type text/plain;
}
location / {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
client_max_body_size 50m;
}
}
EOF
install -m 0644 "${tmp}" "${NGINX_CONFIG}"
rm -f "${tmp}"
nginx -t || fail "Nginx 配置检查失败,请检查 ${NGINX_CONFIG}"
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now nginx
systemctl reload nginx
else
nginx -s reload 2>/dev/null || nginx
fi
}
write_nginx_https_config() {
local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
tmp="$(mktemp)"
cat >"${tmp}" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${hostname};
location ^~ /.well-known/acme-challenge/ {
root ${ACME_WEBROOT};
default_type text/plain;
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${hostname};
ssl_certificate ${CERT_DIR}/fullchain.pem;
ssl_certificate_key ${CERT_DIR}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:NewSzxcnSSL:10m;
ssl_session_timeout 1d;
location / {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 50m;
}
}
EOF
install -m 0644 "${tmp}" "${NGINX_CONFIG}"
rm -f "${tmp}"
nginx -t || fail "HTTPS 配置检查失败,请检查 ${NGINX_CONFIG}"
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
}
ensure_acme() {
if [[ ! -x /root/.acme.sh/acme.sh ]]; then
local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
log "正在安装官方 acme.sh..."
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
fi
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。"
}
install_certificate() {
local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
ensure_acme
log "正在为 ${hostname} 申请或检查 Let's Encrypt 证书..."
if ! /root/.acme.sh/acme.sh --issue \
--server letsencrypt \
--keylength ec-256 \
--domain "${hostname}" \
--webroot "${ACME_WEBROOT}"; then
warn "证书签发命令未创建新证书,将尝试安装已有的有效证书。"
fi
/root/.acme.sh/acme.sh --install-cert \
--ecc \
--domain "${hostname}" \
--fullchain-file "${CERT_DIR}/fullchain.pem" \
--key-file "${CERT_DIR}/privkey.pem" \
--reloadcmd "/usr/local/bin/newszxcn-email reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。"
chmod 0644 "${CERT_DIR}/fullchain.pem"
chmod 0600 "${CERT_DIR}/privkey.pem"
set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem"
set_env LANQIN_TLS_KEY_FILE "/certs/privkey.pem"
set_env LANQIN_SUBMISSION_ADDR ":587"
set_env LANQIN_SUBMISSION_TLS_ADDR ":465"
}
configure_web_mode() {
local web_mode
web_mode="$(env_value LANQIN_INSTALL_WEB_MODE || true)"
case "${web_mode}" in
1)
ensure_nginx
write_nginx_http_config
install_certificate
write_nginx_https_config
compose up -d --remove-orphans --force-recreate lanqin-email
wait_for_health 90 || fail "启用证书后服务未通过健康检查,请执行 newszxcn-email logs。"
;;
2)
warn "请在宝塔或现有 Nginx 中把域名反代到 http://127.0.0.1:8088。"
warn "邮件客户端证书仍需放入 ${CERT_DIR} 并配置 LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE。"
;;
3)
warn "当前为 HTTP 测试模式,不适合正式公网运行。"
;;
"") ;;
*) fail "Web 部署模式配置无效。" ;;
esac
}
backup_database() {
local timestamp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
@@ -190,16 +563,19 @@ remember_current_image() {
}
do_install() {
ensure_docker
refresh_assets
configure_first_install
ensure_update_token
configure_runtime_bindings
ensure_docker
configure_firewall
prepare_directories
log "正在拉取 NewSzxcn Email 镜像..."
compose pull
log "正在启动服务..."
compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
}
@@ -223,7 +599,7 @@ do_update() {
do_rollback
fail "更新失败,已回滚到原镜像。"
fi
success "系统已更新,配置、邮件和数据库均已保留。"
success "系统已更新,配置、邮件、证书和数据库均已保留。"
}
do_rollback() {
@@ -237,6 +613,37 @@ do_rollback() {
success "已回滚到 ${image}"
}
reload_services() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
ensure_docker
compose restart lanqin-email >/dev/null
if command -v nginx >/dev/null 2>&1 && [[ -f "${NGINX_CONFIG}" ]]; then
nginx -t >/dev/null
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
fi
}
do_restart() {
reload_services
wait_for_health 90 || fail "重启后服务未通过健康检查。"
success "邮局服务已重启。"
}
do_certificate() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
[[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。"
ensure_nginx
write_nginx_http_config
install_certificate
write_nginx_https_config
reload_services
success "SSL 证书已安装并应用。"
}
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps
@@ -250,18 +657,36 @@ do_status() {
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose down --remove-orphans
success "容器已移除,${INSTALL_DIR} 中的配置、邮件和数据库仍然保留。"
if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}"
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
fi
fi
success "容器和自动生成的 Nginx 配置已移除,${INSTALL_DIR} 中的邮件、证书、配置和数据库仍然保留。"
}
require_root
require_curl
if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
return 0
fi
exit 0
fi
case "${COMMAND}" in
install) do_install ;;
update) do_update ;;
status) ensure_docker; do_status ;;
logs) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
rollback) ensure_docker; do_rollback ;;
uninstall) ensure_docker; do_uninstall ;;
help|-h|--help) usage ;;
install) require_root; require_curl; do_install ;;
update) require_root; require_curl; do_update ;;
status) require_root; require_curl; ensure_docker; do_status ;;
logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
restart) require_root; require_curl; do_restart ;;
reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;;
esac
+63 -105
View File
@@ -50,15 +50,6 @@ importers:
'@tiptap/core':
specifier: ^3.27.0
version: 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-color':
specifier: ^3.27.0
version: 3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))
'@tiptap/extension-font-family':
specifier: ^3.27.0
version: 3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))
'@tiptap/extension-highlight':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-image':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
@@ -74,9 +65,6 @@ importers:
'@tiptap/extension-text-style':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-underline':
specifier: ^3.27.0
version: 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/pm':
specifier: ^3.27.0
version: 3.27.0
@@ -93,8 +81,8 @@ importers:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.10
version: 3.4.10
specifier: 3.4.12
version: 3.4.12
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -107,12 +95,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: 6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 7.18.2
version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: 2.5.4
version: 2.5.4
@@ -131,10 +116,10 @@ importers:
version: 6.0.2(vite@8.0.16(@types/node@24.13.2)(jiti@1.21.7)(yaml@2.9.0))
autoprefixer:
specifier: 10.4.20
version: 10.4.20(postcss@8.5.15)
version: 10.4.20(postcss@8.5.25)
postcss:
specifier: 8.5.15
version: 8.5.15
specifier: 8.5.25
version: 8.5.25
tailwindcss:
specifier: 3.4.15
version: 3.4.15
@@ -642,10 +627,6 @@ packages:
'@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -789,11 +770,6 @@ packages:
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-color@3.27.0':
resolution: {integrity: sha512-K+2aI4k0yEekZ4Sq+puNWE4/z26E1wfAmRqci6k6T8tAQz5PsDPzgNUSHTB4FSTzH0hLYrFzj/yaggjF9VeHGg==}
peerDependencies:
'@tiptap/extension-text-style': 3.27.0
'@tiptap/extension-document@3.27.0':
resolution: {integrity: sha512-xE+rUAPAA+65Usxbn5OoPVh0I0FSPz5dYprj+uo1mogPgqpcPLGVNMMoRLZ4WdiZ3I451d5+U7CUynIjD/iikw==}
peerDependencies:
@@ -811,11 +787,6 @@ packages:
'@tiptap/core': 3.27.0
'@tiptap/pm': 3.27.0
'@tiptap/extension-font-family@3.27.0':
resolution: {integrity: sha512-cK+5R6KoOoxXmwbA03RYZS7G0//DovLNV/6mhgYPIuO/tNXXcwMGOxUKmMC2aUr95aL00EqGtsX4JotRyBVvXg==}
peerDependencies:
'@tiptap/extension-text-style': 3.27.0
'@tiptap/extension-gapcursor@3.27.0':
resolution: {integrity: sha512-tHIUQmtebBytVpd2f5oCUMAivdN5Yj8zRDpkA5uT3x38s9OdXLjLLYZHDD7b0ANQnN0r1vkxPXi9YF6+2XWZvA==}
peerDependencies:
@@ -831,11 +802,6 @@ packages:
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-highlight@3.27.0':
resolution: {integrity: sha512-p7Epzx8KbxxqIUG68dFk2lpmPp0Bvth7K11oL/pAlaXOR2Z4X9kef5+FJ/fEDehUtWPzfzVn/eO2a5bYN7k2AA==}
peerDependencies:
'@tiptap/core': 3.27.0
'@tiptap/extension-horizontal-rule@3.27.0':
resolution: {integrity: sha512-04Xga9CqIqzKb1cqDk9AV9pTbtleqF+o8X3bb3n7HDgplYHxLjHId6RCzhfSZU6U8VZZF/RJQ1jbTebeYqGSBw==}
peerDependencies:
@@ -1031,6 +997,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1052,8 +1022,8 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.10:
resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -1252,8 +1222,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1339,8 +1309,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
postcss@8.5.25:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
prosemirror-changeset@2.4.1:
@@ -1415,24 +1385,22 @@ packages:
'@types/react':
optional: true
react-resizable-panels@2.1.9:
resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==}
react-router-dom@7.18.2:
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react: '>=18'
react-dom: '>=18'
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
react-router@7.18.2:
resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -1478,6 +1446,9 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2123,8 +2094,6 @@ snapshots:
'@radix-ui/rect@1.1.2': {}
'@remix-run/router@1.23.3': {}
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@@ -2215,10 +2184,6 @@ snapshots:
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-color@3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))':
dependencies:
'@tiptap/extension-text-style': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-document@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2234,10 +2199,6 @@ snapshots:
'@tiptap/pm': 3.27.0
optional: true
'@tiptap/extension-font-family@3.27.0(@tiptap/extension-text-style@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0)))':
dependencies:
'@tiptap/extension-text-style': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))
'@tiptap/extension-gapcursor@3.27.0(@tiptap/extensions@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/extensions': 3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)
@@ -2250,10 +2211,6 @@ snapshots:
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-highlight@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
'@tiptap/extension-horizontal-rule@3.27.0(@tiptap/core@3.27.0(@tiptap/pm@3.27.0))(@tiptap/pm@3.27.0)':
dependencies:
'@tiptap/core': 3.27.0(@tiptap/pm@3.27.0)
@@ -2426,14 +2383,14 @@ snapshots:
dependencies:
tslib: 2.8.1
autoprefixer@10.4.20(postcss@8.5.15):
autoprefixer@10.4.20(postcss@8.5.25):
dependencies:
browserslist: 4.28.2
caniuse-lite: 1.0.30001799
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
postcss: 8.5.15
postcss: 8.5.25
postcss-value-parser: 4.2.0
baseline-browser-mapping@2.10.37: {}
@@ -2476,6 +2433,8 @@ snapshots:
commander@4.1.1: {}
cookie@1.1.1: {}
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -2488,7 +2447,7 @@ snapshots:
dlv@1.1.3: {}
dompurify@3.4.10:
dompurify@3.4.12:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2639,7 +2598,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.12: {}
nanoid@3.3.16: {}
node-releases@2.0.47: {}
@@ -2665,28 +2624,28 @@ snapshots:
pirates@4.0.7: {}
postcss-import@15.1.0(postcss@8.5.15):
postcss-import@15.1.0(postcss@8.5.25):
dependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.15):
postcss-js@4.1.0(postcss@8.5.25):
dependencies:
camelcase-css: 2.0.1
postcss: 8.5.15
postcss: 8.5.25
postcss-load-config@4.0.2(postcss@8.5.15):
postcss-load-config@4.0.2(postcss@8.5.25):
dependencies:
lilconfig: 3.1.3
yaml: 2.9.0
optionalDependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-nested@6.2.0(postcss@8.5.15):
postcss-nested@6.2.0(postcss@8.5.25):
dependencies:
postcss: 8.5.15
postcss: 8.5.25
postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4:
@@ -2696,9 +2655,9 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.15:
postcss@8.5.25:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -2807,22 +2766,19 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
react-resizable-panels@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-router@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
cookie: 1.1.1
react: 18.3.1
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.4(react@18.3.1)
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-style-singleton@2.2.3(@types/react@18.3.12)(react@18.3.1):
dependencies:
@@ -2884,6 +2840,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
set-cookie-parser@2.7.2: {}
source-map-js@1.2.1: {}
sucrase@3.35.1:
@@ -2920,11 +2878,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
postcss: 8.5.15
postcss-import: 15.1.0(postcss@8.5.15)
postcss-js: 4.1.0(postcss@8.5.15)
postcss-load-config: 4.0.2(postcss@8.5.15)
postcss-nested: 6.2.0(postcss@8.5.15)
postcss: 8.5.25
postcss-import: 15.1.0(postcss@8.5.25)
postcss-js: 4.1.0(postcss@8.5.25)
postcss-load-config: 4.0.2(postcss@8.5.25)
postcss-nested: 6.2.0(postcss@8.5.25)
postcss-selector-parser: 6.1.4
resolve: 1.22.12
sucrase: 3.35.1
@@ -2987,7 +2945,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.25
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export LANQIN_SOURCE_ONLY=true
# shellcheck source=install.sh
source "${ROOT_DIR}/install.sh"
fail_test() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
assert_eq() {
local want="$1" got="$2" label="$3"
[[ "${got}" == "${want}" ]] || fail_test "${label}: got '${got}', want '${want}'"
}
test_hostname_validation() {
valid_hostname "mail.example.com" || fail_test "valid hostname rejected"
valid_hostname "mx-1.example.co.uk" || fail_test "valid multi-label hostname rejected"
! valid_hostname "mail_example.com" || fail_test "hostname with underscore accepted"
! valid_hostname "localhost" || fail_test "single-label hostname accepted"
! valid_hostname "-mail.example.com" || fail_test "hostname with leading hyphen accepted"
}
test_password_validation() {
LANQIN_ADMIN_PASSWORD="abc123"
assert_eq "abc123" "$(prompt_admin_password)" "six-character password"
if (LANQIN_ADMIN_PASSWORD="abc12" prompt_admin_password >/dev/null 2>&1); then
fail_test "five-character password accepted"
fi
if (LANQIN_ADMIN_PASSWORD="abc\$123" prompt_admin_password >/dev/null 2>&1); then
fail_test "unsafe env-file password accepted"
fi
if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then
fail_test "password beginning with an env-file comment marker accepted"
fi
}
test_install_configuration() {
local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5"
local temp_dir
temp_dir="$(mktemp -d)"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env.example"
export INSTALL_DIR="${temp_dir}"
export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}"
export LANQIN_PUBLIC_HOSTNAME="mail.example.com"
export LANQIN_ADMIN_USERNAME="admin"
export LANQIN_ADMIN_PASSWORD="abc123"
export LANQIN_INSTALL_WEB_MODE="${web_mode}"
configure_first_install
configure_runtime_bindings
assert_eq "${firewall_mode}" "$(env_value LANQIN_INSTALL_FIREWALL_MODE)" "firewall mode"
assert_eq "${web_mode}" "$(env_value LANQIN_INSTALL_WEB_MODE)" "web mode"
assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind"
assert_eq "${want_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL"
assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag"
assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password"
}
test_nginx_configuration() {
local temp_dir old_path
temp_dir="$(mktemp -d)"
old_path="${PATH}"
mkdir -p "${temp_dir}/bin" "${temp_dir}/install" "${temp_dir}/certs" "${temp_dir}/acme"
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/nginx"
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/systemctl"
chmod 0755 "${temp_dir}/bin/nginx" "${temp_dir}/bin/systemctl"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/install/.env"
export PATH="${temp_dir}/bin:${PATH}"
INSTALL_DIR="${temp_dir}/install"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
ACME_WEBROOT="${temp_dir}/acme"
CERT_DIR="${temp_dir}/certs"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
write_nginx_http_config
grep -Fq 'proxy_pass http://127.0.0.1:8088;' "${NGINX_CONFIG}" || fail_test "HTTP proxy target missing"
grep -Fq 'root '"${ACME_WEBROOT}"';' "${NGINX_CONFIG}" || fail_test "ACME webroot missing"
write_nginx_https_config
grep -Fq 'listen 443 ssl http2;' "${NGINX_CONFIG}" || fail_test "HTTPS listener missing"
# shellcheck disable=SC2016
grep -Fq 'return 301 https://$host$request_uri;' "${NGINX_CONFIG}" || fail_test "HTTPS redirect missing"
grep -Fq "ssl_certificate ${CERT_DIR}/fullchain.pem;" "${NGINX_CONFIG}" || fail_test "certificate path missing"
PATH="${old_path}"
}
test_compose_configuration() {
# shellcheck disable=SC2016
grep -Fq '${LANQIN_HTTP_BIND:-80}:80' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "HTTP port mapping missing"
! grep -Fq 'LANQIN_HTTPS_BIND' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "dead container HTTPS mapping remains"
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
}
test_legacy_configuration_is_preserved() {
local temp_dir
temp_dir="$(mktemp -d)"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env"
export INSTALL_DIR="${temp_dir}"
set_env LANQIN_INSTALL_WEB_MODE ""
set_env LANQIN_HTTP_BIND "127.0.0.1:9090"
configure_first_install
configure_runtime_bindings
assert_eq "127.0.0.1:9090" "$(env_value LANQIN_HTTP_BIND)" "legacy HTTP bind"
}
test_hostname_validation
test_password_validation
test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false"
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
test_install_configuration 3 3 "80" "http://mail.example.com" "true"
test_nginx_configuration
test_compose_configuration
test_legacy_configuration_is_preserved
printf 'install.sh tests passed\n'