Compare commits

...

8 Commits

Author SHA1 Message Date
zxyszx df50f8b3ef fix: reset mailbox view on refresh
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 12:55:21 +08:00
zxyszx 397ce51800 fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 04:14:36 +08:00
zxyszx e789cf9b14 chore: prepare v1.2.6 release
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 03:44:14 +08:00
zxyszx 7e480795fc feat: add administrator password recovery menu 2026-08-04 03:40:34 +08:00
zxyszx a2eb1fbc52 test: support GNU stat permissions 2026-08-04 02:28:21 +08:00
zxyszx cab71c0539 fix: make snapshot validation shellcheck-safe 2026-08-04 02:26:55 +08:00
zxyszx 6301182cfc feat: harden installer management menu 2026-08-04 02:23:46 +08:00
zxyszx 6d0147b639 docs: add NewSzxcn mailbox guide 2026-08-04 01:44:14 +08:00
16 changed files with 1388 additions and 163 deletions
+36
View File
@@ -0,0 +1,36 @@
## 本次更新
### 邮箱管理跳转
- 账号尚未创建邮箱时,点击“前往邮箱管理”统一进入前端个人邮箱管理页。
- 管理员和普通用户使用相同入口,可在前端创建、申请或查看自己的邮箱,不再误跳到后台邮箱列表。
### 管理员密码恢复
- 管理菜单新增“查看管理员登录信息”,可查看安装时或最近一次命令行重置后记录的管理员用户名和密码。
- 新增“重置管理员统一登录密码”,支持回车自动生成 12 位密码,或输入不少于 6 位的自定义密码。
- 重置前自动备份并校验 SQLite 数据库;只更新配置管理员账号,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不影响普通用户及其邮箱。
- 数据库中的密码使用 bcrypt 哈希,无法反向解密;若密码曾在网页中修改,命令行显示的旧记录可能已经失效,可直接使用重置功能设置新密码。
### 一键安装与运维安全
- 管理菜单扩展为安装、更新、修复、状态、重启、日志、SSL、回滚、邮箱指南、管理员密码恢复和卸载等完整操作。
- 更新前创建完整回滚快照,包含数据库、镜像、Compose、环境配置、安装脚本、Nginx 和证书;更新失败或健康检查失败时自动恢复。
- 修复“更新前先覆盖 Compose 和管理脚本”的风险,改为临时下载、语法与 Compose 校验通过后再原子替换。
- 容器停止时也会离线备份数据库;备份为空或完整性检查失败时终止更新。
- 重新安装会完整保存旧安装,安装失败时自动恢复原目录、Nginx 和旧容器。
- 移除“开放全部端口”危险选项,不再清空服务器现有防火墙规则。
- 卸载时可单独停止当前域名的证书自动续期,不影响 acme.sh 管理的其他域名。
### 邮箱指南与快捷命令
- 新增 `ns` 快捷命令,可随时打开服务器管理菜单。
- 新增 `newszxcn-email guide`,动态显示前后台地址、管理员用户名、证书到期时间和自动续期状态。
- 新增完整的 NewSzxcn 邮箱指南,覆盖域名 DNS、自助申请邮箱、未知收件、SSL 自动续期、更新、回滚与日常运维。
### 兼容与验证
- 本次更新不删除现有邮件、域名、账号、证书和配置。
- 已通过 ShellCheck、安装脚本测试、Go API 测试、shadcn/ui 检查和前端生产构建。
**完整更新日志**[v1.2.5...v1.2.6](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.5...v1.2.6)
+21
View File
@@ -0,0 +1,21 @@
## 本次更新
### 修复网页更新出现 502
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
- 前端遇到更新期间的 `502``503``504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
### 更新说明
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
-`v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
### 验证
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
+18
View File
@@ -0,0 +1,18 @@
## 本次更新
### 修复邮箱选择器默认状态
- 没有注册邮箱时,邮箱选择器明确显示“未注册邮箱”。
- 已有邮箱时,每次打开或刷新邮箱页面默认进入“全部邮箱”的“收件箱”,不再恢复上次选择的单个邮箱。
- 用户仍可在当前页面正常切换全部邮箱或单个邮箱,切换后统一返回收件箱。
### 修复邮箱下拉菜单宽度
- 邮箱下拉菜单现在与上方选择框等宽,不再向右多出一截。
- 搜索框、全部邮箱和邮箱地址均在相同宽度内对齐显示。
### 验证
- 已通过前端 TypeScript 检查、生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.7...v1.2.8](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.7...v1.2.8)
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
+16 -6
View File
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
[邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能
@@ -37,13 +37,13 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在
启动失败时自动回滚
`/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
- 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新
@@ -70,15 +72,21 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
sudo newszxcn-email update
```
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
```bash
sudo newszxcn-email rollback
```
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
常用运维命令:
```bash
sudo ns
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
@@ -86,7 +94,9 @@ sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
## DNS 与端口
+1 -1
View File
@@ -1 +1 @@
1.2.5
1.2.8
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to back up database")
return
}
if err := a.triggerUpdateService(r.Context()); err != nil {
a.log.Error("trigger system update", "error", err)
respondError(w, http.StatusBadGateway, "failed to start update")
return
}
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
respondJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
"targetVersion": info.LatestVersion,
"message": "更新已启动,服务会在完成后自动恢复",
})
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
}
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{
Timeout: 30 * time.Second,
Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
return nil
}
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
go func() {
// Let the accepted response reach the browser before Watchtower replaces this container.
time.Sleep(250 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := a.triggerUpdateService(ctx); err != nil {
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
}
}()
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil {
@@ -10,8 +10,10 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSystemVersionAndUpdate(t *testing.T) {
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
defer releaseServer.Close()
var updateRequests atomic.Int32
updateStarted := make(chan struct{}, 1)
releaseUpdate := make(chan struct{})
var releaseUpdateOnce sync.Once
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
defer releaseBlockedUpdate()
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("update method = %s", r.Method)
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
t.Errorf("authorization = %q", got)
}
updateRequests.Add(1)
updateStarted <- struct{}{}
<-releaseUpdate
w.WriteHeader(http.StatusOK)
}))
defer updateServer.Close()
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
t.Fatalf("unexpected version response: %+v", version)
}
var update map[string]any
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
t.Fatalf("update code=%d response=%v", code, update)
type updateResponse struct {
code int
err error
}
if updateRequests.Load() != 1 {
t.Fatalf("update requests=%d", updateRequests.Load())
response := make(chan updateResponse, 1)
go func() {
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
if err != nil {
response <- updateResponse{err: err}
return
}
req.AddCookie(admin.cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
response <- updateResponse{err: err}
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
response <- updateResponse{code: resp.StatusCode}
}()
select {
case result := <-response:
if result.err != nil || result.code != http.StatusAccepted {
t.Fatalf("update response=%+v", result)
}
case <-time.After(2 * time.Second):
releaseBlockedUpdate()
t.Fatal("update response waited for container replacement")
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("scheduled update request did not start")
}
releaseBlockedUpdate()
if got := updateRequests.Load(); got != 1 {
t.Fatalf("update requests=%d", got)
}
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
if err != nil || len(backups) != 1 {
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
const update = useMutation({
mutationFn: async () => {
setUpdatePhase("starting")
const result = await api.updateSystem()
const targetVersion = version.data?.latestVersion
let result: Awaited<ReturnType<typeof api.updateSystem>>
try {
result = await api.updateSystem()
} catch (error) {
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
result = {
ok: true,
currentVersion,
targetVersion,
message: "更新请求已发送,正在等待服务恢复",
}
}
setUpdatePhase("restarting")
await waitForUpdatedService(result.targetVersion)
return result
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function isUpdateConnectionInterruption(error: unknown) {
if (!(error instanceof Error)) return false
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
}
+1 -1
View File
@@ -70,7 +70,7 @@ const exactTranslations: Record<string, Translation> = {
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
"加载邮箱...": { "zh-TW": "載入信箱...", en: "Loading mailboxes..." },
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
"未注册邮箱": { "zh-TW": "未註冊信箱", en: "Unregistered mailbox" },
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
+13 -13
View File
@@ -88,7 +88,6 @@ const filterLabels: Record<MailFilter, string> = {
const emptyAdvancedSearch: AdvancedMailSearch = { from: "", to: "", subject: "", startDate: "", endDate: "", hasAttachments: false, unread: false, starred: false }
const emptyAdvancedSearchDraft: AdvancedMailSearchDraft = { ...emptyAdvancedSearch }
const mailboxSelectionStorageVersion = "2"
const mailCompactBreakpoint = 768
const mailDetailBreakpoint = 768
@@ -122,10 +121,7 @@ export function MailPage() {
const [composeDraft, setComposeDraft] = React.useState<ComposeDraft | undefined>()
const sidebarCollapsed = false
const [mailFilter, setMailFilter] = React.useState<MailFilter>("all")
const [selectedMailboxId, setSelectedMailboxId] = React.useState(() => {
if (localStorage.getItem("lanqin:selected-mailbox-version") !== mailboxSelectionStorageVersion) return "all"
return localStorage.getItem("lanqin:selected-mailbox") || "all"
})
const [selectedMailboxId, setSelectedMailboxId] = React.useState("all")
const [selectedExternalAccountId, setSelectedExternalAccountId] = React.useState("")
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
const [externalFolder, setExternalFolder] = React.useState("INBOX")
@@ -174,7 +170,6 @@ 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
@@ -492,14 +487,19 @@ export function MailPage() {
}
if (!selectedMailboxId || (selectedMailboxId !== "all" && !items.some((item) => item.id === selectedMailboxId))) {
setSelectedMailboxId("all")
setSelectedExternalAccountId("")
setFolder("Inbox")
setMailView("folder")
setSelectedLabelId("")
setSelectedId(null)
setMailFilter("all")
}
}, [mailboxList.isSuccess, mailboxList.data?.items, selectedMailboxId])
React.useEffect(() => {
if (selectedMailboxId) localStorage.setItem("lanqin:selected-mailbox", selectedMailboxId)
else localStorage.removeItem("lanqin:selected-mailbox")
localStorage.setItem("lanqin:selected-mailbox-version", mailboxSelectionStorageVersion)
}, [selectedMailboxId])
localStorage.removeItem("lanqin:selected-mailbox")
localStorage.removeItem("lanqin:selected-mailbox-version")
}, [])
React.useEffect(() => {
setSelectedId(null)
@@ -1469,7 +1469,7 @@ export function MailPage() {
) : !canReadMail ? (
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
<NoMailboxState onManageMailboxes={() => navigate(canManageMailboxes ? "/admin?section=mailboxes" : "/profile?tab=mailboxes")} />
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
) : mailView === "scheduled" && canScheduleMail ? (
<ScheduledSendView
compact={compactMailLayout}
@@ -3220,7 +3220,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
const [mailboxQuery, setMailboxQuery] = React.useState("")
const isAllSelected = selectedMailboxId === "all"
const mailboxUnavailable = loading || mailboxes.length === 0
const displayAddress = loading ? "加载邮箱..." : mailboxes.length === 0 ? "未创建邮箱" : isAllSelected ? "全部邮箱" : selectedMailbox?.address || "选择邮箱"
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)
@@ -3249,7 +3249,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel
align="start"
className={cn(
"max-w-[calc(100vw-32px)] p-1",
collapsed ? "w-[204px]" : "w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)] min-w-[calc(var(--radix-dropdown-menu-trigger-width)+2.375rem)]"
collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-[var(--radix-dropdown-menu-trigger-width)]"
)}
>
{mailboxes.length > 0 && (
+8 -1
View File
@@ -15,11 +15,18 @@ sudo newszxcn-email update
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email rollback
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。
## 最简单部署:单容器镜像版
+154
View File
@@ -0,0 +1,154 @@
# NewSzxcn 邮箱指南
本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。
## 一键安装
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
安装脚本会依次询问防火墙配置、邮件服务器域名、管理员用户名和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装完成后,请记录终端中显示的访问地址、管理员用户名和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
## 登录入口
假设安装时填写的邮件服务器域名为 `mail.example.com`
| 入口 | 地址 | 用途 |
| --- | --- | --- |
| 邮箱前台 | `https://mail.example.com/` | 收发邮件、申请邮箱和账号设置 |
| 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 |
管理员账号是安装时填写的用户名,默认为 `admin`。管理员用户名不是邮箱地址。
## 首次配置
### 1. 添加邮件域名
1. 登录 NewSzxcn Email 管理后台。
2. 进入“域名管理”,点击“添加域名”。
3. 填写需要收发邮件的域名并保存。
4. 点击该域名右侧的“DNS”,查看系统生成的记录。
5. 前往域名服务商的 DNS 管理页面,逐项添加 MX、SPF、DKIM 和 DMARC 记录。
6. 返回管理后台,点击“检测”。
7. 所有记录检测通过后,即可使用该域名创建邮箱。
DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能代替你修改域名服务商的 DNS。
### 2. 开启账号自助申请邮箱
1. 进入“管理后台 -> 系统设置 -> 邮件”。
2. 开启“账号自助申请邮箱”。
3. 在“开放域名”中勾选允许用户申请邮箱的域名。
4. 保存设置。
开启后,用户登录邮箱前台,进入“设置 -> 邮箱管理”,即可在账号配额范围内自行申请邮箱,无需管理员逐个分配。
如果账号还没有邮箱,邮箱前台会显示“还没有可用邮箱”。此时应点击“前往邮箱管理”,进入个人中心申请邮箱。
### 3. 开启无人收件
1. 进入“管理后台 -> 系统设置 -> 邮件”。
2. 开启“无人收件”并保存。
开启后,对于系统中已经添加并启用的邮件域名,即使收件地址尚未注册,服务器仍会接收邮件。例如已经启用 `example.com` 后,发送到 `111@example.com` 的邮件也会被保留。
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
## SSL 证书与自动续期
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
查看当前域名的证书和续期信息:
```bash
/root/.acme.sh/acme.sh --info --domain mail.example.com --ecc
```
查看证书实际到期时间:
```bash
openssl x509 -in /opt/newszxcn-email/certs/fullchain.pem -noout -enddate
```
手动申请、检查或重新安装证书:
```bash
sudo newszxcn-email certificate
```
证书续期计划由 `acme.sh` 和证书颁发机构动态决定,不应把预计续期日期写死在配置或文档中。
## 更新与运维
重新打开安装与运维菜单:
```bash
sudo ns
```
也可以执行 `sudo newszxcn-email menu`,或重新运行一键安装命令。
常用命令:
```bash
sudo newszxcn-email update
sudo newszxcn-email status
sudo newszxcn-email restart
sudo newszxcn-email logs
sudo newszxcn-email certificate
sudo newszxcn-email rollback
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
```
命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。
`guide` 命令会读取当前安装地址、管理员用户名、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后重置配置管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
## 必要端口
请同时检查服务器防火墙和云服务商安全组:
| 端口 | 用途 |
| --- | --- |
| `25/TCP` | 邮件服务器之间收发邮件 |
| `80/TCP` | HTTP 跳转和证书签发验证 |
| `443/TCP` | 邮箱前台和管理后台 |
| `465/TCP` | SMTP SSL 发信 |
| `587/TCP` | SMTP Submission 发信 |
| `993/TCP` | IMAP SSL 收信 |
| `995/TCP` | POP3 SSL 收信 |
部分云服务商默认封锁出站 `25/TCP`。网页可以正常打开并不代表公网邮件一定能够成功投递。
## 数据与备份
默认数据目录为 `/opt/newszxcn-email`。重要数据包括:
```text
/opt/newszxcn-email/
|-- .env
|-- data/
|-- mail/
|-- dkim/
`-- certs/
```
执行服务器快照或异地备份时,应同时保存这些目录。不要公开 `.env`、证书私钥、数据库备份或管理员登录信息。
## 更多文档
- [项目说明](../README.md)
- [Docker 部署说明](../deploy/README.md)
- [API 文档](API.md)
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases)
+653 -115
View File
@@ -6,9 +6,14 @@ RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
COMMAND="${1:-menu}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf"
ACME_WEBROOT="/var/www/newszxcn-acme"
CERT_DIR="${INSTALL_DIR}/certs"
GUIDE_FILE="/root/newszxcn-email-guide.txt"
CLI_PATH="${LANQIN_CLI_PATH:-/usr/local/bin/newszxcn-email}"
CLI_ALIAS_PATH="${LANQIN_CLI_ALIAS_PATH:-/usr/local/bin/ns}"
log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; }
success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; }
@@ -28,7 +33,10 @@ NewSzxcn Email 管理命令
logs 持续查看运行日志
restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次命令行更新前的镜像
rollback 回滚到上次更新前版本
guide 显示并更新 NewSzxcn 邮箱指南
credentials 查看管理员登录信息和记录密码
reset-password 重置管理员统一登录密码(含名下邮箱)
uninstall 停止并移除容器,保留邮件与配置
EOF
}
@@ -68,31 +76,93 @@ ensure_docker() {
}
compose() {
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
local runtime_image=""
if [[ -s "${RUNTIME_IMAGE_PIN}" ]]; then
runtime_image="$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")"
fi
if [[ -n "${runtime_image}" ]]; then
LANQIN_IMAGE="${runtime_image}" docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
else
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
fi
}
clear_runtime_image_pin() {
rm -f "${RUNTIME_IMAGE_PIN}"
}
script_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd
}
refresh_assets() {
stage_assets() {
local source_dir local_source="false"
local compose_new env_example_new installer_new
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}"
compose_new="${INSTALL_DIR}/.docker-compose.yml.new"
env_example_new="${INSTALL_DIR}/.env.example.new"
installer_new="${INSTALL_DIR}/.install.sh.new"
rm -f "${compose_new}" "${env_example_new}" "${installer_new}"
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
install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${compose_new}"
install -m 0644 "${source_dir}/deploy/.env.example" "${env_example_new}"
install -m 0755 "${source_dir}/install.sh" "${installer_new}"
else
curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${INSTALL_DIR}/docker-compose.yml"
curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${INSTALL_DIR}/.env.example"
curl -fsSL "${RAW_BASE}/install.sh" -o /usr/local/bin/newszxcn-email.new
chmod 0755 /usr/local/bin/newszxcn-email.new
mv /usr/local/bin/newszxcn-email.new /usr/local/bin/newszxcn-email
curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${compose_new}"
curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${env_example_new}"
curl -fsSL "${RAW_BASE}/install.sh" -o "${installer_new}"
fi
chmod 0755 "${installer_new}"
bash -n "${installer_new}" || fail "新版安装脚本语法检查失败,现有文件未修改。"
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -x "${installer_new}" || fail "新版安装脚本 ShellCheck 未通过,现有文件未修改。"
fi
if [[ -f "${INSTALL_DIR}/.env" ]] && command -v docker >/dev/null 2>&1; then
docker compose --project-directory "${INSTALL_DIR}" --env-file "${INSTALL_DIR}/.env" -f "${compose_new}" config >/dev/null \
|| fail "新版 Docker Compose 配置检查失败,现有文件未修改。"
fi
}
apply_staged_assets() {
install -m 0644 "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/docker-compose.yml" || return 1
install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1
install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1
mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1
rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new"
}
ensure_cli_alias() {
local target="${CLI_PATH}" alias_path="${CLI_ALIAS_PATH}" existing
[[ -x "${target}" ]] || return 0
if [[ -L "${alias_path}" ]]; then
existing="$(readlink "${alias_path}" 2>/dev/null || true)"
if [[ "${existing}" == "${target}" ]]; then
return 0
fi
warn "未修改 ${alias_path}:现有符号链接指向 ${existing:-未知目标}"
return 0
fi
if [[ -e "${alias_path}" ]]; then
warn "未创建快捷命令 ns${alias_path} 已被其他文件占用。"
return 0
fi
existing="$(type -P ns 2>/dev/null || true)"
if [[ -n "${existing}" && "${existing}" != "${alias_path}" ]]; then
warn "未创建快捷命令 ns:系统中已存在 ${existing}"
return 0
fi
ln -s "${target}" "${alias_path}"
success "快捷命令已创建:输入 ns 可打开管理菜单。"
}
refresh_assets() {
stage_assets
apply_staged_assets
ensure_cli_alias
}
random_secret() {
@@ -165,7 +235,7 @@ prompt_choice() {
}
prompt_menu_choice() {
local default_value="$1" value="${LANQIN_MENU_ACTION:-}"
local default_value="$1" max_value="${2:-12}" value="${LANQIN_MENU_ACTION:-}"
if [[ -z "${value}" ]] && ! has_tty; then
fail "非交互环境请直接使用 install、update、status 等子命令。"
fi
@@ -174,13 +244,13 @@ prompt_menu_choice() {
read -r -p "请选择 [${default_value}]: " value </dev/tty
fi
value="${value:-${default_value}}"
if [[ "${value}" =~ ^[0-9]$ ]]; then
if [[ "${value}" =~ ^[0-9]+$ ]] && (( value >= 0 && value <= max_value )); then
printf '%s' "${value}"
return
fi
prompt_text "[提示] 请输入 0 至 9。\n"
prompt_text "[提示] 请输入 0 至 ${max_value}。\n"
value=""
has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 9。"
has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 ${max_value}"
done
}
@@ -252,14 +322,58 @@ prompt_admin_password() {
done
}
prompt_reset_password() {
local password="${LANQIN_RESET_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 "[提示] 已自动生成 12 位新密码:${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 "[提示] 已自动生成 12 位新密码:${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"
password=""
continue
fi
printf '%s' "${password}"
return
done
}
configure_first_install() {
if [[ -f "${INSTALL_DIR}/.env" ]]; then
return
fi
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")"
prompt_text '\n防火墙配置 [1]\n1. 自动添加邮局必要端口规则(推荐)\n2. 保留现有防火墙,由用户自行配置\n'
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1" "2")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
@@ -343,7 +457,7 @@ configure_restricted_firewall() {
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
done
firewall-cmd --reload >/dev/null
success "firewalld 已仅开放 SSH 和邮局必要端口。"
success "firewalld 已添加 SSH 和邮局必要端口规则。"
return
fi
@@ -355,36 +469,17 @@ configure_restricted_firewall() {
ufw allow "${ssh_port}/tcp" >/dev/null
done
ufw --force enable >/dev/null
success "UFW 已开放 SSH 和邮局必要端口。"
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 ;;
3) warn "检测到旧版开放全部端口配置。为避免破坏现有安全规则,本次不再修改防火墙。" ;;
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
*) fail "防火墙模式配置无效。" ;;
esac
@@ -415,6 +510,16 @@ ensure_nginx() {
fi
}
reload_nginx() {
command -v nginx >/dev/null 2>&1 || return 0
nginx -t >/dev/null 2>&1 || return 1
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
}
write_nginx_http_config() {
local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -505,6 +610,7 @@ EOF
}
ensure_acme() {
ensure_cron_scheduler
if [[ ! -x /root/.acme.sh/acme.sh ]]; then
local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -512,6 +618,33 @@ ensure_acme() {
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
fi
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。"
if ! acme_cron_enabled; then
/root/.acme.sh/acme.sh --install-cronjob >/dev/null || fail "acme.sh 自动续期任务安装失败。"
fi
}
acme_cron_enabled() {
command -v crontab >/dev/null 2>&1 \
&& crontab -l 2>/dev/null | grep -Eq 'acme\.sh"?/acme\.sh[[:space:]]+--cron'
}
ensure_cron_scheduler() {
if ! command -v crontab >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
install_packages cron
else
install_packages cronie
fi
fi
command -v crontab >/dev/null 2>&1 || fail "系统缺少 Cron,无法配置证书自动续期。"
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now cron >/dev/null 2>&1 \
|| systemctl enable --now crond >/dev/null 2>&1 \
|| fail "Cron 服务启动失败,无法保证证书自动续期。"
elif command -v service >/dev/null 2>&1; then
service cron start >/dev/null 2>&1 || service crond start >/dev/null 2>&1 \
|| fail "Cron 服务启动失败,无法保证证书自动续期。"
fi
}
install_certificate() {
@@ -531,7 +664,7 @@ install_certificate() {
--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 重试。"
--reloadcmd "${CLI_PATH} 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"
@@ -564,50 +697,208 @@ configure_web_mode() {
esac
}
backup_database() {
local timestamp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -n "$(compose ps -q lanqin-email 2>/dev/null || true)" ]]; then
compose exec -T lanqin-email sh -c "mkdir -p /data/backups && sqlite3 /data/lanqin.db \".backup '/data/backups/cli-update-${timestamp}.db'\"" >/dev/null
log "数据库已备份到 data/backups/cli-update-${timestamp}.db"
current_image_id() {
local container_id image_ref
container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "${container_id}" ]]; then
docker inspect --format '{{.Image}}' "${container_id}"
return
fi
image_ref="$(env_value LANQIN_IMAGE || true)"
image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null
}
remember_current_image() {
local container_id image_id rollback_tag
container_id="$(compose ps -q lanqin-email 2>/dev/null || true)"
[[ -n "${container_id}" ]] || return 0
image_id="$(docker inspect --format '{{.Image}}' "${container_id}")"
rollback_tag="newszxcn-email:rollback-$(date -u +%Y%m%d%H%M%S)"
docker image tag "${image_id}" "${rollback_tag}"
printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}"
sqlite_integrity_check() {
local database="$1" image="$2" relative result
if command -v sqlite3 >/dev/null 2>&1; then
result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
else
relative="${database#"${INSTALL_DIR}"/data/}"
[[ "${relative}" != "${database}" ]] || return 1
result="$(docker run --rm --entrypoint sqlite3 -v "${INSTALL_DIR}/data:/data" "${image}" "/data/${relative}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
fi
[[ "${result}" == "ok" ]]
}
backup_database() {
local destination="$1" image="$2" container_id running="false" relative container_destination
[[ -s "${INSTALL_DIR}/data/lanqin.db" ]] || { warn "未找到可备份的数据库:${INSTALL_DIR}/data/lanqin.db"; return 1; }
install -d -m 0700 "$(dirname "${destination}")"
rm -f "${destination}"
relative="${destination#"${INSTALL_DIR}"/data/}"
[[ "${relative}" != "${destination}" ]] || { warn "数据库备份必须保存在 ${INSTALL_DIR}/data 内。"; return 1; }
container_destination="/data/${relative}"
container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then
running="true"
fi
if [[ "${running}" == "true" ]]; then
docker exec "${container_id}" sqlite3 /data/lanqin.db ".backup '${container_destination}'" >/dev/null \
|| { warn "运行中数据库备份失败。"; return 1; }
elif command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "${INSTALL_DIR}/data/lanqin.db" ".backup '${destination}'" >/dev/null \
|| { warn "离线数据库备份失败。"; return 1; }
else
docker image inspect "${image}" >/dev/null 2>&1 || { warn "无法找到用于离线备份的旧镜像。"; return 1; }
docker run --rm --entrypoint sqlite3 -v "${INSTALL_DIR}/data:/data" "${image}" /data/lanqin.db ".backup '${container_destination}'" >/dev/null \
|| { warn "离线数据库备份失败。"; return 1; }
fi
[[ -s "${destination}" ]] || { warn "数据库备份文件为空。"; return 1; }
sqlite_integrity_check "${destination}" "${image}" || { warn "数据库备份完整性检查未通过。"; return 1; }
log "数据库已备份并校验:${destination}"
}
create_update_snapshot() {
local timestamp snapshot image version pointer_tmp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
snapshot="${INSTALL_DIR}/data/backups/cli-rollback-${timestamp}"
image="$(current_image_id || true)"
[[ -n "${image}" ]] || { warn "无法确定当前运行镜像。"; return 1; }
docker image inspect "${image}" >/dev/null 2>&1 || { warn "当前镜像不存在:${image}"; return 1; }
install -d -m 0700 "${snapshot}"
cp -p "${INSTALL_DIR}/docker-compose.yml" "${snapshot}/docker-compose.yml" || return 1
cp -p "${INSTALL_DIR}/.env" "${snapshot}/.env" || return 1
if [[ -f "${INSTALL_DIR}/.env.example" ]]; then
cp -p "${INSTALL_DIR}/.env.example" "${snapshot}/.env.example" || return 1
else
: > "${snapshot}/env-example.absent"
fi
if [[ -f "${CLI_PATH}" ]]; then
cp -p "${CLI_PATH}" "${snapshot}/newszxcn-email" || return 1
else
: > "${snapshot}/installer.absent"
fi
if [[ -f "${NGINX_CONFIG}" ]]; then
cp -p "${NGINX_CONFIG}" "${snapshot}/nginx.conf" || return 1
else
: > "${snapshot}/nginx.absent"
fi
if [[ -d "${CERT_DIR}" ]]; then
cp -a "${CERT_DIR}" "${snapshot}/certs" || return 1
else
: > "${snapshot}/certs.absent"
fi
backup_database "${snapshot}/database.db" "${image}" || return 1
version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)"
printf '%s\n' "${image}" > "${snapshot}/image"
printf '%s\n' "${version:-unknown}" > "${snapshot}/version"
cat > "${snapshot}/rollback-manifest.json" <<EOF
{
"image": "${image}",
"database_backup": "${snapshot}/database.db",
"compose_backup": "${snapshot}/docker-compose.yml",
"installer_backup": "${snapshot}/newszxcn-email",
"version": "${version:-unknown}",
"created_at": "${timestamp}"
}
EOF
find "${snapshot}" -maxdepth 1 -type f -exec chmod 0600 {} +
pointer_tmp="${ROLLBACK_POINTER}.new"
printf '%s\n' "${snapshot}" > "${pointer_tmp}"
mv "${pointer_tmp}" "${ROLLBACK_POINTER}"
printf '%s\n' "${image}" > "${ROLLBACK_FILE}"
log "更新回滚快照已创建:${snapshot}"
}
restore_update_snapshot() {
local snapshot="${1:-}" restore_database="${2:-true}" image
if [[ -z "${snapshot}" && -f "${ROLLBACK_POINTER}" ]]; then
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
fi
[[ -d "${snapshot}" && -s "${snapshot}/database.db" && -s "${snapshot}/image" && -s "${snapshot}/docker-compose.yml" && -s "${snapshot}/.env" ]] \
|| { warn "回滚快照不完整。"; return 1; }
if ! {
[[ -f "${snapshot}/.env.example" || -f "${snapshot}/env-example.absent" ]] \
&& [[ -f "${snapshot}/newszxcn-email" || -f "${snapshot}/installer.absent" ]] \
&& [[ -f "${snapshot}/nginx.conf" || -f "${snapshot}/nginx.absent" ]] \
&& [[ -d "${snapshot}/certs" || -f "${snapshot}/certs.absent" ]]
}; then
warn "回滚快照缺少文件状态标记。"
return 1
fi
image="$(tr -d '\r\n' < "${snapshot}/image")"
docker image inspect "${image}" >/dev/null 2>&1 || { warn "回滚镜像已不存在:${image}"; return 1; }
sqlite_integrity_check "${snapshot}/database.db" "${image}" || { warn "回滚数据库完整性检查未通过。"; return 1; }
log "正在恢复更新前快照:${snapshot}"
compose down --remove-orphans >/dev/null 2>&1 || true
install -m 0644 "${snapshot}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" || return 1
install -m 0600 "${snapshot}/.env" "${INSTALL_DIR}/.env" || return 1
if [[ -f "${snapshot}/.env.example" ]]; then
install -m 0644 "${snapshot}/.env.example" "${INSTALL_DIR}/.env.example" || return 1
elif [[ -f "${snapshot}/env-example.absent" ]]; then
rm -f "${INSTALL_DIR}/.env.example"
fi
if [[ -f "${snapshot}/newszxcn-email" ]]; then
install -m 0755 "${snapshot}/newszxcn-email" "${CLI_PATH}" || return 1
elif [[ -f "${snapshot}/installer.absent" ]]; then
rm -f "${CLI_PATH}"
fi
if [[ "${restore_database}" == "true" ]]; then
rm -f "${INSTALL_DIR}/data/lanqin.db-wal" "${INSTALL_DIR}/data/lanqin.db-shm"
install -m 0600 "${snapshot}/database.db" "${INSTALL_DIR}/data/lanqin.db" || return 1
fi
if [[ -d "${snapshot}/certs" ]]; then
rm -rf "${CERT_DIR}"
cp -a "${snapshot}/certs" "${CERT_DIR}" || return 1
elif [[ -f "${snapshot}/certs.absent" ]]; then
rm -rf "${CERT_DIR}"
fi
if [[ -f "${snapshot}/nginx.conf" ]]; then
install -m 0644 "${snapshot}/nginx.conf" "${NGINX_CONFIG}" || return 1
elif [[ -f "${snapshot}/nginx.absent" ]]; then
rm -f "${NGINX_CONFIG}"
fi
printf '%s\n' "${image}" > "${RUNTIME_IMAGE_PIN}"
chmod 0600 "${RUNTIME_IMAGE_PIN}"
compose up -d --remove-orphans --force-recreate || return 1
reload_nginx || return 1
wait_for_health 90 || return 1
ensure_cli_alias
}
do_repair_install() {
refresh_assets
ensure_update_token
configure_runtime_bindings
ensure_docker
backup_database
remember_current_image
configure_firewall
prepare_directories
create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
stage_assets
clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token || ! configure_runtime_bindings; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
fi
if ! (configure_firewall && prepare_directories); then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
fi
log "正在拉取并修复 NewSzxcn Email 服务..."
compose pull
if ! compose pull; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
fi
log "正在启动服务..."
if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。"
do_rollback
fail "修复失败,已回滚到原镜像。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。"
do_rollback
fail "修复失败,已回滚到原镜像。"
restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已恢复到修复前版本。"
fi
configure_web_mode
if ! (configure_web_mode); then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
fi
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
}
do_install() {
@@ -629,54 +920,65 @@ do_install() {
compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
}
do_update() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
ensure_docker
refresh_assets
ensure_update_token
backup_database
remember_current_image
create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
stage_assets
clear_runtime_image_pin
if ! apply_staged_assets || ! ensure_update_token; then
restore_update_snapshot "" false || true
fail "更新文件替换失败,已恢复原安装。"
fi
log "正在拉取最新版..."
compose pull
if ! compose pull; then
restore_update_snapshot "" false || true
fail "镜像拉取失败,已恢复到更新前版本。"
fi
if ! compose up -d --remove-orphans; then
warn "新版本容器启动失败,正在自动回滚。"
do_rollback
fail "更新失败,已回滚到原镜像。"
restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "更新失败,已恢复到更新前版本。"
fi
if ! wait_for_health 90; then
warn "新版本健康检查失败,正在自动回滚。"
do_rollback
fail "更新失败,已回滚到原镜像。"
restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "更新失败,已恢复到更新前版本。"
fi
ensure_cli_alias
generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "系统已更新,配置、邮件、证书和数据库均已保留。"
}
do_rollback() {
[[ -f "${ROLLBACK_FILE}" ]] || fail "没有可用的回滚镜像。"
local image
image="$(tr -d '\r\n' < "${ROLLBACK_FILE}")"
docker image inspect "${image}" >/dev/null 2>&1 || fail "回滚镜像已不存在:${image}"
log "正在回滚到 ${image}..."
LANQIN_IMAGE="${image}" compose up -d --no-deps --force-recreate lanqin-email
wait_for_health 90 || fail "回滚后服务仍未通过健康检查,请查看日志。"
success "已回滚到 ${image}"
[[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
if [[ -z "${confirm}" ]] && has_tty; then
read -r -p "回滚会用更新前数据库覆盖当前数据库,确认继续吗?[y/N]: " confirm </dev/tty
fi
[[ "${confirm}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] || { success "已取消回滚。"; return 0; }
image="$(current_image_id || true)"
[[ -n "${image}" ]] || fail "无法确定当前镜像,已取消回滚。"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
emergency_backup="${INSTALL_DIR}/data/backups/pre-rollback-${timestamp}.db"
backup_database "${emergency_backup}" "${image}" || fail "当前数据库备份失败,已取消回滚。"
restore_update_snapshot || fail "完整回滚失败,请检查回滚快照和服务日志。"
log "回滚前数据库已保留:${emergency_backup}"
success "已恢复镜像、数据库、Compose、环境配置、安装脚本和 Nginx 配置。"
}
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
if [[ -f "${NGINX_CONFIG}" ]]; then
reload_nginx || fail "Nginx 配置检查或重载失败。"
fi
}
@@ -694,9 +996,173 @@ do_certificate() {
install_certificate
write_nginx_https_config
reload_services
generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "SSL 证书已安装并应用。"
}
generate_guide() {
[[ -f "${INSTALL_DIR}/.env" ]] || return 1
local public_url admin_url hostname admin_username certificate_expiry="未安装" renewal_status="未开启" next_renewal="等待 acme.sh 生成续期计划"
local acme_info="" tmp
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
admin_username="$(env_value LANQIN_ADMIN_USERNAME || true)"
public_url="${public_url:-http://${hostname}}"
admin_url="${public_url%/}/admin"
if [[ -s "${CERT_DIR}/fullchain.pem" ]] && command -v openssl >/dev/null 2>&1; then
certificate_expiry="$(openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -enddate 2>/dev/null | sed 's/^notAfter=//' || true)"
certificate_expiry="${certificate_expiry:-无法读取}"
fi
if [[ -x /root/.acme.sh/acme.sh ]]; then
acme_info="$(/root/.acme.sh/acme.sh --info --domain "${hostname}" --ecc 2>/dev/null || true)"
next_renewal="$(printf '%s\n' "${acme_info}" | sed -n "s/^Le_NextRenewTimeStr=['\"]*\([^'\"]*\).*/\1/p" | tail -n 1)"
next_renewal="${next_renewal:-等待 acme.sh 生成续期计划}"
if acme_cron_enabled; then
renewal_status="已开启"
else
renewal_status="已安装 acme.sh,但未检测到定时任务"
fi
fi
tmp="$(mktemp)"
cat > "${tmp}" <<EOF
==================================================
NewSzxcn 邮箱指南
==================================================
【安装信息】
邮箱前台:${public_url}
管理后台:${admin_url}
管理员账号:${admin_username:-admin}
管理员密码:仅在安装完成时显示;修改后请使用新密码
SSL 证书:有效期至 ${certificate_expiry}
自动续期:${renewal_status}
预计续期:${next_renewal}
--------------------------------------------------
一、添加邮件域名并配置 DNS
--------------------------------------------------
1. 登录管理后台,进入「域名管理」。
2. 添加邮件域名并保存,然后点击该域名右侧的「DNS」。
3. 在域名服务商处添加系统列出的 MX、SPF、DKIM、DMARC 记录。
4. 返回管理后台点击「检测」,确认所有记录均已通过。
--------------------------------------------------
二、开启账号自助申请邮箱
--------------------------------------------------
1. 进入「管理后台 -> 系统设置 -> 邮件」。
2. 开启「账号自助申请邮箱」,并勾选至少一个开放域名。
3. 用户登录邮箱前台后,可在「设置 -> 邮箱管理」中自行申请邮箱。
--------------------------------------------------
三、开启无人收件
--------------------------------------------------
1. 进入「管理后台 -> 系统设置 -> 邮件」。
2. 开启「无人收件」并保存。
3. 已启用域名下未注册地址收到的邮件,只能由管理员在「未知收件」中查看。
--------------------------------------------------
四、服务器管理与更新
--------------------------------------------------
打开菜单:ns
完整命令:newszxcn-email menu
更新系统:newszxcn-email update
查看状态:newszxcn-email status
查看日志:newszxcn-email logs
恢复版本:newszxcn-email rollback
查看管理员登录信息:newszxcn-email credentials
重置管理员密码:newszxcn-email reset-password
公开教程:
https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md
EOF
install -m 0600 "${tmp}" "${GUIDE_FILE}"
rm -f "${tmp}"
}
do_guide() {
generate_guide || fail "尚未安装,无法生成邮箱指南。"
cat "${GUIDE_FILE}"
success "指南已更新并保存到 ${GUIDE_FILE}"
}
do_show_admin_credentials() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
local username password public_url
username="$(env_value LANQIN_ADMIN_USERNAME || true)"
password="$(env_value LANQIN_ADMIN_PASSWORD || true)"
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
cat <<EOF
==================================================
NewSzxcn 管理员登录信息
==================================================
登录地址:${public_url:-未记录}
管理员用户名:${username:-admin}
记录密码:${password:-未记录}
==================================================
EOF
warn "当前密码采用 bcrypt 加密,无法从数据库反向查看。这里显示的是安装或最近一次命令行重置时记录的密码;如果之后在网页修改过密码,该记录可能已经失效。"
}
generate_admin_password_hash() {
local password="$1" hash
hash="$(compose exec -T lanqin-email doveadm pw -s BLF-CRYPT -r 10 -p "${password}" 2>/dev/null | tail -n 1)"
hash="${hash#\{BLF-CRYPT\}}"
[[ "${hash}" =~ ^\$2[aby]\$[0-9]{2}\$.{53}$ ]] || return 1
printf '%s' "${hash}"
}
do_reset_admin_password() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
local username password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
ensure_docker
username="$(env_value LANQIN_ADMIN_USERNAME || true)"
username="${username:-admin}"
[[ "${username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名配置无效,无法安全重置。"
user_id="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "SELECT id FROM users WHERE login_name='${username}' AND role='admin' LIMIT 1;" | tr -d '\r\n')"
[[ "${user_id}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "没有找到管理员账号 ${username}"
password="$(prompt_reset_password)"
hash="$(generate_admin_password_hash "${password}")" || fail "无法生成安全密码哈希,管理员密码未修改。"
image="$(current_image_id || true)"
[[ -n "${image}" ]] || fail "无法确定当前镜像,管理员密码未修改。"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup="${INSTALL_DIR}/data/backups/password-reset-${timestamp}.db"
backup_database "${backup}" "${image}" || fail "数据库备份失败,管理员密码未修改。"
env_backup="$(mktemp)"
install -m 0600 "${INSTALL_DIR}/.env" "${env_backup}"
if ! set_env LANQIN_ADMIN_PASSWORD "${password}"; then
rm -f "${env_backup}"
fail "管理员密码记录更新失败,数据库未修改。"
fi
chmod 0600 "${INSTALL_DIR}/.env"
if ! result="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "BEGIN IMMEDIATE; UPDATE users SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id='${user_id}' AND role='admin'; SELECT 'user=' || changes(); UPDATE mailboxes SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id='${user_id}' AND EXISTS (SELECT 1 FROM users WHERE id='${user_id}' AND role='admin'); SELECT 'mailboxes=' || changes(); COMMIT;" | tr -d '\r')"; then
install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env"
rm -f "${env_backup}"
fail "管理员密码写入失败,已恢复原密码记录。"
fi
user_changes="$(printf '%s\n' "${result}" | sed -n 's/^user=//p' | tail -n 1)"
mailbox_changes="$(printf '%s\n' "${result}" | sed -n 's/^mailboxes=//p' | tail -n 1)"
if [[ "${user_changes}" != "1" || ! "${mailbox_changes}" =~ ^[0-9]+$ ]]; then
install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env"
rm -f "${env_backup}"
fail "管理员账号不存在或身份已变化,密码未修改;已恢复原密码记录。"
fi
rm -f "${env_backup}"
success "管理员 ${username} 的统一登录密码已重置。"
printf '新密码:%s\n' "${password}"
log "重置前数据库备份:${backup}"
log "已同步 ${mailbox_changes} 个管理员邮箱的 SMTP/IMAP 密码。"
warn "此次操作只修改管理员账号及其名下邮箱,不会修改普通用户或其邮箱密码。"
}
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps
@@ -709,14 +1175,27 @@ do_status() {
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
if [[ -z "${confirm}" ]] && has_tty; then
read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm </dev/tty
fi
[[ "${confirm}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] || { success "已取消卸载。"; return 0; }
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
compose down --remove-orphans
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
reload_nginx || warn "Nginx 未能重载,请检查并手动重载。"
fi
if [[ -x /root/.acme.sh/acme.sh && -n "${hostname}" ]]; then
if [[ -z "${remove_renewal}" ]] && has_tty; then
read -r -p "是否停止 ${hostname} 的证书自动续期?[Y/n]: " remove_renewal </dev/tty
fi
remove_renewal="${remove_renewal:-y}"
if [[ "${remove_renewal}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]]; then
if /root/.acme.sh/acme.sh --remove --domain "${hostname}" --ecc >/dev/null 2>&1; then
success "已停止 ${hostname} 的证书自动续期。"
else
nginx -s reload
warn "未能移除 ${hostname} 的续期记录,请使用 acme.sh 手动检查。"
fi
fi
fi
@@ -724,28 +1203,74 @@ do_uninstall() {
}
do_backup_reinstall() {
local backup_dir
local backup_dir failed_dir old_image
backup_dir="${INSTALL_DIR}.backup-$(date -u +%Y%m%dT%H%M%SZ)"
failed_dir="${INSTALL_DIR}.failed-$(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
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "旧安装缺少 docker-compose.yml,请先选择修复现有安装。"
ensure_docker
old_image="$(current_image_id || true)"
[[ -n "${old_image}" ]] || fail "无法确定旧安装镜像,已取消重新安装。"
printf '%s\n' "${old_image}" > "${INSTALL_DIR}/.reinstall-image"
if [[ -f "${CLI_PATH}" ]]; then
cp -p "${CLI_PATH}" "${INSTALL_DIR}/.reinstall-installer"
fi
if [[ -f "${NGINX_CONFIG}" ]]; then
cp -p "${NGINX_CONFIG}" "${INSTALL_DIR}/.reinstall-nginx.conf"
else
: > "${INSTALL_DIR}/.reinstall-nginx.absent"
fi
compose down --remove-orphans
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
if ! reload_nginx; then
install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
reload_nginx || true
LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
|| fail "Nginx 配置已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}"
wait_for_health 90 || fail "Nginx 配置已恢复,但旧服务健康检查失败,请查看日志。"
fail "Nginx 重载失败,已恢复旧配置并取消重新安装。"
fi
fi
mv "${INSTALL_DIR}" "${backup_dir}"
if ! mv "${INSTALL_DIR}" "${backup_dir}"; then
if [[ -f "${INSTALL_DIR}/.reinstall-nginx.conf" ]]; then
install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
fi
LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate || true
reload_nginx || true
fail "旧安装目录备份失败,已取消重新安装。"
fi
success "旧安装已完整备份到 ${backup_dir}"
log "现在开始全新安装。"
do_install
if (do_install); then
success "重新安装完成;旧安装备份保留在 ${backup_dir}"
return 0
fi
warn "重新安装失败,正在自动恢复旧安装。"
if [[ -d "${INSTALL_DIR}" ]]; then
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
compose down --remove-orphans >/dev/null 2>&1 || true
fi
mv "${INSTALL_DIR}" "${failed_dir}"
fi
mv "${backup_dir}" "${INSTALL_DIR}" || fail "无法恢复旧安装目录,备份仍位于 ${backup_dir}"
old_image="$(tr -d '\r\n' < "${INSTALL_DIR}/.reinstall-image")"
if [[ -f "${INSTALL_DIR}/.reinstall-installer" ]]; then
install -m 0755 "${INSTALL_DIR}/.reinstall-installer" "${CLI_PATH}"
fi
if [[ -f "${INSTALL_DIR}/.reinstall-nginx.conf" ]]; then
install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
elif [[ -f "${INSTALL_DIR}/.reinstall-nginx.absent" ]]; then
rm -f "${NGINX_CONFIG}"
fi
LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
|| fail "旧安装目录已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}"
reload_nginx || fail "旧安装目录和容器已恢复,但 Nginx 重载失败,请手动检查。"
wait_for_health 90 || fail "旧安装已恢复,但健康检查失败,请查看日志。"
ensure_cli_alias
fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}"
}
do_menu() {
@@ -766,19 +1291,22 @@ do_menu() {
prompt_text ' 状态:未安装\n'
fi
prompt_text '--------------------------------------------------\n'
prompt_text ' 1. 安装 / 重新安装(旧数据自动备份\n'
prompt_text ' 2. 更新系统(数据库自动备份)\n'
prompt_text ' 3. 修复现有安装\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 ' 7. 申请、检查或续期 SSL 证书\n'
prompt_text ' 8. 回滚到上次更新前版本\n'
prompt_text ' 9. NewSzxcn 邮箱指南\n'
prompt_text ' 10. 查看管理员登录信息\n'
prompt_text ' 11. 重置管理员统一登录密码\n'
prompt_text ' 12. 卸载服务(保留数据)\n'
prompt_text ' 0. 退出\n'
prompt_text '==================================================\n'
choice="$(prompt_menu_choice "${default_choice}")"
choice="$(prompt_menu_choice "${default_choice}" "12")"
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
fail "尚未安装,请先选择 1。"
fi
@@ -793,7 +1321,10 @@ do_menu() {
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
7) do_certificate ;;
8) ensure_docker; do_rollback ;;
9) ensure_docker; do_uninstall ;;
9) do_guide ;;
10) do_show_admin_credentials ;;
11) do_reset_admin_password ;;
12) ensure_docker; do_uninstall ;;
esac
}
@@ -804,6 +1335,10 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
exit 0
fi
if [[ "${EUID}" -eq 0 ]]; then
ensure_cli_alias
fi
case "${COMMAND}" in
help|-h|--help) usage ;;
menu) require_root; require_curl; do_menu ;;
@@ -815,6 +1350,9 @@ case "${COMMAND}" in
reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
guide) require_root; require_curl; do_guide ;;
credentials) require_root; require_curl; do_show_admin_credentials ;;
reset-password) require_root; require_curl; do_reset_admin_password ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;;
esac
+387 -11
View File
@@ -36,6 +36,11 @@ test_password_validation() {
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
LANQIN_RESET_PASSWORD="reset1"
assert_eq "reset1" "$(prompt_reset_password)" "six-character reset password"
if (LANQIN_RESET_PASSWORD="reset" prompt_reset_password >/dev/null 2>&1); then
fail_test "five-character reset password accepted"
fi
}
test_install_configuration() {
@@ -114,38 +119,409 @@ test_menu_choice() {
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"
export LANQIN_MENU_ACTION=12
assert_eq "12" "$(prompt_menu_choice 1 12)" "menu uninstall action"
if (has_tty() { return 1; }; LANQIN_MENU_ACTION=13 prompt_menu_choice 1 12 >/dev/null 2>&1); then
fail_test "out-of-range menu action accepted"
fi
unset LANQIN_MENU_ACTION
}
test_backup_reinstall_preserves_existing_directory() (
local temp_dir backup_dir
test_admin_credentials() (
local temp_dir output
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}"
cat > "${INSTALL_DIR}/.env" <<'EOF'
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
LANQIN_ADMIN_USERNAME=admin
LANQIN_ADMIN_PASSWORD=recorded-password
EOF
output="$(do_show_admin_credentials 2>&1)"
[[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing"
[[ "${output}" == *'管理员用户名:admin'* ]] || fail_test "administrator username missing"
[[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing"
[[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing"
)
test_admin_password_hash_parsing() (
compose() {
# shellcheck disable=SC2016
printf '{BLF-CRYPT}$2y$10$123456789012345678901u1234567890123456789012345678901\n'
}
# shellcheck disable=SC2016
assert_eq '$2y$10$123456789012345678901u1234567890123456789012345678901' "$(generate_admin_password_hash 'unused')" "Dovecot bcrypt hash parsing"
)
test_admin_password_reset_only_updates_admin_account() (
local temp_dir compose_calls backup_path
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
compose_calls="${temp_dir}/compose-calls"
mkdir -p "${INSTALL_DIR}/data/backups"
cat > "${INSTALL_DIR}/.env" <<'EOF'
LANQIN_ADMIN_USERNAME=admin
LANQIN_ADMIN_PASSWORD=old-password
EOF
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:test-image\n'; }
backup_database() {
backup_path="$1"
printf 'backup\n' > "${backup_path}"
}
prompt_reset_password() { printf 'new-password'; }
# shellcheck disable=SC2016
generate_admin_password_hash() { printf '$2y$10$123456789012345678901u1234567890123456789012345678901'; }
compose() {
printf '%s\n' "$*" >> "${compose_calls}"
if [[ "$*" == *'SELECT id FROM users'* ]]; then
printf 'admin-user-id\n'
elif [[ "$*" == *'UPDATE users SET password_hash'* ]]; then
printf 'user=1\nmailboxes=2\n'
fi
}
do_reset_admin_password >/dev/null
assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password"
[[ -s "${backup_path}" ]] || fail_test "password reset database backup missing"
grep -Fq "login_name='admin' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not role restricted"
grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated"
grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized"
grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator"
)
test_offline_database_backup() (
local temp_dir destination
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}/data/backups"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO test_items(value) VALUES ("saved");'
compose() { return 0; }
destination="${INSTALL_DIR}/data/backups/offline.db"
backup_database "${destination}" "unused-image"
[[ -s "${destination}" ]] || fail_test "offline database backup missing"
assert_eq "saved" "$(sqlite3 "${destination}" 'SELECT value FROM test_items LIMIT 1;')" "offline database content"
)
test_guide_generation() (
local temp_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
GUIDE_FILE="${temp_dir}/guide.txt"
mkdir -p "${CERT_DIR}"
cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com"
set_env LANQIN_ADMIN_USERNAME "admin"
generate_guide
grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing"
grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing"
grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing"
[[ "$(stat -c '%a' "${GUIDE_FILE}" 2>/dev/null || stat -f '%Lp' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600"
)
test_acme_cron_detection() (
crontab() {
printf '49 0,6,12,18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null\n'
}
acme_cron_enabled || fail_test "quoted acme.sh Cron entry was not detected"
)
test_cli_alias_safety() (
local temp_dir
temp_dir="$(mktemp -d)"
CLI_PATH="${temp_dir}/newszxcn-email"
CLI_ALIAS_PATH="${temp_dir}/ns"
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
ensure_cli_alias
[[ -L "${CLI_ALIAS_PATH}" ]] || fail_test "ns alias was not created"
assert_eq "${CLI_PATH}" "$(readlink "${CLI_ALIAS_PATH}")" "ns alias target"
rm -f "${CLI_ALIAS_PATH}"
printf 'occupied\n' > "${CLI_ALIAS_PATH}"
ensure_cli_alias
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
)
test_compose_runtime_image_pin() (
local temp_dir calls
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
calls="${temp_dir}/docker-calls"
mkdir -p "${INSTALL_DIR}"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'sha256:rollback-image\n' > "${RUNTIME_IMAGE_PIN}"
docker() {
printf '%s|%s\n' "${LANQIN_IMAGE:-}" "$*" >> "${calls}"
}
compose ps
grep -Fq 'sha256:rollback-image|compose ' "${calls}" || fail_test "rollback image pin was not applied to Compose"
clear_runtime_image_pin
compose ps
[[ "$(tail -n 1 "${calls}" | cut -d '|' -f 1)" == "" ]] || fail_test "cleared image pin still affected Compose"
)
test_update_snapshot_restore() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups" "${CERT_DIR}"
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\nOLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf 'old-certificate\n' > "${CERT_DIR}/fullchain.pem"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("before-update");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() {
if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
printf '1.2.4\n'
fi
return 0
}
compose() {
if [[ "${1:-}" == "up" ]]; then
grep -Fq 'sha256:old-image' "${RUNTIME_IMAGE_PIN}" || fail_test "restore started without image pin"
fi
return 0
}
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
[[ -s "${snapshot}/rollback-manifest.json" ]] || fail_test "rollback manifest missing"
printf 'new-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'NEW_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho new-installer\n' > "${CLI_PATH}"
printf 'new-nginx\n' > "${NGINX_CONFIG}"
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'DELETE FROM test_items; INSERT INTO test_items VALUES ("after-update");'
restore_update_snapshot "${snapshot}"
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "Compose file was not restored"
grep -Fq 'OLD_ENV=yes' "${INSTALL_DIR}/.env" || fail_test "environment file was not restored"
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "environment example was not restored"
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "installer was not restored"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored"
grep -Fq 'old-certificate' "${CERT_DIR}/fullchain.pem" || fail_test "certificate was not restored"
assert_eq "before-update" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT value FROM test_items;')" "restored database content"
assert_eq "sha256:old-image" "$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")" "restored runtime image pin"
)
test_snapshot_restores_absent_optional_files() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("saved");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() { return 0; }
compose() { return 0; }
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
[[ -f "${snapshot}/env-example.absent" ]] || fail_test "missing env example marker"
[[ -f "${snapshot}/installer.absent" ]] || fail_test "missing installer marker"
[[ -f "${snapshot}/nginx.absent" ]] || fail_test "missing Nginx marker"
[[ -f "${snapshot}/certs.absent" ]] || fail_test "missing certificate marker"
mkdir -p "${CERT_DIR}"
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\n' > "${CLI_PATH}"
printf 'new-nginx\n' > "${NGINX_CONFIG}"
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
restore_update_snapshot "${snapshot}"
[[ ! -e "${INSTALL_DIR}/.env.example" ]] || fail_test "new env example survived rollback"
[[ ! -e "${CLI_PATH}" ]] || fail_test "new installer survived rollback"
[[ ! -e "${NGINX_CONFIG}" ]] || fail_test "new Nginx configuration survived rollback"
[[ ! -e "${CERT_DIR}" ]] || fail_test "new certificate directory survived rollback"
)
test_pre_start_restore_preserves_current_database() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE received_mail (subject TEXT); INSERT INTO received_mail VALUES ("before-snapshot");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() { return 0; }
compose() { return 0; }
reload_nginx() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'INSERT INTO received_mail VALUES ("received-during-pull");'
restore_update_snapshot "${snapshot}" false
assert_eq "2" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT COUNT(*) FROM received_mail;')" "database preserved before new container start"
assert_eq "received-during-pull" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT subject FROM received_mail ORDER BY rowid DESC LIMIT 1;')" "mail received during pull"
)
test_failed_asset_validation_preserves_production() (
local temp_dir source_dir
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source"
INSTALL_DIR="${temp_dir}/install"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${source_dir}/deploy" "${INSTALL_DIR}"
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'OLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
printf 'sha256:pinned-image\n' > "${RUNTIME_IMAGE_PIN}"
printf 'invalid compose\n' > "${source_dir}/deploy/docker-compose.yml"
cp "${ROOT_DIR}/deploy/.env.example" "${source_dir}/deploy/.env.example"
cp "${ROOT_DIR}/install.sh" "${source_dir}/install.sh"
script_dir() { printf '%s\n' "${source_dir}"; }
docker() { return 1; }
if (stage_assets >/dev/null 2>&1); then
fail_test "invalid Compose file passed staging validation"
fi
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "production Compose changed after failed validation"
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "production env example changed after failed validation"
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "production installer changed after failed validation"
grep -Fq 'sha256:pinned-image' "${RUNTIME_IMAGE_PIN}" || fail_test "runtime image pin changed after failed validation"
)
test_backup_reinstall_restores_on_failure() (
local temp_dir failed_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:old-image\n'; }
compose() { return 0; }
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
do_install() {
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "fresh install started before old directory was moved"
mkdir -p "${INSTALL_DIR}"
printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker"
return 1
}
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"
if (do_backup_reinstall); then
fail_test "failed reinstall unexpectedly succeeded"
fi
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install directory was not restored"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "old Nginx configuration was not restored"
failed_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.failed-*' -print -quit)"
[[ -n "${failed_dir}" ]] || fail_test "failed reinstall directory was not preserved"
)
test_backup_reinstall_recovers_from_nginx_reload_failure() (
local temp_dir compose_calls reload_count_file
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
compose_calls="${temp_dir}/compose-calls"
reload_count_file="${temp_dir}/reload-count"
mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf '0\n' > "${reload_count_file}"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:old-image\n'; }
compose() { printf '%s\n' "$*" >> "${compose_calls}"; return 0; }
reload_nginx() {
local count
count="$(cat "${reload_count_file}")"
printf '%s\n' "$((count + 1))" > "${reload_count_file}"
[[ "${count}" -gt 0 ]]
}
wait_for_health() { return 0; }
do_install() { fail_test "fresh install started after Nginx reload failure"; }
if (do_backup_reinstall >/dev/null 2>&1); then
fail_test "reinstall continued after Nginx reload failure"
fi
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install changed after Nginx reload failure"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored after reload failure"
grep -Fq 'up -d --remove-orphans --force-recreate' "${compose_calls}" || fail_test "old containers were not restarted after Nginx reload failure"
)
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
test_admin_credentials
test_admin_password_hash_parsing
test_admin_password_reset_only_updates_admin_account
test_offline_database_backup
test_guide_generation
test_acme_cron_detection
test_cli_alias_safety
test_compose_runtime_image_pin
test_update_snapshot_restore
test_snapshot_restores_absent_optional_files
test_pre_start_restore_preserves_current_database
test_failed_asset_validation_preserves_production
test_backup_reinstall_restores_on_failure
test_backup_reinstall_recovers_from_nginx_reload_failure
printf 'install.sh tests passed\n'