smtpclient

package
v0.0.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 9, 2024 License: MIT Imports: 27 Imported by: 2

Documentation

Overview

Package smtpclient is an SMTP client, for submitting to an SMTP server or delivering from a queue.

Email clients can submit a message to SMTP server, after which the server is responsible for delivery to the final destination. A submission client typically connects with TLS, and PKIX-verifies the server's certificate. The client then authenticates using a SASL mechanism.

Email servers manage a message queue, from which they will try to deliver messages. In case of temporary failures, the message is kept in the queue and tried again later. For delivery, no authentication is done. TLS is opportunistic by default (TLS certificates not verified), but TLS and certificate verification can be opted into by domains by specifying an MTA-STS policy for the domain, or DANE TLSA records for their MX hosts.

Delivering a message from a queue would involve:

  1. Looking up an MTA-STS policy, through a cache.
  2. Resolving the MX targets for a domain, through smtpclient.GatherDestinations, and for each destination try delivery through:
  3. Looking up IP addresses for the destination, with smtpclient.GatherIPs.
  4. Looking up TLSA records for DANE, in case of authentic DNS responses (DNSSEC), with smtpclient.GatherTLSA.
  5. Dialing the MX target with smtpclient.Dial.
  6. Initializing a SMTP session with smtpclient.New, with proper TLS configuration based on discovered MTA-STS and DANE policies, and finally calling client.Deliver.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	MetricCommands             stub.HistogramVec = stub.HistogramVecIgnore{}
	MetricTLSRequiredNoIgnored stub.CounterVec   = stub.CounterVecIgnore{}
	MetricPanicInc                               = func() {}
)
View Source
var (
	ErrSize                  = errors.New("message too large for remote smtp server") // SMTP server announced a maximum message size and the message to be delivered exceeds it.
	Err8bitmimeUnsupported   = errors.New("remote smtp server does not implement 8bitmime extension, required by message")
	ErrSMTPUTF8Unsupported   = errors.New("remote smtp server does not implement smtputf8 extension, required by message")
	ErrRequireTLSUnsupported = errors.New("remote smtp server does not implement requiretls extension, required for delivery")
	ErrStatus                = errors.New("remote smtp server sent unexpected response status code") // Relatively common, e.g. when a 250 OK was expected and server sent 451 temporary error.
	ErrProtocol              = errors.New("smtp protocol error")                                     // After a malformed SMTP response or inconsistent multi-line response.
	ErrTLS                   = errors.New("tls error")                                               // E.g. handshake failure, or hostname verification was required and failed.
	ErrBotched               = errors.New("smtp connection is botched")                              // Set on a client, and returned for new operations, after an i/o error or malformed SMTP response.
	ErrClosed                = errors.New("client is closed")
)
View Source
var DialHook func(ctx context.Context, dialer Dialer, timeout time.Duration, addr string, laddr net.Addr) (net.Conn, error)

DialHook can be used during tests to override the regular dialer from being used.

Functions

func Dial added in v0.0.8

func Dial(ctx context.Context, elog *slog.Logger, dialer Dialer, host dns.IPDomain, ips []net.IP, port int, dialedIPs map[string][]net.IP, localIPs []net.IP) (conn net.Conn, ip net.IP, rerr error)

Dial connects to host by dialing ips, taking previous attempts in dialedIPs into accounts (for greylisting, blocklisting and ipv4/ipv6).

If the previous attempt used IPv4, this attempt will use IPv6 (useful in case one of the IPs is in a DNSBL).

The second attempt for an address family we prefer the same IP as earlier, to increase our chances if remote is doing greylisting.

Dial updates dialedIPs, callers may want to save it so it can be taken into account for future delivery attempts.

The first matching protocol family from localIPs is set for the local side of the TCP connection.

func GatherDestinations added in v0.0.8

func GatherDestinations(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, origNextHop dns.IPDomain) (haveMX, origNextHopAuthentic, expandedNextHopAuthentic bool, expandedNextHop dns.Domain, hosts []dns.IPDomain, permanent bool, err error)

GatherDestinations looks up the hosts to deliver email to a domain ("next-hop"). If it is an IP address, it is the only destination to try. Otherwise CNAMEs of the domain are followed. Then MX records for the expanded CNAME are looked up. If no MX record is present, the original domain is returned. If an MX record is present but indicates the domain does not accept email, ErrNoMail is returned. If valid MX records were found, the MX target hosts are returned.

haveMX indicates if an MX record was found.

origNextHopAuthentic indicates if the DNS record for the initial domain name was DNSSEC secure (CNAME, MX).

expandedNextHopAuthentic indicates if the DNS records after following CNAMEs were DNSSEC secure.

These authentic results are needed for DANE, to determine where to look up TLSA records, and which names to allow in the remote TLS certificate. If MX records were found, both the original and expanded next-hops must be authentic for DANE to be option. For a non-IP with no MX records found, the authentic result can be used to decide which of the names to use as TLSA base domain.

func GatherIPs added in v0.0.8

func GatherIPs(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, host dns.IPDomain, dialedIPs map[string][]net.IP) (authentic bool, expandedAuthentic bool, expandedHost dns.Domain, ips []net.IP, dualstack bool, rerr error)

GatherIPs looks up the IPs to try for connecting to host, with the IPs ordered to take previous attempts into account. For use with DANE, the CNAME-expanded name is returned, and whether the DNS responses were authentic.

func GatherTLSA added in v0.0.8

func GatherTLSA(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, host dns.Domain, expandedAuthentic bool, expandedHost dns.Domain) (daneRequired bool, daneRecords []adns.TLSA, tlsaBaseDomain dns.Domain, err error)

GatherTLSA looks up TLSA record for either expandedHost or host, and returns records usable for DANE with SMTP, and host names to allow in DANE-TA certificate name verification.

If no records are found, this isn't necessarily an error. It can just indicate the domain/host does not opt-in to DANE, and nil records and a nil error are returned.

Only usable records are returned. If any record was found, DANE is required and this is indicated with daneRequired. If no usable records remain, the caller must do TLS, but not verify the remote TLS certificate.

Returned values are always meaningful, also when an error was returned.

func GatherTLSANames added in v0.0.8

func GatherTLSANames(haveMX, expandedNextHopAuthentic, expandedTLSABaseDomainAuthentic bool, origNextHop, expandedNextHop, origTLSABaseDomain, expandedTLSABaseDomain dns.Domain) []dns.Domain

GatherTLSANames returns the allowed names in TLS certificates for verification with PKIX-* or DANE-TA. The first name should be used for SNI.

If there was no MX record, the next-hop domain parameters (i.e. the original email destination host, and its CNAME-expanded host, that has MX records) are ignored and only the base domain parameters are taken into account.

Types

type Client

type Client struct {
	ExtLimits             map[string]string // For LIMITS extension, only if present and valid, with uppercase keys.
	ExtLimitMailMax       int               // Max "MAIL" commands in a connection, if > 0.
	ExtLimitRcptMax       int               // Max "RCPT" commands in a transaction, if > 0.
	ExtLimitRcptDomainMax int               // Max unique domains in a connection, if > 0.
	// contains filtered or unexported fields
}

Client is an SMTP client that can deliver messages to a mail server.

Use New to make a new client.

Example
package main

import (
	"context"
	"crypto/tls"
	"log"
	"log/slog"
	"net"
	"slices"
	"strings"

	"github.com/mjl-/mox/dns"
	"github.com/mjl-/mox/sasl"
	"github.com/mjl-/mox/smtpclient"
)

func main() {
	// Submit a message to an SMTP server, with authentication. The SMTP server is
	// responsible for getting the message delivered.

	// Make TCP connection to submission server.
	conn, err := net.Dial("tcp", "submit.example.org:465")
	if err != nil {
		log.Fatalf("dial submission server: %v", err)
	}
	defer conn.Close()

	// Initialize the SMTP session, with a EHLO, STARTTLS and authentication.
	// Verify the server TLS certificate with PKIX/WebPKI.
	ctx := context.Background()
	tlsVerifyPKIX := true
	opts := smtpclient.Opts{
		Auth: func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
			// If the server is known to support a SCRAM PLUS variant, you should only use
			// that, detecting and preventing authentication mechanism downgrade attacks
			// through TLS channel binding.
			username := "mjl"
			password := "test1234"

			// Prefer strongest authentication mechanism, allow up to older CRAM-MD5.
			if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") {
				return sasl.NewClientSCRAMSHA256PLUS(username, password, *cs), nil
			}
			if slices.Contains(mechanisms, "SCRAM-SHA-256") {
				return sasl.NewClientSCRAMSHA256(username, password, true), nil
			}
			if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-1-PLUS") {
				return sasl.NewClientSCRAMSHA1PLUS(username, password, *cs), nil
			}
			if slices.Contains(mechanisms, "SCRAM-SHA-1") {
				return sasl.NewClientSCRAMSHA1(username, password, true), nil
			}
			if slices.Contains(mechanisms, "CRAM-MD5") {
				return sasl.NewClientCRAMMD5(username, password), nil
			}
			// No mutually supported mechanism found, connection will fail.
			return nil, nil
		},
	}
	localname := dns.Domain{ASCII: "localhost"}
	remotename := dns.Domain{ASCII: "submit.example.org"}
	client, err := smtpclient.New(ctx, slog.Default(), conn, smtpclient.TLSImmediate, tlsVerifyPKIX, localname, remotename, opts)
	if err != nil {
		log.Fatalf("initialize smtp to submission server: %v", err)
	}
	defer client.Close()

	// Send the message to the server, which will add it to its queue.
	req8bitmime := false // ASCII-only, so 8bitmime not required.
	reqSMTPUTF8 := false // No UTF-8 headers, so smtputf8 not required.
	requireTLS := false  // Not supported by most servers at the time of writing.
	msg := "From: <mjl@example.org>\r\nTo: <other@example.org>\r\nSubject: hi\r\n\r\nnice to test you.\r\n"
	err = client.Deliver(ctx, "mjl@example.org", "other@example.com", int64(len(msg)), strings.NewReader(msg), req8bitmime, reqSMTPUTF8, requireTLS)
	if err != nil {
		log.Fatalf("submit message to smtp server: %v", err)
	}

	// Message has been submitted.
}
Output:

func New

func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode, tlsVerifyPKIX bool, ehloHostname, remoteHostname dns.Domain, opts Opts) (*Client, error)

New initializes an SMTP session on the given connection, returning a client that can be used to deliver messages.

New optionally starts TLS (for submission), reads the server greeting, identifies itself with a HELO or EHLO command, initializes TLS with STARTTLS if remote supports it and optionally authenticates. If successful, a client is returned on which eventually Close must be called. Otherwise an error is returned and the caller is responsible for closing the connection.

Connecting to the correct host for delivery can be done using the Gather functions, and with Dial. The queue managing outgoing messages typically decides which host to deliver to, taking multiple MX records with preferences, other DNS records, MTA-STS, retries and special cases into account.

tlsMode indicates if and how TLS may/must (not) be used.

tlsVerifyPKIX indicates if TLS certificates must be validated against the PKIX/WebPKI certificate authorities (if TLS is done).

DANE-verification is done when opts.DANERecords is not nil.

TLS verification errors will be ignored if opts.IgnoreTLSVerification is set.

If TLS is done, PKIX verification is always performed for tracking the results for TLS reporting, but if tlsVerifyPKIX is false, the verification result does not affect the connection.

At the time of writing, delivery of email on the internet is done with opportunistic TLS without PKIX verification by default. Recipient domains can opt-in to PKIX verification by publishing an MTA-STS policy, or opt-in to DANE verification by publishing DNSSEC-protected TLSA records in DNS.

func (*Client) Botched

func (c *Client) Botched() bool

Botched returns whether this connection is botched, e.g. a protocol error occurred and the connection is in unknown state, and cannot be used for message delivery.

func (*Client) Close

func (c *Client) Close() (rerr error)

Close cleans up the client, closing the underlying connection.

If the connection is initialized and not botched, a QUIT command is sent and the response read with a short timeout before closing the underlying connection.

Close returns any error encountered during QUIT and closing.

func (*Client) Conn added in v0.0.8

func (c *Client) Conn() (net.Conn, error)

Conn returns the connection with initialized SMTP session. Once the caller uses this connection it is in control, and responsible for closing the connection, and other functions on the client must not be called anymore.

func (*Client) Deliver

func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, msgSize int64, msg io.Reader, req8bitmime, reqSMTPUTF8, requireTLS bool) (rerr error)

Deliver attempts to deliver a message to a mail server.

mailFrom must be an email address, or empty in case of a DSN. rcptTo must be an email address.

If the message contains bytes with the high bit set, req8bitmime must be true. If set, the remote server must support the 8BITMIME extension or delivery will fail.

If the message is internationalized, e.g. when headers contain non-ASCII character, or when UTF-8 is used in a localpart, reqSMTPUTF8 must be true. If set, the remote server must support the SMTPUTF8 extension or delivery will fail.

If requireTLS is true, the remote server must support the REQUIRETLS extension, or delivery will fail.

Deliver uses the following SMTP extensions if the remote server supports them: 8BITMIME, SMTPUTF8, SIZE, PIPELINING, ENHANCEDSTATUSCODES, STARTTLS.

Returned errors can be of type Error, one of the Err-variables in this package or other underlying errors, e.g. for i/o. Use errors.Is to check.

func (*Client) DeliverMultiple added in v0.0.10

func (c *Client) DeliverMultiple(ctx context.Context, mailFrom string, rcptTo []string, msgSize int64, msg io.Reader, req8bitmime, reqSMTPUTF8, requireTLS bool) (rcptResps []Response, rerr error)

DeliverMultiple is like Deliver, but attempts to deliver a message to multiple recipients. Errors about the entire transaction, such as i/o errors or error responses to the MAIL FROM or DATA commands, are returned by a non-nil rerr. If rcptTo has a single recipient, an error to the RCPT TO command is returned in rerr instead of rcptResps. Otherwise, the SMTP response for each recipient is returned in rcptResps.

The caller should take extLimit* into account when sending. And recognize recipient response code "452" to mean that a recipient limit was reached, another transaction can be attempted immediately after instead of marking the delivery attempt as failed. Also code "552" must be treated like temporary error code "452" for historic reasons.

func (*Client) Reset

func (c *Client) Reset() (rerr error)

Reset sends an SMTP RSET command to reset the message transaction state. Deliver automatically sends it if needed.

func (*Client) Supports8BITMIME

func (c *Client) Supports8BITMIME() bool

Supports8BITMIME returns whether the SMTP server supports the 8BITMIME extension, needed for sending data with non-ASCII bytes.

func (*Client) SupportsRequireTLS added in v0.0.8

func (c *Client) SupportsRequireTLS() bool

SupportsRequireTLS returns whether the SMTP server supports the REQUIRETLS extension. The REQUIRETLS extension is only announced after enabling STARTTLS.

func (*Client) SupportsSMTPUTF8

func (c *Client) SupportsSMTPUTF8() bool

SupportsSMTPUTF8 returns whether the SMTP server supports the SMTPUTF8 extension, needed for sending messages with UTF-8 in headers or in an (SMTP) address.

func (*Client) SupportsStartTLS added in v0.0.8

func (c *Client) SupportsStartTLS() bool

SupportsStartTLS returns whether the SMTP server supports the STARTTLS extension.

func (*Client) TLSConnectionState added in v0.0.9

func (c *Client) TLSConnectionState() *tls.ConnectionState

TLSConnectionState returns TLS details if TLS is enabled, and nil otherwise.

type Dialer added in v0.0.8

type Dialer interface {
	DialContext(ctx context.Context, network, addr string) (c net.Conn, err error)
}

Dialer is used to dial mail servers, an interface to facilitate testing.

type Error

type Error struct {
	// Whether failure is permanent, typically because of 5xx response.
	Permanent bool
	// SMTP response status, e.g. 2xx for success, 4xx for transient error and 5xx for
	// permanent failure.
	Code int
	// Short enhanced status, minus first digit and dot. Can be empty, e.g. for io
	// errors or if remote does not send enhanced status codes. If remote responds with
	// "550 5.7.1 ...", the Secode will be "7.1".
	Secode string
	// SMTP command causing failure.
	Command string
	// For errors due to SMTP responses, the full SMTP line excluding CRLF that caused
	// the error. First line of a multi-line response.
	Line string
	// Optional additional lines in case of multi-line SMTP response.  Most SMTP
	// responses are single-line, leaving this field empty.
	MoreLines []string
	// Underlying error, e.g. one of the Err variables in this package, or io errors.
	Err error
}

Error represents a failure to deliver a message.

Code, Secode, Command and Line are only set for SMTP-level errors, and are zero values otherwise.

func (Error) Error

func (e Error) Error() string

Error returns a readable error string.

func (Error) Unwrap

func (e Error) Unwrap() error

Unwrap returns the underlying Err.

type Opts added in v0.0.8

type Opts struct {
	// If auth is non-nil, authentication will be done with the returned sasl client.
	// The function should select the preferred mechanism. Mechanisms are in upper
	// case.
	//
	// The TLS connection state can be used for the SCRAM PLUS mechanisms, binding the
	// authentication exchange to a TLS connection. It is only present for TLS
	// connections.
	//
	// If no mechanism is supported, a nil client and nil error can be returned, and
	// the connection will fail.
	Auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error)

	DANERecords        []adns.TLSA  // If not nil, DANE records to verify.
	DANEMoreHostnames  []dns.Domain // For use with DANE, where additional certificate host names are allowed.
	DANEVerifiedRecord *adns.TLSA   // If non-empty, set to the DANE record that verified the TLS connection.

	// If set, TLS verification errors (for DANE or PKIX) are ignored. Useful for
	// delivering messages with message header "TLS-Required: No".
	// Certificates are still verified, and results are still tracked for TLS
	// reporting, but the connections will continue.
	IgnoreTLSVerifyErrors bool

	// If not nil, used instead of the system default roots for TLS PKIX verification.
	RootCAs *x509.CertPool

	// TLS verification successes/failures is added to these TLS reporting results.
	// Once the STARTTLS handshake is attempted, a successful/failed connection is
	// tracked.
	RecipientDomainResult *tlsrpt.Result // MTA-STS or no policy.
	HostResult            *tlsrpt.Result // DANE or no policy.
}

Opts influence behaviour of Client.

type Response added in v0.0.10

type Response Error

type TLSMode

type TLSMode string

TLSMode indicates if TLS must, should or must not be used.

const (
	// TLS immediately ("implicit TLS"), directly starting TLS on the TCP connection,
	// so not using STARTTLS. Whether PKIX and/or DANE is verified is specified
	// separately.
	TLSImmediate TLSMode = "immediate"

	// Required TLS with STARTTLS for SMTP servers. The STARTTLS command is always
	// executed, even if the server does not announce support.
	// Whether PKIX and/or DANE is verified is specified separately.
	TLSRequiredStartTLS TLSMode = "requiredstarttls"

	// Use TLS with STARTTLS if remote claims to support it.
	TLSOpportunistic TLSMode = "opportunistic"

	// TLS must not be attempted, e.g. due to earlier TLS handshake error.
	TLSSkip TLSMode = "skip"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL