Compare commits

...

7 Commits

Author SHA1 Message Date
zxyszx 497aefb903 fix: finalize v1.2.5 release notes
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 00:13:57 +08:00
zxyszx 1af4b7250e feat: link mailbox self-service setup guidance 2026-08-04 00:03:11 +08:00
zxyszx 1ce75ef241 fix: unify empty mailbox management action 2026-08-03 23:57:10 +08:00
zxyszx 99b8b7dee8 fix: guide admins to mailbox management 2026-08-03 23:52:42 +08:00
zxyszx 697ed236cc feat: add unified installer management menu 2026-08-03 23:29:02 +08:00
zxyszx b55c298ef4 feat: add safe fresh reinstall option 2026-08-03 23:23:41 +08:00
zxyszx 800d482c77 fix: prompt before modifying existing installs 2026-08-03 23:09:59 +08:00
10 changed files with 270 additions and 44 deletions
+33
View File
@@ -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)
+24 -20
View File
@@ -212,31 +212,35 @@ 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)"
generate_args=(-f "tag_name=${tag}")
if [[ -n "${previous_tag}" ]]; then
generate_args+=(-f "previous_tag_name=${previous_tag}")
fi
version_notes=".github/release-notes/${tag}.md"
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
{
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
echo
if [[ -n "${previous_tag}" ]]; then
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
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}")
fi
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
{
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
echo
echo
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
else
echo "- 首个公开版本。"
echo
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
fi
} > generated-release-notes.md
if [[ -n "${previous_tag}" ]]; then
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
echo
echo
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
else
echo "- 首个公开版本。"
echo
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
fi
} > generated-release-notes.md
fi
fi
cat > release-notes.md <<EOF
# NewSzxcn Email ${tag}
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
## 注意
+5
View File
@@ -35,6 +35,11 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在
启动失败时自动回滚。
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
+1 -1
View File
@@ -1 +1 @@
1.2.4
1.2.5
+8
View File
@@ -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" },
+5 -3
View File
@@ -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>
)
@@ -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)
+13 -11
View File
@@ -11,7 +11,7 @@ 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, Trash2, Type, Underline, Undo2, Upload, X } from "lucide-react"
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"
@@ -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>
+17 -3
View File
@@ -82,6 +82,7 @@ export function ProfilePage() {
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
const canViewStats = hasPermission(user, "mail.stats.view")
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
const canConfigureMailboxApply = hasPermission(user, "admin.settings.update")
const visibleTabKeys = tabKeys.filter((key) => {
if (key === "profile") return true
if (key === "mailboxes") return canAccessMail || canApplyMailbox
@@ -453,6 +454,7 @@ export function ProfilePage() {
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
applyOptions={mailboxApplyOptions.data}
applyPending={applyMailbox.isPending}
canConfigureApply={canConfigureMailboxApply}
selectedMailboxId={mailboxId}
externalImapEnabled={externalImapEnabled}
externalAccounts={externalImapAccounts.data?.items || []}
@@ -464,6 +466,7 @@ export function ProfilePage() {
onSelect={setMailboxId}
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
onConfigureApply={() => navigate("/admin?section=settings&settingsTab=mail")}
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
@@ -1000,6 +1003,7 @@ function MailboxManagement({
mailboxes,
applyOptions,
applyPending,
canConfigureApply,
selectedMailboxId,
externalImapEnabled,
externalAccounts,
@@ -1011,6 +1015,7 @@ function MailboxManagement({
onSelect,
onOpen,
onApply,
onConfigureApply,
onCreateExternal,
onStartExternalOAuth,
onUpdateExternal,
@@ -1022,6 +1027,7 @@ function MailboxManagement({
mailboxes: Mailbox[]
applyOptions?: MailboxApplyOptions
applyPending: boolean
canConfigureApply: boolean
selectedMailboxId: string
externalImapEnabled: boolean
externalAccounts: ExternalImapAccount[]
@@ -1033,6 +1039,7 @@ function MailboxManagement({
onSelect: (id: string) => void
onOpen: (id: string) => void
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
onConfigureApply: () => void
onCreateExternal: (payload: ExternalImapAccountPayload) => void
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
@@ -1208,9 +1215,16 @@ function MailboxManagement({
</select>
<Button className="h-[42px] px-0" disabled={!canApply || applyPending || !selectedDomain || !localPart.trim()}>{applyPending ? "创建中" : "创建"}</Button>
</form>
<p className="mt-4 text-sm text-muted-foreground">
{canApply ? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。" : "提示:当前账号暂不可创建新邮箱。"}
</p>
<div className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<span>{canApply
? "提示:邮箱数量受账号配额限制,管理员可在后台为单个账号调整可创建数量。"
: canConfigureApply && applyOptions?.enabled
? "提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。"
: canConfigureApply
? "提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。"
: "提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。"}</span>
{!canApply && canConfigureApply && <Button type="button" variant="link" className="h-auto p-0 text-sm" onClick={onConfigureApply}></Button>}
</div>
</section>
<section className="rounded-lg border bg-card">
+134 -6
View File
@@ -4,7 +4,7 @@ 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"
@@ -21,7 +21,8 @@ NewSzxcn Email 管理命令
用法:newszxcn-email <command>
install 首次安装或修复部署
menu 显示安装与运维菜单
install 首次安装;已有安装会先完整备份再重新安装
update 备份数据库并更新到最新版
status 查看容器与健康状态
logs 持续查看运行日志
@@ -146,20 +147,40 @@ prompt_value() {
}
prompt_choice() {
local variable="$1" prompt="$2" default_value="$3" value
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}" =~ ^[123]$ ]]; then
if [[ "${value}" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= max_value )); then
printf '%s' "${value}"
return
fi
prompt_text "[提示] 请输入 1、2 或 3。\n"
prompt_text "[提示] 请输入 1${max_value}。\n"
value=""
has_tty || fail "${variable} 必须设置为 1、2 或 3"
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
}
@@ -562,7 +583,39 @@ remember_current_image() {
printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}"
}
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
@@ -670,6 +723,80 @@ do_uninstall() {
success "容器和自动生成的 Nginx 配置已移除,${INSTALL_DIR} 中的邮件、证书、配置和数据库仍然保留。"
}
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
@@ -679,6 +806,7 @@ fi
case "${COMMAND}" in
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 ;;
+30
View File
@@ -109,6 +109,34 @@ test_legacy_configuration_is_preserved() {
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"
@@ -117,5 +145,7 @@ 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'