release: prepare v1.2.31
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

This commit is contained in:
zxyszx
2026-08-12 06:01:56 +08:00
parent c92140c9cc
commit 9a572e0100
5 changed files with 155 additions and 32 deletions
+7
View File
@@ -0,0 +1,7 @@
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
+1 -1
View File
@@ -1 +1 @@
1.2.30 1.2.31
+74 -23
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
maxChars = 8000 maxChars = 8000
} }
text, truncated := truncateRunes(text, maxChars) text, truncated := truncateRunes(text, maxChars)
translatedHTMLResult := make(chan string, 1)
if strings.TrimSpace(msg.BodyHTML) != "" {
go func() {
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
translatedHTMLResult <- translatedHTML
}()
} else {
translatedHTMLResult <- ""
}
translated, source, err := googleFreeTranslate(r.Context(), text, target) translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil { if err != nil {
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err) a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed") respondError(w, http.StatusBadGateway, "translation failed")
return return
} }
translatedHTML := "" translatedHTML := <-translatedHTMLResult
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}) respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
} }
@@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
maxChars = 8000 maxChars = 8000
} }
text, truncated := truncateRunes(text, maxChars) text, truncated := truncateRunes(text, maxChars)
translatedHTMLResult := make(chan string, 1)
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
go func() {
translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
translatedHTMLResult <- translatedHTML
}()
} else {
translatedHTMLResult <- ""
}
translated, source, err := googleFreeTranslate(r.Context(), text, target) translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil { if err != nil {
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err) a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed") respondError(w, http.StatusBadGateway, "translation failed")
return return
} }
translatedHTML := "" translatedHTML := <-translatedHTMLResult
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}) 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) { func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate)
}
type htmlTextTranslator func(context.Context, string, string) (string, string, error)
func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) {
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil) nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
if err != nil { if err != nil {
return "", err return "", err
} }
type translationJob struct {
node *html.Node
original string
text string
}
remaining := maxChars remaining := maxChars
var translateNode func(*html.Node) error jobs := make([]translationJob, 0)
translateNode = func(n *html.Node) error { var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) { if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
return nil return
} }
if n.Type == html.TextNode { if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data) text := strings.TrimSpace(n.Data)
if text != "" && containsTranslatableLetter(text) && remaining > 0 { if text != "" && containsTranslatableLetter(text) && remaining > 0 {
limited, _ := truncateRunes(text, remaining) limited, _ := truncateRunes(text, remaining)
remaining -= utf8.RuneCountInString(limited) remaining -= utf8.RuneCountInString(limited)
translated, _, err := googleFreeTranslate(ctx, limited, target) jobs = append(jobs, translationJob{node: n, original: text, text: limited})
if err != nil {
return err
}
n.Data = strings.Replace(n.Data, text, translated, 1)
} }
} }
for c := n.FirstChild; c != nil; c = c.NextSibling { for c := n.FirstChild; c != nil; c = c.NextSibling {
if err := translateNode(c); err != nil { collect(c)
return err
}
} }
return nil
} }
for _, n := range nodes { for _, n := range nodes {
if err := translateNode(n); err != nil { collect(n)
return "", err }
} results := make([]string, len(jobs))
jobIndexes := make(chan int)
errCh := make(chan error, 1)
workers := min(4, len(jobs))
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobIndexes {
translated, _, translateErr := translator(ctx, jobs[index].text, target)
if translateErr != nil {
select {
case errCh <- translateErr:
default:
}
continue
}
results[index] = translated
}
}()
}
for index := range jobs {
jobIndexes <- index
}
close(jobIndexes)
wg.Wait()
select {
case translateErr := <-errCh:
return "", translateErr
default:
}
for index, job := range jobs {
job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1)
} }
var b bytes.Buffer var b bytes.Buffer
for _, n := range nodes { for _, n := range nodes {
+21 -1
View File
@@ -1,6 +1,10 @@
package app package app
import "testing" import (
"context"
"strings"
"testing"
)
func TestParseGoogleTranslateResponse(t *testing.T) { func TestParseGoogleTranslateResponse(t *testing.T) {
raw := []any{ raw := []any{
@@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) {
} }
} }
func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) {
translator := func(_ context.Context, text, target string) (string, string, error) {
return strings.ToUpper(text) + "-" + target, "en", nil
}
got, err := translateHTMLTextNodesWith(context.Background(), nil, `<p>Hello <strong>world</strong></p><pre>keep me</pre>`, "zh-CN", 100, translator)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `<p>HELLO-zh-CN <strong>WORLD-zh-CN</strong></p>`) {
t.Fatalf("translated HTML = %q", got)
}
if !strings.Contains(got, `<pre>keep me</pre>`) {
t.Fatalf("code block was translated: %q", got)
}
}
func TestTruncateRunes(t *testing.T) { func TestTruncateRunes(t *testing.T) {
got, truncated := truncateRunes("你好world", 4) got, truncated := truncateRunes("你好world", 4)
if got != "你好wo" || !truncated { if got != "你好wo" || !truncated {
+52 -7
View File
@@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ConfirmDialog } from "@/components/confirm-dialog" import { ConfirmDialog } from "@/components/confirm-dialog"
import { import {
@@ -3213,23 +3214,41 @@ function CompactMessageDetail({
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) { function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
const qc = useQueryClient()
const [translatedText, setTranslatedText] = React.useState("") const [translatedText, setTranslatedText] = React.useState("")
const [translatedHtml, setTranslatedHtml] = React.useState("") const [translatedHtml, setTranslatedHtml] = React.useState("")
const [showTranslated, setShowTranslated] = React.useState(false) const [showTranslated, setShowTranslated] = React.useState(false)
const [truncated, setTruncated] = React.useState(false) const [truncated, setTruncated] = React.useState(false)
const [autoTranslate, setAutoTranslate] = React.useState(() => {
try {
return window.localStorage.getItem("newszxcn.mail.auto-translate") !== "false"
} catch {
return true
}
})
const { toast } = useToast() const { toast } = useToast()
const targetLanguage = normalizeTranslationLanguage(language) const targetLanguage = normalizeTranslationLanguage(language)
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet]) const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language) const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language)
const translationKey = React.useMemo(() => ["mail-translation", message.externalAccountId || "local", message.id, targetLanguage] as const, [message.externalAccountId, message.id, targetLanguage])
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText]) const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
const applyTranslation = React.useCallback((result: Awaited<ReturnType<typeof api.translateMessage>>, display = true) => {
setTranslatedText(result.translatedText)
setTranslatedHtml(result.translatedHtml || "")
setTruncated(result.truncated)
setShowTranslated(display)
}, [])
const translate = useMutation({ const translate = useMutation({
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!), mutationFn: async ({ force = false }: { force?: boolean } = {}) => {
onSuccess: (result) => { if (!force) {
setTranslatedText(result.translatedText) const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
setTranslatedHtml(result.translatedHtml || "") if (cached) return cached
setTruncated(result.truncated) }
setShowTranslated(true) const result = message.externalAccountId ? await api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : await api.translateMessage(message.id, targetLanguage!)
qc.setQueryData(translationKey, result)
return result
}, },
onSuccess: (result) => applyTranslation(result),
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }), onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
}) })
@@ -3239,8 +3258,30 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
setShowTranslated(false) setShowTranslated(false)
setTruncated(false) setTruncated(false)
translate.reset() translate.reset()
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
if (cached) applyTranslation(cached, autoTranslate)
}, [message.id, language]) }, [message.id, language])
React.useEffect(() => {
if (!autoTranslate || !shouldShow || translate.isPending) return
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
if (cached) {
applyTranslation(cached)
return
}
translate.mutate({ force: false })
}, [autoTranslate, message.id, shouldShow, targetLanguage])
const changeAutoTranslate = (checked: boolean) => {
setAutoTranslate(checked)
if (!checked) setShowTranslated(false)
try {
window.localStorage.setItem("newszxcn.mail.auto-translate", String(checked))
} catch {
// Keep the setting for this session when browser storage is unavailable.
}
}
return ( return (
<> <>
{(shouldShow || translatedText) && ( {(shouldShow || translatedText) && (
@@ -3251,8 +3292,12 @@ function TranslatableMailBody({ message, language }: { message: MailMessage; lan
{truncated && <span className="ml-1"></span>} {truncated && <span className="ml-1"></span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<Switch checked={autoTranslate} onCheckedChange={changeAutoTranslate} aria-label="自动翻译邮件" />
</label>
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>} {translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate()}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button> <Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate({ force: !!translatedText })}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
</div> </div>
</div> </div>
</div> </div>