feat: harden SMTP transport
Add explicit TLS modes, context-aware delivery, and typed transport errors. Preserve raw MIME messages and cover the new delivery paths with local SMTP tests.
This commit is contained in:
+256
-298
@@ -2,33 +2,72 @@ package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const delimiter = "**=myohmy689407924327"
|
||||
|
||||
const (
|
||||
mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
|
||||
delimeter = "**=myohmy689407924327"
|
||||
DefaultConnectTimeout = 10 * time.Second
|
||||
DefaultOperationTimeout = 30 * time.Second
|
||||
maximumEnvelopeRecipients = 100
|
||||
)
|
||||
|
||||
type InsecureConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
From string // Sender email address
|
||||
// TLSMode makes transport security an explicit configuration decision.
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
TLSModeNone TLSMode = "none"
|
||||
TLSModeSTARTTLS TLSMode = "starttls"
|
||||
TLSModeImplicit TLSMode = "implicit"
|
||||
)
|
||||
|
||||
// Config is the canonical transport configuration. RootCAs, when non-nil,
|
||||
// deliberately replaces the operating-system roots; callers that want to add
|
||||
// a private CA should start from x509.SystemCertPool and append to it.
|
||||
type Config struct {
|
||||
Auth smtp.Auth
|
||||
Host string
|
||||
Port string
|
||||
From string
|
||||
TLSMode TLSMode
|
||||
ConnectTimeout time.Duration
|
||||
OperationTimeout time.Duration
|
||||
RootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
// InsecureConfig is retained for source compatibility. NewInsecureNoAuth is
|
||||
// an explicitly plaintext, no-authentication development transport.
|
||||
type InsecureConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
From string
|
||||
ConnectTimeout time.Duration
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// SecureConfig is retained for the legacy secure constructors.
|
||||
type SecureConfig struct {
|
||||
Auth smtp.Auth
|
||||
Host string
|
||||
Port string
|
||||
From string // Sender email address
|
||||
Auth smtp.Auth
|
||||
Host string
|
||||
Port string
|
||||
From string
|
||||
ConnectTimeout time.Duration
|
||||
OperationTimeout time.Duration
|
||||
RootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
type MessageWithAttachments struct {
|
||||
@@ -38,308 +77,209 @@ type MessageWithAttachments struct {
|
||||
Attachments []EmailAttachment
|
||||
}
|
||||
|
||||
// RawMessage keeps the SMTP envelope separate from the caller-built RFC/MIME
|
||||
// bytes. To and Body are deprecated compatibility fields; new callers should
|
||||
// use EnvelopeRecipients and Data. EnvelopeFrom may be empty to use Config.From.
|
||||
type RawMessage struct {
|
||||
To string
|
||||
EnvelopeFrom string
|
||||
EnvelopeRecipients []string
|
||||
Data []byte
|
||||
|
||||
// Deprecated: use EnvelopeRecipients.
|
||||
To string
|
||||
// Deprecated: use Data.
|
||||
Body string
|
||||
}
|
||||
|
||||
// SMTPClientIface and SmtpDialFn remain exported for source compatibility with
|
||||
// earlier test doubles. The canonical transport uses context-aware net.Conn
|
||||
// dialing directly.
|
||||
type SMTPClientIface interface {
|
||||
StartTLS(*tls.Config) error
|
||||
Auth(a smtp.Auth) error
|
||||
Auth(smtp.Auth) error
|
||||
Close() error
|
||||
Data() (io.WriteCloser, error)
|
||||
Mail(from string) error
|
||||
Mail(string) error
|
||||
Quit() error
|
||||
Rcpt(to string) error
|
||||
Rcpt(string) error
|
||||
}
|
||||
|
||||
type SmtpDialFn func(hostPort string) (SMTPClientIface, error)
|
||||
type SmtpDialFn func(hostPort string) (SMTPClientIface, error) //nolint:revive // Retained for source compatibility.
|
||||
|
||||
type dialContextFunc func(context.Context, string, string) (net.Conn, error)
|
||||
|
||||
type EmailService struct {
|
||||
auth smtp.Auth
|
||||
host string
|
||||
port string
|
||||
from string
|
||||
tlsconfig *tls.Config
|
||||
dial SmtpDialFn
|
||||
auth smtp.Auth
|
||||
host string
|
||||
port string
|
||||
from string
|
||||
tlsMode TLSMode
|
||||
connectTimeout time.Duration
|
||||
operationTimeout time.Duration
|
||||
rootCAs *x509.CertPool
|
||||
dialContext dialContextFunc
|
||||
configurationErr error
|
||||
sendRawHook func(context.Context, RawMessage) error
|
||||
}
|
||||
|
||||
// New constructs the canonical validated SMTP transport.
|
||||
func New(config Config) (*EmailService, error) {
|
||||
config.Host = strings.TrimSpace(config.Host)
|
||||
config.Port = strings.TrimSpace(config.Port)
|
||||
config.From = strings.TrimSpace(config.From)
|
||||
if config.ConnectTimeout == 0 {
|
||||
config.ConnectTimeout = DefaultConnectTimeout
|
||||
}
|
||||
if config.OperationTimeout == 0 {
|
||||
config.OperationTimeout = DefaultOperationTimeout
|
||||
}
|
||||
if err := validateConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootCAs := config.RootCAs
|
||||
if rootCAs != nil {
|
||||
rootCAs = rootCAs.Clone()
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: config.ConnectTimeout}
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsMode: config.TLSMode,
|
||||
connectTimeout: config.ConnectTimeout,
|
||||
operationTimeout: config.OperationTimeout,
|
||||
rootCAs: rootCAs,
|
||||
dialContext: dialer.DialContext,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewInsecureNoAuth constructs explicit plaintext SMTP for trusted local
|
||||
// development systems such as Mailpit. It never sends authentication data.
|
||||
func NewInsecureNoAuth(config InsecureConfig) *EmailService {
|
||||
return &EmailService{
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
dial: dial,
|
||||
}
|
||||
return legacyService(Config{
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
From: config.From,
|
||||
TLSMode: TLSModeNone,
|
||||
ConnectTimeout: config.ConnectTimeout,
|
||||
OperationTimeout: config.OperationTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
func NewInsecure(config SecureConfig) *EmailService {
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
},
|
||||
dial: dial,
|
||||
}
|
||||
}
|
||||
|
||||
var validCommonNames = []string{
|
||||
"ISRG Root X1",
|
||||
"R3",
|
||||
"R10",
|
||||
"R13",
|
||||
"R11",
|
||||
"E5",
|
||||
"E7",
|
||||
"DST Root CA X3",
|
||||
"DigiCert Global Root G2",
|
||||
"DigiCert Global G2 TLS RSA SHA256 2020 CA1",
|
||||
}
|
||||
|
||||
func customVerify(host string) func(cs tls.ConnectionState) error {
|
||||
return func(cs tls.ConnectionState) error {
|
||||
// Ensure we have at least one peer certificate
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return fmt.Errorf("no peer certificates provided")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Set up verification options with a DNSName check.
|
||||
// This will perform hostname verification automatically.
|
||||
opts := x509.VerifyOptions{
|
||||
CurrentTime: now,
|
||||
DNSName: host, // assuming config.Host is accessible here
|
||||
Intermediates: x509.NewCertPool(),
|
||||
}
|
||||
// Add all certificates except the leaf as intermediates.
|
||||
for i := 1; i < len(cs.PeerCertificates); i++ {
|
||||
opts.Intermediates.AddCert(cs.PeerCertificates[i])
|
||||
}
|
||||
|
||||
// Verify the certificate chain (including hostname check via opts.DNSName)
|
||||
if _, err := cs.PeerCertificates[0].Verify(opts); err != nil {
|
||||
return fmt.Errorf("certificate chain verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Perform additional custom checks
|
||||
for _, cert := range cs.PeerCertificates {
|
||||
if now.After(cert.NotAfter) {
|
||||
return fmt.Errorf("certificate expired on %s", cert.NotAfter)
|
||||
}
|
||||
if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) {
|
||||
return fmt.Errorf("certificate will expire soon on %s", cert.NotAfter)
|
||||
}
|
||||
|
||||
// Check that the issuer's CommonName is in our allowed list.
|
||||
if !slices.Contains(validCommonNames, cert.Issuer.CommonName) {
|
||||
return fmt.Errorf("untrusted certificate issuer: %s", cert.Issuer.CommonName)
|
||||
}
|
||||
|
||||
// Check that the public key algorithms
|
||||
switch cert.PublicKeyAlgorithm {
|
||||
case x509.RSA, x509.ECDSA:
|
||||
// OK
|
||||
default:
|
||||
return fmt.Errorf("unsupported public key algorithm: %v",
|
||||
cert.PublicKeyAlgorithm)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// NewInsecure is retained for compatibility but no longer disables TLS
|
||||
// verification. It now requires verified STARTTLS exactly like NewSecure.
|
||||
func NewInsecure(config SecureConfig) *EmailService { return NewSecure(config) }
|
||||
|
||||
// NewSecure constructs verified, required STARTTLS.
|
||||
func NewSecure(config SecureConfig) *EmailService {
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
VerifyConnection: customVerify(config.Host),
|
||||
},
|
||||
dial: dial,
|
||||
}
|
||||
return legacyService(Config{
|
||||
Auth: config.Auth,
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
From: config.From,
|
||||
TLSMode: TLSModeSTARTTLS,
|
||||
ConnectTimeout: config.ConnectTimeout,
|
||||
OperationTimeout: config.OperationTimeout,
|
||||
RootCAs: config.RootCAs,
|
||||
})
|
||||
}
|
||||
|
||||
// NewSecure465 constructs verified implicit TLS. The name is retained for
|
||||
// compatibility; TLS mode is explicit and does not depend on the port value.
|
||||
func NewSecure465(config SecureConfig) *EmailService {
|
||||
tlsCfg := tls.Config{
|
||||
// Ideally, InsecureSkipVerify: false,
|
||||
// or do a proper certificate validation
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
VerifyConnection: customVerify(config.Host),
|
||||
return legacyService(Config{
|
||||
Auth: config.Auth,
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
From: config.From,
|
||||
TLSMode: TLSModeImplicit,
|
||||
ConnectTimeout: config.ConnectTimeout,
|
||||
OperationTimeout: config.OperationTimeout,
|
||||
RootCAs: config.RootCAs,
|
||||
})
|
||||
}
|
||||
|
||||
func legacyService(config Config) *EmailService {
|
||||
service, err := New(config)
|
||||
if err == nil {
|
||||
return service
|
||||
}
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tlsCfg,
|
||||
dial: func(hostPort string) (SMTPClientIface, error) {
|
||||
return dialTLS(hostPort, &tlsCfg)
|
||||
},
|
||||
connectTimeout: defaultDuration(config.ConnectTimeout, DefaultConnectTimeout),
|
||||
operationTimeout: defaultDuration(config.OperationTimeout, DefaultOperationTimeout),
|
||||
configurationErr: err,
|
||||
}
|
||||
}
|
||||
|
||||
func dial(hostPort string) (SMTPClientIface, error) {
|
||||
client, err := smtp.Dial(hostPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func defaultDuration(value, fallback time.Duration) time.Duration {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return client, nil
|
||||
return value
|
||||
}
|
||||
|
||||
func dialTLS(hostPort string, tlsConfig *tls.Config) (SMTPClientIface, error) {
|
||||
// 1) Create a raw TCP connection
|
||||
conn, err := net.Dial("tcp", hostPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func validateConfig(config Config) error {
|
||||
if config.Host == "" || strings.ContainsAny(config.Host, "\r\n\t /") {
|
||||
return permanentError("validate configuration", errors.New("SMTP host is invalid"))
|
||||
}
|
||||
|
||||
// 2) Wrap it with TLS
|
||||
tlsConn := tls.Client(conn, tlsConfig)
|
||||
|
||||
// 3) Now create the SMTP client on this TLS connection
|
||||
host, _, _ := net.SplitHostPort(hostPort)
|
||||
c, err := smtp.NewClient(tlsConn, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
port, err := strconv.Atoi(config.Port)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return permanentError("validate configuration", errors.New("SMTP port is invalid"))
|
||||
}
|
||||
return c, nil
|
||||
if err := validateEnvelopeAddress(config.From); err != nil {
|
||||
return permanentError("validate configuration", fmt.Errorf("envelope sender: %w", err))
|
||||
}
|
||||
switch config.TLSMode {
|
||||
case TLSModeNone, TLSModeSTARTTLS, TLSModeImplicit:
|
||||
default:
|
||||
return permanentError("validate configuration", errors.New("SMTP TLS mode is invalid"))
|
||||
}
|
||||
if config.ConnectTimeout <= 0 || config.OperationTimeout <= 0 {
|
||||
return permanentError("validate configuration", errors.New("SMTP timeouts must be positive"))
|
||||
}
|
||||
if config.TLSMode == TLSModeNone && config.Auth != nil {
|
||||
return permanentError("validate configuration", errors.New("SMTP authentication requires TLS"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailService) SendEmail(emailData MessageWithAttachments) error {
|
||||
msg, err := newMessage(e.from, emailData.To, emailData.Subject).
|
||||
// SendEmailContext builds the legacy HTML/attachment message and sends it
|
||||
// through the canonical context-aware raw path.
|
||||
func (service *EmailService) SendEmailContext(ctx context.Context, emailData MessageWithAttachments) error {
|
||||
if strings.TrimSpace(emailData.Subject) == "" || containsHeaderBreak(emailData.Subject) {
|
||||
return permanentError("prepare message", errors.New("subject is invalid"))
|
||||
}
|
||||
message, err := newMessage(service.from, emailData.To, emailData.Subject).
|
||||
withAttachments(emailData.Body, emailData.Attachments)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while preparing email: %w", err)
|
||||
}
|
||||
|
||||
switch e.port {
|
||||
case "465":
|
||||
return e.sendTLS(emailData.To, msg)
|
||||
default:
|
||||
return e.send(emailData.To, msg)
|
||||
return permanentError("prepare message", err)
|
||||
}
|
||||
return service.SendRawContext(ctx, RawMessage{
|
||||
EnvelopeFrom: service.from,
|
||||
EnvelopeRecipients: []string{emailData.To},
|
||||
Data: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *EmailService) SendRaw(emailData RawMessage) error {
|
||||
switch e.port {
|
||||
case "465":
|
||||
return e.sendTLS(emailData.To, []byte(emailData.Body))
|
||||
default:
|
||||
return e.send(emailData.To, []byte(emailData.Body))
|
||||
}
|
||||
// SendEmail is a compatibility wrapper with an explicit bounded lifetime.
|
||||
func (service *EmailService) SendEmail(emailData MessageWithAttachments) error {
|
||||
ctx, cancel := service.legacyContext()
|
||||
defer cancel()
|
||||
return service.SendEmailContext(ctx, emailData)
|
||||
}
|
||||
|
||||
func (e *EmailService) send(to string, msg []byte) error {
|
||||
c, err := e.dial(e.host + ":" + e.port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DIAL: %s", err)
|
||||
}
|
||||
|
||||
if e.tlsconfig != nil {
|
||||
if err = c.StartTLS(e.tlsconfig); err != nil {
|
||||
return fmt.Errorf("c.StartTLS: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
if e.auth != nil {
|
||||
if err = c.Auth(e.auth); err != nil {
|
||||
return fmt.Errorf("c.Auth: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// To && From
|
||||
if err = c.Mail(e.from); err != nil {
|
||||
return fmt.Errorf("c.Mail: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("c.Rcpt: %s", err)
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("c.Data: %s", err)
|
||||
}
|
||||
|
||||
written, err := w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("w.Write: %s", err)
|
||||
}
|
||||
|
||||
if written <= 0 {
|
||||
return fmt.Errorf("%d bytes written", written)
|
||||
}
|
||||
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("w.Close: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Quit(); err != nil {
|
||||
return fmt.Errorf("w.Quit: %s", err)
|
||||
}
|
||||
return nil
|
||||
// SendRaw is a compatibility wrapper with an explicit bounded lifetime.
|
||||
func (service *EmailService) SendRaw(emailData RawMessage) error {
|
||||
ctx, cancel := service.legacyContext()
|
||||
defer cancel()
|
||||
return service.SendRawContext(ctx, emailData)
|
||||
}
|
||||
|
||||
func (e *EmailService) sendTLS(to string, msg []byte) error {
|
||||
c, err := e.dial(e.host + ":" + e.port) // dialTLS
|
||||
if err != nil {
|
||||
return fmt.Errorf("DIAL: %s", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Auth
|
||||
if err = c.Auth(e.auth); err != nil {
|
||||
return fmt.Errorf("c.Auth: %s", err)
|
||||
}
|
||||
|
||||
// To && From
|
||||
if err = c.Mail(e.from); err != nil {
|
||||
return fmt.Errorf("c.Mail: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("c.Rcpt: %s", err)
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("c.Data: %s", err)
|
||||
}
|
||||
|
||||
written, err := w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("w.Write: %s", err)
|
||||
}
|
||||
|
||||
if written <= 0 {
|
||||
return fmt.Errorf("%d bytes written", written)
|
||||
}
|
||||
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("w.Close: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Quit(); err != nil {
|
||||
return fmt.Errorf("w.Quit: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
func (service *EmailService) legacyContext() (context.Context, context.CancelFunc) {
|
||||
connectTimeout := defaultDuration(service.connectTimeout, DefaultConnectTimeout)
|
||||
operationTimeout := defaultDuration(service.operationTimeout, DefaultOperationTimeout)
|
||||
return context.WithTimeout(context.Background(), connectTimeout+10*operationTimeout)
|
||||
}
|
||||
|
||||
type message struct {
|
||||
@@ -353,40 +293,43 @@ func newMessage(from, to, subject string) message {
|
||||
}
|
||||
|
||||
func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) {
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = m.from
|
||||
headers["To"] = m.to
|
||||
headers["Subject"] = m.subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
|
||||
var message bytes.Buffer
|
||||
|
||||
for k, v := range headers {
|
||||
message.WriteString(k)
|
||||
message.WriteString(": ")
|
||||
message.WriteString(v)
|
||||
message.WriteString("\r\n")
|
||||
if err := validateEnvelopeAddress(m.from); err != nil {
|
||||
return nil, fmt.Errorf("from address: %w", err)
|
||||
}
|
||||
if err := validateEnvelopeAddress(m.to); err != nil {
|
||||
return nil, fmt.Errorf("recipient address: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(m.subject) == "" || containsHeaderBreak(m.subject) {
|
||||
return nil, errors.New("subject is invalid")
|
||||
}
|
||||
|
||||
message.WriteString("Content-Type: " + fmt.Sprintf("multipart/mixed; boundary=\"%s\"\r\n", delimeter))
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
||||
message.WriteString(body + "\r\n\r\n")
|
||||
var output bytes.Buffer
|
||||
output.WriteString("From: " + m.from + "\r\n")
|
||||
output.WriteString("To: " + m.to + "\r\n")
|
||||
output.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", m.subject) + "\r\n")
|
||||
output.WriteString("MIME-Version: 1.0\r\n")
|
||||
_, _ = fmt.Fprintf(&output, "Content-Type: multipart/mixed; boundary=%q\r\n\r\n", delimiter)
|
||||
output.WriteString("--" + delimiter + "\r\n")
|
||||
output.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
||||
output.WriteString(body + "\r\n\r\n")
|
||||
|
||||
for _, attachment := range attachments {
|
||||
attachmentRawFile, err := attachment.ReadContent()
|
||||
if strings.TrimSpace(attachment.Title) == "" || containsHeaderBreak(attachment.Title) {
|
||||
return nil, errors.New("attachment title is invalid")
|
||||
}
|
||||
content, err := attachment.ReadContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n")
|
||||
message.WriteString("Content-Type: application/octet-stream\r\n")
|
||||
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n")
|
||||
output.WriteString("--" + delimiter + "\r\n")
|
||||
output.WriteString("Content-Disposition: " + mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Title}) + "\r\n")
|
||||
output.WriteString("Content-Type: application/octet-stream\r\n")
|
||||
output.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
output.WriteString(base64.StdEncoding.EncodeToString(content) + "\r\n")
|
||||
}
|
||||
|
||||
message.WriteString("--" + delimeter + "--") // End the message
|
||||
return message.Bytes(), nil
|
||||
output.WriteString("--" + delimiter + "--\r\n")
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
type EmailAttachment struct {
|
||||
@@ -394,10 +337,25 @@ type EmailAttachment struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func (e EmailAttachment) ReadContent() ([]byte, error) {
|
||||
bts, err := io.ReadAll(e.File)
|
||||
func (attachment EmailAttachment) ReadContent() ([]byte, error) {
|
||||
content, err := io.ReadAll(attachment.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading attachment: %s", err)
|
||||
return nil, fmt.Errorf("load attachment: %w", err)
|
||||
}
|
||||
return bts, nil
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func validateEnvelopeAddress(value string) error {
|
||||
if value == "" || value != strings.TrimSpace(value) || containsHeaderBreak(value) {
|
||||
return errors.New("address is invalid")
|
||||
}
|
||||
parsed, err := mail.ParseAddress(value)
|
||||
if err != nil || parsed.Address != value {
|
||||
return errors.New("address must be one mailbox without a display name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsHeaderBreak(value string) bool {
|
||||
return strings.ContainsAny(value, "\r\n")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user