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
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:
@@ -0,0 +1,7 @@
|
|||||||
|
- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。
|
||||||
|
- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。
|
||||||
|
- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。
|
||||||
|
- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。
|
||||||
|
- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。
|
||||||
|
- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。
|
||||||
|
- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 translate = useMutation({
|
const applyTranslation = React.useCallback((result: Awaited<ReturnType<typeof api.translateMessage>>, display = true) => {
|
||||||
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!),
|
|
||||||
onSuccess: (result) => {
|
|
||||||
setTranslatedText(result.translatedText)
|
setTranslatedText(result.translatedText)
|
||||||
setTranslatedHtml(result.translatedHtml || "")
|
setTranslatedHtml(result.translatedHtml || "")
|
||||||
setTruncated(result.truncated)
|
setTruncated(result.truncated)
|
||||||
setShowTranslated(true)
|
setShowTranslated(display)
|
||||||
|
}, [])
|
||||||
|
const translate = useMutation({
|
||||||
|
mutationFn: async ({ force = false }: { force?: boolean } = {}) => {
|
||||||
|
if (!force) {
|
||||||
|
const cached = qc.getQueryData<Awaited<ReturnType<typeof api.translateMessage>>>(translationKey)
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user