Skip to content
Snippets Groups Projects
  • ale's avatar
    370ffd97
    Add packages to generate and send emails · 370ffd97
    ale authored
    We're using language-specific Markdown templates to allow simultaneous
    generation of text and HTML for fancy multipart/alternative
    emails. The package also supports PGP/MIME for signatures (no
    encryption yet).
    370ffd97
    History
    Add packages to generate and send emails
    ale authored
    We're using language-specific Markdown templates to allow simultaneous
    generation of text and HTML for fancy multipart/alternative
    emails. The package also supports PGP/MIME for signatures (no
    encryption yet).
mail.go 3.18 KiB
package mail

import (
	"bytes"
	"errors"
	"fmt"
	"net"
	"net/mail"
	"net/smtp"
	"strings"
	"time"

	"git.autistici.org/ai3/go-common/mail/message"
	"git.autistici.org/ai3/go-common/mail/pgp"
)

var userAgent = "go-mailer/0.1"

type Config struct {
	SenderName     string `yaml:"sender_name"`
	SenderAddr     string `yaml:"sender_addr"`
	SigningKeyFile string `yaml:"signing_key_file"`
	SigningKeyID   string `yaml:"signing_key_id"`

	SMTP struct {
		Server       string `yaml:"server"`
		AuthUser     string `yaml:"auth_user"`
		AuthPassword string `yaml:"auth_password"`
	} `yaml:"smtp"`
}

type Mailer struct {
	sender       *mail.Address
	senderDomain string
	middleware   message.MiddlewareList

	smtpServer string
	smtpAuth   smtp.Auth
}

func New(config *Config) (*Mailer, error) {
	if config.SenderAddr == "" {
		return nil, errors.New("sender_addr must not be empty")
	}
	senderDomainParts := strings.Split(config.SenderAddr, "@")
	senderDomain := senderDomainParts[len(senderDomainParts)-1]

	sender := &mail.Address{
		Name:    config.SenderName,
		Address: config.SenderAddr,
	}

	// Load the signing key, if specified.
	var mwl message.MiddlewareList
	if config.SigningKeyFile != "" {
		signer, err := pgp.NewSigner(config.SigningKeyFile, config.SigningKeyID, sender)
		if err != nil {
			return nil, err
		}
		mwl.Add(signer)
	}

	// Parse SMTP authentication params.
	var smtpAuth smtp.Auth
	if config.SMTP.AuthUser != "" {
		// The hostname is used by net/smtp to validate the TLS certificate.
		hostname := config.SMTP.Server
		if h, _, err := net.SplitHostPort(hostname); err == nil {
			hostname = h
		}
		smtpAuth = smtp.PlainAuth("", config.SMTP.AuthUser, config.SMTP.AuthPassword, hostname)
	}

	return &Mailer{
		sender:       sender,
		senderDomain: senderDomain,
		middleware:   mwl,
		smtpServer:   config.SMTP.Server,
		smtpAuth:     smtpAuth,
	}, nil
}

func (m *Mailer) WithEnvelope(msg *message.Part, rcpt, subject string) (*message.Part, error) {
	rcptAddr := mail.Address{Address: rcpt}
	hdr := msg.Header
	hdr.Set("From", m.sender.String())
	hdr.Set("To", rcptAddr.String())
	hdr.Set("Subject", subject)
	hdr.Set("User-Agent", userAgent)
	hdr.Set("MIME-Version", "1.0")
	hdr.Set("Message-ID", fmt.Sprintf("<%s>", m.randomMessageID()))
	hdr.Set("Date", currentTimeFn().Format(time.RFC1123Z))

	var err error
	msg, err = m.middleware.Process(msg)
	if err != nil {
		return nil, err
	}

	return msg, nil
}

func (m *Mailer) Render(msg *message.Part) (body string, err error) {
	var buf bytes.Buffer
	err = msg.Render(&buf)
	if err == nil {
		body = buf.String()
	}
	return
}

func (m *Mailer) randomMessageID() string {
	// Re-use randomBoundary.
	return fmt.Sprintf("%s@%s", message.RandomBoundaryFn(), m.senderDomain)
}

func (m *Mailer) Send(msg *message.Part, rcpt, subject string) error {
	var err error
	msg, err = m.WithEnvelope(msg, rcpt, subject)
	if err != nil {
		return err
	}
	var buf bytes.Buffer
	if err := msg.Render(&buf); err != nil {
		return err
	}
	return smtp.SendMail(m.smtpServer, m.smtpAuth, m.sender.Address, []string{rcpt}, buf.Bytes())
}

// Let us stub out the time function for testing.
var currentTimeFn func() time.Time = currentTime

func currentTime() time.Time {
	return time.Now().UTC()
}