From ecc3e8a2464d96e2f74227c0a29da1cb2363561f Mon Sep 17 00:00:00 2001 From: LanQin Date: Sat, 27 Jun 2026 15:35:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E6=94=AF=E6=8C=81=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=AD=A3=E6=96=87=20HTML=20=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E5=B9=B6=E5=88=87=E6=8D=A2=E6=98=BE=E7=A4=BA=E8=AF=91=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端翻译接口新增返回 translatedHtml,优先翻译邮件 HTML 文本节点并跳过脚本、样式等标签。 - 前端合并翻译结果展示,支持在原文与译文之间切换,并直接渲染翻译后的邮件 HTML。 --- apps/api/internal/app/mail_translate.go | 80 ++++++++++++++++++++++++- apps/web/src/lib/api-types.ts | 2 +- apps/web/src/pages/mail.tsx | 43 ++++++------- 3 files changed, 102 insertions(+), 23 deletions(-) diff --git a/apps/api/internal/app/mail_translate.go b/apps/api/internal/app/mail_translate.go index 57d2e2b..8b8ac83 100644 --- a/apps/api/internal/app/mail_translate.go +++ b/apps/api/internal/app/mail_translate.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "context" "encoding/json" "errors" @@ -13,6 +14,7 @@ import ( "unicode/utf8" "github.com/go-chi/chi/v5" + "golang.org/x/net/html" ) const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single" @@ -23,6 +25,7 @@ type translateMailMessageRequest struct { type translateMailMessageResponse struct { TranslatedText string `json:"translatedText"` + TranslatedHTML string `json:"translatedHtml,omitempty"` SourceLanguage string `json:"sourceLanguage,omitempty"` TargetLanguage string `json:"targetLanguage"` Truncated bool `json:"truncated"` @@ -67,7 +70,11 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) respondError(w, http.StatusBadGateway, "translation failed") return } - respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) + translatedHTML := "" + if strings.TrimSpace(msg.BodyHTML) != "" { + translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars) + } + respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) } func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) { @@ -130,7 +137,76 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http. respondError(w, http.StatusBadGateway, "translation failed") return } - respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) + translatedHTML := "" + if err == nil && strings.TrimSpace(stored.BodyHTML) != "" { + translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars) + } + respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) +} + +func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) { + nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil) + if err != nil { + return "", err + } + remaining := maxChars + var translateNode func(*html.Node) error + translateNode = func(n *html.Node) error { + if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) { + return nil + } + if n.Type == html.TextNode { + text := strings.TrimSpace(n.Data) + if text != "" && containsTranslatableLetter(text) && remaining > 0 { + limited, _ := truncateRunes(text, remaining) + remaining -= utf8.RuneCountInString(limited) + translated, _, err := googleFreeTranslate(ctx, limited, target) + if err != nil { + return err + } + n.Data = strings.Replace(n.Data, text, translated, 1) + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if err := translateNode(c); err != nil { + return err + } + } + return nil + } + for _, n := range nodes { + if err := translateNode(n); err != nil { + return "", err + } + } + var b bytes.Buffer + for _, n := range nodes { + if err := html.Render(&b, n); err != nil { + return "", err + } + } + if policy != nil { + return policy.Sanitize(b.String()), nil + } + return b.String(), nil +} + +func shouldSkipHTMLTranslationElement(tag string) bool { + switch strings.ToLower(tag) { + case "script", "style", "code", "pre", "textarea": + return true + default: + return false + } +} + +func containsTranslatableLetter(value string) bool { + for _, r := range value { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '\u4e00' && r <= '\u9fff') { + return true + } + } + return false } func normalizeTranslateTarget(value string) string { diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index acf11ff..88393ab 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -69,7 +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 MailTranslation = { translatedText: string; translatedHtml?: 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/pages/mail.tsx b/apps/web/src/pages/mail.tsx index e937180..c568215 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -1341,8 +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)}
)}
}
@@ -2457,8 +2456,7 @@ function CompactMessageDetail({ />
- - + {selected.attachments && selected.attachments.length > 0 &&
附件
{selected.attachments.map((a) => canDownloadAttachments ? {a.filename}{formatBytes(a.sizeBytes)} :
{a.filename}{formatBytes(a.sizeBytes)}
)}
}
@@ -2469,18 +2467,22 @@ function CompactMessageDetail({ } -function MessageTranslateBox({ message, language }: { message: MailMessage; language: Language }) { + +function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) { const [translatedText, setTranslatedText] = React.useState("") + const [translatedHtml, setTranslatedHtml] = 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 translatedMessage = React.useMemo(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText]) const translate = useMutation({ mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!), onSuccess: (result) => { setTranslatedText(result.translatedText) + setTranslatedHtml(result.translatedHtml || "") setTruncated(result.truncated) setShowTranslated(true) }, @@ -2489,29 +2491,30 @@ function MessageTranslateBox({ message, language }: { message: MailMessage; lang React.useEffect(() => { setTranslatedText("") + setTranslatedHtml("") setShowTranslated(false) setTruncated(false) translate.reset() }, [message.id, language]) - if (!shouldShow && !translatedText) return null - return ( -
-
-
- {translatedText ? `已翻译为 ${translationTargetLabel(language)}` : `检测到邮件可能不是当前语言,可翻译为 ${translationTargetLabel(language)}`} - {truncated && (内容较长,仅翻译前半部分)} + <> + {(shouldShow || translatedText) && ( +
+
+
+ {translatedText ? `已翻译为 ${translationTargetLabel(language)},当前${showTranslated ? "显示译文" : "显示原文"}` : `检测到邮件可能不是当前语言,可翻译为 ${translationTargetLabel(language)}`} + {truncated && (内容较长,仅翻译前半部分)} +
+
+ {translatedText && } + +
+
-
- {translatedText && } - -
-
- {translatedText && showTranslated && ( -
{translatedText}
)} -
+ {translatedText && showTranslated ? : } + ) }