Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 497aefb903 | |||
| 1af4b7250e | |||
| 1ce75ef241 | |||
| 99b8b7dee8 | |||
| 697ed236cc | |||
| b55c298ef4 | |||
| 800d482c77 |
@@ -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)
|
||||
@@ -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 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 ;;
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user