From ed68abb00db58f6bcde8dfb65880f00c22efee56 Mon Sep 17 00:00:00 2001 From: LanQin Date: Sat, 27 Jun 2026 13:30:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E6=94=AF=E6=8C=81=E5=A4=96?= =?UTF-8?q?=E9=83=A8=20IMAP=20=E9=82=AE=E4=BB=B6=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增外部账号邮件翻译接口与路由。 - 前端翻译请求优先走外部账号邮件翻译接口。 --- apps/api/internal/app/mail_translate.go | 63 +++++++++++++++++++++++++ apps/api/internal/app/router_auth.go | 1 + apps/web/src/lib/api.ts | 1 + apps/web/src/pages/mail.tsx | 2 +- 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/api/internal/app/mail_translate.go b/apps/api/internal/app/mail_translate.go index a6bc91b..57d2e2b 100644 --- a/apps/api/internal/app/mail_translate.go +++ b/apps/api/internal/app/mail_translate.go @@ -70,6 +70,69 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) } +func (a *App) handleTranslateExternalIMAPMessage(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 + } + account, ok := a.externalIMAPAccountForMailRequest(w, r) + if !ok { + return + } + folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId")) + if !ok { + return + } + client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account) + if err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + defer client.Close() + raw, remote, err := client.FetchRaw(r.Context(), folder, uid) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to load remote message") + return + } + stored, _, err := a.parseMaildirMessage(raw, account.Username) + text := "" + if err == nil { + text = strings.TrimSpace(stored.BodyText) + if text == "" { + text = strings.TrimSpace(stored.Snippet) + } + } + if text == "" { + text = strings.TrimSpace(remote.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("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "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": diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 2b85031..dfc4fa0 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -89,6 +89,7 @@ func (a *App) Router() http.Handler { 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) r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage) + r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/translate", a.handleTranslateExternalIMAPMessage) r.With(a.requirePermission(PermissionMailAttachments), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment) r.With(a.requirePermission(PermissionMailOrganize), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead) r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index b077038..355a42d 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -165,6 +165,7 @@ export const api = { }, 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 }), + translateExternalMessage: (id: string, remoteId: string, targetLanguage: string) => request(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/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 106cf39..e937180 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -2478,7 +2478,7 @@ function MessageTranslateBox({ message, language }: { message: MailMessage; lang 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!), + mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!), onSuccess: (result) => { setTranslatedText(result.translatedText) setTruncated(result.truncated)