964 lines
26 KiB
Go
964 lines
26 KiB
Go
package parsemail
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"net/textproto"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Smail/config"
|
||
"git.pcux.cn/xuans/Smail/models"
|
||
"git.pcux.cn/xuans/Smail/utils/context"
|
||
"github.com/emersion/go-message"
|
||
_ "github.com/emersion/go-message/charset"
|
||
"github.com/emersion/go-message/mail"
|
||
"github.com/microcosm-cc/bluemonday"
|
||
log "github.com/sirupsen/logrus"
|
||
"github.com/spf13/cast"
|
||
xhtml "golang.org/x/net/html"
|
||
xhtmlatom "golang.org/x/net/html/atom"
|
||
)
|
||
|
||
type User struct {
|
||
EmailAddress string `json:"EmailAddress"`
|
||
Name string `json:"Name"`
|
||
}
|
||
|
||
func (u User) Build() string {
|
||
if u.Name != "" {
|
||
return fmt.Sprintf("\"%s\" <%s>", mime.QEncoding.Encode("utf-8", u.Name), u.EmailAddress)
|
||
}
|
||
return fmt.Sprintf("<%s>", u.EmailAddress)
|
||
}
|
||
|
||
func (u User) GetDomainAccount() (string, string) {
|
||
infos := strings.Split(u.EmailAddress, "@")
|
||
if len(infos) >= 2 {
|
||
return infos[0], infos[1]
|
||
}
|
||
|
||
return "", ""
|
||
}
|
||
|
||
type Attachment struct {
|
||
Filename string
|
||
ContentType string
|
||
Content []byte
|
||
ContentID string
|
||
}
|
||
|
||
// EmailAuthentication 表示收信时的发件人身份认证结果。
|
||
type EmailAuthentication struct {
|
||
SPFPassed bool
|
||
DKIMPassed bool
|
||
Dangerous bool
|
||
}
|
||
|
||
// NewEmailAuthentication 根据 SPF 和 DKIM 结果创建统一的邮件认证结论。
|
||
func NewEmailAuthentication(spfPassed, dkimPassed bool) *EmailAuthentication {
|
||
return &EmailAuthentication{
|
||
SPFPassed: spfPassed,
|
||
DKIMPassed: dkimPassed,
|
||
Dangerous: !spfPassed && !dkimPassed,
|
||
}
|
||
}
|
||
|
||
// Email is the type used for email messages
|
||
type Email struct {
|
||
InReplyTo string
|
||
ThreadReferences string
|
||
EnvelopeFrom string
|
||
ReplyTo []*User
|
||
From *User
|
||
To []*User
|
||
Bcc []*User
|
||
Cc []*User
|
||
Subject string
|
||
Text []byte // Plaintext message (optional)
|
||
HTML []byte // Html message (optional)
|
||
Sender *User // override From as SMTP envelope sender (optional)
|
||
Headers textproto.MIMEHeader
|
||
Attachments []*Attachment
|
||
ReadReceipt []string
|
||
Date string
|
||
Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件
|
||
MessageId int64
|
||
MsgID string // RFC-compliant Message-ID, persisted in DB
|
||
Size int
|
||
Authentication *EmailAuthentication
|
||
}
|
||
|
||
func (e *Email) SenderFilterAddresses() []string {
|
||
if e == nil {
|
||
return nil
|
||
}
|
||
addresses := []string{e.EnvelopeFrom}
|
||
if e.From != nil {
|
||
addresses = append(addresses, e.From.EmailAddress)
|
||
}
|
||
if e.Sender != nil {
|
||
addresses = append(addresses, e.Sender.EmailAddress)
|
||
}
|
||
return addresses
|
||
}
|
||
|
||
type StoredEmailFields struct {
|
||
ReplyTo string
|
||
To string
|
||
Bcc string
|
||
Cc string
|
||
Sender string
|
||
Attachments string
|
||
}
|
||
|
||
func (e *Email) EncodeStoredFields() (StoredEmailFields, error) {
|
||
if e == nil {
|
||
return StoredEmailFields{}, errors.New("email is nil")
|
||
}
|
||
marshal := func(field string, value any) (string, error) {
|
||
data, err := json.Marshal(value)
|
||
if err != nil {
|
||
return "", fmt.Errorf("encode %s: %w", field, err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
var fields StoredEmailFields
|
||
var err error
|
||
if fields.ReplyTo, err = marshal("reply-to", e.ReplyTo); err != nil {
|
||
return StoredEmailFields{}, err
|
||
}
|
||
if fields.To, err = marshal("recipients", e.To); err != nil {
|
||
return StoredEmailFields{}, err
|
||
}
|
||
if fields.Bcc, err = marshal("bcc", e.Bcc); err != nil {
|
||
return StoredEmailFields{}, err
|
||
}
|
||
if fields.Cc, err = marshal("cc", e.Cc); err != nil {
|
||
return StoredEmailFields{}, err
|
||
}
|
||
if fields.Sender, err = marshal("sender", e.Sender); err != nil {
|
||
return StoredEmailFields{}, err
|
||
}
|
||
if fields.Attachments, err = marshal("attachments", e.Attachments); err != nil {
|
||
return StoredEmailFields{}, err
|
||
}
|
||
return fields, nil
|
||
}
|
||
|
||
// GenerateMsgID creates an RFC-compliant Message-ID unique enough to avoid spam filters.
|
||
// It must be called once at email creation time and the result persisted to DB.
|
||
func GenerateMsgID(domain string) (string, error) {
|
||
domain = strings.TrimSpace(domain)
|
||
if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "\r\n\t <>@") {
|
||
return "", errors.New("invalid message id domain")
|
||
}
|
||
randBytes := make([]byte, 16)
|
||
if _, err := io.ReadFull(rand.Reader, randBytes); err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("%x.%d@%s", randBytes, time.Now().UnixNano(), domain), nil
|
||
}
|
||
|
||
// Xss filter policy
|
||
var (
|
||
strictPolicy *bluemonday.Policy
|
||
relaxedPolicy *bluemonday.Policy
|
||
)
|
||
|
||
func init() {
|
||
strictPolicy = bluemonday.StrictPolicy()
|
||
|
||
relaxedPolicy = bluemonday.NewPolicy()
|
||
|
||
relaxedPolicy.AllowElements("p", "br", "strong", "em", "u", "b", "i", "h1", "h2", "h3", "h4", "h5", "h6")
|
||
relaxedPolicy.AllowElements("div", "span", "center")
|
||
relaxedPolicy.AllowElements("ul", "ol", "li")
|
||
relaxedPolicy.AllowElements("blockquote", "cite")
|
||
|
||
relaxedPolicy.AllowElements("table", "tbody", "thead", "tr", "td", "th")
|
||
relaxedPolicy.AllowAttrs("width", "height", "border", "cellpadding", "cellspacing").OnElements("table")
|
||
relaxedPolicy.AllowAttrs("align", "valign", "colspan", "rowspan").OnElements("td", "th")
|
||
relaxedPolicy.AllowAttrs("align").OnElements("tr")
|
||
|
||
relaxedPolicy.AllowAttrs("class", "id").Globally()
|
||
|
||
relaxedPolicy.AllowAttrs("bgcolor", "color", "background").Globally()
|
||
relaxedPolicy.AllowAttrs("align").OnElements("p", "div", "h1", "h2", "h3", "h4", "h5", "h6")
|
||
|
||
relaxedPolicy.AllowElements("img")
|
||
relaxedPolicy.AllowAttrs("src", "alt", "width", "height", "align").OnElements("img")
|
||
|
||
relaxedPolicy.AllowElements("a")
|
||
relaxedPolicy.AllowAttrs("href").OnElements("a")
|
||
relaxedPolicy.RequireNoReferrerOnLinks(true)
|
||
relaxedPolicy.AddTargetBlankToFullyQualifiedLinks(true)
|
||
relaxedPolicy.RequireNoFollowOnLinks(true)
|
||
|
||
relaxedPolicy.AllowElements("font")
|
||
relaxedPolicy.AllowAttrs("size", "color", "face").OnElements("font")
|
||
|
||
relaxedPolicy.AllowURLSchemes("http", "https", "mailto", "cid")
|
||
relaxedPolicy.AllowRelativeURLs(true)
|
||
|
||
relaxedPolicy.SkipElementsContent("script", "object", "embed", "iframe", "frame", "frameset")
|
||
}
|
||
|
||
func SanitizeHTML(htmlContent string) string {
|
||
if htmlContent == "" {
|
||
return ""
|
||
}
|
||
|
||
sanitized := relaxedPolicy.Sanitize(htmlContent)
|
||
sanitized = sanitizeHTMLFragment(sanitized)
|
||
|
||
dataUrlRegex := regexp.MustCompile(`href\s*=\s*["']data:[^"']*["']`)
|
||
sanitized = dataUrlRegex.ReplaceAllString(sanitized, `rel="nofollow"`)
|
||
|
||
jsUrlRegex := regexp.MustCompile(`href\s*=\s*["']javascript:[^"']*["']`)
|
||
sanitized = jsUrlRegex.ReplaceAllString(sanitized, `rel="nofollow"`)
|
||
|
||
expressionRegex := regexp.MustCompile(`(?i)expression\s*\(.*?\)`)
|
||
sanitized = expressionRegex.ReplaceAllString(sanitized, "")
|
||
|
||
styleExpressionRegex := regexp.MustCompile(`(?i)style\s*=\s*["'][^"']*expression[^"']*["']`)
|
||
sanitized = styleExpressionRegex.ReplaceAllString(sanitized, "")
|
||
|
||
cssJsRegex := regexp.MustCompile(`(?i)javascript\s*:`)
|
||
sanitized = cssJsRegex.ReplaceAllString(sanitized, "")
|
||
|
||
return sanitized
|
||
}
|
||
|
||
func sanitizeHTMLFragment(content string) string {
|
||
container := &xhtml.Node{Type: xhtml.ElementNode, Data: "div", DataAtom: xhtmlatom.Div}
|
||
nodes, err := xhtml.ParseFragment(strings.NewReader(content), container)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
var builder strings.Builder
|
||
for _, node := range nodes {
|
||
if sanitizeHTMLNode(node) {
|
||
if err := xhtml.Render(&builder, node); err != nil {
|
||
return ""
|
||
}
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func sanitizeHTMLNode(node *xhtml.Node) bool {
|
||
if node.Type == xhtml.ElementNode && node.Data == "style" {
|
||
return false
|
||
}
|
||
if node.Type == xhtml.ElementNode {
|
||
filtered := node.Attr[:0]
|
||
for _, attr := range node.Attr {
|
||
if strings.EqualFold(attr.Key, "style") {
|
||
continue
|
||
}
|
||
if node.Data == "img" && strings.EqualFold(attr.Key, "src") {
|
||
value := strings.TrimSpace(strings.ToLower(attr.Val))
|
||
if strings.HasPrefix(value, "http:") || strings.HasPrefix(value, "https:") || strings.HasPrefix(value, "//") {
|
||
continue
|
||
}
|
||
}
|
||
filtered = append(filtered, attr)
|
||
}
|
||
node.Attr = filtered
|
||
}
|
||
for child := node.FirstChild; child != nil; {
|
||
next := child.NextSibling
|
||
if !sanitizeHTMLNode(child) {
|
||
node.RemoveChild(child)
|
||
}
|
||
child = next
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Sanitize Text
|
||
func SanitizeText(text string) string {
|
||
return strictPolicy.Sanitize(text)
|
||
}
|
||
|
||
func users2String(users []*User) string {
|
||
ret := ""
|
||
for _, user := range users {
|
||
if ret != "" {
|
||
ret += ", "
|
||
}
|
||
ret += user.Build()
|
||
}
|
||
return ret
|
||
}
|
||
|
||
func (e *Email) BuildTo2String() string {
|
||
return users2String(e.To)
|
||
}
|
||
|
||
func (e *Email) BuildCc2String() string {
|
||
return users2String(e.Cc)
|
||
}
|
||
|
||
func (e *Email) BuildBcc2String() string {
|
||
return users2String(e.Bcc)
|
||
}
|
||
|
||
func NewEmailFromModel(d models.Email) (*Email, error) {
|
||
|
||
var to []*User
|
||
if err := decodeModelField(d.To, &to, "recipients"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var replyTo []*User
|
||
if err := decodeModelField(d.ReplyTo, &replyTo, "reply-to"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var sender *User
|
||
if err := decodeModelField(d.Sender, &sender, "sender"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var bcc []*User
|
||
if err := decodeModelField(d.Bcc, &bcc, "bcc"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var cc []*User
|
||
if err := decodeModelField(d.Cc, &cc, "cc"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var attachments []*Attachment
|
||
if err := decodeModelField(d.Attachments, &attachments, "attachments"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ret := &Email{
|
||
MessageId: cast.ToInt64(d.Id),
|
||
InReplyTo: NormalizeThreadHeader(d.InReplyTo),
|
||
ThreadReferences: NormalizeThreadHeader(d.ThreadReferences),
|
||
MsgID: d.MsgID,
|
||
From: &User{
|
||
Name: d.FromName,
|
||
EmailAddress: d.FromAddress,
|
||
},
|
||
To: to,
|
||
Subject: d.Subject,
|
||
Text: []byte(d.Text.String),
|
||
HTML: []byte(d.Html.String),
|
||
Sender: sender,
|
||
ReplyTo: replyTo,
|
||
Bcc: bcc,
|
||
Cc: cc,
|
||
Attachments: attachments,
|
||
Date: d.SendDate.Format(time.RFC3339),
|
||
}
|
||
if d.Type == 0 {
|
||
ret.Authentication = NewEmailAuthentication(d.SPFCheck == 1, d.DKIMCheck == 1)
|
||
}
|
||
return ret, nil
|
||
}
|
||
|
||
func decodeModelField(raw string, target any, field string) error {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return nil
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), target); err != nil {
|
||
return fmt.Errorf("decode stored %s: %w", field, err)
|
||
}
|
||
return nil
|
||
}
|
||
func NewEmailFromReader(to []string, r io.Reader, size int) (*Email, error) {
|
||
ret := &Email{}
|
||
m, err := message.Read(r)
|
||
if err != nil {
|
||
log.Errorf("email解析错误! Error %+v", err)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse message: %w", err)
|
||
}
|
||
|
||
ret.Size = size
|
||
ret.InReplyTo = NormalizeThreadHeader(m.Header.Get("In-Reply-To"))
|
||
ret.ThreadReferences = NormalizeThreadHeader(m.Header.Get("References"))
|
||
// Preserve the original Message-ID from the sender so it is stored and reused consistently.
|
||
if mid := m.Header.Get("Message-Id"); mid != "" {
|
||
ret.MsgID = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(mid), ">"), "<")
|
||
}
|
||
ret.From = buildUser(m.Header.Get("From"))
|
||
|
||
smtpTo := buildUsers(to)
|
||
|
||
ret.To = buildUsers(m.Header.Values("To"))
|
||
ret.Cc = buildUsers(m.Header.Values("Cc"))
|
||
|
||
ret.Bcc = []*User{}
|
||
|
||
for _, user := range smtpTo {
|
||
in := false
|
||
for _, u := range ret.To {
|
||
if strings.EqualFold(u.EmailAddress, user.EmailAddress) {
|
||
in = true
|
||
break
|
||
}
|
||
}
|
||
for _, u := range ret.Cc {
|
||
if strings.EqualFold(u.EmailAddress, user.EmailAddress) {
|
||
in = true
|
||
break
|
||
}
|
||
}
|
||
if !in {
|
||
ret.Bcc = append(ret.Bcc, user)
|
||
}
|
||
|
||
}
|
||
|
||
ret.ReplyTo = buildUsers(m.Header.Values("Reply-To"))
|
||
ret.Sender = buildUser(m.Header.Get("Sender"))
|
||
if ret.Sender == nil || strings.TrimSpace(ret.Sender.EmailAddress) == "" {
|
||
ret.Sender = ret.From
|
||
}
|
||
|
||
subject, err := m.Header.Text("Subject")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode message subject: %w", err)
|
||
}
|
||
ret.Subject = strictPolicy.Sanitize(subject)
|
||
|
||
sendTime, err := time.Parse(time.RFC1123Z, m.Header.Get("Date"))
|
||
if err != nil {
|
||
sendTime = time.Now()
|
||
}
|
||
ret.Date = sendTime.Format(time.RFC3339)
|
||
if err = m.Walk(func(path []int, entity *message.Entity, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
if entity == nil {
|
||
return fmt.Errorf("message part is missing")
|
||
}
|
||
return formatContent(entity, ret)
|
||
}); err != nil {
|
||
return nil, fmt.Errorf("read message content: %w", err)
|
||
}
|
||
|
||
if ret.From != nil {
|
||
ret.From.Name = strictPolicy.Sanitize(ret.From.Name)
|
||
ret.From.EmailAddress = strictPolicy.Sanitize(ret.From.EmailAddress)
|
||
}
|
||
|
||
return ret, nil
|
||
}
|
||
|
||
func formatContent(entity *message.Entity, ret *Email) error {
|
||
if entity == nil {
|
||
return fmt.Errorf("message part is missing")
|
||
}
|
||
contentType, p, err := entity.Header.ContentType()
|
||
|
||
if err != nil {
|
||
log.Errorf("email read error! %+v", err)
|
||
return err
|
||
}
|
||
disp := strings.ToLower(entity.Header.Get("Content-Disposition"))
|
||
isAttachment := strings.Contains(disp, "attachment")
|
||
switch contentType {
|
||
case "multipart/alternative":
|
||
case "multipart/mixed":
|
||
case "text/plain":
|
||
body, err := io.ReadAll(entity.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if isAttachment {
|
||
fileName := getFileName(entity, p)
|
||
|
||
ret.Attachments = append(ret.Attachments, &Attachment{
|
||
Filename: SanitizeText(fileName),
|
||
ContentType: SanitizeText(strings.TrimSpace(contentType)),
|
||
Content: body,
|
||
ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"),
|
||
})
|
||
|
||
return nil
|
||
}
|
||
|
||
ret.Text = []byte(strictPolicy.Sanitize(string(body)))
|
||
case "text/html":
|
||
body, err := io.ReadAll(entity.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if isAttachment {
|
||
fileName := getFileName(entity, p)
|
||
|
||
ret.Attachments = append(ret.Attachments, &Attachment{
|
||
Filename: SanitizeText(fileName),
|
||
ContentType: SanitizeText(strings.TrimSpace(contentType)),
|
||
Content: body,
|
||
ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"),
|
||
})
|
||
|
||
return nil
|
||
}
|
||
|
||
ret.HTML = []byte(SanitizeHTML(string(body)))
|
||
case "multipart/related":
|
||
default:
|
||
c, err := io.ReadAll(entity.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fileName := getFileName(entity, p)
|
||
|
||
ret.Attachments = append(ret.Attachments, &Attachment{
|
||
Filename: SanitizeText(fileName),
|
||
ContentType: SanitizeText(strings.TrimSpace(contentType)),
|
||
Content: c,
|
||
ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"),
|
||
})
|
||
}
|
||
|
||
return nil
|
||
}
|
||
func getFileName(entity *message.Entity, p map[string]string) string {
|
||
fileName := p["name"]
|
||
|
||
if fileName != "" {
|
||
return fileName
|
||
}
|
||
|
||
_, params, err := entity.Header.ContentDisposition()
|
||
if err == nil && params["filename"] != "" {
|
||
return params["filename"]
|
||
}
|
||
|
||
return "no_name_file"
|
||
}
|
||
|
||
func BuilderUser(str string) *User {
|
||
return buildUser(str)
|
||
}
|
||
|
||
var emailAddressRe = regexp.MustCompile(`<(.*@.*)>`)
|
||
|
||
func buildUser(str string) *User {
|
||
str = strings.TrimSpace(str)
|
||
if str == "" {
|
||
return &User{}
|
||
}
|
||
|
||
user := &User{}
|
||
|
||
addr, err := mail.ParseAddress(str)
|
||
if err == nil {
|
||
user.EmailAddress = strings.TrimSpace(addr.Address)
|
||
|
||
name := strings.TrimSpace(addr.Name)
|
||
if name != "" {
|
||
decoder := mime.WordDecoder{}
|
||
if decoded, err := decoder.Decode(name); err == nil {
|
||
name = decoded
|
||
}
|
||
user.Name = strictPolicy.Sanitize(name)
|
||
}
|
||
return user
|
||
}
|
||
|
||
matched := emailAddressRe.FindStringSubmatch(str)
|
||
if len(matched) == 2 {
|
||
user.EmailAddress = strings.TrimSpace(matched[1])
|
||
namePart := strings.ReplaceAll(str, matched[0], "")
|
||
namePart = strings.Trim(strings.TrimSpace(namePart), "\"")
|
||
|
||
decoder := mime.WordDecoder{}
|
||
if decoded, err := decoder.Decode(strings.ReplaceAll(namePart, "\"", "")); err == nil {
|
||
user.Name = strictPolicy.Sanitize(strings.TrimSpace(decoded))
|
||
} else {
|
||
user.Name = strictPolicy.Sanitize(strings.TrimSpace(namePart))
|
||
}
|
||
} else {
|
||
user.EmailAddress = strictPolicy.Sanitize(str)
|
||
}
|
||
|
||
return user
|
||
}
|
||
|
||
func buildUsers(strs []string) []*User {
|
||
var ret []*User
|
||
for _, line := range strs {
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
addresses, err := mail.ParseAddressList(line)
|
||
if err == nil {
|
||
for _, address := range addresses {
|
||
ret = append(ret, buildUser(address.String()))
|
||
}
|
||
continue
|
||
}
|
||
if user := buildUser(line); user != nil {
|
||
ret = append(ret, user)
|
||
}
|
||
}
|
||
return ret
|
||
}
|
||
|
||
func writeMessagePart(w io.WriteCloser, content []byte) error {
|
||
n, writeErr := w.Write(content)
|
||
closeErr := w.Close()
|
||
if writeErr != nil {
|
||
return errors.Join(writeErr, closeErr)
|
||
}
|
||
if n != len(content) {
|
||
return errors.Join(io.ErrShortWrite, closeErr)
|
||
}
|
||
return closeErr
|
||
}
|
||
|
||
func closeMessageWriters(primary error, closers ...io.Closer) error {
|
||
for _, closer := range closers {
|
||
primary = errors.Join(primary, closer.Close())
|
||
}
|
||
return primary
|
||
}
|
||
|
||
func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User, forwardAddress string) ([]byte, error) {
|
||
var b bytes.Buffer
|
||
|
||
cfg := config.CurrentConfig()
|
||
if e == nil || sender == nil || cfg == nil || len(cfg.Domains) == 0 {
|
||
return nil, errors.New("invalid forwarding configuration")
|
||
}
|
||
forwardUser := buildUser(forwardAddress)
|
||
if forwardUser == nil || forwardUser.EmailAddress == "" {
|
||
return nil, errors.New("invalid forwarding address")
|
||
}
|
||
to := []*mail.Address{{Name: forwardUser.Name, Address: forwardUser.EmailAddress}}
|
||
|
||
senderEmailAddress := fmt.Sprintf("%s@%s", sender.Account, cfg.Domains[0])
|
||
senderAddress := []*mail.Address{{Name: sender.Name, Address: senderEmailAddress}}
|
||
// Create our mail header
|
||
var h mail.Header
|
||
h.SetDate(time.Now())
|
||
h.SetAddressList("From", senderAddress)
|
||
h.SetAddressList("Sender", senderAddress)
|
||
h.SetAddressList("To", to)
|
||
if e.From != nil && e.From.EmailAddress != "" {
|
||
h.SetAddressList("Reply-To", []*mail.Address{{Name: e.From.Name, Address: e.From.EmailAddress}})
|
||
h.Set("X-Original-From", e.From.Build())
|
||
}
|
||
h.Set("X-Forwarded-By", senderEmailAddress)
|
||
h.Set("X-Forwarded-To", forwardUser.EmailAddress)
|
||
h.SetText("Subject", e.Subject)
|
||
msgID, err := GenerateMsgID(cfg.Domain)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("generate message id: %w", err)
|
||
}
|
||
h.SetMessageID(msgID)
|
||
if e.MsgID != "" {
|
||
h.Set("References", fmt.Sprintf("<%s>", e.MsgID))
|
||
h.Set("In-Reply-To", fmt.Sprintf("<%s>", e.MsgID))
|
||
}
|
||
|
||
// Create a new mail writer
|
||
mw, err := mail.CreateWriter(&b, h)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create mail writer: %w", err)
|
||
}
|
||
|
||
// Create a text part
|
||
tw, err := mw.CreateInline()
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create inline writer: %w", err), mw)
|
||
}
|
||
var th mail.InlineHeader
|
||
th.Set("Content-Type", "text/plain")
|
||
w, err := tw.CreatePart(th)
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create text part: %w", err), tw, mw)
|
||
}
|
||
if err := writeMessagePart(w, e.Text); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("write text part: %w", err), tw, mw)
|
||
}
|
||
|
||
var html mail.InlineHeader
|
||
html.Set("Content-Type", "text/html")
|
||
w, err = tw.CreatePart(html)
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create html part: %w", err), tw, mw)
|
||
}
|
||
if err := writeMessagePart(w, e.HTML); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("write html part: %w", err), tw, mw)
|
||
}
|
||
|
||
if err := tw.Close(); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("close inline writer: %w", err), mw)
|
||
}
|
||
|
||
// Create an attachment
|
||
for _, attachment := range e.Attachments {
|
||
if attachment == nil {
|
||
return nil, closeMessageWriters(errors.New("nil attachment"), mw)
|
||
}
|
||
var ah mail.AttachmentHeader
|
||
ah.Set("Content-Type", attachment.ContentType)
|
||
if attachment.ContentID != "" {
|
||
ah.Set("Content-ID", fmt.Sprintf("<%s>", attachment.ContentID))
|
||
}
|
||
ah.SetFilename(attachment.Filename)
|
||
w, err = mw.CreateAttachment(ah)
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create attachment: %w", err), mw)
|
||
}
|
||
if err := writeMessagePart(w, attachment.Content); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("write attachment: %w", err), mw)
|
||
}
|
||
}
|
||
|
||
if err := mw.Close(); err != nil {
|
||
return nil, fmt.Errorf("close mail writer: %w", err)
|
||
}
|
||
|
||
if instance == nil {
|
||
return b.Bytes(), nil
|
||
}
|
||
|
||
// dkim 签名后返回
|
||
return instance.Sign(b.String())
|
||
}
|
||
|
||
func (e *Email) BuildPart(ctx *context.Context, loc []int) []byte {
|
||
if len(loc) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// 处理顶层 part (part 1 = alternative, part 2+ = attachments)
|
||
if len(loc) == 1 {
|
||
partIdx := loc[0]
|
||
if partIdx == 1 {
|
||
// Part 1 是 alternative,不能直接获取,需要获取子部分
|
||
return nil
|
||
}
|
||
// Part 2, 3, ... 是附件
|
||
attachIdx := partIdx - 2
|
||
if attachIdx >= 0 && attachIdx < len(e.Attachments) {
|
||
encoded := base64.StdEncoding.EncodeToString(e.Attachments[attachIdx].Content)
|
||
encoded += "\r\n"
|
||
return []byte(encoded)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 处理 alternative 的子部分 (1.1, 1.2)
|
||
if loc[0] == 1 && len(loc) >= 2 {
|
||
subIdx := loc[1]
|
||
|
||
// 根据 BODYSTRUCTURE 的构建顺序:先 text,后 html
|
||
// 如果只有一个存在,那个就是 1.1
|
||
hasText := len(e.Text) > 0
|
||
hasHtml := len(e.HTML) > 0
|
||
|
||
if hasText && hasHtml {
|
||
// 两者都有: 1.1=text, 1.2=html
|
||
if subIdx == 1 {
|
||
encoded := base64.StdEncoding.EncodeToString(e.Text)
|
||
encoded += "\r\n"
|
||
return []byte(encoded)
|
||
}
|
||
if subIdx == 2 {
|
||
encoded := base64.StdEncoding.EncodeToString(e.HTML)
|
||
encoded += "\r\n"
|
||
return []byte(encoded)
|
||
}
|
||
} else if hasText {
|
||
// 只有 text: 1.1=text
|
||
if subIdx == 1 {
|
||
encoded := base64.StdEncoding.EncodeToString(e.Text)
|
||
encoded += "\r\n"
|
||
return []byte(encoded)
|
||
}
|
||
} else if hasHtml {
|
||
// 只有 html: 1.1=html
|
||
if subIdx == 1 {
|
||
encoded := base64.StdEncoding.EncodeToString(e.HTML)
|
||
encoded += "\r\n"
|
||
return []byte(encoded)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (e *Email) BuildBytes(ctx *context.Context, dkim bool) ([]byte, error) {
|
||
var b bytes.Buffer
|
||
|
||
if e == nil || e.From == nil || e.From.EmailAddress == "" {
|
||
return nil, errors.New("invalid sender")
|
||
}
|
||
cfg := config.CurrentConfig()
|
||
if e.MsgID == "" && (cfg == nil || cfg.Domain == "") {
|
||
return nil, errors.New("message id domain is not configured")
|
||
}
|
||
from := []*mail.Address{{Name: e.From.Name, Address: e.From.EmailAddress}}
|
||
to := []*mail.Address{}
|
||
for _, user := range e.To {
|
||
if user == nil || user.EmailAddress == "" {
|
||
return nil, errors.New("invalid recipient")
|
||
}
|
||
to = append(to, &mail.Address{
|
||
Name: user.Name,
|
||
Address: user.EmailAddress,
|
||
})
|
||
}
|
||
|
||
// Create our mail header
|
||
var h mail.Header
|
||
if e.Date != "" {
|
||
t, err := time.Parse(time.RFC3339, e.Date)
|
||
if err != nil {
|
||
// Keep compatibility with Date values stored before timezone offsets were preserved.
|
||
t, err = time.ParseInLocation(time.DateTime, e.Date, time.Local)
|
||
}
|
||
if err != nil {
|
||
log.WithContext(ctx).Errorf("Time Error ! Err:%+v", err)
|
||
h.SetDate(time.Now())
|
||
} else {
|
||
h.SetDate(t)
|
||
}
|
||
} else {
|
||
h.SetDate(time.Now())
|
||
}
|
||
if e.MsgID != "" {
|
||
h.SetMessageID(e.MsgID)
|
||
} else {
|
||
h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, cfg.Domain))
|
||
}
|
||
h.SetAddressList("From", from)
|
||
h.SetMsgIDList("In-Reply-To", ThreadMessageIDs(e.InReplyTo))
|
||
h.SetMsgIDList("References", ThreadMessageIDs(e.ThreadReferences))
|
||
h.SetAddressList("Sender", from)
|
||
h.SetAddressList("To", to)
|
||
h.SetText("Subject", e.Subject)
|
||
if len(e.ReplyTo) != 0 {
|
||
replyTo := make([]*mail.Address, 0, len(e.ReplyTo))
|
||
for _, user := range e.ReplyTo {
|
||
if user == nil || user.EmailAddress == "" {
|
||
return nil, errors.New("invalid reply-to recipient")
|
||
}
|
||
replyTo = append(replyTo, &mail.Address{Name: user.Name, Address: user.EmailAddress})
|
||
}
|
||
h.SetAddressList("Reply-To", replyTo)
|
||
}
|
||
if len(e.Cc) != 0 {
|
||
cc := []*mail.Address{}
|
||
for _, user := range e.Cc {
|
||
if user == nil || user.EmailAddress == "" {
|
||
return nil, errors.New("invalid cc recipient")
|
||
}
|
||
cc = append(cc, &mail.Address{
|
||
Name: user.Name,
|
||
Address: user.EmailAddress,
|
||
})
|
||
}
|
||
h.SetAddressList("Cc", cc)
|
||
}
|
||
|
||
// Create a new mail writer
|
||
mw, err := mail.CreateWriter(&b, h)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create mail writer: %w", err)
|
||
}
|
||
|
||
// Create a text part
|
||
tw, err := mw.CreateInline()
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create inline writer: %w", err), mw)
|
||
}
|
||
|
||
if len(e.Text) > 0 {
|
||
var th mail.InlineHeader
|
||
th.Header.Set("Content-Transfer-Encoding", "base64")
|
||
th.SetContentType("text/plain", map[string]string{
|
||
"charset": "UTF-8",
|
||
})
|
||
w, err := tw.CreatePart(th)
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create text part: %w", err), tw, mw)
|
||
}
|
||
if err := writeMessagePart(w, e.Text); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("write text part: %w", err), tw, mw)
|
||
}
|
||
}
|
||
|
||
var html mail.InlineHeader
|
||
html.SetContentType("text/html", map[string]string{
|
||
"charset": "UTF-8",
|
||
})
|
||
html.Header.Set("Content-Transfer-Encoding", "base64")
|
||
w, err := tw.CreatePart(html)
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create html part: %w", err), tw, mw)
|
||
}
|
||
htmlContent := e.HTML
|
||
if len(htmlContent) == 0 {
|
||
htmlContent = e.Text
|
||
}
|
||
if err := writeMessagePart(w, htmlContent); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("write html part: %w", err), tw, mw)
|
||
}
|
||
|
||
if err := tw.Close(); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("close inline writer: %w", err), mw)
|
||
}
|
||
|
||
// Create an attachment
|
||
for _, attachment := range e.Attachments {
|
||
if attachment == nil {
|
||
return nil, closeMessageWriters(errors.New("nil attachment"), mw)
|
||
}
|
||
var ah mail.AttachmentHeader
|
||
ah.Set("Content-Type", attachment.ContentType)
|
||
if attachment.ContentID != "" {
|
||
ah.Set("Content-ID", fmt.Sprintf("<%s>", attachment.ContentID))
|
||
}
|
||
ah.SetFilename(attachment.Filename)
|
||
w, err = mw.CreateAttachment(ah)
|
||
if err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("create attachment: %w", err), mw)
|
||
}
|
||
if err := writeMessagePart(w, attachment.Content); err != nil {
|
||
return nil, closeMessageWriters(fmt.Errorf("write attachment: %w", err), mw)
|
||
}
|
||
}
|
||
|
||
if err := mw.Close(); err != nil {
|
||
return nil, fmt.Errorf("close mail writer: %w", err)
|
||
}
|
||
|
||
if dkim {
|
||
if instance == nil {
|
||
return nil, errors.New("DKIM signer is not initialized")
|
||
}
|
||
// dkim 签名后返回
|
||
return instance.Sign(b.String())
|
||
}
|
||
return b.Bytes(), nil
|
||
}
|