feat(email): 实现邮件详情免配置一键翻译与原文切换功能并发布V2.14.0
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-18 11:02:23 +08:00
parent 2cedb02bcc
commit 8cf7c55a0b
10 changed files with 568 additions and 17 deletions

View File

@ -0,0 +1,116 @@
package email
import (
"context"
"fmt"
"net/http"
"strings"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/detail"
"git.pcux.cn/xuans/Smail/services/translate"
smailContext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
type emailTranslateRequest struct {
ID int `json:"id"`
Subject string `json:"subject"`
Text string `json:"text"`
TargetLang string `json:"target_lang"`
}
type emailTranslateResponse struct {
SubjectTranslated string `json:"subject_translated"`
ContentTranslated string `json:"content_translated"`
DetectedLang string `json:"detected_lang"`
TargetLang string `json:"target_lang"`
}
// TranslateEmail 邮件免配置一键翻译接口
func TranslateEmail(ctx *smailContext.Context, w http.ResponseWriter, req *http.Request) {
var retData emailTranslateRequest
if err := decodeEmailJSONRequest(w, req, &retData, maxEmailJSONRequestBytes); err != nil {
log.WithContext(ctx).WithError(err).Info("invalid email translate request")
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
}
targetLang := strings.TrimSpace(retData.TargetLang)
if targetLang == "" {
targetLang = "zh-CN"
}
var subjectToTranslate string
var contentToTranslate string
if retData.ID > 0 {
emailData, err := detail.GetEmailDetail(ctx, retData.ID, false)
if err != nil {
log.WithContext(ctx).WithError(err).Warnf("translate email: load email detail failed for id=%d", retData.ID)
response.NewErrorResponse(response.ServerError, "获取邮件失败", "").FPrint(w)
return
}
subjectToTranslate = emailData.Subject
if emailData.Text.Valid && strings.TrimSpace(emailData.Text.String) != "" {
contentToTranslate = emailData.Text.String
} else if emailData.Html.Valid {
contentToTranslate = emailData.Html.String
}
} else {
subjectToTranslate = retData.Subject
contentToTranslate = retData.Text
}
if strings.TrimSpace(subjectToTranslate) == "" && strings.TrimSpace(contentToTranslate) == "" {
response.NewSuccessResponse(emailTranslateResponse{
SubjectTranslated: "",
ContentTranslated: "",
DetectedLang: "auto",
TargetLang: targetLang,
}).FPrint(w)
return
}
callCtx := req.Context()
if callCtx == nil {
callCtx = context.Background()
}
var translatedSubject string
var detectedLang = "auto"
if strings.TrimSpace(subjectToTranslate) != "" {
subRes, err := translate.TranslateText(callCtx, subjectToTranslate, targetLang)
if err != nil {
log.WithContext(ctx).WithError(err).Warn("translate subject failed")
response.NewErrorResponse(response.ServerError, fmt.Sprintf("翻译主题失败: %v", err), "").FPrint(w)
return
}
translatedSubject = subRes.TranslatedText
if subRes.SourceLang != "" && subRes.SourceLang != "auto" {
detectedLang = subRes.SourceLang
}
}
var translatedContent string
if strings.TrimSpace(contentToTranslate) != "" {
contentRes, err := translate.TranslateText(callCtx, contentToTranslate, targetLang)
if err != nil {
log.WithContext(ctx).WithError(err).Warn("translate content failed")
response.NewErrorResponse(response.ServerError, fmt.Sprintf("翻译内容失败: %v", err), "").FPrint(w)
return
}
translatedContent = contentRes.TranslatedText
if detectedLang == "auto" && contentRes.SourceLang != "" && contentRes.SourceLang != "auto" {
detectedLang = contentRes.SourceLang
}
}
response.NewSuccessResponse(emailTranslateResponse{
SubjectTranslated: translatedSubject,
ContentTranslated: translatedContent,
DetectedLang: detectedLang,
TargetLang: targetLang,
}).FPrint(w)
}

View File

@ -0,0 +1,55 @@
package email
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.pcux.cn/xuans/Smail/services/translate"
"git.pcux.cn/xuans/Smail/utils/context"
)
func TestTranslateEmailDirectText(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[[["你好","Hello",null,null,10]],null,"en"]`))
}))
defer ts.Close()
translate.SetApiBaseURL(ts.URL)
translate.SetDefaultClient(ts.Client())
reqBody := emailTranslateRequest{
Subject: "Hello",
Text: "Hello",
TargetLang: "zh-CN",
}
bodyBytes, _ := json.Marshal(reqBody)
r := httptest.NewRequest(http.MethodPost, "/api/v1/email/translate", bytes.NewReader(bodyBytes))
w := httptest.NewRecorder()
ctx := &context.Context{UserID: 1}
TranslateEmail(ctx, w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Code int `json:"code"`
Data emailTranslateResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
if resp.Data.SubjectTranslated != "你好" {
t.Errorf("expected SubjectTranslated='你好', got %q", resp.Data.SubjectTranslated)
}
if resp.Data.ContentTranslated != "你好" {
t.Errorf("expected ContentTranslated='你好', got %q", resp.Data.ContentTranslated)
}
}

View File

@ -67,6 +67,7 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/email/del", "/api/email/del", http.MethodPost, 256<<10, email.EmailDelete)
registerV1MethodRoute(mux, "/api/v1/email/read", "/api/email/read", http.MethodPost, 256<<10, email.MarkRead)
registerV1MethodRoute(mux, "/api/v1/email/detail", "/api/email/detail", http.MethodPost, 256<<10, email.EmailDetail)
registerV1MethodRoute(mux, "/api/v1/email/translate", "/api/email/translate", http.MethodPost, 256<<10, email.TranslateEmail)
registerV1MethodRoute(mux, "/api/v1/email/move", "/api/email/move", http.MethodPost, 256<<10, email.Move)
registerV1MethodRoute(mux, "/api/v1/email/send", "/api/email/send", http.MethodPost, 40<<20, email.Send)
registerV1MethodRoute(mux, "/api/v1/email/draft/save", "/api/email/draft/save", http.MethodPost, 40<<20, email.SaveDraft)

View File

@ -0,0 +1,123 @@
package translate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var defaultClient = &http.Client{
Timeout: 15 * time.Second,
}
var apiBaseURL = "https://translate.googleapis.com/translate_a/single"
// Result 包含翻译后内容及源语言
type Result struct {
OriginalText string `json:"original_text"`
TranslatedText string `json:"translated_text"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
}
// SetApiBaseURL 设置翻译引擎基础地址(主要用于单元测试与代理)
func SetApiBaseURL(u string) {
if u != "" {
apiBaseURL = u
}
}
// SetDefaultClient 设置 HTTP 客户端(主要用于单元测试与超时配置)
func SetDefaultClient(c *http.Client) {
if c != nil {
defaultClient = c
}
}
// TranslateText 调用免配置翻译引擎将文本翻译为目标语言(默认 zh-CN)
func TranslateText(ctx context.Context, text string, targetLang string) (*Result, error) {
text = strings.TrimSpace(text)
if text == "" {
return &Result{
OriginalText: "",
TranslatedText: "",
SourceLang: "auto",
TargetLang: targetLang,
}, nil
}
if targetLang == "" {
targetLang = "zh-CN"
}
apiURL := fmt.Sprintf("%s?client=gtx&sl=auto&tl=%s&dt=t", apiBaseURL, url.QueryEscape(targetLang))
formData := url.Values{}
formData.Set("q", text)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("创建翻译请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := defaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求翻译服务失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("翻译服务返回异常状态码 %d: %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("读取翻译响应失败: %w", err)
}
var rawResponse []any
if err := json.Unmarshal(bodyBytes, &rawResponse); err != nil {
return nil, fmt.Errorf("解析翻译响应结构失败: %w", err)
}
if len(rawResponse) == 0 {
return nil, errors.New("翻译响应格式为空")
}
sentences, ok := rawResponse[0].([]any)
if !ok {
return nil, errors.New("未找到译文段落列表")
}
var translatedSb strings.Builder
for _, item := range sentences {
if sentenceArr, ok := item.([]any); ok && len(sentenceArr) > 0 {
if seg, ok := sentenceArr[0].(string); ok {
translatedSb.WriteString(seg)
}
}
}
sourceLang := "auto"
if len(rawResponse) > 2 {
if sl, ok := rawResponse[2].(string); ok && sl != "" {
sourceLang = sl
}
}
return &Result{
OriginalText: text,
TranslatedText: translatedSb.String(),
SourceLang: sourceLang,
TargetLang: targetLang,
}, nil
}

View File

@ -0,0 +1,61 @@
package translate
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestTranslateEmptyText(t *testing.T) {
res, err := TranslateText(context.Background(), "", "zh-CN")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.TranslatedText != "" {
t.Fatalf("expected empty translated text, got %q", res.TranslatedText)
}
}
func TestTranslateMockServer(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST method, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Errorf("parse form failed: %v", err)
}
q := r.Form.Get("q")
if q != "Hello world" {
t.Errorf("expected q='Hello world', got %q", q)
}
w.Header().Set("Content-Type", "application/json")
// 模拟 Google GTX 翻译返回结构
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[[["你好世界","Hello world",null,null,10]],null,"en"]`))
}))
defer ts.Close()
oldURL := apiBaseURL
oldClient := defaultClient
defer func() {
SetApiBaseURL(oldURL)
SetDefaultClient(oldClient)
}()
SetApiBaseURL(ts.URL)
SetDefaultClient(ts.Client())
res, err := TranslateText(context.Background(), "Hello world", "zh-CN")
if err != nil {
t.Fatalf("TranslateText failed: %v", err)
}
if res.TranslatedText != "你好世界" {
t.Fatalf("expected '你好世界', got %q", res.TranslatedText)
}
if res.SourceLang != "en" {
t.Fatalf("expected source lang 'en', got %q", res.SourceLang)
}
}

View File

@ -256,3 +256,33 @@
font-size: 16px;
}
}
.translate-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.translation-banner {
display: flex;
align-items: center;
gap: 8px;
background: var(--el-color-primary-light-9, #ecf5ff);
color: var(--el-color-primary, #409eff);
border: 1px solid var(--el-color-primary-light-7, #d9ecff);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 14px;
}
.banner-icon {
font-size: 16px;
}
.toggle-back-link {
margin-left: auto;
font-size: 13px;
cursor: pointer;
}

View File

@ -1,8 +1,8 @@
import {onBeforeUnmount, ref, watch} from 'vue'
import {computed, onBeforeUnmount, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {Document, Back, Delete, Download} from '@element-plus/icons-vue';
import {Document, Back, Delete, Download, Operation} from '@element-plus/icons-vue';
import {ElMessage, ElMessageBox} from 'element-plus';
@ -37,6 +37,12 @@ export function usePage() {
const loadError = ref('')
let requestSequence = 0
// 翻译相关状态
const isTranslated = ref(false)
const translating = ref(false)
const translationCache = ref(null)
const detectedLang = ref('')
const parseAddresses = (value) => {
try {
const addresses = JSON.parse(value || '[]')
@ -57,6 +63,12 @@ export function usePage() {
loadError.value = ''
loading.value = true
// 重置翻译状态
isTranslated.value = false
translating.value = false
translationCache.value = null
detectedLang.value = ''
const rawId = String(route.params.id || '')
const id = Number(rawId)
if (!/^\d+$/.test(rawId) || !Number.isSafeInteger(id) || id <= 0) {
@ -67,7 +79,6 @@ export function usePage() {
try {
const res = await http.post('/api/v1/email/detail', {id})
// 切换邮件或离开页面后,旧请求不得覆盖当前状态。
if (sequence !== requestSequence) return
if (res?.errorNo !== 0) {
loadError.value = res?.errorMsg || '读取邮件失败,请重试'
@ -82,7 +93,6 @@ export function usePage() {
tos.value = parseAddresses(data.to)
ccs.value = parseAddresses(data.cc)
showCC.value = ccs.value.length > 0
// 仅在本人的发件副本展示密送;后端仍负责所有权校验和收件副本脱敏。
bccs.value = data.type === 1 ? parseAddresses(data.bcc) : []
showBCC.value = bccs.value.length > 0
} catch {
@ -99,6 +109,66 @@ export function usePage() {
requestSequence++
})
const toggleTranslate = async () => {
if (loading.value || loadError.value) return
if (isTranslated.value) {
isTranslated.value = false
return
}
if (translationCache.value) {
isTranslated.value = true
return
}
const emailId = Number(detailData.value?.id)
if (!Number.isSafeInteger(emailId) || emailId <= 0) return
translating.value = true
try {
const res = await http.post('/api/v1/email/translate', {
id: emailId,
target_lang: 'zh-CN'
})
if (res?.errorNo === 0 && res.data) {
translationCache.value = res.data
detectedLang.value = res.data.detected_lang || 'auto'
isTranslated.value = true
ElMessage.success('翻译完成')
} else {
ElMessage.error(res?.errorMsg || '翻译失败,请稍后重试')
}
} catch (err) {
ElMessage.error('翻译服务连接失败,请稍后重试')
} finally {
translating.value = false
}
}
const displaySubject = computed(() => {
if (isTranslated.value && translationCache.value?.subject_translated) {
return translationCache.value.subject_translated
}
return detailData.value?.subject || ''
})
const displayContent = computed(() => {
if (isTranslated.value && translationCache.value?.content_translated) {
return translationCache.value.content_translated
}
if (detailData.value?.html) {
return detailData.value.html
}
return detailData.value?.text || ''
})
const displayIsText = computed(() => {
if (isTranslated.value) {
return true
}
return !detailData.value?.html
})
const backToList = () => router.push({name: 'list'})
const handleReply = () => {
const id = Number(detailData.value.id)
@ -178,6 +248,7 @@ export function usePage() {
Back,
Delete,
Download,
Operation,
ElMessage,
ElMessageBox,
lang,
@ -202,7 +273,14 @@ export function usePage() {
formatSenderName,
hasSenderName,
formatDetailDate,
handleDelete
handleDelete,
isTranslated,
translating,
detectedLang,
displaySubject,
displayContent,
displayIsText,
toggleTranslate
}
}
@ -210,5 +288,6 @@ export {
Back,
Delete,
Document,
Download
Download,
Operation
}

View File

@ -5,6 +5,16 @@
<el-icon><Back /></el-icon>
</el-button>
<div v-if="!loading && !loadError" class="action-buttons">
<el-button
:type="isTranslated ? 'warning' : 'default'"
:loading="translating"
plain
@click="toggleTranslate"
class="translate-btn"
>
<el-icon><Operation /></el-icon>
<span>{{ isTranslated ? '查看原文' : '一键翻译' }}</span>
</el-button>
<el-button
v-if="detailData.type === 0"
type="primary"
@ -38,7 +48,14 @@
:closable="false"
show-icon
/>
<h1 class="mail-subject">{{ detailData.subject }}</h1>
<div v-if="isTranslated" class="translation-banner">
<el-icon class="banner-icon"><Operation /></el-icon>
<span>已自动翻译为中文(源语言:{{ detectedLang || '自动识别' }})</span>
<el-button link type="primary" @click="toggleTranslate" class="toggle-back-link">查看原文</el-button>
</div>
<h1 class="mail-subject">{{ displaySubject }}</h1>
<div class="mail-meta-card">
<div class="meta-left">
@ -78,10 +95,10 @@
<el-divider class="custom-divider"/>
<div class="mail-body">
<div class="body-text" v-if="detailData.html === ''">
{{ detailData.text }}
<div class="body-text" v-if="displayIsText">
{{ displayContent }}
</div>
<div class="body-html" v-else v-html="detailData.html"></div>
<div class="body-html" v-else v-html="displayContent"></div>
</div>
<div v-if="detailData.attachments && detailData.attachments.length > 0" class="attachments-section">
@ -100,7 +117,7 @@
</template>
<script setup>
import { usePage, Back, Delete, Document, Download } from './EmailDetail.js'
import { usePage, Back, Delete, Document, Download, Operation } from './EmailDetail.js'
const {
handleReply,
@ -119,7 +136,14 @@ const {
bccs,
formatSenderName,
hasSenderName,
formatDetailDate
formatDetailDate,
isTranslated,
translating,
detectedLang,
displaySubject,
displayContent,
displayIsText,
toggleTranslate
} = usePage()
</script>

View File

@ -57,7 +57,7 @@
<el-icon><Setting /></el-icon>
<span class="settings-label">{{ lang.settings }}</span>
</div>
<span class="app-version-badge">V2.13.1</span>
<span class="app-version-badge">V2.14.0</span>
</button>
</div>
</div>

View File

@ -2,12 +2,12 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { reactive, ref, watch } from './vue.mjs'
import { computed, reactive, ref, watch } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/EmailDetail/EmailDetail.js', import.meta.url), 'utf8')
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}/g, '') + '\nreturn usePage()',
['ref', 'watch', 'onBeforeUnmount', 'useRoute', 'useRouter', 'useGroupStore', 'http', 'ElMessage', 'ElMessageBox', 'lang', 'Document', 'Back', 'Delete', 'Download']
['computed', 'ref', 'watch', 'onBeforeUnmount', 'useRoute', 'useRouter', 'useGroupStore', 'http', 'ElMessage', 'ElMessageBox', 'lang', 'Document', 'Back', 'Delete', 'Download', 'Operation']
)
const settle = () => new Promise(resolve => setImmediate(resolve))
const email = id => ({errorNo: 0, data: {id, subject: `mail-${id}`, to: '[{"EmailAddress":"test@example.com"}]', cc: '[]', attachments: []}})
@ -17,9 +17,9 @@ function fixture(post, id = '15') {
const requests = []
const navigations = []
let unmount
const page = createPage(ref, watch, callback => { unmount = callback }, () => route,
const page = createPage(computed, ref, watch, callback => { unmount = callback }, () => route,
() => ({push: target => navigations.push(target)}), () => ({tag: ''}),
{post: async (...args) => { requests.push(args); return post(...args) }}, {}, {}, {}, {}, {}, {}, {})
{post: async (...args) => { requests.push(args); return post(...args) }}, {success: () => {}, error: () => {}, warning: () => {}, info: () => {}}, {}, {}, {}, {}, {}, {})
return {page, route, requests, navigations, unmount: () => unmount()}
}
@ -142,3 +142,65 @@ test('离开详情后未完成请求不再写入状态', async () => {
await settle()
assert.equal(page.detailData.value.id, undefined)
})
test('一键翻译与查看原文切换,支持缓存翻译结果', async () => {
let translateCalls = 0
const postHandler = async (url, data) => {
if (url === '/api/v1/email/detail') {
return {
errorNo: 0,
data: {
id: 15,
subject: 'Hello World',
text: 'This is an English email.',
type: 0,
to: '[]',
cc: '[]',
attachments: []
}
}
}
if (url === '/api/v1/email/translate') {
translateCalls++
return {
errorNo: 0,
data: {
id: 15,
subject_translated: '你好世界',
content_translated: '这是一封英文邮件。',
detected_lang: 'en'
}
}
}
return { errorNo: 500, errorMsg: '未知请求' }
}
const { page } = fixture(postHandler, '15')
await settle()
assert.equal(page.displaySubject.value, 'Hello World')
assert.equal(page.displayContent.value, 'This is an English email.')
assert.equal(page.isTranslated.value, false)
// 第一次触发翻译
await page.toggleTranslate()
assert.equal(translateCalls, 1)
assert.equal(page.isTranslated.value, true)
assert.equal(page.displaySubject.value, '你好世界')
assert.equal(page.displayContent.value, '这是一封英文邮件。')
assert.equal(page.detectedLang.value, 'en')
// 点击查看原文
await page.toggleTranslate()
assert.equal(translateCalls, 1)
assert.equal(page.isTranslated.value, false)
assert.equal(page.displaySubject.value, 'Hello World')
assert.equal(page.displayContent.value, 'This is an English email.')
// 再次切换到译文(应命中本地缓存,不重复发请求)
await page.toggleTranslate()
assert.equal(translateCalls, 1)
assert.equal(page.isTranslated.value, true)
assert.equal(page.displaySubject.value, '你好世界')
assert.equal(page.displayContent.value, '这是一封英文邮件。')
})