Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 497aefb903 | |||
| 1af4b7250e | |||
| 1ce75ef241 | |||
| 99b8b7dee8 | |||
| 697ed236cc | |||
| b55c298ef4 | |||
| 800d482c77 | |||
| ccd5c4efd5 | |||
| da888234b9 | |||
| 2f7494e5e6 | |||
| 1350611908 | |||
| 9a489992ed | |||
| 18f8d870e8 | |||
| 6059954596 | |||
| 65bc16bd92 | |||
| 7eac123f0a | |||
| 550d40a023 |
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 一键部署与运维
|
||||
|
||||
- 新增统一管理菜单,支持一键安装、更新、修复、查看状态、重启服务、查看日志、配置 SSL、版本回滚和卸载。
|
||||
- 空白服务器进入安装流程,检测到已有安装时可直接更新或修复,减少重复操作。
|
||||
- 重新安装前自动完整备份旧安装目录,避免误覆盖现有配置和数据。
|
||||
- 更新前自动备份 SQLite 数据库,更新失败时支持回滚。
|
||||
- 完善交互式安装引导,可选择防火墙策略,并依次设置邮件服务器域名、管理员用户名、管理员密码以及 Nginx 和 SSL。
|
||||
- 管理员用户名默认使用 `admin`;管理员密码可回车自动生成 12 位随机密码,也可输入不少于 6 位的自定义密码。
|
||||
|
||||
### 邮箱与账号体验
|
||||
|
||||
- 修复管理员登录名 `admin` 被误显示为邮箱地址的问题。
|
||||
- 邮箱列表加载时显示“加载邮箱...”,账号尚未创建邮箱时显示“未创建邮箱”。
|
||||
- 统一空邮箱状态下的操作按钮为“前往邮箱管理”。
|
||||
- 管理员及具备邮箱管理权限的用户跳转至后台邮箱管理,普通用户跳转至个人中心的邮箱申请页面。
|
||||
|
||||
### 自助申请邮箱
|
||||
|
||||
- 在未创建邮箱页面明确标注开关位置:`后台管理 -> 系统设置 -> 邮件 -> 账号自助申请邮箱`。
|
||||
- 区分“未开启自助申请”和“未选择开放域名”两种状态,并给出对应处理提示。
|
||||
- 管理员可通过“前往设置”直接进入后台邮件设置页。
|
||||
- 普通用户无法自行申请时会提示联系管理员处理。
|
||||
- 修复通过链接进入系统设置时未自动切换到“邮件”设置标签的问题。
|
||||
- 补充简体中文、繁体中文和英文界面文案。
|
||||
|
||||
### 兼容与验证
|
||||
|
||||
- 更新和修复流程保留现有端口、反向代理、邮件数据、证书及数据库配置。
|
||||
- 已通过 ShellCheck、安装脚本测试、Go 后端测试和前端生产构建检查。
|
||||
|
||||
**完整更新日志**:[v1.2.4...v1.2.5](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.4...v1.2.5)
|
||||
@@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -39,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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -26,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:
|
||||
@@ -135,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
|
||||
@@ -160,7 +169,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=LanQin Email ${{ matrix.name }}
|
||||
org.opencontainers.image.title=NewSzxcn Email ${{ matrix.name }}
|
||||
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
|
||||
|
||||
- name: Build and push
|
||||
@@ -203,6 +212,11 @@ jobs:
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
|
||||
if [[ -f "${version_notes}" ]]; then
|
||||
cp "${version_notes}" generated-release-notes.md
|
||||
else
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
@@ -224,10 +238,9 @@ jobs:
|
||||
fi
|
||||
} > generated-release-notes.md
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
@@ -264,7 +277,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
title="LanQin Email ${tag}"
|
||||
title="NewSzxcn Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
else
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -29,10 +29,24 @@ 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)
|
||||
```
|
||||
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
|
||||
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在
|
||||
启动失败时自动回滚。
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
@@ -67,10 +81,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 +120,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
@@ -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`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
|
||||
+8
-8
@@ -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
@@ -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=
|
||||
|
||||
@@ -108,7 +108,13 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
loginName, err := cleanLoginName(req.LoginName, req.Email)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -137,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)
|
||||
@@ -183,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
LoginName string `json:"loginName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
@@ -211,6 +218,15 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
requestedLoginName := strings.TrimSpace(req.LoginName)
|
||||
loginName := existing.LoginName
|
||||
if requestedLoginName != "" {
|
||||
loginName, err = cleanUsername(requestedLoginName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
@@ -278,8 +294,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
emailIdentity := existing.Email
|
||||
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
|
||||
emailIdentity = loginName
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
badRequest(w, errors.New("登录名已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -320,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)
|
||||
@@ -538,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
|
||||
|
||||
@@ -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,7 +1430,19 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin username: %w", err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
|
||||
return nil
|
||||
}
|
||||
adminEmail := normalizeEmail(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return errors.New("invalid admin email")
|
||||
}
|
||||
@@ -1451,7 +1483,13 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
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(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
@@ -1572,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
|
||||
@@ -1582,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
|
||||
@@ -1598,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),
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1421,6 +1421,69 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminUsername: "admin",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a := newTestAppWithConfig(t, cfg)
|
||||
|
||||
var domains, mailboxes int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM domains`).Scan(&domains); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domains != 0 || mailboxes != 0 {
|
||||
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("username login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "rootadmin",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusOK {
|
||||
t.Fatalf("rename administrator code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "root@example.test",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusBadRequest {
|
||||
t.Fatalf("email-shaped login name code=%d", code)
|
||||
}
|
||||
|
||||
oldLogin := &testClient{t: t, server: ts}
|
||||
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("old username login code=%d", code)
|
||||
}
|
||||
newLogin := &testClient{t: t, server: ts}
|
||||
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("renamed username login code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -1905,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}
|
||||
@@ -1947,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}
|
||||
@@ -2124,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)
|
||||
@@ -2324,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}
|
||||
@@ -2433,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()
|
||||
|
||||
@@ -2517,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))}
|
||||
@@ -2526,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))}
|
||||
@@ -2706,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"})
|
||||
@@ -2744,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)
|
||||
}
|
||||
@@ -2755,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")
|
||||
@@ -2805,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")
|
||||
@@ -2859,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}
|
||||
@@ -2934,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}
|
||||
@@ -3066,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}
|
||||
@@ -3321,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",
|
||||
@@ -3418,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)
|
||||
@@ -3450,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)
|
||||
@@ -3524,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)
|
||||
@@ -3539,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)
|
||||
}
|
||||
@@ -3565,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)
|
||||
@@ -3607,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)
|
||||
@@ -3758,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")
|
||||
}
|
||||
}
|
||||
@@ -3768,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)
|
||||
}
|
||||
@@ -3813,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)
|
||||
}
|
||||
@@ -3889,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}
|
||||
@@ -4039,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}
|
||||
@@ -4506,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)
|
||||
@@ -4587,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()
|
||||
|
||||
@@ -4690,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)
|
||||
@@ -4743,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)
|
||||
}
|
||||
@@ -4765,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)
|
||||
@@ -4838,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)
|
||||
|
||||
@@ -4878,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)
|
||||
|
||||
@@ -4921,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}
|
||||
@@ -4974,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}
|
||||
@@ -5027,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}
|
||||
@@ -5073,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}
|
||||
@@ -5166,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)
|
||||
|
||||
@@ -5212,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)
|
||||
|
||||
@@ -5241,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)
|
||||
|
||||
@@ -5282,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)
|
||||
|
||||
|
||||
@@ -50,7 +50,13 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
loginName, err := cleanLoginName(req.LoginName, req.Email)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
@@ -64,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, "验证码生成失败,请稍后重试")
|
||||
@@ -81,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
|
||||
}
|
||||
@@ -106,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)
|
||||
@@ -170,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] {
|
||||
@@ -186,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})
|
||||
}
|
||||
|
||||
@@ -239,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)
|
||||
|
||||
@@ -14,6 +14,7 @@ type Config struct {
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
@@ -70,6 +71,7 @@ func LoadConfig() Config {
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1034,10 @@ func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -187,6 +194,17 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) {
|
||||
return loginName, nil
|
||||
}
|
||||
|
||||
func cleanUsername(value string) (string, error) {
|
||||
username, err := cleanLoginName(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return "", errors.New("登录名不能使用邮箱地址")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
@@ -164,7 +164,7 @@ export const api = {
|
||||
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
|
||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
||||
|
||||
@@ -69,6 +69,8 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
||||
"加载邮箱...": { "zh-TW": "載入信箱...", en: "Loading mailboxes..." },
|
||||
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
@@ -179,6 +181,12 @@ const exactTranslations: Record<string, Translation> = {
|
||||
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
|
||||
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
|
||||
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
|
||||
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
|
||||
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
|
||||
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
|
||||
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
|
||||
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
|
||||
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
|
||||
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
|
||||
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
||||
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
||||
|
||||
@@ -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}>
|
||||
<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>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
@@ -132,7 +133,7 @@ export function AdminPage() {
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} initialTab={params.get("settingsTab")} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -422,13 +423,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 = {
|
||||
@@ -1018,7 +1019,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemSettingsSection({ settings, domains }: { settings?: SystemSettings; domains: Domain[] }) {
|
||||
function SystemSettingsSection({ settings, domains, initialTab }: { settings?: SystemSettings; domains: Domain[]; initialTab?: string | null }) {
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const qc = useQueryClient()
|
||||
@@ -1030,7 +1031,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
|
||||
const canResetTemplates = hasPermission(user, "admin.templates.reset")
|
||||
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
|
||||
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
|
||||
const requestedTab = initialTab as SettingsTab | undefined
|
||||
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base")
|
||||
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
|
||||
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
|
||||
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
|
||||
@@ -1858,7 +1860,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", "停用"]]} />
|
||||
@@ -1896,6 +1898,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
}, [user, open])
|
||||
const mut = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateUser(user.id, {
|
||||
loginName: String(form.get("loginName") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
role,
|
||||
disabled: disabled === "disabled",
|
||||
@@ -1910,7 +1913,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>编辑账号</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly />
|
||||
<Field name="loginName" label="登录名" defaultValue={accountLoginName(user)} type="text" autoComplete="off" />
|
||||
<Field name="displayName" label="显示名称" defaultValue={user.displayName} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} />
|
||||
@@ -1927,7 +1930,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 }) {
|
||||
@@ -1947,7 +1950,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[] }) {
|
||||
@@ -1978,11 +1981,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 公钥。收件服务器用此密钥验证邮件是否由你发出。"
|
||||
|
||||
+15
-31
@@ -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, Mailbox as MailboxIcon, 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("")
|
||||
@@ -174,6 +174,7 @@ export function MailPage() {
|
||||
const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
const canViewUnknownMail = user?.role === "admin"
|
||||
const canManageMailboxes = hasPermission(user, "admin.mailboxes.view")
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
|
||||
@@ -1186,9 +1187,9 @@ export function MailPage() {
|
||||
<MailboxSwitcher
|
||||
collapsed={sidebarCollapsed}
|
||||
mailboxes={mailboxList.data?.items || []}
|
||||
loading={mailboxList.isLoading}
|
||||
selectedMailboxId={selectedMailboxId}
|
||||
selectedMailbox={selectedMailbox}
|
||||
fallbackAddress={selectedMailbox?.address || me.data?.user.email || ""}
|
||||
unreadCount={mailboxUnreadCount}
|
||||
onSelect={switchMailbox}
|
||||
/>
|
||||
@@ -1468,7 +1469,7 @@ export function MailPage() {
|
||||
) : !canReadMail ? (
|
||||
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
|
||||
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
|
||||
<NoMailboxState onOpenSettings={openSettings} />
|
||||
<NoMailboxState onManageMailboxes={() => navigate(canManageMailboxes ? "/admin?section=mailboxes" : "/profile?tab=mailboxes")} />
|
||||
) : mailView === "scheduled" && canScheduleMail ? (
|
||||
<ScheduledSendView
|
||||
compact={compactMailLayout}
|
||||
@@ -2061,7 +2062,7 @@ function externalAccountSubtitle(account: ExternalImapAccount) {
|
||||
return [name, account.host, mode].filter(Boolean).join(" · ")
|
||||
}
|
||||
|
||||
function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
function NoMailboxState({ onManageMailboxes }: { onManageMailboxes: () => void }) {
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center p-6">
|
||||
<div className="w-full max-w-md rounded-lg border border-dashed p-8 text-center">
|
||||
@@ -2069,9 +2070,9 @@ function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">还没有可用邮箱</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。</div>
|
||||
<Button className="mt-5" onClick={onOpenSettings}>
|
||||
<Settings className="h-4 w-4" />前往个人中心
|
||||
<div className="mt-2 text-sm text-muted-foreground">请前往邮箱管理,创建、申请或联系管理员分配邮箱。</div>
|
||||
<Button className="mt-5" onClick={onManageMailboxes}>
|
||||
<MailboxIcon className="h-4 w-4" />前往邮箱管理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3215,10 +3216,11 @@ function UnreadBadge({ count, tone = "danger" }: { count?: number; tone?: "dange
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMailbox, fallbackAddress, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailboxId: string; selectedMailbox?: Mailbox; fallbackAddress?: string; unreadCount: number; onSelect: (mailboxId: string) => void }) {
|
||||
function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, selectedMailbox, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; loading: boolean; selectedMailboxId: string; selectedMailbox?: Mailbox; unreadCount: number; onSelect: (mailboxId: string) => void }) {
|
||||
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||
const isAllSelected = selectedMailboxId === "all"
|
||||
const displayAddress = isAllSelected ? "全部邮箱" : selectedMailbox?.address || fallbackAddress || "选择邮箱"
|
||||
const mailboxUnavailable = loading || mailboxes.length === 0
|
||||
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未创建邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
|
||||
const selectedUnreadCount = isAllSelected ? unreadCount : (selectedMailbox?.unreadCount ?? unreadCount)
|
||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||
const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery)
|
||||
@@ -3232,13 +3234,13 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
|
||||
<Button disabled={mailboxUnavailable} variant="outline" className={cn("h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background", collapsed && "w-8 flex-none justify-center px-0")} title={displayAddress}>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{displayAddress}</span>
|
||||
<UnreadBadge count={selectedUnreadCount} />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{!mailboxUnavailable && <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -4728,24 +4730,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`
|
||||
|
||||
+89
-723
File diff suppressed because it is too large
Load Diff
@@ -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}>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
+15
-2
@@ -20,6 +20,18 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
||||
# 手动部署可执行:openssl rand -hex 24
|
||||
LANQIN_UPDATE_TOKEN=
|
||||
|
||||
# 一键安装器记录的部署方式。手动部署时可以留空。
|
||||
LANQIN_INSTALL_FIREWALL_MODE=
|
||||
LANQIN_INSTALL_WEB_MODE=
|
||||
|
||||
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
|
||||
LANQIN_HTTP_BIND=80
|
||||
LANQIN_SMTP_BIND=25
|
||||
LANQIN_SMTPS_BIND=465
|
||||
LANQIN_SUBMISSION_BIND=587
|
||||
LANQIN_IMAPS_BIND=993
|
||||
LANQIN_POP3S_BIND=995
|
||||
|
||||
# =========================
|
||||
# 对外访问地址
|
||||
# =========================
|
||||
@@ -39,8 +51,9 @@ LANQIN_TLS_KEY_FILE=
|
||||
# =========================
|
||||
# 初始管理员
|
||||
# =========================
|
||||
# 第一次启动时会创建这个管理员账号。
|
||||
LANQIN_ADMIN_EMAIL=admin@example.com
|
||||
# 第一次启动时只创建管理员账号,不会自动创建同名邮箱或域名。
|
||||
# 登录名不能使用邮箱地址,之后可在后台“账号”中修改。
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
|
||||
# 生产环境必须改掉默认密码。
|
||||
LANQIN_ADMIN_PASSWORD=ChangeMe123!
|
||||
|
||||
+11
-2
@@ -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` 即可。
|
||||
@@ -26,7 +28,7 @@ sudo newszxcn-email rollback
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_USERNAME / LANQIN_ADMIN_PASSWORD
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -154,6 +156,13 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
## 邮件客户端 TLS 证书
|
||||
|
||||
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
|
||||
此时可在 `.env` 调整 Web 端口绑定,避免与宿主机 Nginx 的 `80/443` 冲突:
|
||||
|
||||
```dotenv
|
||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
||||
```
|
||||
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
@@ -174,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`。更新后执行:
|
||||
|
||||
@@ -6,19 +6,17 @@ services:
|
||||
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "25:25"
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "993:993"
|
||||
- "995:995"
|
||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||
- "${LANQIN_IMAPS_BIND:-993}:993"
|
||||
- "${LANQIN_POP3S_BIND:-995}:995"
|
||||
volumes:
|
||||
- ./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"
|
||||
|
||||
+596
-36
@@ -4,8 +4,11 @@ set -Eeuo pipefail
|
||||
REPOSITORY="zxyszx/NewSzxcn-Email"
|
||||
RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
|
||||
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
|
||||
COMMAND="${1:-install}"
|
||||
COMMAND="${1:-menu}"
|
||||
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' "$*"; }
|
||||
@@ -18,10 +21,13 @@ NewSzxcn Email 管理命令
|
||||
|
||||
用法:newszxcn-email <command>
|
||||
|
||||
install 首次安装或修复部署
|
||||
menu 显示安装与运维菜单
|
||||
install 首次安装;已有安装会先完整备份再重新安装
|
||||
update 备份数据库并更新到最新版
|
||||
status 查看容器与健康状态
|
||||
logs 持续查看运行日志
|
||||
restart 重启服务并重载 Nginx
|
||||
certificate 申请或续期自动模式的 SSL 证书
|
||||
rollback 回滚到上次命令行更新前的镜像
|
||||
uninstall 停止并移除容器,保留邮件与配置
|
||||
EOF
|
||||
@@ -37,6 +43,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 +76,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 +103,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 +134,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 +146,144 @@ prompt_value() {
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
prompt_choice() {
|
||||
local variable="$1" prompt="$2" default_value="$3" max_value="${4:-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}" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= max_value )); then
|
||||
printf '%s' "${value}"
|
||||
return
|
||||
fi
|
||||
prompt_text "[提示] 请输入 1 至 ${max_value}。\n"
|
||||
value=""
|
||||
has_tty || fail "${variable} 必须设置为 1 至 ${max_value}。"
|
||||
done
|
||||
}
|
||||
|
||||
prompt_menu_choice() {
|
||||
local default_value="$1" value="${LANQIN_MENU_ACTION:-}"
|
||||
if [[ -z "${value}" ]] && ! has_tty; then
|
||||
fail "非交互环境请直接使用 install、update、status 等子命令。"
|
||||
fi
|
||||
while true; do
|
||||
if [[ -z "${value}" ]] && has_tty; then
|
||||
read -r -p "请选择 [${default_value}]: " value </dev/tty
|
||||
fi
|
||||
value="${value:-${default_value}}"
|
||||
if [[ "${value}" =~ ^[0-9]$ ]]; then
|
||||
printf '%s' "${value}"
|
||||
return
|
||||
fi
|
||||
prompt_text "[提示] 请输入 0 至 9。\n"
|
||||
value=""
|
||||
has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 9。"
|
||||
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_email 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_email="$(prompt_value LANQIN_ADMIN_EMAIL "初始管理员邮箱" "admin@${hostname#mail.}")"
|
||||
[[ "${admin_email}" == *@*.* ]] || fail "管理员邮箱格式不正确。"
|
||||
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_EMAIL "${admin_email}"
|
||||
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,14 +298,105 @@ 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}"
|
||||
local attempts="${1:-60}" bind port
|
||||
bind="$(env_value LANQIN_HTTP_BIND || true)"
|
||||
bind="${bind:-80}"
|
||||
port="${bind##*:}"
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if curl -fsS --max-time 3 http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
@@ -167,6 +404,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)"
|
||||
@@ -186,17 +583,52 @@ remember_current_image() {
|
||||
printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
do_repair_install() {
|
||||
refresh_assets
|
||||
ensure_update_token
|
||||
configure_runtime_bindings
|
||||
ensure_docker
|
||||
backup_database
|
||||
remember_current_image
|
||||
configure_firewall
|
||||
prepare_directories
|
||||
log "正在拉取并修复 NewSzxcn Email 服务..."
|
||||
compose pull
|
||||
log "正在启动服务..."
|
||||
if ! compose up -d --remove-orphans; then
|
||||
warn "修复后容器启动失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "修复失败,已回滚到原镜像。"
|
||||
fi
|
||||
if ! wait_for_health 90; then
|
||||
warn "修复后健康检查失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "修复失败,已回滚到原镜像。"
|
||||
fi
|
||||
configure_web_mode
|
||||
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
do_backup_reinstall
|
||||
return
|
||||
fi
|
||||
|
||||
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 端口可访问。"
|
||||
}
|
||||
@@ -210,13 +642,17 @@ do_update() {
|
||||
remember_current_image
|
||||
log "正在拉取最新版..."
|
||||
compose pull
|
||||
compose up -d --remove-orphans
|
||||
if ! compose up -d --remove-orphans; then
|
||||
warn "新版本容器启动失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "更新失败,已回滚到原镜像。"
|
||||
fi
|
||||
if ! wait_for_health 90; then
|
||||
warn "新版本健康检查失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "更新失败,已回滚到原镜像。"
|
||||
fi
|
||||
success "系统已更新,配置、邮件和数据库均已保留。"
|
||||
success "系统已更新,配置、邮件、证书和数据库均已保留。"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
@@ -230,10 +666,41 @@ 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
|
||||
if curl -fsS --max-time 3 http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||
if wait_for_health 1; then
|
||||
success "Web 与 API 健康检查正常。"
|
||||
else
|
||||
fail "健康检查失败。"
|
||||
@@ -243,18 +710,111 @@ 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
|
||||
do_backup_reinstall() {
|
||||
local backup_dir
|
||||
backup_dir="${INSTALL_DIR}.backup-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] && command -v docker >/dev/null 2>&1; then
|
||||
ensure_docker
|
||||
compose down --remove-orphans
|
||||
fi
|
||||
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
|
||||
|
||||
mv "${INSTALL_DIR}" "${backup_dir}"
|
||||
success "旧安装已完整备份到 ${backup_dir}。"
|
||||
log "现在开始全新安装。"
|
||||
do_install
|
||||
}
|
||||
|
||||
do_menu() {
|
||||
local installed="false" default_choice="1" public_url="" choice
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
installed="true"
|
||||
default_choice="2"
|
||||
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
|
||||
fi
|
||||
|
||||
prompt_text '\n==================================================\n'
|
||||
prompt_text ' NewSzxcn Email 一键安装与管理\n'
|
||||
prompt_text '==================================================\n'
|
||||
if [[ "${installed}" == "true" ]]; then
|
||||
prompt_text " 状态:已安装\n 路径:${INSTALL_DIR}\n"
|
||||
[[ -n "${public_url}" ]] && prompt_text " 地址:${public_url}\n"
|
||||
else
|
||||
prompt_text ' 状态:未安装\n'
|
||||
fi
|
||||
prompt_text '--------------------------------------------------\n'
|
||||
prompt_text ' 1. 安装 / 重新安装(旧数据自动备份)\n'
|
||||
prompt_text ' 2. 更新系统(数据库自动备份)\n'
|
||||
prompt_text ' 3. 修复现有安装\n'
|
||||
prompt_text ' 4. 查看运行状态\n'
|
||||
prompt_text ' 5. 重启服务\n'
|
||||
prompt_text ' 6. 查看实时日志\n'
|
||||
prompt_text ' 7. 申请或续期 SSL 证书\n'
|
||||
prompt_text ' 8. 回滚上个命令行版本\n'
|
||||
prompt_text ' 9. 卸载服务(保留数据)\n'
|
||||
prompt_text ' 0. 退出\n'
|
||||
prompt_text '==================================================\n'
|
||||
|
||||
choice="$(prompt_menu_choice "${default_choice}")"
|
||||
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
|
||||
fail "尚未安装,请先选择 1。"
|
||||
fi
|
||||
|
||||
case "${choice}" in
|
||||
0) success "已退出,未作任何修改。" ;;
|
||||
1) do_install ;;
|
||||
2) do_update ;;
|
||||
3) do_repair_install ;;
|
||||
4) ensure_docker; do_status ;;
|
||||
5) do_restart ;;
|
||||
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
|
||||
7) do_certificate ;;
|
||||
8) ensure_docker; do_rollback ;;
|
||||
9) ensure_docker; do_uninstall ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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 ;;
|
||||
menu) require_root; require_curl; do_menu ;;
|
||||
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
|
||||
|
||||
Generated
+63
-105
@@ -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:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/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_menu_choice() {
|
||||
export LANQIN_MENU_ACTION=0
|
||||
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
|
||||
export LANQIN_MENU_ACTION=1
|
||||
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
|
||||
export LANQIN_MENU_ACTION=9
|
||||
assert_eq "9" "$(prompt_menu_choice 1)" "menu uninstall action"
|
||||
unset LANQIN_MENU_ACTION
|
||||
}
|
||||
|
||||
test_backup_reinstall_preserves_existing_directory() (
|
||||
local temp_dir backup_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/newszxcn-email"
|
||||
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
|
||||
|
||||
do_install() {
|
||||
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "fresh install started before old directory was moved"
|
||||
}
|
||||
|
||||
do_backup_reinstall
|
||||
backup_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.backup-*' -print -quit)"
|
||||
[[ -n "${backup_dir}" ]] || fail_test "existing install backup directory missing"
|
||||
grep -Fq 'existing-data' "${backup_dir}/marker" || fail_test "existing install data was not preserved"
|
||||
)
|
||||
|
||||
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
|
||||
test_menu_choice
|
||||
test_backup_reinstall_preserves_existing_directory
|
||||
|
||||
printf 'install.sh tests passed\n'
|
||||
Reference in New Issue
Block a user