From b8d45530367eee01925821e42736c2fe7918ded3 Mon Sep 17 00:00:00 2001 From: LanQin Date: Sat, 27 Jun 2026 13:22:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E5=A2=9E=E5=8A=A0=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增邮件翻译接口与响应类型,支持按目标语言翻译当前邮件内容。 - 配置化控制翻译开关与最大字符数,避免超长内容影响请求。 - 在邮件详情页增加翻译提示与译文切换,支持简体中文、繁體中文和英文。 - 补充翻译响应解析与截断逻辑的单元测试。 --- apps/api/internal/app/config.go | 4 + apps/api/internal/app/mail_translate.go | 158 +++++++++++++++++++ apps/api/internal/app/mail_translate_test.go | 32 ++++ apps/api/internal/app/router_auth.go | 1 + apps/web/src/lib/api-types.ts | 1 + apps/web/src/lib/api.ts | 3 +- apps/web/src/pages/mail.tsx | 77 +++++++++ 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 apps/api/internal/app/mail_translate.go create mode 100644 apps/api/internal/app/mail_translate_test.go diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 04b6c0a..a96cf9b 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -49,6 +49,8 @@ type Config struct { ExternalIMAPGmailClientSecret string ExternalIMAPOutlookClientID string ExternalIMAPOutlookClientSecret string + MailTranslateEnabled bool + MailTranslateMaxChars int } func LoadConfig() Config { @@ -95,6 +97,8 @@ func LoadConfig() Config { ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""), ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""), ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""), + MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true), + MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000), } } diff --git a/apps/api/internal/app/mail_translate.go b/apps/api/internal/app/mail_translate.go new file mode 100644 index 0000000..a6bc91b --- /dev/null +++ b/apps/api/internal/app/mail_translate.go @@ -0,0 +1,158 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/go-chi/chi/v5" +) + +const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single" + +type translateMailMessageRequest struct { + TargetLanguage string `json:"targetLanguage"` +} + +type translateMailMessageResponse struct { + TranslatedText string `json:"translatedText"` + SourceLanguage string `json:"sourceLanguage,omitempty"` + TargetLanguage string `json:"targetLanguage"` + Truncated bool `json:"truncated"` +} + +func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) { + if !a.cfg.MailTranslateEnabled { + respondError(w, http.StatusForbidden, "mail translation is disabled") + return + } + var req translateMailMessageRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + target := normalizeTranslateTarget(req.TargetLanguage) + if target == "" { + respondError(w, http.StatusBadRequest, "unsupported target language") + return + } + msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true) + if err != nil { + respondError(w, http.StatusNotFound, "message not found") + return + } + text := strings.TrimSpace(msg.BodyText) + if text == "" { + text = strings.TrimSpace(msg.Snippet) + } + if text == "" { + respondError(w, http.StatusBadRequest, "message has no translatable text") + return + } + maxChars := a.cfg.MailTranslateMaxChars + if maxChars <= 0 { + maxChars = 8000 + } + text, truncated := truncateRunes(text, maxChars) + translated, source, err := googleFreeTranslate(r.Context(), text, target) + if err != nil { + a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err) + respondError(w, http.StatusBadGateway, "translation failed") + return + } + respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) +} + +func normalizeTranslateTarget(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "zh", "zh-cn", "zh-hans", "zh_cn": + return "zh-CN" + case "zh-tw", "zh-hant", "zh_hk", "zh-hk", "zh-mo": + return "zh-TW" + case "en", "en-us", "en-gb": + return "en" + default: + return "" + } +} + +func truncateRunes(value string, max int) (string, bool) { + if max <= 0 || utf8.RuneCountInString(value) <= max { + return value, false + } + out := make([]rune, 0, max) + for i, r := range value { + if len(out) >= max { + return string(out), i < len(value) + } + out = append(out, r) + } + return string(out), false +} + +func googleFreeTranslate(ctx context.Context, text, target string) (string, string, error) { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + params := url.Values{} + params.Set("client", "gtx") + params.Set("sl", "auto") + params.Set("tl", target) + params.Set("dt", "t") + params.Set("q", text) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleTranslateEndpoint+"?"+params.Encode(), nil) + if err != nil { + return "", "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1024)) + return "", "", fmt.Errorf("google translate status %d", res.StatusCode) + } + var raw any + if err := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024)).Decode(&raw); err != nil { + return "", "", err + } + translated, source := parseGoogleTranslateResponse(raw) + translated = strings.TrimSpace(translated) + if translated == "" { + return "", source, errors.New("empty translation") + } + return translated, source, nil +} + +func parseGoogleTranslateResponse(raw any) (string, string) { + root, _ := raw.([]any) + var b strings.Builder + if len(root) > 0 { + if sentences, ok := root[0].([]any); ok { + for _, item := range sentences { + parts, ok := item.([]any) + if !ok || len(parts) == 0 { + continue + } + if s, ok := parts[0].(string); ok { + b.WriteString(s) + } + } + } + } + source := "" + if len(root) > 2 { + if s, ok := root[2].(string); ok { + source = s + } + } + return b.String(), source +} diff --git a/apps/api/internal/app/mail_translate_test.go b/apps/api/internal/app/mail_translate_test.go new file mode 100644 index 0000000..8519de1 --- /dev/null +++ b/apps/api/internal/app/mail_translate_test.go @@ -0,0 +1,32 @@ +package app + +import "testing" + +func TestParseGoogleTranslateResponse(t *testing.T) { + raw := []any{ + []any{ + []any{"你好", "Hello", nil, nil, float64(3)}, + []any{",世界", ", world", nil, nil, float64(3)}, + }, + nil, + "en", + } + translated, source := parseGoogleTranslateResponse(raw) + if translated != "你好,世界" { + t.Fatalf("translated = %q", translated) + } + if source != "en" { + t.Fatalf("source = %q", source) + } +} + +func TestTruncateRunes(t *testing.T) { + got, truncated := truncateRunes("你好world", 4) + if got != "你好wo" || !truncated { + t.Fatalf("truncateRunes() = %q, %v", got, truncated) + } + got, truncated = truncateRunes("你好", 4) + if got != "你好" || truncated { + t.Fatalf("truncateRunes() = %q, %v", got, truncated) + } +} diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 1b78d65..2b85031 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -84,6 +84,7 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage) + r.With(a.requirePermission(PermissionMailRead)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages) diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index c878d7b..acf11ff 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -69,6 +69,7 @@ export type MailMessage = { export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSCheckResult = { domain: string; status: string; checks: Record } export type ListResponse = { items: T[]; nextCursor?: string } +export type MailTranslation = { translatedText: string; sourceLanguage?: string; targetLanguage: string; truncated: boolean } export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] } export type DraftPayload = Omit & { attachments?: SendPayload["attachments"] } export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d3f337e..b077038 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -164,6 +164,7 @@ export const api = { return request>(`/api/mail/starred?${params.toString()}`) }, message: (id: string, options: { markRead?: boolean } = {}) => request(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`), + translateMessage: (id: string, targetLanguage: string) => request(`/api/mail/messages/${id}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), send: (payload: SendPayload) => request("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), scheduledSends: (mailboxId?: string) => request>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), scheduleSend: (payload: ScheduleSendPayload) => request("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 6a23d98..106cf39 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -1282,6 +1282,7 @@ export function MailPage() { canOrganize={canOrganizeMail && mailView !== "external"} canManageLabels={canManageLabels && mailView !== "external"} canDownloadAttachments={canDownloadAttachments} + language={language} /> ) : ( @@ -1340,6 +1341,7 @@ export function MailPage() {
+ {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
@@ -2216,6 +2218,7 @@ function CompactMailView({ canOrganize, canManageLabels, canDownloadAttachments, + language, }: { title: string icon?: React.ReactNode @@ -2255,6 +2258,7 @@ function CompactMailView({ canOrganize: boolean canManageLabels: boolean canDownloadAttachments: boolean + language: Language }) { const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1 const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined @@ -2284,6 +2288,7 @@ function CompactMailView({ canOrganize={canOrganize} canManageLabels={canManageLabels} canDownloadAttachments={canDownloadAttachments} + language={language} /> ) } @@ -2347,6 +2352,7 @@ function CompactMessageDetail({ canOrganize, canManageLabels, canDownloadAttachments, + language, }: { selected?: MailMessage loading: boolean @@ -2369,6 +2375,7 @@ function CompactMessageDetail({ canOrganize: boolean canManageLabels: boolean canDownloadAttachments: boolean + language: Language }) { return (
@@ -2450,6 +2457,7 @@ function CompactMessageDetail({ />
+ {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
@@ -2460,6 +2468,75 @@ function CompactMessageDetail({ ) } + +function MessageTranslateBox({ message, language }: { message: MailMessage; language: Language }) { + const [translatedText, setTranslatedText] = React.useState("") + const [showTranslated, setShowTranslated] = React.useState(false) + const [truncated, setTruncated] = React.useState(false) + const { toast } = useToast() + const targetLanguage = normalizeTranslationLanguage(language) + const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet]) + const shouldShow = targetLanguage && shouldOfferMessageTranslation(sourceText, language) + const translate = useMutation({ + mutationFn: () => api.translateMessage(message.id, targetLanguage!), + onSuccess: (result) => { + setTranslatedText(result.translatedText) + setTruncated(result.truncated) + setShowTranslated(true) + }, + onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) + + React.useEffect(() => { + setTranslatedText("") + setShowTranslated(false) + setTruncated(false) + translate.reset() + }, [message.id, language]) + + if (!shouldShow && !translatedText) return null + + return ( +
+
+
+ {translatedText ? `已翻译为 ${translationTargetLabel(language)}` : `检测到邮件可能不是当前语言,可翻译为 ${translationTargetLabel(language)}`} + {truncated && (内容较长,仅翻译前半部分)} +
+
+ {translatedText && } + +
+
+ {translatedText && showTranslated && ( +
{translatedText}
+ )} +
+ ) +} + +function normalizeTranslationLanguage(language: Language) { + if (language === "zh-CN") return "zh-CN" + if (language === "zh-TW") return "zh-TW" + if (language === "en") return "en" + return "" +} + +function translationTargetLabel(language: Language) { + if (language === "zh-CN") return "简体中文" + if (language === "zh-TW") return "繁體中文" + return "English" +} + +function shouldOfferMessageTranslation(text: string, language: Language) { + if (!text.trim()) return false + const cjkCount = (text.match(/[\u4e00-\u9fff]/g) || []).length + const latinCount = (text.match(/[a-zA-Z]/g) || []).length + if (language === "zh-CN" || language === "zh-TW") return latinCount > 80 && latinCount > cjkCount * 3 + if (language === "en") return cjkCount > 20 + return false +} + function MailHtmlFrame({ message }: { message: MailMessage }) { const iframeRef = React.useRef(null) const [height, setHeight] = React.useState(260)