diff --git a/backend/controllers/email/translate.go b/backend/controllers/email/translate.go
new file mode 100644
index 00000000..80e0052f
--- /dev/null
+++ b/backend/controllers/email/translate.go
@@ -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)
+}
diff --git a/backend/controllers/email/translate_test.go b/backend/controllers/email/translate_test.go
new file mode 100644
index 00000000..65f0e7e0
--- /dev/null
+++ b/backend/controllers/email/translate_test.go
@@ -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)
+ }
+}
diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go
index 95b32b21..f2fa7409 100644
--- a/backend/listen/http_server/api_v1_compat.go
+++ b/backend/listen/http_server/api_v1_compat.go
@@ -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)
diff --git a/backend/services/translate/translate.go b/backend/services/translate/translate.go
new file mode 100644
index 00000000..a30c9cd9
--- /dev/null
+++ b/backend/services/translate/translate.go
@@ -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
+}
diff --git a/backend/services/translate/translate_test.go b/backend/services/translate/translate_test.go
new file mode 100644
index 00000000..853d974f
--- /dev/null
+++ b/backend/services/translate/translate_test.go
@@ -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)
+ }
+}
diff --git a/frontend/src/views/EmailDetail/EmailDetail.css b/frontend/src/views/EmailDetail/EmailDetail.css
index 409303e0..143f8f86 100644
--- a/frontend/src/views/EmailDetail/EmailDetail.css
+++ b/frontend/src/views/EmailDetail/EmailDetail.css
@@ -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;
+}
diff --git a/frontend/src/views/EmailDetail/EmailDetail.js b/frontend/src/views/EmailDetail/EmailDetail.js
index 62ccd289..6699d170 100644
--- a/frontend/src/views/EmailDetail/EmailDetail.js
+++ b/frontend/src/views/EmailDetail/EmailDetail.js
@@ -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
}
diff --git a/frontend/src/views/EmailDetail/EmailDetail.vue b/frontend/src/views/EmailDetail/EmailDetail.vue
index cb4d4230..0a41b114 100644
--- a/frontend/src/views/EmailDetail/EmailDetail.vue
+++ b/frontend/src/views/EmailDetail/EmailDetail.vue
@@ -5,6 +5,16 @@