all repos — postern

@ c3d755b5d212b0d99538e0143bde5801275e4cd0

Modern mail management

clone
git clone https:///postern.git

internal/dkim/sign.go (view raw)

  1package dkim
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto"
  7	"fmt"
  8	"io"
  9	"log/slog"
 10	"net/mail"
 11	"strings"
 12	"time"
 13
 14	"github.com/emersion/go-msgauth/dkim"
 15	"go.turscar.ie/dkim2"
 16)
 17
 18func rfc6376Section541() []string {
 19	return []string{
 20		"From",
 21		"Reply-To",
 22		"Subject",
 23		"Date",
 24		"To",
 25		"Cc",
 26		"Resent-Date",
 27		"Resent-From",
 28		"Resent-To",
 29		"Resent-Cc",
 30		"In-Reply-To",
 31		"References",
 32		"List-Id",
 33		"List-Help",
 34		"List-Unsubscribe",
 35		"List-Subscribe",
 36		"List-Post",
 37		"List-Owner",
 38		"List-Archive",
 39	}
 40}
 41
 42// Sign signs the message with the first enabled selector for the From: domain and returns the signed message.
 43func (d *DKIM) Sign(ctx context.Context, message []byte, mailFrom string, rcptTo []string) ([]byte, error) {
 44	// TODO check if we can make this a zero-copy operation with io.Reader instead of message []byte
 45	// for DMARC alignment, we first need to find the From: domain
 46	msg, err := mail.ReadMessage(bytes.NewReader(message))
 47	if err != nil {
 48		return nil, fmt.Errorf("parsing message: %w", err)
 49	}
 50
 51	fromHeader := msg.Header.Get("From")
 52	if fromHeader == "" {
 53		return nil, fmt.Errorf("from header is empty")
 54	}
 55	parsedFromHeader, err := mail.ParseAddress(fromHeader)
 56	if err != nil {
 57		return nil, fmt.Errorf("parsing from header: %w", err)
 58	}
 59	i := strings.LastIndex(parsedFromHeader.Address, "@")
 60	if i == -1 {
 61		return nil, fmt.Errorf("from header does not contain @")
 62	}
 63	domain := parsedFromHeader.Address[i+1:]
 64
 65	slog.Info("[DKIM] signing based on From: header", "domain", domain, "from", fromHeader)
 66
 67	signer, selector, err := d.retrievePrivateKey(ctx, "", domain)
 68	if err != nil {
 69		return nil, fmt.Errorf("reading signing key: %w", err)
 70	}
 71
 72	var buffer bytes.Buffer
 73	_, err = io.Copy(&buffer, bytes.NewReader(message))
 74	if err != nil {
 75		return nil, fmt.Errorf("copy buffer: %w", err)
 76	}
 77
 78	err = dkim.Sign(&buffer, &buffer, &dkim.SignOptions{
 79		Domain:                 domain,
 80		Selector:               selector,
 81		Identifier:             "",
 82		Signer:                 signer.(crypto.Signer),
 83		Hash:                   crypto.SHA256,
 84		HeaderCanonicalization: dkim.CanonicalizationSimple,
 85		BodyCanonicalization:   dkim.CanonicalizationSimple,
 86		HeaderKeys:             rfc6376Section541(),
 87		Expiration:             time.Time{},
 88		QueryMethods:           nil,
 89	})
 90	if err != nil {
 91		return nil, fmt.Errorf("signing DKIM: %w", err)
 92	}
 93
 94	err = dkim2.Sign(&buffer, &buffer, dkim2.SignOptions{
 95		Timestamp:    time.Now().UTC().Unix(),
 96		Domain:       domain,
 97		Exploded:     false,
 98		DoNotExplode: true,
 99		DoNotModify:  true,
100		Feedback:     false,
101		FeedHere:     false,
102		MailFrom:     mailFrom,
103		RcptTo:       rcptTo,
104		Keys: []dkim2.SigningKey{{
105			Selector: selector,
106			Signer:   signer.(crypto.Signer),
107		}},
108	})
109	if err != nil {
110		return nil, fmt.Errorf("signing DKIM2: %w", err)
111	}
112
113	return buffer.Bytes(), nil
114}