up
This commit is contained in:
@@ -17,3 +17,9 @@
|
||||
- 部署模板目录 internal/web/templates/ + admin/ 子目录
|
||||
- systemd 增加 AmbientCapabilities=CAP_NET_BIND_SERVICE(绑定 25/465 等特权端口)
|
||||
- 附件目录 /srv/mail_go/attachments/
|
||||
|
||||
## MailGo 邮件处理 Bug 修复
|
||||
- **RFC 2047 地址解码**:SMTP Data() 和 IMAP CreateMessage() 中 From/To/Cc 从 `header.Get()` 改为 `mailutil.FormatAddressList()`(内部用 `header.AddressList()` 自动解码 RFC 2047 编码的中文名等)
|
||||
- **charset 转码**:新增 `internal/mailutil/codec.go`,`DecodeCharset()` 检测 Content-Type charset 参数,用 `golang.org/x/text/encoding/htmlindex` 将 gb2312 等非 UTF-8 编码转 UTF-8
|
||||
- **原始邮件数据保留**:Message 模型新增 `RawData` 字段(`type:mediumtext`),SMTP/IMAP 接收时存原始邮件;IMAP `buildRawMessage()` 优先使用 RawData,降级才从字段重建
|
||||
- **邮件显示修复**:HTML 邮件改用 `<iframe srcdoc sandbox>` 隔离渲染,防止 Outlook 等邮件的嵌套 `<html><body>` 标签破坏页面 DOM 结构导致内容不显示
|
||||
|
||||
@@ -59,6 +59,7 @@ type Message struct {
|
||||
Subject string `gorm:"size:1024" json:"subject"`
|
||||
TextBody string `gorm:"type:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" json:"html_body"`
|
||||
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||
Date time.Time `json:"date"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
@@ -502,9 +503,9 @@ func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Li
|
||||
}
|
||||
|
||||
header := mr.Header
|
||||
fromAddr := header.Get("From")
|
||||
toAddr := header.Get("To")
|
||||
ccAddr := header.Get("Cc")
|
||||
fromAddr := mailutil.FormatAddressList(&header, "From")
|
||||
toAddr := mailutil.FormatAddressList(&header, "To")
|
||||
ccAddr := mailutil.FormatAddressList(&header, "Cc")
|
||||
subject, _ := header.Subject()
|
||||
messageID, _ := header.MessageID()
|
||||
msgDate, _ := header.Date()
|
||||
@@ -527,12 +528,18 @@ func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Li
|
||||
|
||||
switch h := p.Header.(type) {
|
||||
case *asgomail.InlineHeader:
|
||||
contentType, _, _ := h.ContentType()
|
||||
contentType, params, _ := h.ContentType()
|
||||
buf, _ := io.ReadAll(p.Body)
|
||||
// 检测并转换字符集
|
||||
charset := ""
|
||||
if cs, ok := params["charset"]; ok {
|
||||
charset = cs
|
||||
}
|
||||
decoded := mailutil.DecodeCharset(buf, charset)
|
||||
if strings.HasPrefix(contentType, "text/plain") {
|
||||
textBody = string(buf)
|
||||
textBody = decoded
|
||||
} else if strings.HasPrefix(contentType, "text/html") {
|
||||
htmlBody = string(buf)
|
||||
htmlBody = decoded
|
||||
}
|
||||
case *asgomail.AttachmentHeader:
|
||||
// Attachments from APPEND are not saved in this simple implementation
|
||||
@@ -565,6 +572,7 @@ func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Li
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HtmlBody: htmlBody,
|
||||
RawData: string(data),
|
||||
IsRead: isRead,
|
||||
IsFlagged: isFlagged,
|
||||
Date: msgDate,
|
||||
@@ -717,7 +725,14 @@ func parseAddressList(addrStr string) []*imap.Address {
|
||||
}
|
||||
|
||||
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
||||
// If RawData is available, it uses the original raw data directly.
|
||||
func buildRawMessage(msg *db.Message) []byte {
|
||||
// 优先使用原始邮件数据
|
||||
if msg.RawData != "" {
|
||||
return []byte(msg.RawData)
|
||||
}
|
||||
|
||||
// 降级:从字段重建
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Write headers
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package mailutil provides utilities for email processing:
|
||||
// charset conversion and RFC 2047 address formatting.
|
||||
package mailutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
asgomail "github.com/emersion/go-message/mail"
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
)
|
||||
|
||||
// DecodeCharset converts bytes from the given charset to a UTF-8 string.
|
||||
// If charset is empty, "utf-8", or "us-ascii", it returns string(buf) directly.
|
||||
// If the bytes are already valid UTF-8, no conversion is attempted.
|
||||
func DecodeCharset(buf []byte, charset string) string {
|
||||
cs := strings.ToLower(strings.Trim(charset, `"'`))
|
||||
if cs == "" || cs == "utf-8" || cs == "us-ascii" {
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// 已是合法 UTF-8,无需转换
|
||||
if utf8.Valid(buf) {
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
enc, err := htmlindex.Get(cs)
|
||||
if err != nil {
|
||||
// 未知字符集,原样返回
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
decoded, err := enc.NewDecoder().Bytes(buf)
|
||||
if err != nil {
|
||||
// 解码失败,原样返回
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
return string(decoded)
|
||||
}
|
||||
|
||||
// FormatAddressList 解析 RFC 2047 编码的地址列表头并格式化为可读字符串。
|
||||
// 例如: "=?gb2312?B?zuIgzsS35Q==?= <a@b.com>" → "吴文锋 <a@b.com>"
|
||||
func FormatAddressList(header *asgomail.Header, key string) string {
|
||||
addrs, err := header.AddressList(key)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
// 降级:返回原始值
|
||||
return header.Get(key)
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, addr := range addrs {
|
||||
if addr.Name != "" {
|
||||
parts = append(parts, fmt.Sprintf("%s <%s>", addr.Name, addr.Address))
|
||||
} else {
|
||||
parts = append(parts, addr.Address)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// ExtractCharset 从 Content-Type 头值中提取 charset 参数。
|
||||
// 例如: "text/plain; charset=gb2312" → "gb2312"
|
||||
func ExtractCharset(contentType string) string {
|
||||
parts := strings.Split(contentType, ";")
|
||||
for _, part := range parts[1:] {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToLower(part), "charset=") {
|
||||
return strings.Trim(strings.TrimPrefix(part, "charset="), `"'`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/storage"
|
||||
|
||||
@@ -166,12 +167,12 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
return fmt.Errorf("failed to parse MIME message: %w", err)
|
||||
}
|
||||
|
||||
// Extract headers from the top-level mail header
|
||||
// Extract headers — 使用 AddressList 解码 RFC 2047 编码的地址
|
||||
header := mr.Header
|
||||
|
||||
fromAddr := header.Get("From")
|
||||
toAddr := header.Get("To")
|
||||
ccAddr := header.Get("Cc")
|
||||
fromAddr := mailutil.FormatAddressList(&header, "From")
|
||||
toAddr := mailutil.FormatAddressList(&header, "To")
|
||||
ccAddr := mailutil.FormatAddressList(&header, "Cc")
|
||||
subject, _ := header.Subject()
|
||||
messageID, _ := header.MessageID()
|
||||
date, _ := header.Date()
|
||||
@@ -195,16 +196,22 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
|
||||
switch h := p.Header.(type) {
|
||||
case *mail.InlineHeader:
|
||||
contentType, _, _ := h.ContentType()
|
||||
contentType, params, _ := h.ContentType()
|
||||
buf, readErr := io.ReadAll(p.Body)
|
||||
if readErr != nil {
|
||||
log.Printf("SMTP: error reading inline part: %v", readErr)
|
||||
continue
|
||||
}
|
||||
// 检测并转换字符集
|
||||
charset := ""
|
||||
if cs, ok := params["charset"]; ok {
|
||||
charset = cs
|
||||
}
|
||||
decoded := mailutil.DecodeCharset(buf, charset)
|
||||
if strings.HasPrefix(contentType, "text/plain") {
|
||||
textBody = string(buf)
|
||||
textBody = decoded
|
||||
} else if strings.HasPrefix(contentType, "text/html") {
|
||||
htmlBody = string(buf)
|
||||
htmlBody = decoded
|
||||
}
|
||||
|
||||
case *mail.AttachmentHeader:
|
||||
@@ -257,6 +264,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HtmlBody: htmlBody,
|
||||
RawData: string(data),
|
||||
IsRead: false,
|
||||
IsFlagged: false,
|
||||
Date: date,
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>查看邮件 - MailGo 管理后台</title>
|
||||
{{template "styles" .}}
|
||||
<style>
|
||||
.mail-body-iframe {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
@@ -35,7 +44,7 @@
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
{{if .message.HtmlBody}}
|
||||
{{.message.HtmlBody | safeHTML}}
|
||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||
{{else}}
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||
{{end}}
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>查看邮件 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
<style>
|
||||
.mail-body-iframe {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
@@ -32,7 +41,7 @@
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
{{if .message.HtmlBody}}
|
||||
{{.message.HtmlBody | safeHTML}}
|
||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||
{{else}}
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user