8a042ae3dc
- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。 - 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。 - 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。 - 更新前端导航、个人中心 2FA 配置以及相关部署示例。
181 lines
4.6 KiB
Go
181 lines
4.6 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net"
|
|
"net/smtp"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type MIMEMessage struct {
|
|
From string
|
|
To []string
|
|
CC []string
|
|
BCC []string
|
|
Subject string
|
|
Text string
|
|
HTML string
|
|
MessageID string
|
|
Date time.Time
|
|
Attachments []AttachmentInput
|
|
}
|
|
|
|
func BuildMIME(m MIMEMessage) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
writeHeader := func(k, v string) {
|
|
if strings.TrimSpace(v) != "" {
|
|
fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
|
|
}
|
|
}
|
|
writeHeader("From", m.From)
|
|
writeHeader("To", strings.Join(m.To, ", "))
|
|
writeHeader("Cc", strings.Join(m.CC, ", "))
|
|
writeHeader("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
|
|
writeHeader("Message-ID", m.MessageID)
|
|
writeHeader("Date", m.Date.Format(time.RFC1123Z))
|
|
writeHeader("MIME-Version", "1.0")
|
|
|
|
mixed := multipart.NewWriter(&buf)
|
|
writeHeader("Content-Type", `multipart/mixed; boundary="`+mixed.Boundary()+`"`)
|
|
buf.WriteString("\r\n")
|
|
|
|
var altBuf bytes.Buffer
|
|
alt := multipart.NewWriter(&altBuf)
|
|
textHeader := textprotoMIMEHeader(map[string]string{"Content-Type": `text/plain; charset="utf-8"`, "Content-Transfer-Encoding": "base64"})
|
|
textPart, err := alt.CreatePart(textHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase64(textPart, []byte(m.Text))
|
|
htmlHeader := textprotoMIMEHeader(map[string]string{"Content-Type": `text/html; charset="utf-8"`, "Content-Transfer-Encoding": "base64"})
|
|
htmlPart, err := alt.CreatePart(htmlHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase64(htmlPart, []byte(m.HTML))
|
|
if err := alt.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
altMixedHeader := textprotoMIMEHeader(map[string]string{"Content-Type": `multipart/alternative; boundary="` + alt.Boundary() + `"`})
|
|
altMixedPart, err := mixed.CreatePart(altMixedHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := altMixedPart.Write(altBuf.Bytes()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, att := range m.Attachments {
|
|
data, err := base64.StdEncoding.DecodeString(att.ContentBase64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
contentType := att.ContentType
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
filename := mime.QEncoding.Encode("utf-8", att.Filename)
|
|
h := textprotoMIMEHeader(map[string]string{
|
|
"Content-Type": contentType + `; name="` + filename + `"`,
|
|
"Content-Disposition": `attachment; filename="` + filename + `"`,
|
|
"Content-Transfer-Encoding": "base64",
|
|
})
|
|
part, err := mixed.CreatePart(h)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase64(part, data)
|
|
}
|
|
if err := mixed.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func textprotoMIMEHeader(values map[string]string) textproto.MIMEHeader {
|
|
h := textproto.MIMEHeader{}
|
|
for k, v := range values {
|
|
h.Set(k, v)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func writeBase64(w io.Writer, data []byte) {
|
|
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
|
|
base64.StdEncoding.Encode(encoded, data)
|
|
for len(encoded) > 76 {
|
|
_, _ = w.Write(encoded[:76])
|
|
_, _ = w.Write([]byte("\r\n"))
|
|
encoded = encoded[76:]
|
|
}
|
|
_, _ = w.Write(encoded)
|
|
_, _ = w.Write([]byte("\r\n"))
|
|
}
|
|
|
|
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
|
|
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
|
|
}
|
|
|
|
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
|
|
addr := net.JoinHostPort(cfg.SMTPHost, cfg.SMTPPort)
|
|
var auth smtp.Auth
|
|
if cfg.SMTPUsername != "" {
|
|
auth = smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost)
|
|
}
|
|
if !cfg.SMTPRequireTLS {
|
|
return smtp.SendMail(addr, auth, from, recipients, mimeBytes)
|
|
}
|
|
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SMTPHost, MinVersion: tls.VersionTLS12})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
client, err := smtp.NewClient(conn, cfg.SMTPHost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
if auth != nil {
|
|
if err := client.Auth(auth); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := client.Mail(from); err != nil {
|
|
return err
|
|
}
|
|
for _, rcpt := range recipients {
|
|
if err := client.Rcpt(rcpt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
wc, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := wc.Write(mimeBytes); err != nil {
|
|
_ = wc.Close()
|
|
return err
|
|
}
|
|
if err := wc.Close(); err != nil {
|
|
return err
|
|
}
|
|
return client.Quit()
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, "\n", "<br>")
|
|
return s
|
|
}
|