up
This commit is contained in:
@@ -17,3 +17,9 @@
|
|||||||
- 部署模板目录 internal/web/templates/ + admin/ 子目录
|
- 部署模板目录 internal/web/templates/ + admin/ 子目录
|
||||||
- systemd 增加 AmbientCapabilities=CAP_NET_BIND_SERVICE(绑定 25/465 等特权端口)
|
- systemd 增加 AmbientCapabilities=CAP_NET_BIND_SERVICE(绑定 25/465 等特权端口)
|
||||||
- 附件目录 /srv/mail_go/attachments/
|
- 附件目录 /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"`
|
Subject string `gorm:"size:1024" json:"subject"`
|
||||||
TextBody string `gorm:"type:text" json:"text_body"`
|
TextBody string `gorm:"type:text" json:"text_body"`
|
||||||
HtmlBody string `gorm:"type:text" json:"html_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"`
|
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/emersion/go-imap"
|
"github.com/emersion/go-imap"
|
||||||
@@ -502,9 +503,9 @@ func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Li
|
|||||||
}
|
}
|
||||||
|
|
||||||
header := mr.Header
|
header := mr.Header
|
||||||
fromAddr := header.Get("From")
|
fromAddr := mailutil.FormatAddressList(&header, "From")
|
||||||
toAddr := header.Get("To")
|
toAddr := mailutil.FormatAddressList(&header, "To")
|
||||||
ccAddr := header.Get("Cc")
|
ccAddr := mailutil.FormatAddressList(&header, "Cc")
|
||||||
subject, _ := header.Subject()
|
subject, _ := header.Subject()
|
||||||
messageID, _ := header.MessageID()
|
messageID, _ := header.MessageID()
|
||||||
msgDate, _ := header.Date()
|
msgDate, _ := header.Date()
|
||||||
@@ -527,12 +528,18 @@ func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Li
|
|||||||
|
|
||||||
switch h := p.Header.(type) {
|
switch h := p.Header.(type) {
|
||||||
case *asgomail.InlineHeader:
|
case *asgomail.InlineHeader:
|
||||||
contentType, _, _ := h.ContentType()
|
contentType, params, _ := h.ContentType()
|
||||||
buf, _ := io.ReadAll(p.Body)
|
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") {
|
if strings.HasPrefix(contentType, "text/plain") {
|
||||||
textBody = string(buf)
|
textBody = decoded
|
||||||
} else if strings.HasPrefix(contentType, "text/html") {
|
} else if strings.HasPrefix(contentType, "text/html") {
|
||||||
htmlBody = string(buf)
|
htmlBody = decoded
|
||||||
}
|
}
|
||||||
case *asgomail.AttachmentHeader:
|
case *asgomail.AttachmentHeader:
|
||||||
// Attachments from APPEND are not saved in this simple implementation
|
// 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,
|
Subject: subject,
|
||||||
TextBody: textBody,
|
TextBody: textBody,
|
||||||
HtmlBody: htmlBody,
|
HtmlBody: htmlBody,
|
||||||
|
RawData: string(data),
|
||||||
IsRead: isRead,
|
IsRead: isRead,
|
||||||
IsFlagged: isFlagged,
|
IsFlagged: isFlagged,
|
||||||
Date: msgDate,
|
Date: msgDate,
|
||||||
@@ -717,7 +725,14 @@ func parseAddressList(addrStr string) []*imap.Address {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
// 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 {
|
func buildRawMessage(msg *db.Message) []byte {
|
||||||
|
// 优先使用原始邮件数据
|
||||||
|
if msg.RawData != "" {
|
||||||
|
return []byte(msg.RawData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:从字段重建
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
// Write headers
|
// 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/config"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
"mail_go/internal/storage"
|
"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)
|
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
|
header := mr.Header
|
||||||
|
|
||||||
fromAddr := header.Get("From")
|
fromAddr := mailutil.FormatAddressList(&header, "From")
|
||||||
toAddr := header.Get("To")
|
toAddr := mailutil.FormatAddressList(&header, "To")
|
||||||
ccAddr := header.Get("Cc")
|
ccAddr := mailutil.FormatAddressList(&header, "Cc")
|
||||||
subject, _ := header.Subject()
|
subject, _ := header.Subject()
|
||||||
messageID, _ := header.MessageID()
|
messageID, _ := header.MessageID()
|
||||||
date, _ := header.Date()
|
date, _ := header.Date()
|
||||||
@@ -195,16 +196,22 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
|
|
||||||
switch h := p.Header.(type) {
|
switch h := p.Header.(type) {
|
||||||
case *mail.InlineHeader:
|
case *mail.InlineHeader:
|
||||||
contentType, _, _ := h.ContentType()
|
contentType, params, _ := h.ContentType()
|
||||||
buf, readErr := io.ReadAll(p.Body)
|
buf, readErr := io.ReadAll(p.Body)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
log.Printf("SMTP: error reading inline part: %v", readErr)
|
log.Printf("SMTP: error reading inline part: %v", readErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 检测并转换字符集
|
||||||
|
charset := ""
|
||||||
|
if cs, ok := params["charset"]; ok {
|
||||||
|
charset = cs
|
||||||
|
}
|
||||||
|
decoded := mailutil.DecodeCharset(buf, charset)
|
||||||
if strings.HasPrefix(contentType, "text/plain") {
|
if strings.HasPrefix(contentType, "text/plain") {
|
||||||
textBody = string(buf)
|
textBody = decoded
|
||||||
} else if strings.HasPrefix(contentType, "text/html") {
|
} else if strings.HasPrefix(contentType, "text/html") {
|
||||||
htmlBody = string(buf)
|
htmlBody = decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
case *mail.AttachmentHeader:
|
case *mail.AttachmentHeader:
|
||||||
@@ -257,6 +264,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
Subject: subject,
|
Subject: subject,
|
||||||
TextBody: textBody,
|
TextBody: textBody,
|
||||||
HtmlBody: htmlBody,
|
HtmlBody: htmlBody,
|
||||||
|
RawData: string(data),
|
||||||
IsRead: false,
|
IsRead: false,
|
||||||
IsFlagged: false,
|
IsFlagged: false,
|
||||||
Date: date,
|
Date: date,
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>查看邮件 - MailGo 管理后台</title>
|
<title>查看邮件 - MailGo 管理后台</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
|
<style>
|
||||||
|
.mail-body-iframe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
@@ -35,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mail-body">
|
<div class="mail-body">
|
||||||
{{if .message.HtmlBody}}
|
{{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}}
|
{{else}}
|
||||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>查看邮件 - MailGo</title>
|
<title>查看邮件 - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
|
<style>
|
||||||
|
.mail-body-iframe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
@@ -32,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mail-body">
|
<div class="mail-body">
|
||||||
{{if .message.HtmlBody}}
|
{{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}}
|
{{else}}
|
||||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user