// Package client implements the client of the Simple Mail Transfer Protocol as defined in RFC 5321.
// It provides basic functionality you need to use smtp.
//
// It also implements the following extensions:
//
// - 8BITMIME (RFC 1652)
// - ENHANCEDSTATUSCODES (RFC 2034)
// - AUTH (RFC 2554)
// - DELIVERBY (RFC 2852)
// - CHUNKING (RFC 3030)
// - BINARYMIME (RFC 3030)
// - STARTTLS (RFC 3207)
// - DSN (RFC 3461, RFC 6533)
// - SMTPUTF8 (RFC 6531)
// - MT-PRIORITY (RFC 6710)
// - RRVS (RFC 7293)
// - REQUIRETLS (RFC 8689)
//
// Additional extensions may be handled by other packages.
package client
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/uponusolutions/go-sasl"
"github.com/uponusolutions/go-smtp"
"github.com/uponusolutions/go-smtp/internal/parse"
"github.com/uponusolutions/go-smtp/internal/smtpreader"
"github.com/uponusolutions/go-smtp/internal/smtpwriter"
)
// Client implements a SMTP Client with .
type Client struct {
cfg Config
// The buffer is used if chunking/bdat is used with buffering.
// It is created on first use and it's size is chunkingMaxSize.
chunkingBuffer []byte
// keep a reference to the connection so it can be used to create a TLS
// connection later
conn net.Conn
receiver *smtpreader.Receiver
sender *smtpwriter.Sender
// connAddress is set on dial and is not reset on disconnect
// to be able to use ServerAddress() after disconnect
connAddress string // Format address:port.
// connName is set when the server greets the client and is not reset on disconnect
// to be able to use ServerAddress() after disconnect
connName string // server greet name
// connName is set when the server greets the client and is not reset on disconnect
// to be able to use Extension() after disconnect
connExt map[string]string // supported extensions of the server after ehlo
// connPipelining is set after ehlo and reset when connExt is set
connPipelining pipeliningState
}
type pipeliningState struct {
// How many responses are pending?
// Is counted up when a request is sent without retrieving the response.
pending int
// Marks if the group has concluded through a command which must be the last command in a group.
// If it is set, the pending responses needs to be consumed before sending commands again.
concluded bool
}
type pipeliningType int
const (
pipeliningTypeForbidden pipeliningType = iota
pipeliningTypeAnywhere
pipeliningTypeLast
)
// New returns a new smtp client.
func New(opts ...Option) *Client {
cfg := DefaultConfig()
for _, o := range opts {
o(&cfg)
}
return NewFromConfig(cfg)
}
// NewFromConfig returns a new smtp client from existing config.
func NewFromConfig(cfg Config) *Client {
return &Client{
cfg: cfg,
}
}
// MailOptions contains parameters for the MAIL command.
type MailOptions struct {
// Size of the body. Can be 0 if not specified by client.
Size int64
// TLS is required for the message transmission.
//
// The message should be rejected if it can't be transmitted
// with TLS.
RequireTLS bool
// The message envelope or message header contains UTF-8-encoded strings.
// This flag is set by SMTPUTF8-aware (RFC 6531) client.
UTF8 UTF8
// Value of RET= argument, FULL or HDRS.
Return smtp.DSNReturn
// Envelope identifier set by the client.
EnvelopeID string
// Accepted Domain from Exchange Online, e.g. from OutgoingConnector
XOORG *string
// The authorization identity asserted by the message sender in decoded
// form with angle brackets stripped.
//
// nil value indicates missing AUTH, non-nil empty string indicates
// AUTH=<>.
//
// Defined in RFC 4954.
Auth *string
// Value of BY= argument or nil if unset.
DeliverBy *smtp.DeliverByOptions
// Value of MT-PRIORITY= or nil if unset.
MTPriority *int
}
// VrfyOptions contains parameters for the VRFY command.
type VrfyOptions struct {
// The message envelope or message header contains UTF-8-encoded strings.
// This flag is set by SMTPUTF8-aware (RFC 6531) client.
UTF8 UTF8
}
// Dial returns a connection to an SMTP server at addr. The addr must
// include a port, as in "mail.example.com:smtp".
func (c *Client) Dial(ctx context.Context, addr string) error {
dialer := net.Dialer{Timeout: c.cfg.dialTimeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
c.connAddress = addr
return c.initConn(conn, true)
}
// DialTLS returns a connection to an SMTP server at addr via TLS.
// The addr must include a port, as in "mail.example.com:smtps".
//
// A nil tlsConfig is equivalent to a zero tls.Config.
func (c *Client) DialTLS(ctx context.Context, config *tls.Config, addr string) error {
tlsDialer := tls.Dialer{
NetDialer: &net.Dialer{Timeout: c.cfg.dialTimeout},
Config: config,
}
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
c.connAddress = addr
return c.initConn(conn, true)
}
// initConn sets the underlying network connection for the client,
// expect greeting from the server if enabled and
// calls hello to finish basic setup
func (c *Client) initConn(conn net.Conn, expectGreet bool) error {
c.setConn(conn)
if expectGreet {
if err := c.greet(); err != nil {
return err
}
}
return c.Hello()
}
// setConn sets the underlying network connection.
func (c *Client) setConn(conn net.Conn) {
c.conn = conn
if c.receiver != nil {
c.receiver.Reset(conn)
} else {
c.receiver = smtpreader.NewReceiver(conn, c.cfg.readerSize, c.cfg.maxLineLength)
}
if c.sender != nil {
c.sender.Reset(conn)
} else {
c.sender = smtpwriter.NewSender(conn, c.cfg.writerSize)
}
}
// Close closes the connection.
func (c *Client) Close() (err error) {
if c.conn == nil {
return nil
}
c.receiver.Reset(nil)
c.sender.Reset(nil)
err = c.conn.Close()
c.conn = nil
return err
}
// Greet reads the greeting of the server
// if an error occurred the connection is closed
func (c *Client) greet() error {
// Initial greeting timeout. RFC 5321 recommends 5 minutes.
timeout := smtp.Timeout(c.conn, c.cfg.commandTimeout)
defer timeout()
status, err := c.receiver.ReadResponse()
// probably connectivity error
if err != nil {
_ = c.Close()
return err
}
// status unexpected or no message received
if status.Code != 220 || len(status.Lines) == 0 {
_ = c.Close()
return status
}
if idx := strings.IndexRune(status.Lines[0], ' '); idx >= 0 {
c.connName = status.Lines[0][:idx]
} else {
c.connName = status.Lines[0]
}
return nil
}
// Hello runs a hello exchange
// if an error occurred the connection is closed
func (c *Client) Hello() error {
// verify if local name is valid
if strings.ContainsAny(c.cfg.localName, "\n\r") {
return errors.New("smtp: the local name must not contain CR or LF")
}
err := c.ehlo()
if status, ok := err.(*smtp.Status); err != nil && (ok && (status.Code == 500 || status.Code == 502)) {
// The server doesn't support EHLO, fallback to HELO
err = c.helo()
}
if err != nil {
_ = c.Close()
}
return err
}
func (c *Client) setExt(ext map[string]string) {
c.connExt = ext
c.connPipelining.concluded = false
c.connPipelining.pending = 0
}
func (c *Client) consumeResponse() error {
if !c.PipeliningActive() {
return ErrPipeliningNotEnabled
}
if c.connPipelining.pending == 0 {
return ErrPipeliningNothingPending
}
// first consume concludes group
c.connPipelining.concluded = true
c.connPipelining.pending--
if c.connPipelining.pending == 0 {
c.connPipelining.concluded = false
}
return nil
}
// cmd is a convenience function that sends a command and returns the response
// does not support or handle anything related to pipelining
func (c *Client) cmd(expectCode int, message string) (*smtp.Status, error) {
timeout := smtp.Timeout(c.conn, c.cfg.commandTimeout)
defer timeout()
_, err := c.sender.WriteString(message)
if err != nil {
return nil, err
}
_, err = c.sender.Write(smtp.Crnl)
if err != nil {
return nil, err
}
err = c.sender.Flush()
if err != nil {
return nil, err
}
status, err := c.receiver.ReadResponse()
if err != nil {
return nil, err
}
if smtpreader.IsCodeUnexpected(status.Code, expectCode) {
return nil, status
}
return status, nil
}
// cmdValid is a convenience function that sends a command and returns an error if the expected code doesn't match
// can handle pipelining
func (c *Client) cmdValid(pType pipeliningType, expectCode int, message string) error {
timeout := smtp.Timeout(c.conn, c.cfg.commandTimeout)
defer timeout()
if c.PipeliningActive() {
// group is concluded, please consume all responses
if c.connPipelining.concluded {
return ErrPipeliningGroupConcluded
}
// RFC 2920
// Client SMTP implementations MAY elect to operate in a nonblocking
// fashion, processing server responses immediately upon receipt, even
// if there is still data pending transmission from the client's
// previous TCP send operation. If nonblocking operation is not
// supported, however, client SMTP implementations MUST also check the
// TCP window size and make sure that each group of commands fits
// entirely within the window. The window size is usually, but not
// always, 4K octets. Failure to perform this check can lead to
// deadlock conditions.
// Need to flush to prevent congestion and concluse group forcefully.
// If the buffer can not hold the first request, ignore congestion.
// Typically the 4k buffio is enough to make it fast, it don't expect any buffers from tcp.
// Outcomment to see TestClient_SendMailDirectManyRcptsPipelining failing.
if len(message)+2 > c.sender.Available() && c.connPipelining.pending > 0 {
err := c.sender.Flush()
if err != nil {
return err
}
c.connPipelining.concluded = true
return ErrPipeliningCongestion
}
}
_, err := c.sender.WriteString(message)
if err != nil {
return err
}
_, err = c.sender.Write(smtp.Crnl)
if err != nil {
return err
}
if !c.PipeliningActive() || pType == pipeliningTypeLast {
err = c.sender.Flush()
if err != nil {
return err
}
}
if c.PipeliningActive() && pType > pipeliningTypeForbidden {
c.connPipelining.pending++
if pType == pipeliningTypeLast {
c.connPipelining.concluded = true
}
return nil
}
return c.receiver.ReadResponseValid(expectCode)
}
// Conclude concludes the current pipelining group.
// Do not call if pipelining is not active.
func (c *Client) Conclude() error {
timeout := smtp.Timeout(c.conn, c.cfg.commandTimeout)
defer timeout()
if !c.PipeliningActive() {
return ErrPipeliningNotEnabled
}
c.connPipelining.concluded = true
return c.sender.Flush()
}
func (c *Client) readResponseValid(expectCode int) error {
if err := c.consumeResponse(); err != nil {
return err
}
timeout := smtp.Timeout(c.conn, c.cfg.commandTimeout)
defer timeout()
// Make sure everything is flushed, probably already done if group has ended.
if err := c.sender.Flush(); err != nil {
return err
}
return c.receiver.ReadResponseValid(expectCode)
}
// ClearResponses consumes x pending responses from pipelining
// 0 means to clear all pending requests, -1 all except 1
func (c *Client) ClearResponses(x int) error {
if !c.PipeliningActive() {
return ErrPipeliningNotEnabled
}
if x == 0 {
x = c.connPipelining.pending
} else if x < 0 {
x = c.connPipelining.pending + x
if x <= 0 {
return nil
}
}
for i := 0; i < x; i++ {
if err := c.readResponseValid(0); err != nil {
return err
}
}
return nil
}
// helo sends the HELO greeting to the server. It should be used only when the
// server does not support ehlo.
func (c *Client) helo() error {
c.setExt(nil)
return c.cmdValid(pipeliningTypeForbidden, 250, "HELO "+c.cfg.localName)
}
// ehlo sends the EHLO (extended hello) greeting to the server. It
// should be the preferred greeting for servers that support it.
func (c *Client) ehlo() error {
status, err := c.cmd(250, "EHLO "+c.cfg.localName)
if err != nil {
return err
}
ext := make(map[string]string)
if len(status.Lines) > 1 {
for _, line := range status.Lines[1:] {
i := strings.IndexByte(line, ' ')
if i < 0 {
ext[line] = ""
} else {
ext[line[:i]] = line[i+1:]
}
}
}
c.setExt(ext)
return err
}
// StartTLS sends the STARTTLS command and encrypts all further communication.
// Only servers that advertise the STARTTLS extension support this function.
//
// A nil config is equivalent to a zero tls.Config.
//
// If the server rejects the command it returns the error and the connection is not closed.
// If the server accepts the command but the tls handshake fails, the connection is closed.
func (c *Client) StartTLS(config *tls.Config, serverName string) error {
if c.PipeliningActive() && c.connPipelining.pending > 0 {
return ErrPipeliningNoPendingRequired
}
if err := c.cmdValid(pipeliningTypeForbidden, 220, "STARTTLS"); err != nil {
return err
}
if config == nil {
config = &tls.Config{
ServerName: serverName,
}
} else if config.ServerName == "" && serverName != "" {
// Make a copy to avoid polluting argument
config = config.Clone()
config.ServerName = serverName
}
conn := tls.Client(c.conn, config)
timeout := smtp.Timeout(conn, c.cfg.tlsHandshakeTimeout)
defer timeout()
err := conn.Handshake()
if err != nil {
_ = c.Close()
return err
}
err = c.initConn(conn, false)
if err != nil {
return err
}
return nil
}
// TLSConnectionState returns the client's TLS connection state.
// The return values are their zero values if STARTTLS did
// not succeed.
func (c *Client) TLSConnectionState() (tls.ConnectionState, bool) {
tc, ok := c.conn.(*tls.Conn)
if !ok {
return tls.ConnectionState{}, false
}
return tc.ConnectionState(), true
}
// PipeliningActive return if pipelining is active.
func (c *Client) PipeliningActive() bool {
_, ok := c.connExt["PIPELINING"]
return ok && c.cfg.pipelining && c.conn != nil
}
// Verify checks the validity of an email address on the server.
// If Verify returns nil, the address is valid. A non-nil return
// does not necessarily indicate an invalid address. Many servers
// will not verify addresses for security reasons.
//
// If server returns an error, it will be of type *smtp.
func (c *Client) Verify(addr string, opts *VrfyOptions) error {
if err := validateLine(addr); err != nil {
return err
}
var sb strings.Builder
sb.Grow(2048)
fmt.Fprintf(&sb, "VRFY %s", addr)
// By default utf8 is preferred
if opts == nil || opts.UTF8 != UTF8Disabled {
if _, ok := c.connExt["SMTPUTF8"]; ok {
sb.WriteString(" SMTPUTF8")
} else if opts != nil && opts.UTF8 == UTF8Force {
return errors.New("smtp: server does not support SMTPUTF8")
}
}
return c.cmdValid(pipeliningTypeLast, 250, sb.String())
}
// VerifyResponse returns the result of previous send verify command if pipelining is enabled
func (c *Client) VerifyResponse() error {
return c.readResponseValid(250)
}
// Auth authenticates a client using the provided authentication mechanism.
// Only servers that advertise the AUTH extension support this function.
//
// If server returns an error, it will be of type *smtp.
func (c *Client) Auth(saslClient sasl.Client) error {
if c.PipeliningActive() && c.connPipelining.pending > 0 {
return ErrPipeliningNoPendingRequired
}
if saslClient == nil {
return errors.New("smtp: SASL client is missing")
}
encoding := base64.StdEncoding
mech, resp, err := saslClient.Start()
if err != nil {
return err
}
var resp64 []byte
if len(resp) > 0 {
resp64 = make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp)
} else if resp != nil {
resp64 = []byte{'='}
}
var status *smtp.Status
// 512 - len("AUTH") - 2 * len(" ") - len(\r\n)
if len(mech)+len(resp64) > 504 || len(resp64) == 0 {
// The initial response (if any) does not fit in the 512-octet command line (RFC 5321
// section 4.5.3.1.4), so send it as the reply to the first challenge instead
// (RFC 4954 section 4). https://github.com/emersion/go-smtp/issues/301
status, err = c.cmd(0, "AUTH "+mech)
if err == nil && status.Code == 334 && len(resp64) > 0 {
status, err = c.cmd(0, string(resp64))
}
} else {
status, err = c.cmd(0, "AUTH "+mech+" "+string(resp64))
}
if err != nil {
return err
}
for {
switch status.Code {
case 334:
// failed to decode answer => abort
msg, err := encoding.DecodeString(strings.Join(status.Lines, "\n"))
if err != nil {
return c.authAbort(err)
}
// sasl returned error or nil instead
resp, err = saslClient.Next(msg)
if err != nil {
return c.authAbort(err)
}
resp64 = make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp)
status, err = c.cmd(0, string(resp64))
if err != nil {
return err
}
case 235:
return nil
default:
// status like 501 mean authentication aborted,
// but essentially everything except 335 and 235 can be interpreted as such.
return status
}
}
}
func (c *Client) authAbort(err error) error {
// If abort fails, the state is strange
if errAbort := c.cmdValid(pipeliningTypeForbidden, 501, "*"); errAbort != nil {
err = errors.Join(err, errAbort)
}
return err
}
// Mail issues a MAIL command to the server using the provided email address.
// If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME
// parameter.
// This initiates a mail transaction and is followed by one or more Rcpt calls.
//
// If opts is not nil, MAIL arguments provided in the structure will be added
// to the command. Handling of unsupported options depends on the extension.
//
// If server returns an error, it will be of type *smtp.
// nolint:revive
func (c *Client) Mail(from string, opts *MailOptions) error {
if err := validateLine(from); err != nil {
return err
}
var sb strings.Builder
// A high enough power of 2 than 510+14+26+11+9+9+39+500
sb.Grow(2048)
fmt.Fprintf(&sb, "MAIL FROM:<%s>", from)
if _, ok := c.connExt["8BITMIME"]; ok {
sb.WriteString(" BODY=8BITMIME")
}
if _, ok := c.connExt["SIZE"]; ok && opts != nil && opts.Size != 0 {
fmt.Fprintf(&sb, " SIZE=%v", opts.Size)
}
if opts != nil && opts.RequireTLS {
if _, ok := c.connExt["REQUIRETLS"]; !ok {
return errors.New("smtp: server does not support REQUIRETLS")
}
sb.WriteString(" REQUIRETLS")
}
// By default utf8 is preferred
if opts == nil || opts.UTF8 != UTF8Disabled {
if _, ok := c.connExt["SMTPUTF8"]; ok {
sb.WriteString(" SMTPUTF8")
} else if opts != nil && opts.UTF8 == UTF8Force {
return errors.New("smtp: server does not support SMTPUTF8")
}
}
if _, ok := c.connExt["DSN"]; ok && opts != nil {
switch opts.Return {
case smtp.DSNReturnFull, smtp.DSNReturnHeaders:
fmt.Fprintf(&sb, " RET=%s", string(opts.Return))
case "":
// This space is intentionally left blank
default:
return errors.New("smtp: Unknown RET parameter value")
}
if opts.EnvelopeID != "" {
if !parse.IsPrintableASCII(opts.EnvelopeID) {
return errors.New("smtp: Malformed ENVID parameter value")
}
fmt.Fprintf(&sb, " ENVID=%s", encodeXtext(opts.EnvelopeID))
}
}
if opts != nil && opts.Auth != nil {
if _, ok := c.connExt["AUTH"]; ok {
fmt.Fprintf(&sb, " AUTH=%s", encodeXtext(*opts.Auth))
}
// We can safely discard parameter if server does not support AUTH.
}
if opts != nil && opts.XOORG != nil {
if _, ok := c.connExt["XOORG"]; ok {
fmt.Fprintf(&sb, " XOORG=%s", encodeXtext(*opts.XOORG))
}
// We can safely discard parameter if server does not support AUTH.
}
if _, ok := c.connExt["DELIVERBY"]; ok && opts != nil && opts.DeliverBy != nil {
if opts.DeliverBy.Mode == smtp.DeliverByReturn &&
(opts.DeliverBy.Seconds < 1 || opts.DeliverBy.Seconds > 999999999) {
return errors.New("smtp: DELIVERBY mode must be between 1 and 999999999 with return mode")
}
if opts.DeliverBy.Mode == smtp.DeliverByNotify &&
(opts.DeliverBy.Seconds < -999999999 || opts.DeliverBy.Seconds > 999999999) {
return errors.New("smtp: DELIVERBY mode must be between -999999999 and 999999999 with notify mode")
}
arg := fmt.Sprintf(" BY=%d;%s", int(opts.DeliverBy.Seconds), opts.DeliverBy.Mode)
if opts.DeliverBy.Trace {
arg += "T"
}
sb.WriteString(arg)
}
if _, ok := c.connExt["MT-PRIORITY"]; ok && opts != nil && opts.MTPriority != nil {
if *opts.MTPriority < -9 || *opts.MTPriority > 9 {
return errors.New("smtp: MT-PRIORITY must be between -9 and 9")
}
fmt.Fprintf(&sb, " MT-PRIORITY=%d", *opts.MTPriority)
}
return c.cmdValid(pipeliningTypeAnywhere, 250, sb.String())
}
// MailResponse returns the result of previous send mail command if pipelining is enabled
func (c *Client) MailResponse() error {
return c.readResponseValid(250)
}
// Rcpt issues a RCPT command to the server using the provided email address.
// A call to Rcpt must be preceded by a call to Mail and may be followed by
// a Data call or another Rcpt call.
//
// If opts is not nil, RCPT arguments provided in the structure will be added
// to the command. Handling of unsupported options depends on the extension.
//
// If server returns an error, it will be of type *smtp.
func (c *Client) Rcpt(to string, opts *smtp.RcptOptions) error {
if err := validateLine(to); err != nil {
return err
}
var sb strings.Builder
// A high enough power of 2 than 510+29+501
sb.Grow(2048)
fmt.Fprintf(&sb, "RCPT TO:<%s>", to)
if _, ok := c.connExt["DSN"]; ok && opts != nil {
if err := rcptDSN(&sb, opts, c.connExt); err != nil {
return err
}
}
if _, ok := c.connExt["RRVS"]; ok && opts != nil && !opts.RequireRecipientValidSince.IsZero() {
fmt.Fprintf(&sb, " RRVS=%s", opts.RequireRecipientValidSince.Format(time.RFC3339))
}
return c.cmdValid(pipeliningTypeAnywhere, 25, sb.String())
}
// RcptResponse returns the result of previous send rcpt command if pipelining is enabled
func (c *Client) RcptResponse() error {
return c.readResponseValid(25)
}
func rcptDSN(sb *strings.Builder, opts *smtp.RcptOptions, ext map[string]string) error {
if len(opts.Notify) != 0 {
sb.WriteString(" NOTIFY=")
if err := parse.CheckNotifySet(opts.Notify); err != nil {
return errors.New("smtp: Malformed NOTIFY parameter value")
}
for i, v := range opts.Notify {
if i != 0 {
sb.WriteString(",")
}
sb.WriteString(string(v))
}
}
if opts.OriginalRecipient != "" {
var enc string
switch opts.OriginalRecipientType {
case smtp.DSNAddressTypeRFC822:
if !parse.IsPrintableASCII(opts.OriginalRecipient) {
return errors.New("smtp: Illegal address")
}
enc = encodeXtext(opts.OriginalRecipient)
case smtp.DSNAddressTypeUTF8:
if _, ok := ext["SMTPUTF8"]; ok {
enc = encodeUTF8AddrUnitext(opts.OriginalRecipient)
} else {
enc = encodeUTF8AddrXtext(opts.OriginalRecipient)
}
default:
return errors.New("smtp: Unknown address type")
}
fmt.Fprintf(sb, " ORCPT=%s;%s", string(opts.OriginalRecipientType), enc)
}
return nil
}
// Content issues a DATA or BDAT (prefer BDAT if available) command to
// the server and returns a writer that
// can be used to write the mail headers and body. The caller should
// close the writer before calling any more methods on c. A call to
// Data must be preceded by one or more calls to Rcpt.
//
// If server returns an error, it will be of type *smtp.
func (c *Client) Content(size int) (*ContentCloser, error) {
if _, ok := c.connExt["CHUNKING"]; c.cfg.chunkingMaxSize >= 0 && ok {
if c.PipeliningActive() {
return nil, nil
}
return c.Bdat(size)
}
return c.Data()
}
// ContentResponse returns the result of previous send data command if pipelining is enabled
func (c *Client) ContentResponse(size int) (*ContentCloser, error) {
if _, ok := c.connExt["CHUNKING"]; c.cfg.chunkingMaxSize >= 0 && ok {
return c.Bdat(size)
}
return c.DataResponse()
}
// Data issues a DATA command to the server and returns a writer that
// can be used to write the mail headers and body. The caller should
// close the writer before calling any more methods on c. A call to
// Data must be preceded by one or more calls to Rcpt.
//
// If server returns an error, it will be of type *smtp.
func (c *Client) Data() (*ContentCloser, error) {
if err := c.cmdValid(pipeliningTypeLast, 354, "DATA"); err != nil {
return nil, err
}
if c.PipeliningActive() {
return nil, nil
}
return &ContentCloser{c: c, writer: smtpwriter.NewDot(c.sender.Writer)}, nil
}
// DataResponse returns the result of previous send data command if pipelining is enabled
func (c *Client) DataResponse() (*ContentCloser, error) {
if err := c.readResponseValid(354); err != nil {
return nil, err
}
return &ContentCloser{c: c, writer: smtpwriter.NewDot(c.sender.Writer)}, nil
}
// Bdat issues a BDAT command to the server and returns a writer that
// can be used to write the mail headers and body. The caller should
// close the writer before calling any more methods on c. A call to
// Data must be preceded by one or more calls to Rcpt.
//
// If server returns an error, it will be of type *smtp.
func (c *Client) Bdat(size int) (*ContentCloser, error) {
if c.PipeliningActive() && c.connPipelining.pending > 0 {
return nil, ErrPipeliningNoPendingRequired
}
if c.cfg.chunkingMaxSize < 0 {
return nil, errors.New("smtp: chunking is disabled on the client by negative chunking max size)")
}
if _, ok := c.connExt["CHUNKING"]; !ok {
return nil, errors.New("smtp: server doesn't support chunking")
}
// if chunking max size is active but smaller than a typically []byte write call, the buffer is just overhead
if c.cfg.chunkingBuffer && size == 0 && (c.cfg.chunkingMaxSize == 0 || c.cfg.chunkingMaxSize > 4096) {
// c.bdatBuffer is init on first use and always reuse it
bufferSize := defaultChunkingMaxSize
if c.cfg.chunkingMaxSize > 0 {
bufferSize = c.cfg.chunkingMaxSize
}
if len(c.chunkingBuffer) < bufferSize {
c.chunkingBuffer = make([]byte, bufferSize)
}
return &ContentCloser{c: c, writer: smtpwriter.NewBdatWriterBuffered(c.cfg.chunkingMaxSize, c.sender.Writer, func() error {
return c.receiver.ReadResponseValid(250)
}, size, c.chunkingBuffer[:bufferSize])}, nil
}
return &ContentCloser{c: c, writer: smtpwriter.NewBdat(c.cfg.chunkingMaxSize, c.sender.Writer, func() error {
return c.receiver.ReadResponseValid(250)
}, size)}, nil
}
// Extension reports whether an extension is support by the server.
// The extension name is case-insensitive. If the extension is supported,
// Extension also returns a string that contains any parameters the
// server specifies for the extension.
func (c *Client) Extension(ext string) (bool, string) {
ext = strings.ToUpper(ext)
param, ok := c.connExt[ext]
return ok, param
}
// SupportsAuth checks whether an authentication mechanism is supported.
func (c *Client) SupportsAuth(mech string) bool {
mechs, ok := c.connExt["AUTH"]
if !ok {
return false
}
for m := range strings.SplitSeq(mechs, " ") {
if strings.EqualFold(m, mech) {
return true
}
}
return false
}
// MaxMessageSize returns the maximum message size accepted by the server.
// 0 means unlimited.
//
// If the server doesn't convey this information, ok = false is returned.
func (c *Client) MaxMessageSize() (size int, ok bool) {
v := c.connExt["SIZE"]
if v == "" {
return 0, false
}
size, err := strconv.Atoi(v)
if err != nil || size < 0 {
return 0, false
}
return size, true
}
// Reset sends the RSET command to the server, aborting the current mail
// transaction.
func (c *Client) Reset() error {
return c.cmdValid(pipeliningTypeAnywhere, 250, "RSET")
}
// ResetResponse returns the result of previous send rset command if pipelining is enabled
func (c *Client) ResetResponse() error {
return c.readResponseValid(250)
}
// Noop sends the NOOP command to the server. It does nothing but check
// that the connection to the server is okay.
func (c *Client) Noop() error {
return c.cmdValid(pipeliningTypeLast, 250, "NOOP")
}
// NoopResponse returns the result of previous send noop command if pipelining is enabled
func (c *Client) NoopResponse() error {
return c.readResponseValid(250)
}
// Quit sends the QUIT command and closes the connection to the server.
// If Quit fails the connection will still be closed.
// If pipelining is active and the command is send successful,
// the connection is only closed after QuitResponse is called.
func (c *Client) Quit() error {
if c.conn == nil {
return nil
}
if err := c.cmdValid(pipeliningTypeLast, 221, "QUIT"); err != nil {
_ = c.Close()
return err
}
if c.PipeliningActive() {
return nil
}
return c.Close()
}
// QuitResponse returns the result of previous send quit command if pipelining is enabled
func (c *Client) QuitResponse() error {
if err := c.readResponseValid(221); err != nil {
_ = c.Close()
return err
}
return c.Close()
}
// Connected returns the current server name.
func (c *Client) Connected() bool {
return c.conn != nil
}
// ServerAddress returns the current server address.
func (c *Client) ServerAddress() string {
return c.connAddress
}
// ServerName returns the current server name.
func (c *Client) ServerName() string {
return c.connName
}
package client
import (
"time"
)
const defaultChunkingMaxSize = 1048576 * 2
// DefaultConfig returns the default configuration of a client.
func DefaultConfig() Config {
return Config{
localName: "localhost",
// As recommended by RFC 5321. For DATA command reply (3xx one) RFC
// recommends a slightly shorter timeout but we do not bother
// differentiating these.
commandTimeout: 5 * time.Minute,
// 10 minutes + 2 minute buffer in case the server is doing transparent
// forwarding and also follows recommended timeouts.
submissionTimeout: 12 * time.Minute,
// 30 seconds, very generous
tlsHandshakeTimeout: 30 * time.Second,
// 30 seconds, very generous
dialTimeout: 30 * time.Second,
// Doubled maximum line length per RFC 5321 (Section 4.5.3.1.6)
maxLineLength: 2000,
// Reader buffer of textproto
readerSize: 4096,
// Writer buffer of textproto
writerSize: 4096,
// Default chunking max size, 2 MiB
chunkingMaxSize: defaultChunkingMaxSize,
// chunking buffer enabled by default
chunkingBuffer: true,
}
}
// Security describes how the connection is etablished.
type Security int32
const (
// SecurityPreferStartTLS tries to use StartTls but fallbacks to plain.
SecurityPreferStartTLS Security = 0
// SecurityPlain is always just a plain connection.
SecurityPlain Security = 1
// SecurityTLS does a implicit tls connection.
SecurityTLS Security = 2
// SecurityStartTLS always does starttls.
SecurityStartTLS Security = 3
)
// UTF8 describes how SMTPUTF8 is used.
type UTF8 int32
const (
// UTF8Prefer uses SMTPUTF8 if possible.
UTF8Prefer UTF8 = 0
// UTF8Force always uses SMTPUTF8.
UTF8Force UTF8 = 1
// UTF8Disabled never uses SMTPUTF8.
UTF8Disabled UTF8 = 2
)
// Config contains all configuration needed to configure a smtp client.
type Config struct {
localName string // the name to use in HELO/EHLO/LHLO
// Time to wait for tls handshake to succeed.
tlsHandshakeTimeout time.Duration
// Time to wait for dial to succeed.
dialTimeout time.Duration
// Time to wait for command responses (this includes 3xx reply to DATA).
commandTimeout time.Duration
// Time to wait for responses after final dot.
submissionTimeout time.Duration
// Max line length, defaults to 2000
maxLineLength int
// Reader size
readerSize int
// Writer size
writerSize int
// Chunking max size
// A zero value disables chunk size limitation.
// A negative value disables chunking from the client.
chunkingMaxSize int
// If no size is available and chunkingMaxSize > 4096 then
// the buffer is automatically used.
// If you guarantee that you reader has large enough chunks,
// you can disable the chunking buffer here.
chunkingBuffer bool
// Enable pipelining if the server supports it.
pipelining bool
}
// Option defines a client option.
type Option func(c *Config)
// WithSubmissionTimeout sets the submission timeout.
func WithSubmissionTimeout(submissionTimeout time.Duration) Option {
return func(c *Config) {
c.submissionTimeout = submissionTimeout
}
}
// WithCommandTimeout sets the command timeout.
func WithCommandTimeout(commandTimeout time.Duration) Option {
return func(c *Config) {
c.commandTimeout = commandTimeout
}
}
// WithDialTimeout sets the dial timeout.
func WithDialTimeout(dialTimeout time.Duration) Option {
return func(c *Config) {
c.dialTimeout = dialTimeout
}
}
// WithTlsHandshakeTimeout sets tls handshake timeout.
func WithTlsHandshakeTimeout(tlsHandshakeTimeout time.Duration) Option {
return func(c *Config) {
c.tlsHandshakeTimeout = tlsHandshakeTimeout
}
}
// WithLocalName sets the HELO local name.
func WithLocalName(localName string) Option {
return func(c *Config) {
c.localName = localName
}
}
// WithMaxLineLength sets the max line length.
func WithMaxLineLength(maxLineLength int) Option {
return func(c *Config) {
c.maxLineLength = maxLineLength
}
}
// WithChunkingMaxSize sets the chunking max size.
// A zero value disables chunk size limitation.
// A negative value disables chunking from the client.
func WithChunkingMaxSize(chunkingMaxSize int) Option {
return func(c *Config) {
c.chunkingMaxSize = chunkingMaxSize
}
}
// WithChunkingBuffer sets if the chunking buffer is used when necessary
// If no size is available and chunkingMaxSize > 4096 then
// the buffer is automatically used.
// If you guarantee that you reader has large enough chunks,
// you can disable the chunking buffer here.
// It is enabled by default.
func WithChunkingBuffer(enabled bool) Option {
return func(c *Config) {
c.chunkingBuffer = enabled
}
}
// WithPipelining sets if pipelining is used when the server supports it.
func WithPipelining(enabled bool) Option {
return func(c *Config) {
c.pipelining = enabled
}
}
// WithReaderSize sets the reader size.
func WithReaderSize(readerSize int) Option {
return func(c *Config) {
c.readerSize = readerSize
}
}
// WithWriterSize sets the reader size.
func WithWriterSize(writerSize int) Option {
return func(c *Config) {
c.writerSize = writerSize
}
}
package client
import (
"errors"
"io"
"github.com/uponusolutions/go-smtp"
)
// ContentCloser function.
type ContentCloser struct {
writer io.WriteCloser
c *Client
closed bool
}
// Writer returns inner io.Writer which possible implement more methods (e.g. ReadFrom)
func (d *ContentCloser) Writer() io.Writer {
return d.writer
}
// Write writes do underlying writer.
func (d *ContentCloser) Write(p []byte) (n int, err error) {
return d.writer.Write(p)
}
// CloseWithResponse closes the data closer and returns code, msg.
func (d *ContentCloser) CloseWithResponse() (*smtp.Status, error) {
if d.closed {
return nil, errors.New("smtp: data writer closed twice")
}
d.closed = true
if err := d.writer.Close(); err != nil {
return nil, err
}
timeout := smtp.Timeout(d.c.conn, d.c.cfg.submissionTimeout)
defer timeout()
status, err := d.c.receiver.ReadResponse()
if err == nil && status.Code != 250 {
err = status
status = nil
}
return status, err
}
// Close closes the data closer.
func (d *ContentCloser) Close() error {
_, err := d.CloseWithResponse()
return err
}
package client
import (
"errors"
"strconv"
"strings"
)
// validateLine checks to see if a line has CR or LF.
func validateLine(line string) error {
if strings.ContainsAny(line, "\n\r") {
return errors.New("smtp: a line must not contain CR or LF")
}
return nil
}
func encodeXtext(raw string) string {
var out strings.Builder
out.Grow(len(raw))
for _, ch := range raw {
switch {
case ch >= '!' && ch <= '~' && ch != '+' && ch != '=':
// printable non-space US-ASCII except '+' and '='
out.WriteRune(ch)
default:
out.WriteRune('+')
out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16)))
}
}
return out.String()
}
// encodeUTF8AddrUnitext encodes raw string to the utf-8-addr-unitext form in RFC 6533.
func encodeUTF8AddrUnitext(raw string) string {
var out strings.Builder
out.Grow(len(raw))
for _, ch := range raw {
switch {
case ch >= '!' && ch <= '~' && ch != '+' && ch != '=':
// printable non-space US-ASCII except '+' and '='
out.WriteRune(ch)
case ch <= '\x7F':
// other ASCII: CTLs, space and specials
out.WriteRune('\\')
out.WriteRune('x')
out.WriteRune('{')
out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16)))
out.WriteRune('}')
default:
// UTF-8 non-ASCII
out.WriteRune(ch)
}
}
return out.String()
}
// encodeUTF8AddrXtext encodes raw string to the utf-8-addr-xtext form in RFC 6533.
func encodeUTF8AddrXtext(raw string) string {
var out strings.Builder
out.Grow(len(raw))
for _, ch := range raw {
switch {
case ch >= '!' && ch <= '~' && ch != '+' && ch != '=':
// printable non-space US-ASCII except '+' and '='
out.WriteRune(ch)
default:
out.WriteRune('\\')
out.WriteRune('x')
out.WriteRune('{')
out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16)))
out.WriteRune('}')
}
}
return out.String()
}
package smtp
import (
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
)
var (
// ErrTooLongLine occurs if the smtp line is too long.
ErrTooLongLine = errors.New("smtp: too long a line in input stream")
// Crnl \r\n
Crnl = []byte{'\r', '\n'}
// Dotcrnl .\r\n
Dotcrnl = []byte{'.', '\r', '\n'}
// Crlfdot \r\n.
Crlfdot = []byte{'\r', '\n', '.'}
)
// EnhancedCode is the SMTP enhanced code
type EnhancedCode [3]int
// Status specifies the error code, enhanced error code (if any) and
// message returned by the server.
type Status struct {
Code int
EnhancedCode EnhancedCode
Lines []string
}
// NoEnhancedCode is used to indicate that enhanced error code should not be
// included in response.
//
// Note that RFC 2034 requires an enhanced code to be included in all 2xx, 4xx
// and 5xx responses.
var NoEnhancedCode = EnhancedCode{0, 0, 0}
// ToPart returns the part of the string after the code
// which is defined by the enhanced code with the trailing whitespace.
// E.g. "5.1.1 "
func (enhCode EnhancedCode) ToPart() []byte {
if enhCode == NoEnhancedCode {
return nil
}
return []byte{
strconv.Itoa(enhCode[0])[0],
'.',
strconv.Itoa(enhCode[1])[0],
'.',
strconv.Itoa(enhCode[2])[0],
' ',
}
}
// NewStatusM creates a new status with multiple message lines.
func NewStatusM(code int, enhCode EnhancedCode, msg []string) *Status {
return &Status{
Code: code,
EnhancedCode: enhCode,
Lines: msg,
}
}
// NewStatusS creates a new status with a single message line.
func NewStatusS(code int, enhCode EnhancedCode, msg string) *Status {
return &Status{
Code: code,
EnhancedCode: enhCode,
Lines: []string{msg},
}
}
// Error returns a error string.
func (s *Status) Error() string {
return s.String()
}
// Positive returns true if the status code is 2xx.
func (s *Status) Positive() bool {
return s.Code/100 == 2
}
// Temporary returns true if the status code is 4xx.
func (s *Status) Temporary() bool {
return s.Code/100 == 4
}
// Permanent returns true if the status code is 5xx.
func (s *Status) Permanent() bool {
return s.Code/100 == 5
}
// Text returns all lines joined by \n in a single string.
func (s *Status) Text() string {
return strings.Join(s.Lines, "\n")
}
// String returns the status as a formatted message.
// Important: Should not be used for sending a response.
func (s *Status) String() string {
base := fmt.Sprintf("%03d", s.Code)
if s.EnhancedCode != NoEnhancedCode {
base += fmt.Sprintf(" %d.%d.%d", s.EnhancedCode[0], s.EnhancedCode[1], s.EnhancedCode[2])
}
if len(s.Lines) > 0 {
return base + " " + s.Text()
}
return base
}
// StatusWriter is an interface which needs to be implemented by a writer to be used by Status.WriteTo.
type StatusWriter interface {
io.ByteWriter
io.StringWriter
io.Writer
}
// writeLine writes a single reply line. last selects the terminating form.
func writeLine(w StatusWriter, code string, enhCode []byte, message string, last bool) (i int, err error) {
var p int
i, err = w.WriteString(code)
if err != nil {
return i, err
}
if last {
// RFC 5321 permits omitting the space when there is no text, but
// RFC 4954 requires it for the 334 challenge and net/textproto
// rejects any reply shorter than four bytes. Always emit it.
err = w.WriteByte(' ')
} else {
err = w.WriteByte('-')
}
if err != nil {
return i, err
}
i++ // single byte added
p, err = w.Write(enhCode)
i += p
if err != nil {
return i, err
}
p, err = w.WriteString(message)
i += p
if err != nil {
return i, err
}
p, err = w.Write(Crnl)
i += p
return i, err
}
// WriteTo writes the smtp status reply.
func (s *Status) WriteTo(w StatusWriter) (int64, error) {
codeString := strconv.Itoa(s.Code)
enhCodeString := s.EnhancedCode.ToPart()
// no message set
if len(s.Lines) == 0 {
p, err := writeLine(w, codeString, enhCodeString, "", true)
return int64(p), err
}
var i int
for q, m := range s.Lines {
p, err := writeLine(w, codeString, enhCodeString, m, q+1 == len(s.Lines))
i += p
if err != nil {
return int64(i), err
}
}
return int64(i), nil
}
// LogValue implements slog.LogValuer so that formatting a Status is deferred
// to the handler and skipped entirely when the record is dropped.
func (s *Status) LogValue() slog.Value {
return slog.GroupValue(
slog.Int("code", s.Code),
slog.String("enhCode", fmt.Sprintf("%d.%d.%d", s.EnhancedCode[0], s.EnhancedCode[1], s.EnhancedCode[2])),
slog.String("text", s.Text()),
)
}
var (
// Reset is returned by Reader passed to Data function if client does not
// send another BDAT command and instead issues RSET command.
Reset = &Status{
Code: 250,
EnhancedCode: EnhancedCode{2, 0, 0},
Lines: []string{"Session reset"},
}
// VRFY default return.
VRFY = &Status{
Code: 252,
EnhancedCode: EnhancedCode{2, 5, 0},
Lines: []string{"Cannot VRFY user, but will accept message"},
}
// Noop default return.
Noop = &Status{
Code: 250,
EnhancedCode: EnhancedCode{2, 0, 0},
Lines: []string{"I have successfully done nothing"},
}
// Quit is returned by Reader passed to Data function if client does not
// send another BDAT command and instead issues QUIT command.
Quit = &Status{
Code: 221,
EnhancedCode: EnhancedCode{2, 0, 0},
Lines: []string{"Bye"},
}
// ErrConnection is returned if a connection error occurs.
ErrConnection = &Status{
Code: 421,
EnhancedCode: EnhancedCode{4, 4, 0},
Lines: []string{"Connection error, sorry"},
}
// ErrDataTooLarge is returned if the maximum message size is exceeded.
ErrDataTooLarge = &Status{
Code: 552,
EnhancedCode: EnhancedCode{5, 3, 4},
Lines: []string{"Maximum message size exceeded"},
}
// ErrAuthFailed is returned if the authentication failed.
ErrAuthFailed = &Status{
Code: 535,
EnhancedCode: EnhancedCode{5, 7, 8},
Lines: []string{"Authentication failed"},
}
// ErrAuthRequired is returned if the authentication is required.
ErrAuthRequired = &Status{
Code: 502,
EnhancedCode: EnhancedCode{5, 7, 0},
Lines: []string{"Please authenticate first"},
}
// ErrAuthUnsupported is returned if the authentication is not supported.
ErrAuthUnsupported = &Status{
Code: 502,
EnhancedCode: EnhancedCode{5, 7, 0},
Lines: []string{"Authentication not supported"},
}
// ErrAuthUnknownMechanism is returned if the authentication unsupported.
ErrAuthUnknownMechanism = &Status{
Code: 504,
EnhancedCode: EnhancedCode{5, 7, 4},
Lines: []string{"Unsupported authentication mechanism"},
}
// ErrNoRecipients is returned if no recipients are set.
ErrNoRecipients = &Status{
Code: 502,
EnhancedCode: EnhancedCode{5, 5, 1},
Lines: []string{"Missing RCPT TO command."},
}
// ErrBadSequence is returned if a command is invalid at this point.
ErrBadSequence = &Status{
Code: 503,
EnhancedCode: EnhancedCode{5, 5, 1},
Lines: []string{"Bad sequence of commands"},
}
)
package limit
import (
"errors"
"time"
)
// ErrRatelimit is returned if limit reached and strict mode is enabled.
var ErrRatelimit = errors.New("rate limit occurred")
// RatelimitConfig configures a rate limit.
type RatelimitConfig struct {
Rate int
Duration time.Duration
Strict bool
}
// Ratelimit is used e.g. to limit the calls to a function.
type Ratelimit struct {
start time.Time
count int
config *RatelimitConfig
}
// New creates a new rate limit.
func New(config *RatelimitConfig) *Ratelimit {
return &Ratelimit{
config: config,
start: time.Now(),
count: 0,
}
}
// Take returns when it is allowed to do something again.
func (c *Ratelimit) Take() error {
c.count++
if c.count <= c.config.Rate {
return nil
}
now := time.Now()
dur := now.Sub(c.start)
if dur < c.config.Duration {
if c.config.Strict {
return ErrRatelimit
}
time.Sleep(c.config.Duration - dur)
now = time.Now()
}
c.start = now
c.count = 1
return nil
}
package parse
import (
"errors"
"fmt"
"strings"
"github.com/uponusolutions/go-smtp"
)
// CutPrefixFold is a version of strings.CutPrefix which is case-insensitive.
func CutPrefixFold(s, prefix string) (string, bool) {
if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
return "", false
}
return s[len(prefix):], true
}
// Cmd parses a line and returns the command, argument or an error.
// Command is converted to upper case.
func Cmd(line string) (cmd string, arg string, err error) {
line = strings.TrimRight(line, "\r\n")
l := len(line)
switch {
case l == 0:
return "", "", nil
case l < 4:
return "", "", fmt.Errorf("command too short: %q", line)
case l == 4:
return strings.ToUpper(line), "", nil
case l == 5:
// Too long to be only command, too short to have args
return "", "", fmt.Errorf("mangled command: %q", line)
// Commands are always 4 characters long, except STARTTLS which is 8 characters long.
// STARTTLS has no parameters (RFC 3207).
case l == 8 && strings.EqualFold(line, "STARTTLS"):
return "STARTTLS", "", nil
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
return "", "", fmt.Errorf("mangled command: %q", line)
}
return strings.ToUpper(line[0:4]), strings.TrimSpace(line[5:]), nil
}
// Arg is a single ESMTP argument (e.g. SIZE=1024) with an uppercased key.
type Arg struct {
Key string
Value string
}
// Args takes the arguments proceeding a command and parses them
// into a list of key/value pairs after uppercasing each key. Sample arg
// string:
//
// " BODY=8BITMIME SIZE=1024 SMTPUTF8"
//
// The leading space is mandatory.
func Args(s string) ([]Arg, error) {
if len(s) == 0 {
return nil, nil
}
args := make([]Arg, 0, strings.Count(s, " "))
// Only whitespace is an allowed separator, FieldsSec acceots all Unicode whitespace
for arg := range strings.SplitSeq(s, " ") {
if arg == "" {
return nil, fmt.Errorf("empty arg in arg string: %q", s)
}
key, value, _ := strings.Cut(arg, "=")
if strings.IndexByte(value, '=') >= 0 {
return nil, fmt.Errorf("failed to parse arg string: %q", arg)
}
args = append(args, Arg{Key: strings.ToUpper(key), Value: value})
}
return args, nil
}
// HelloArgument parses helo argument
func HelloArgument(arg string) (string, error) {
domain := arg
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
domain = arg[:idx]
}
if domain == "" {
return "", errors.New("invalid domain")
}
return domain, nil
}
// Parser parses command arguments defined in RFC 5321 section 4.1.2.
type Parser struct {
S string
}
func (p *Parser) peekByte() (byte, bool) {
if len(p.S) == 0 {
return 0, false
}
return p.S[0], true
}
func (p *Parser) readByte() (byte, bool) {
ch, ok := p.peekByte()
if ok {
p.S = p.S[1:]
}
return ch, ok
}
func (p *Parser) acceptByte(ch byte) bool {
got, ok := p.peekByte()
if !ok || got != ch {
return false
}
p.readByte()
return true
}
func (p *Parser) expectByte(ch byte) error {
if !p.acceptByte(ch) {
if len(p.S) == 0 {
return fmt.Errorf("expected '%v', got EOF", string(ch))
}
return fmt.Errorf("expected '%v', got '%v'", string(ch), string(p.S[0]))
}
return nil
}
// ReversePath parses a recipient.
func (p *Parser) ReversePath() (string, error) {
if after, ok := strings.CutPrefix(p.S, "<>"); ok {
// remove leading whitespace after the reverse-path
p.S, _ = strings.CutPrefix(after, " ")
return "", nil
}
return p.Path()
}
// Path parses a recipient.
func (p *Parser) Path() (string, error) {
hasBracket := p.acceptByte('<')
if p.acceptByte('@') {
i := strings.IndexByte(p.S, ':')
if i < 0 {
return "", errors.New("malformed a-d-l")
}
p.S = p.S[i+1:]
}
mbox, err := p.Mailbox()
if err != nil {
return "", fmt.Errorf("in mailbox: %v", err)
}
if hasBracket {
if err := p.expectByte('>'); err != nil {
return "", err
}
}
// remove leading whitespace after the reverse-path
p.S, _ = strings.CutPrefix(p.S, " ")
return mbox, nil
}
// Mailbox parses a mailbox.
func (p *Parser) Mailbox() (string, error) {
localPart, err := p.localPart()
if err != nil {
return "", fmt.Errorf("in local-part: %v", err)
} else if localPart == "" {
return "", errors.New("local-part is empty")
}
if err := p.expectByte('@'); err != nil {
return "", err
}
var sb strings.Builder
sb.WriteString(localPart)
sb.WriteByte('@')
for {
ch, ok := p.peekByte()
if !ok {
break
}
if ch == ' ' || ch == '\t' || ch == '>' {
break
}
p.readByte()
sb.WriteByte(ch)
}
if strings.HasSuffix(sb.String(), "@") {
return "", errors.New("domain is empty")
}
return sb.String(), nil
}
func (p *Parser) localPart() (string, error) {
var sb strings.Builder
if p.acceptByte('"') { // quoted-string
for {
ch, ok := p.readByte()
switch ch {
case '\\':
ch, ok = p.readByte()
case '"':
return sb.String(), nil
default:
}
if !ok {
return "", errors.New("malformed quoted-string")
}
sb.WriteByte(ch)
}
} else { // dot-string
for {
ch, ok := p.peekByte()
if !ok {
return sb.String(), nil
}
switch ch {
case '@':
return sb.String(), nil
case '(', ')', '<', '>', '[', ']', ':', ';', '\\', ',', '"', ' ', '\t':
return "", errors.New("malformed dot-string")
}
p.readByte()
sb.WriteByte(ch)
}
}
}
// IsPrintableASCII checks if string contains only printable ascii.
func IsPrintableASCII(val string) bool {
for _, ch := range val {
if ch < ' ' || '~' < ch {
return false
}
}
return true
}
// CheckNotifySet checks if a DSNNotify array isn't malformed.
func CheckNotifySet(values []smtp.DSNNotify) error {
if len(values) == 0 {
return errors.New("malformed NOTIFY parameter value")
}
seen := map[smtp.DSNNotify]struct{}{}
for _, val := range values {
switch val {
case smtp.DSNNotifyNever, smtp.DSNNotifyDelayed, smtp.DSNNotifyFailure, smtp.DSNNotifySuccess:
if _, ok := seen[val]; ok {
return errors.New("malformed NOTIFY parameter value")
}
default:
return errors.New("malformed NOTIFY parameter value")
}
seen[val] = struct{}{}
}
if _, ok := seen[smtp.DSNNotifyNever]; ok && len(seen) > 1 {
return errors.New("malformed NOTIFY parameter value")
}
return nil
}
package smtpreader
import (
"io"
"strconv"
"strings"
"github.com/uponusolutions/go-smtp"
)
type bdat struct {
size int64
last bool
bytesReceived int64
maxMessageBytes int64
input io.Reader
chunk io.Reader
nextCommand func() (string, string, error)
}
// BdatArg parses the arguments of a bdat command.
// Return smtp errors if something isn't parseable.
func BdatArg(arg string) (int64, bool, error) {
args := strings.Fields(arg)
if len(args) == 0 {
return 0, true, smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Missing chunk size argument")
}
if len(args) > 2 {
return 0, true, smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Too many arguments")
}
last := false
if len(args) == 2 {
if !strings.EqualFold(args[1], "LAST") {
return 0, true, smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Unknown BDAT argument")
}
last = true
}
// ParseUint instead of Atoi so we will not accept negative values.
size, err := strconv.ParseUint(args[0], 10, 32)
if err != nil || (size == 0 && !last) {
return 0, true, smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed size argument")
}
return int64(size), last, nil
}
// NewBdat creates a new bdat reader.
func NewBdat(size int64, last bool, maxMessageBytes int64, input io.Reader, nextCommand func() (string, string, error)) io.Reader {
return &bdat{
maxMessageBytes: maxMessageBytes,
size: size,
last: last,
bytesReceived: 0,
input: input,
nextCommand: nextCommand,
}
}
func (d *bdat) Read(b []byte) (int, error) {
if d.size == 0 {
if d.last {
return 0, io.EOF
}
d.chunk = nil
cmd, arg, err := d.nextCommand()
if err != nil {
if err == io.EOF {
return 0, smtp.ErrConnection
}
return 0, err
}
switch cmd {
case "BDAT":
d.size, d.last, err = BdatArg(arg)
if err != nil {
return 0, err
}
if d.last && d.size == 0 {
return 0, io.EOF
}
case "RSET":
return 0, smtp.Reset
case "QUIT":
return 0, smtp.Quit
default:
return 0, smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "BDAT, RSET or QUIT command expected")
}
}
if d.maxMessageBytes != 0 && d.bytesReceived+d.size > d.maxMessageBytes {
return 0, smtp.ErrDataTooLarge
}
if d.chunk == nil {
d.chunk = io.LimitReader(d.input, int64(d.size))
}
n, err := d.chunk.Read(b)
d.bytesReceived += int64(n)
d.size -= int64(n)
// the reader should never throw EOF
if err == io.EOF {
err = smtp.ErrConnection
}
return n, err
}
package smtpreader
import (
"bufio"
"bytes"
"io"
"github.com/uponusolutions/go-smtp"
)
type dot struct {
r *bufio.Reader
state int
limited bool
n int64 // Maximum bytes remaining.
}
// NewDot creates a new dot reader.
func NewDot(reader *bufio.Reader, maxMessageBytes int64) io.Reader {
dr := &dot{
r: reader,
}
if maxMessageBytes > 0 {
dr.limited = true
dr.n = maxMessageBytes
}
return dr
}
const (
stateBegin = iota // Initial state, beginning of first line.
stateLine // Somewhere inside a line.
stateCR // Wrote \r.
stateEOF // Reached .\r\n end marker line.
)
// Read reads in some more bytes.
// Run data through a simple state machine to
// elide leading dots and detect End-of-Data
// (<CR><LF>.<CR><LF>) line.
// nolint:revive
func (r *dot) Read(b []byte) (int, error) {
if r.state == stateEOF {
return 0, io.EOF
}
if r.limited {
if r.n <= 0 {
return 0, smtp.ErrDataTooLarge
}
if int64(len(b)) > r.n {
b = b[0:r.n]
}
}
var n int // Data written to b.
var skipped int // How many.
c, err := r.peek(len(b))
// To reach this state it is necessary that 5 bytes are still in the buffer ready to be consumed.
// So there shouldn't be a case where err happens and there aren't 5 bytes in c at this point.
switch r.state {
case stateCR:
// write \n
b[0] = '\n'
n++
skipped += 2
if len(c) >= 5 && c[3] == '\r' && c[4] == '\n' {
r.state = stateEOF
skipped += 2 // skip .\n\r
return r.finalize(n, skipped, err)
}
r.state = stateLine
b = b[1:]
c = c[3:]
case stateBegin:
// nothing to do
if len(c) == 0 {
return r.finalize(n, skipped, err)
}
if c[0] == '.' {
// need to check .\r\n
if len(c) < 3 {
return r.finalize(n, skipped, err)
}
// already finish, empty data
if c[1] == '\r' && c[2] == '\n' {
skipped += 3
r.state = stateEOF
return r.finalize(n, skipped, err)
}
skipped++
c = c[1:]
}
r.state = stateLine
default:
}
for {
i := bytes.Index(c, smtp.Crlfdot)
// No full \r\n. found.
if i == -1 {
l := len(c)
if l > 1 && c[l-2] == '\r' && c[l-1] == '\n' && err == nil {
// Ends with \r\n, write everything before.
n += copy(b, c[:l-2])
} else if l > 0 && c[l-1] == '\r' && err == nil {
// Ends with \r, write everything before.
n += copy(b, c[:l-1])
} else {
n += copy(b, c)
}
break
}
// i is \r, \n.\r\n needs to be accessible
if len(c)-1 < i+4 {
if err != nil {
n += copy(b, c[:i+2])
if len(b) >= len(c[:i+2]) {
skipped = len(c) - (i + 2)
} else if len(b) == len(c[:i+2])-1 {
r.state = stateCR // Next time we want to write '\n'.
skipped-- // Prevent \r from being discarded
}
} else if i > 0 {
// Not enough bytes to check for \r\n.\r\n,
// write everything before
n += copy(b, c[:i])
}
break
}
p := copy(b, c[:i+2])
n += p
// b was to small
if p < i+2 {
// we only wrote \r
if i+2-p == 1 {
r.state = stateCR // Next time we want to write '\n'.
skipped-- // Prevent \r from being discarded
}
break
}
// The end \r\n.\n\r
if c[i+3] == '\r' && c[i+4] == '\n' {
r.state = stateEOF
skipped += 3 // skip .\r\n
break
}
skipped++ // . isn't written
b = b[i+2:]
c = c[i+3:]
}
return r.finalize(n, skipped, err)
}
func (r *dot) finalize(n int, skipped int, err error) (int, error) {
// n + skipped is always smaller then what was peeked,
// so it is guaranteed to work
_, _ = r.r.Discard(n + skipped)
if r.limited {
r.n -= int64(n)
}
// as long as something was written, we do not propagate the err if any
if n > 0 {
return n, nil
}
if err == io.EOF && r.state != stateEOF {
err = io.ErrUnexpectedEOF
} else if err == nil && r.state == stateEOF {
err = io.EOF
}
return n, err
}
func (r *dot) peek(blen int) ([]byte, error) {
minimumPeek := 5
if r.state == stateBegin {
minimumPeek = 3
}
// IMPORTANT: We cannot wait on read,
// because no EOL returns. So we call peek with minimumPeek to fill the buffer probably with more
// to get as much data as possible in the second peek.
if r.r.Buffered() < minimumPeek {
_, _ = r.r.Peek(minimumPeek)
}
// min 5, max buffer size, default len(b)
return r.r.Peek(max(min(blen, r.r.Buffered()), minimumPeek))
}
package smtpreader
import (
"bufio"
"errors"
"fmt"
"io"
"net/textproto"
"strconv"
"strings"
"github.com/uponusolutions/go-smtp"
)
// Receiver is used as a wrapper around a connection to read from it
type Receiver struct {
*bufio.Reader
maxLineLength int
lineLengthExceeded bool
}
// NewReceiver creates a new connection wrapper.
func NewReceiver(
conn io.Reader,
readerSize int,
maxLineLength int,
) *Receiver {
if readerSize == 0 {
readerSize = 4096 // default
}
return &Receiver{
Reader: bufio.NewReaderSize(conn, readerSize),
maxLineLength: maxLineLength,
lineLengthExceeded: false,
}
}
// ReadFullLine reads a single line from r,
// eliding the final \n or \r\n from the returned string.
func (t *Receiver) ReadFullLine() (string, error) {
line, err := t.readLineSlice()
return string(line), err
}
// ReadResponse reads a multi-line response of the form:
//
// code-message line 1
// code-message line 2
// ...
// code message line n
//
// where code is a three-digit status code. The first line starts with the
// code and a hyphen. The response is terminated by a line that starts
// with the same code followed by a space. Each line in message is
// separated by a newline (\n).
//
// See page 36 of RFC 959 (https://www.ietf.org/rfc/rfc959.txt) for
// details of another form of response accepted:
//
// code-message line 1
// message line 2
// ...
// code message line n
func (t *Receiver) ReadResponse() (*smtp.Status, error) {
status, continued, err := t.readFirstCodeLine()
if err != nil {
return nil, err
}
if err = t.readResponseExtra(status, continued, true); err != nil {
return nil, err
}
return status, nil
}
// ReadResponseValid returns an error if the code does not match expectation.
func (t *Receiver) ReadResponseValid(expectCode int) error {
status, continued, err := t.readFirstCodeLine()
if err != nil {
return err
}
unexpected := IsCodeUnexpected(status.Code, expectCode)
err = t.readResponseExtra(status, continued, unexpected)
if err != nil {
return err
}
if unexpected {
return status
}
return nil
}
func (t *Receiver) readResponseExtra(status *smtp.Status, continued bool, appendMessage bool) error {
var message string
var err error
encCodePart := status.EnhancedCode.ToPart()
for continued {
continued, message, err = t.readExtraCodeLine(strconv.Itoa(status.Code), encCodePart)
if err != nil {
return err
}
if appendMessage {
status.Lines = append(status.Lines, message)
}
}
return nil
}
func (t *Receiver) readFirstCodeLine() (*smtp.Status, bool, error) {
line, err := t.ReadFullLine()
if err != nil {
return nil, false, err
}
return parseFirstCodeLine(line)
}
func parseFirstCodeLine(line string) (*smtp.Status, bool, error) {
if len(line) < 4 || line[3] != ' ' && line[3] != '-' {
return nil, false, textproto.ProtocolError(fmt.Sprintf("short response: %q", line))
}
continued := line[3] == '-'
code, err := strconv.Atoi(line[0:3])
if err != nil || code < 100 {
return nil, false, textproto.ProtocolError(fmt.Sprintf("invalid response code: %q", line))
}
message := line[4:]
// all 2xx, 4xx, and 5xx response lines
if code >= 300 && code < 400 {
return smtp.NewStatusS(code, smtp.NoEnhancedCode, message), continued, nil
}
index := strings.Index(message, " ")
if index == -1 {
return smtp.NewStatusS(code, smtp.NoEnhancedCode, message), continued, nil
}
enhCode, err := parseEnhancedCode(message[:index])
if err != nil {
return smtp.NewStatusS(code, smtp.NoEnhancedCode, message), continued, nil
}
message = message[index+1:]
return smtp.NewStatusS(code, enhCode, message), continued, nil
}
func (t *Receiver) readExtraCodeLine(codeString string, enhCodePart []byte) (continued bool, message string, err error) {
line, err := t.ReadFullLine()
if err != nil {
return false, "", err
}
return parseExtraCodeLine(line, codeString, enhCodePart)
}
// parseExtraCodeLine does strict verification like described in RFC 5321
func parseExtraCodeLine(line string, codeString string, enhCodePart []byte) (bool, string, error) {
if len(line) < 4+len(enhCodePart) ||
(line[3] != ' ' && line[3] != '-') ||
line[0:3] != codeString ||
line[4:(4+len(enhCodePart))] != string(enhCodePart) {
return false, "", textproto.ProtocolError(fmt.Sprintf("invalid response: %q", line))
}
return line[3] == '-', line[4+len(enhCodePart):], nil
}
func parseEnhancedCode(s string) (smtp.EnhancedCode, error) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return smtp.NoEnhancedCode, errors.New("wrong amount of enhanced code parts")
}
code := smtp.NoEnhancedCode
for i, part := range parts {
num, err := strconv.Atoi(part)
if err != nil {
return smtp.NoEnhancedCode, err
}
code[i] = num
}
return code, nil
}
// IsCodeUnexpected validates if the code is unexpected.
// If the prefix of the status does not match the digits in expectCode,
// ReadResponse returns with err set to &Error{code, message}.
// For example, if expectCode is 31, an error will be returned if
// the status is not in the range [310,319].
func IsCodeUnexpected(code int, expectCode int) bool {
return 1 <= expectCode && expectCode < 10 && code/100 != expectCode ||
10 <= expectCode && expectCode < 100 && code/10 != expectCode ||
100 <= expectCode && expectCode < 1000 && code != expectCode
}
func (t *Receiver) readLineSlice() ([]byte, error) {
// If the line limit was exceeded once, the connection shouldn't be used anymore.
if t.lineLengthExceeded {
return nil, smtp.ErrTooLongLine
}
var line []byte
for {
l, more, err := t.ReadLine()
if err != nil {
return nil, err
}
if t.maxLineLength > 0 && len(l)+len(line) > t.maxLineLength {
t.lineLengthExceeded = true
return nil, smtp.ErrTooLongLine
}
// Avoid the copy if the first call produced a full line.
if line == nil && !more {
return l, nil
}
line = append(line, l...)
if !more {
break
}
}
return line, nil
}
package smtpwriter
import (
"bufio"
"errors"
"io"
"strconv"
)
var (
ending = []byte("BDAT 0 LAST\r\n")
prefix = []byte("BDAT ")
lastcrlf = []byte(" LAST\r\n")
crlf = []byte("\r\n")
)
// NewBdat returns a writer that can be used to write bdat commands to w.
// The caller should close the BdatWriter before the next call to a method on w.
func NewBdat(maxChunkSize int, writer *bufio.Writer, read func() error, size int) io.WriteCloser {
return &bdat{
w: writer,
read: read,
maxChunkSize: maxChunkSize,
remainingSize: size,
knownSize: size > 0,
remainingChunkSize: 0,
}
}
type bdat struct {
w *bufio.Writer
read func() error
// maximum bdat chunk size
maxChunkSize int
// size still expected to be written
remainingSize int
// if true then size is known, so remainingSize is respected
knownSize bool
// if larger then 0 bdat command is send and some bytes are left to write
// only used if knownSize is true
remainingChunkSize int
// if Write was called at least once this flag is true.
started bool
}
// Write writes bytes as multiple bdat commands split by max chunk size.
func (d *bdat) Write(b []byte) (n int, err error) {
d.started = true
var p int
// something left to write
if d.remainingChunkSize > 0 {
p, err = d.writeBdat(b[:min(len(b), d.remainingChunkSize)], d.remainingChunkSize == d.remainingSize)
n += p
b = b[p:]
if err != nil {
return n, err
}
if len(b) == 0 {
return n, nil
}
}
// more then max chunk size received
for d.maxChunkSize > 0 && len(b) > d.maxChunkSize {
p, err = d.writeBdat(b[:d.maxChunkSize], false)
n += p
b = b[p:]
if err != nil {
return n, err
}
}
if d.knownSize && d.remainingSize < len(b) {
return n, errors.New("got more bytes than expected, check length")
}
p, err = d.writeBdat(b, d.knownSize && (d.maxChunkSize == 0 || d.remainingSize <= d.maxChunkSize))
n += p
return n, err
}
// write writes b until everything is written
func (d *bdat) write(b []byte) (n int, err error) {
var p int
for n < len(b) {
p, err = d.w.Write(b[n:])
n += p
if err != nil {
return n, err
}
}
if d.remainingChunkSize > 0 {
d.remainingChunkSize -= n
}
if d.knownSize {
d.remainingSize -= n
}
return n, nil
}
// writeBdat writes b as bdat command and checks return
// b must be smaller or equal maxChunkSize
// if size is known we always use max chunk size
func (d *bdat) writeBdat(b []byte, last bool) (n int, err error) {
if d.remainingChunkSize == 0 {
size := len(b)
// if size is known we can create nice chunks
if d.knownSize && (d.maxChunkSize == 0 || size < d.maxChunkSize) {
if d.maxChunkSize == 0 {
size = d.remainingSize
} else {
size = min(d.maxChunkSize, d.remainingSize)
}
d.remainingChunkSize = size
}
if err = d.bdat(size, last); err != nil {
return n, err
}
} else if len(b) > d.remainingChunkSize {
// just finish the current chunk
b = b[:d.remainingChunkSize]
}
n, err = d.write(b)
if err != nil {
return n, err
}
// Are there still bytes missing to finish bdat chunk?
if d.remainingChunkSize > 0 {
return n, nil
}
if err = d.w.Flush(); err != nil {
return n, err
}
// last read is done outside of bdat reader
if last {
return n, nil
}
return n, d.read()
}
// write BDAT <SIZE> \r\n
func (d *bdat) bdat(size int, last bool) (err error) {
if _, err = d.w.Write(prefix); err != nil {
return err
}
if _, err = d.w.Write([]byte(strconv.Itoa(size))); err != nil {
return err
}
if last {
if _, err = d.w.Write(lastcrlf); err != nil {
return err
}
return nil
}
if _, err = d.w.Write(crlf); err != nil {
return err
}
return nil
}
func (d *bdat) Close() error {
// The close came to early, more bytes expected
if d.knownSize && d.remainingSize > 0 {
if _, err := d.Write(make([]byte, d.remainingSize)); err != nil {
return err
}
}
// if size is not known a bdat 0 last is neccesary
if !d.knownSize || !d.started {
if _, err := d.w.Write(ending); err != nil {
return err
}
}
return d.w.Flush()
}
package smtpwriter
import (
"bufio"
"io"
)
// NewBdatWriterBuffered returns a writer that can be used to write bdat commands to w.
// The caller should close the BdatWriter before the next call to a method on w.
func NewBdatWriterBuffered(maxChunkSize int, writer *bufio.Writer, read func() error, size int, buffer []byte) io.WriteCloser {
return &bdatWriterBuffered{
writer: bdat{
w: writer,
read: read,
maxChunkSize: maxChunkSize,
remainingSize: size,
knownSize: size > 0,
},
buffer: buffer,
position: 0,
}
}
type bdatWriterBuffered struct {
buffer []byte
position int
writer bdat
}
// ReadFrom implements io.ReadFrom.
func (d *bdatWriterBuffered) ReadFrom(r io.Reader) (n int64, err error) {
var p int
for err == nil {
p, err = r.Read(d.buffer[d.position:])
n += int64(p)
if p+d.position >= len(d.buffer) {
_, err = d.writer.Write(d.buffer)
d.position = 0
} else {
d.position += p
}
}
// io.EOF is not returned (see io.Copy)
if err == io.EOF {
return n, nil
}
return n, err
}
// Write writes bytes as multiple bdat commands split by max chunk size.
func (d *bdatWriterBuffered) Write(b []byte) (n int, err error) {
var p int
for n < len(b) {
p, err = d.write(b[n:])
n += p
if err != nil {
return n, err
}
}
return n, err
}
func (d *bdatWriterBuffered) write(b []byte) (n int, err error) {
available := len(d.buffer) - d.position
if available == 0 {
_, err = d.writer.Write(d.buffer)
d.position = 0
}
if len(b) > available {
// ignore buffer, just pass it directly
if d.position == 0 {
n, err = d.writer.Write(b)
return n, err
}
n = available
copy(d.buffer[d.position:], b[:available])
} else {
n = len(b)
copy(d.buffer[d.position:], b)
}
d.position += n
return n, err
}
func (d *bdatWriterBuffered) Close() error {
if d.position > 0 {
// force bdat last, if size is unknown
if !d.writer.knownSize {
d.writer.knownSize = true
d.writer.remainingSize = d.position
}
if _, err := d.writer.Write(d.buffer[:d.position]); err != nil {
return err
}
d.position = 0
}
return d.writer.Close()
}
// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Based on the modifications from
// https://github.com/go-textproto/textproto/blob/v0/writer.go
package smtpwriter
import (
"bufio"
"bytes"
"io"
"github.com/uponusolutions/go-smtp"
)
// NewDot returns a writer that can be used to write a dot-encoding to w.
// It takes care of inserting leading dots when necessary,
// translating line-ending \n into \r\n, and adding the final .\r\n line
// when the DotWriter is closed. The caller should close the
// DotWriter before the next call to a method on w.
//
// See the documentation for Reader's DotReader method for details about dot-encoding.
func NewDot(writer *bufio.Writer) io.WriteCloser {
return &dot{
W: writer,
}
}
type dot struct {
W *bufio.Writer
state int
}
const (
wstateBegin = iota // starting state
wstateBeginLine // beginning of line
wstateCR // wrote \r (possibly at end of line)
wstateData // writing data in middle of line
)
func (d *dot) Write(b []byte) (n int, err error) {
var (
i int
p []byte
pLen int
bw = d.W
)
for len(b) > 0 {
i = bytes.IndexByte(b, '\n')
if i >= 0 {
p, b = b[:i+1], b[i+1:]
} else {
p, b = b, nil
}
pLen = len(p)
// A leading dot must be stuffed at the beginning of every line,
// including the very first one (wstateBegin).
if (d.state == wstateBeginLine || d.state == wstateBegin) && p[0] == '.' {
err = bw.WriteByte('.')
if err != nil {
return n, err
}
}
if b == nil {
// no end of line found in p
if p[pLen-1] == '\r' {
// p ends with \r
d.state = wstateCR
} else {
// just write it down
d.state = wstateData
}
if _, err = bw.Write(p); err != nil {
return n, err
}
} else if d.state == wstateCR && pLen == 1 {
// if b isn't nil and pLen is 1, then it must be a \n
// as \r was send before, just write crnl
d.state = wstateBeginLine
if err = bw.WriteByte('\n'); err != nil {
return n, err
}
} else {
// line is ending
d.state = wstateBeginLine
if pLen >= 2 && p[pLen-2] == '\r' {
// fastpath if line ending is correct \r\n
if _, err = bw.Write(p); err != nil {
return n, err
}
} else {
// data + crnl
if _, err = bw.Write(p[:pLen-1]); err != nil {
return n, err
}
if _, err = bw.Write(smtp.Crnl); err != nil {
return n, err
}
}
}
n += pLen
}
return n, err
}
func (d *dot) Close() error {
bw := d.W
// nolint revive
switch d.state {
default:
if err := bw.WriteByte('\r'); err != nil {
return err
}
fallthrough
case wstateCR:
// normally \r gets ignored if no \n follows, but at closing we just take it as a line break
// same behavior as original textproto
if err := bw.WriteByte('\n'); err != nil {
return err
}
fallthrough
case wstateBeginLine:
if _, err := bw.Write(smtp.Dotcrnl); err != nil {
return err
}
}
return bw.Flush()
}
package smtpwriter
import (
"bufio"
"io"
)
// Sender is used as a wrapper around a connection to write to it.
type Sender struct {
*bufio.Writer
}
// NewSender creates a new writer wrapper.
func NewSender(
conn io.Writer,
writerSize int,
) *Sender {
if writerSize == 0 {
writerSize = 4096 // default
}
return &Sender{
Writer: bufio.NewWriterSize(conn, writerSize),
}
}
package mailer
import (
"crypto/tls"
"github.com/uponusolutions/go-sasl"
"github.com/uponusolutions/go-smtp/client"
)
// DefaultConfig returns the default configuration of a mailer.
func DefaultConfig() Config {
return Config{
extra: additionalConfig{
security: SecurityPreferStartTLS,
},
client: client.DefaultConfig(),
}
}
// NewConfig creates a new config with the given options.
func NewConfig(opts ...Option) Config {
cfg := DefaultConfig()
for _, o := range opts {
o(&cfg)
}
return cfg
}
// Security describes how the connection is etablished.
type Security int32
const (
// SecurityPreferStartTLS tries to use StartTLS but fallbacks to plain if
// StartTLS is not available or TLS Handshake failed.
SecurityPreferStartTLS Security = 0
// SecurityPlain is always just a plain connection.
SecurityPlain Security = 1
// SecurityTLS does a implicit tls connection.
SecurityTLS Security = 2
// SecurityStartTLS always does starttls.
SecurityStartTLS Security = 3
)
// UTF8 describes how SMTPUTF8 is used.
type UTF8 int32
const (
// UTF8Prefer uses SMTPUTF8 if possible.
UTF8Prefer UTF8 = 0
// UTF8Force always uses SMTPUTF8.
UTF8Force UTF8 = 1
// UTF8Disabled never uses SMTPUTF8.
UTF8Disabled UTF8 = 2
)
// additionalConfig contains all the extra configuration needed to define an smtp client.
type additionalConfig struct {
serverAddresses [][]string // Format address:port.
serverAddressIndex int // first server address to try
saslClient sasl.Client // support authentication
security Security // Defines the connection is secured
abortOnRcptReject bool // Send a mail even if some recipients aren't accepted
tlsConfig *tls.Config
}
// Config contains a client config and the mailer config additions.
type Config struct {
extra additionalConfig
client client.Config
}
// Option defines a client option.
type Option func(c *Config)
// WithBasic allows to set all settings of the basic smtp client.
func WithBasic(opts ...client.Option) Option {
return func(c *Config) {
for _, e := range opts {
e(&c.client)
}
}
}
// WithServerAddresses sets the SMTP servers address.
func WithServerAddresses(addrs ...string) Option {
return func(c *Config) {
c.extra.serverAddresses = [][]string{addrs}
}
}
// WithServerAddressesPrio sets the SMTP servers address.
func WithServerAddressesPrio(addrs ...[]string) Option {
return func(c *Config) {
c.extra.serverAddresses = addrs
}
}
// WithServerAddressIndex sets the SMTP server index.
func WithServerAddressIndex(index int) Option {
return func(c *Config) {
c.extra.serverAddressIndex = index
}
}
// WithSASLClient sets the SASL client.
func WithSASLClient(cl sasl.Client) Option {
return func(c *Config) {
c.extra.saslClient = cl
}
}
// WithSecurity sets the TLS config.
func WithSecurity(security Security) Option {
return func(c *Config) {
c.extra.security = security
}
}
// WithTLSConfig sets the TLS config.
func WithTLSConfig(cfg *tls.Config) Option {
return func(c *Config) {
c.extra.tlsConfig = cfg
}
}
// WithAbortOnRcptReject aborts sending if at last one recipient is rejected by the server.
func WithAbortOnRcptReject(abortOnRcptReject bool) Option {
return func(c *Config) {
c.extra.abortOnRcptReject = abortOnRcptReject
}
}
// Package mailer implements a mailer to send mail by smtp.
// It uses the client library and provides high level functionality.
package mailer
import (
"context"
"errors"
"io"
"net"
"slices"
"github.com/uponusolutions/go-smtp"
"github.com/uponusolutions/go-smtp/client"
"github.com/uponusolutions/go-smtp/resolve"
)
// Mailer implements a smtp client with .
type Mailer struct {
client *client.Client
cfg additionalConfig
}
// New returns a new smtp client.
// When not set via options a default tls.Config and used an StartTLS is preferred but not enforced.
// You need to set at least the server address to get a working mailer.
func New(opts ...Option) *Mailer {
cfg := DefaultConfig()
for _, o := range opts {
o(&cfg)
}
return NewFromConfig(cfg)
}
// NewFromConfig returns a new smtp client from existing config.
func NewFromConfig(cfg Config) *Mailer {
return &Mailer{
cfg: cfg.extra,
client: client.NewFromConfig(cfg.client),
}
}
// Client returns the inner smtp client. Use with caution.
func (c *Mailer) Client() *client.Client {
return c.client
}
// Connect connects to one of the available smtp server.
// When server supports auth and clients SaslClient is set, auth is called.
// Security is enforced like configured (Plain, TLS, StartTLS or PreferStartTLS)
// If an error occures, the connection is closed if open.
func (c *Mailer) Connect(ctx context.Context) error {
var err error
for i := 0; i < len(c.cfg.serverAddresses); i++ {
for p := 0; p < len(c.cfg.serverAddresses[i]); p++ {
// use c.serverAddressIndex
address := c.cfg.serverAddresses[i][(p+c.cfg.serverAddressIndex)%len(c.cfg.serverAddresses[i])]
err = c.connectAddress(ctx, address)
if err == nil {
return nil
}
}
}
return err
}
// Connect connects to the SMTP server (addr).
// When server supports auth and clients SaslClient is set, auth is called.
// Security is enforced like configured (Plain, TLS, StartTLS or PreferStartTLS)
// If an error occures, the connection is closed if open.
func (c *Mailer) connectAddress(ctx context.Context, addr string) error {
var err error
switch c.cfg.security {
case SecurityTLS:
err = c.client.DialTLS(ctx, c.cfg.tlsConfig, addr)
case SecurityPlain, SecurityStartTLS, SecurityPreferStartTLS:
fallthrough
default:
err = c.client.Dial(ctx, addr)
}
if err != nil {
return err
}
if c.cfg.security == SecurityStartTLS || c.cfg.security == SecurityPreferStartTLS {
if ok, _ := c.client.Extension("STARTTLS"); !ok {
if c.cfg.security == SecurityStartTLS {
_ = c.Disconnect()
return errors.New("smtp: server doesn't support STARTTLS")
}
} else {
serverName, _, _ := net.SplitHostPort(addr)
err = c.client.StartTLS(c.cfg.tlsConfig, serverName)
if err != nil {
if c.cfg.security != SecurityPreferStartTLS {
return err
}
// recover from failure if prefer start tls --- switch to plain
err = c.client.Dial(ctx, addr)
if err != nil {
return err
}
}
}
}
return c.auth()
}
func (c *Mailer) auth() error {
// Authenticate if authentication is possible and sasl client available.
if ok, _ := c.client.Extension("AUTH"); ok && c.cfg.saslClient != nil {
if err := c.client.Auth(c.cfg.saslClient); err != nil {
_ = c.Disconnect()
return err
}
}
return nil
}
// Len defines the Len method existing in some structs to get the length of the internal []byte (e.g. bytes.Buffer)
type Len interface {
Len() int
}
type pipeliningPending struct {
mail bool
rcpts int
rcptsOffset int
data bool
}
func (c *Mailer) prepare(
ctx context.Context,
from string,
mailOptions *client.MailOptions,
rcpts []string,
rcptsOptions []*smtp.RcptOptions,
size int,
) (*client.ContentCloser, []resolve.Failure, error) {
if !c.client.Connected() {
err := c.Connect(ctx)
if err != nil {
return nil, nil, err
}
}
if len(rcpts) < 1 {
return nil, nil, errors.New("no recipients")
}
if mailOptions == nil && size > 0 {
mailOptions = &client.MailOptions{
Size: int64(size),
}
}
pipelining := &pipeliningPending{}
// MAIL FROM:
// No congestion possible on the first call
if err := c.client.Mail(from, mailOptions); err != nil {
return nil, nil, err
}
pipelining.mail = true
failures := []resolve.Failure{}
// RCPT TO:
for i, addr := range rcpts {
var rcptsOption *smtp.RcptOptions
if len(rcptsOptions) > i {
rcptsOption = rcptsOptions[i]
}
if err := c.client.Rcpt(addr, rcptsOption); err != nil {
if err == client.ErrPipeliningCongestion {
if _, failures, err = c.handleResponses(pipelining, rcpts, failures, size); err != nil {
return nil, failures, err
}
err = c.client.Rcpt(addr, rcptsOption)
}
if err != nil {
failures, err = rcptError(addr, c.cfg.abortOnRcptReject, failures, err)
if err != nil {
// reset open mail transfer
if errRset := c.reset(); errRset != nil {
return nil, failures, errors.Join(err, errRset)
}
return nil, nil, err
}
}
}
pipelining.rcpts++
}
// sync before calling data if abortOnRcptReject is true
if c.client.PipeliningActive() && c.cfg.abortOnRcptReject {
var err error
if _, failures, err = c.handleResponses(pipelining, rcpts, failures, size); err != nil {
return nil, failures, err
}
}
// DATA
w, err := c.client.Content(size)
if err == client.ErrPipeliningCongestion {
if _, failures, err = c.handleResponses(pipelining, rcpts, failures, size); err != nil {
return nil, failures, err
}
w, err = c.client.Content(size)
}
if err != nil {
return nil, failures, err
}
pipelining.data = true
// pipelining is active
if w == nil {
return c.handleResponses(pipelining, rcpts, failures, size)
}
return w, failures, nil
}
func (c *Mailer) handleResponses(pipelining *pipeliningPending, rcpts []string, failures []resolve.Failure, size int) (*client.ContentCloser, []resolve.Failure, error) {
if pipelining.mail {
pipelining.mail = false
if err := c.client.MailResponse(); err != nil {
// clear all pending responses, data couldn't be accepted
if responseErr := c.client.ClearResponses(0); responseErr != nil {
return nil, nil, errors.Join(err, responseErr)
}
return nil, nil, err
}
}
pendingRcpts := rcpts[pipelining.rcptsOffset : pipelining.rcptsOffset+pipelining.rcpts]
pipelining.rcptsOffset += pipelining.rcpts
pipelining.rcpts = 0
for _, addr := range pendingRcpts {
if err := c.client.RcptResponse(); err != nil {
failures, err = rcptError(addr, c.cfg.abortOnRcptReject, failures, err)
if err != nil {
// pipelining.data is never true here, because abortOnRcptReject forces sync before calling data
if errResponse := c.client.ClearResponses(0); errResponse != nil {
return nil, failures, errors.Join(err, errResponse)
}
if errReset := c.reset(); errReset != nil {
return nil, failures, errors.Join(err, errReset)
}
return nil, failures, err
}
}
}
if pipelining.data {
pipelining.data = false
w, err := c.client.ContentResponse(size)
if err != nil {
if errReset := c.reset(); errReset != nil {
return nil, failures, errors.Join(err, errReset)
}
return nil, failures, err
}
// no rcpt was accepted - RFC 2920
// the client cannot assume that the DATA command will be rejected just because none of the RCPT TO commands worked.
if len(failures) == len(rcpts) {
if err := w.Close(); err != nil {
return nil, failures, err
}
return nil, failures, nil
}
return w, failures, nil
}
return nil, failures, nil
}
// reset resets open mail transfer and is pipelining aware
func (c *Mailer) reset() (err error) {
err = c.client.Reset()
if !c.client.PipeliningActive() {
return err
}
if err != nil {
return err
}
return c.client.ResetResponse()
}
func rcptError(addr string, abortOnRcptReject bool, failures []resolve.Failure, err error) ([]resolve.Failure, error) {
// continue sending if code is not 421 and abort on rcpt reject is disabled
if smtpErr, ok := err.(*smtp.Status); !ok || abortOnRcptReject || smtpErr.Code == 421 {
return nil, err
}
failures = append(failures, resolve.Failure{
Rcpts: []string{addr},
Error: err,
})
return failures, nil
}
// Send send an email from
// address from, to addresses to, with message stream in.
//
// It will use an existing connection if possible or create a new one otherwise.
//
// The addresses in the rcpts parameter are the SMTP RCPT addresses.
//
// The in parameter should be a stream of an RFC 822-style email with headers
// first, a blank line, and then the message body. The lines of in
// should be CRLF terminated. The in headers should usually include
// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc"
// messages is accomplished by including an email address in the to
// parameter but not including it in the in headers.
func (c *Mailer) Send(ctx context.Context, from string, rcpt []string, in io.Reader) (status *smtp.Status, failures []resolve.Failure, err error) {
return c.SendAdvanced(ctx, from, nil, rcpt, nil, in)
}
// SendAdvanced send an email from
// address from, to addresses to, with message stream in.
//
// It will use an existing connection if possible or create a new one otherwise.
//
// The addresses in the rcpts parameter are the SMTP RCPT addresses.
// If mailOptions isn't set, default values are used (e.g. size determined from in)
// If rcptsOptions isn't set for some rcpts (e.g. len(rcpts) > len(rcptsOptions)),
// default values are used for these recipients.
//
// The in parameter should be a stream of an RFC 822-style email with headers
// first, a blank line, and then the message body. The lines of in
// should be CRLF terminated. The in headers should usually include
// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc"
// messages is accomplished by including an email address in the to
// parameter but not including it in the in headers.
//
// The status and err can both be empty if all recipients were rejected by the server
func (c *Mailer) SendAdvanced(
ctx context.Context,
from string,
mailOptions *client.MailOptions,
rcpts []string,
rcptsOptions []*smtp.RcptOptions,
in io.Reader,
) (status *smtp.Status, failures []resolve.Failure, err error) {
size := 0
if wt, ok := in.(Len); ok {
size = wt.Len()
}
w, failures, err := c.prepare(ctx, from, mailOptions, rcpts, rcptsOptions, size)
if err != nil {
// if err isn't smtp.StatusBase we are in an unknown state, close connection
if _, ok := err.(*smtp.Status); !ok {
if errClose := c.client.Close(); errClose != nil {
err = errors.Join(err, errClose)
}
return nil, failures, err
}
return nil, failures, err
}
if w == nil {
return nil, failures, nil
}
_, err = io.Copy(w.Writer(), in)
if err != nil {
// if err isn't smtp.StatusBase we are in an unknown state, close connection
if _, ok := err.(*smtp.Status); !ok {
err = errors.Join(err, c.client.Close())
}
return nil, failures, err
}
status, err = w.CloseWithResponse()
// if err isn't smtp.StatusBase we are in an unknown state, close connection
if _, ok := err.(*smtp.Status); err != nil && !ok {
err = errors.Join(err, c.client.Close())
}
if err != nil {
return nil, failures, err
}
return status, failures, err
}
// Verify checks the validity of an email address on the server.
// If Verify returns nil, the address is valid. A non-nil return
// does not necessarily indicate an invalid address. Many servers
// will not verify addresses for security reasons.
func (c *Mailer) Verify(addr string, opts *client.VrfyOptions) error {
err := c.client.Verify(addr, opts)
if !c.client.PipeliningActive() {
return err
}
if err != nil {
return err
}
return c.client.VerifyResponse()
}
// Disconnect ends current connection gracefully, if any exists.
func (c *Mailer) Disconnect() (err error) {
err = c.client.Quit()
if !c.client.PipeliningActive() {
return err
}
if err != nil {
return err
}
return c.client.QuitResponse()
}
// Terminate ends current connection forcefully.
func (c *Mailer) Terminate() error {
return c.client.Close()
}
// Connected returns the current server name.
func (c *Mailer) Connected() bool {
return c.client.Connected()
}
// ServerAddress returns the current server address.
func (c *Mailer) ServerAddress() string {
return c.client.ServerAddress()
}
// ServerName returns the current server name.
func (c *Mailer) ServerName() string {
return c.client.ServerName()
}
// Report contains responses and failures after sending a mail.
type Report struct {
Responses []Response
Failures []resolve.Failure
}
// Response contains the response of a smtp server for specific recipients.
type Response struct {
Status *smtp.Status
Rcpts []string
}
// Send just sends a mail.
// in is called multiple times if there are recipients from different servers.
// The option abort on recipient rejection is permanently not supported.
func Send(ctx context.Context, from string, rcpts []string, in func() io.Reader, opts ...Option) (res Report, err error) {
r := resolve.New(nil)
config := NewConfig(opts...)
if config.extra.abortOnRcptReject {
return Report{}, errors.New("abort on recipient rejection is not supported")
}
var mx resolve.Result
if len(config.extra.serverAddresses) > 0 {
mx = resolve.Result{
Servers: []resolve.Server{
{
Rcpts: rcpts,
Addresses: config.extra.serverAddresses,
},
},
}
} else {
mx, err = r.Recipients(context.Background(), rcpts)
if err != nil {
return Report{}, err
}
}
res.Failures = mx.Failures
for _, server := range mx.Servers {
status, failures, err := send(ctx, server, from, config, in())
if len(failures) > 0 {
rcpts := []string{}
outer:
for _, rcpt := range server.Rcpts {
for _, fail := range failures {
if slices.Contains(fail.Rcpts, rcpt) {
continue outer
}
}
rcpts = append(rcpts, rcpt)
}
server.Rcpts = rcpts
res.Failures = append(res.Failures, failures...)
}
// Only blame the transaction error on recipients that do not
// As there should be recipients left
if err != nil {
if len(server.Rcpts) > 0 {
res.Failures = append(res.Failures, resolve.Failure{
Rcpts: server.Rcpts,
Error: err,
})
}
continue
}
// No request was made if all rcpts were rejected.
if len(server.Rcpts) > 0 {
res.Responses = append(res.Responses, Response{
Status: status,
Rcpts: server.Rcpts,
})
}
}
return res, nil
}
func send(ctx context.Context, server resolve.Server, from string, config Config, in io.Reader) (status *smtp.Status, failures []resolve.Failure, err error) {
config.extra.serverAddresses = server.Addresses
client := NewFromConfig(config)
defer func() { _ = client.Disconnect() }()
return client.Send(ctx, from, server.Rcpts, in)
}
package mailer
// Copied from golang/go and modified see multiReader
import (
"bytes"
"io"
)
// ReaderWriteToLen extends io.Reader with Len and WriteTo.
type ReaderWriteToLen interface {
io.Reader
io.WriterTo
Len() int
}
// Modified from https://github.com/golang/go/blob/master/src/io/multi_test.go
type multiReader struct {
readers []ReaderWriteToLen
}
type eofReader struct{}
func (eofReader) Read([]byte) (int, error) {
return 0, io.EOF
}
func (eofReader) Len() int {
return 0
}
func (eofReader) WriteTo(_ io.Writer) (sum int64, err error) {
return 0, nil
}
func (mr *multiReader) Read(p []byte) (n int, err error) {
for len(mr.readers) > 0 {
// Optimization to flatten nested multiReaders (Issue 13558).
if len(mr.readers) == 1 {
if r, ok := mr.readers[0].(*multiReader); ok {
mr.readers = r.readers
continue
}
}
n, err = mr.readers[0].Read(p)
if err == io.EOF {
// Use eofReader instead of nil to avoid nil panic
// after performing flatten (Issue 18232).
mr.readers[0] = eofReader{} // permit earlier GC
mr.readers = mr.readers[1:]
}
if n > 0 || err != io.EOF {
if err == io.EOF && len(mr.readers) > 0 {
// Don't return EOF yet. More readers remain.
err = nil
}
return n, err
}
}
return 0, io.EOF
}
func (mr *multiReader) WriteTo(w io.Writer) (sum int64, err error) {
n := int64(0)
for _, r := range mr.readers {
rn, err := r.WriteTo(w)
n += rn
if err != nil {
return n, err
}
}
return n, nil
}
func (mr *multiReader) Len() int {
length := 0
for _, r := range mr.readers {
length += r.Len()
}
return length
}
// MultiReader is a utility struct to ease MultiReader creation.
type MultiReader struct {
readers []ReaderWriteToLen
}
// AddReader adds the next readers to the factory.
// The new [MultiReader] takes ownership of the Readers,
func (mrf *MultiReader) AddReader(readers ...ReaderWriteToLen) {
mrf.readers = append(mrf.readers, readers...)
}
// AddBytes adds the multiple byte slices to the factory.
// The new [MultiReader] takes ownership of the bytes,
func (mrf *MultiReader) AddBytes(arrbytes ...[]byte) {
for _, reader := range arrbytes {
mrf.readers = append(mrf.readers, bytes.NewBuffer(reader))
}
}
// Reader creates a MultiReader from all added readers.
func (mrf *MultiReader) Reader() ReaderWriteToLen {
if len(mrf.readers) == 0 {
return eofReader{}
}
var reader ReaderWriteToLen
if len(mrf.readers) == 1 {
reader = mrf.readers[0]
} else {
reader = NewMultiReader(mrf.readers...)
}
// reset state
mrf.readers = nil
return reader
}
// NewMultiReader returns a Reader that's the logical concatenation of
// the provided input readers. They're read sequentially. Once all
// inputs have returned EOF, Read will return EOF. If any of the readers
// return a non-nil, non-EOF error, Read will return that error.
// The new [Reader] takes ownership of the Readers,
func NewMultiReader(readers ...ReaderWriteToLen) ReaderWriteToLen {
return &multiReader{readers}
}
// Package resolve implements a resolver to get prioritized server addresses for recipients.
// Typically by retrieving mx records by dns.
package resolve
import (
"context"
"errors"
"fmt"
"net"
"slices"
"strings"
)
// LookupMX describes the functions needed for a struct to be used as a resolver.
type LookupMX interface {
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
}
// Resolver is
type Resolver struct {
resolver LookupMX
}
// New creates a new resolver. If nil is given the net.DefaultResolver is used.
func New(resolver LookupMX) Resolver {
if resolver == nil {
resolver = net.DefaultResolver
}
return Resolver{
resolver: resolver,
}
}
// Server contains prioritized server addresses for specific recipients.
type Server struct {
Addresses [][]string
Rcpts []string
}
// Failure contains recipients where no server could be resolved and the corresponding error.
type Failure struct {
Error error
Rcpts []string
}
// Result contains Servers and Fails of an recipients server resolve.
type Result struct {
Servers []Server
Failures []Failure
}
func (r *Result) Error() error {
fails := make([]error, len(r.Failures))
for i, fail := range r.Failures {
fails[i] = fmt.Errorf("error for %s: %w", fail.Rcpts, fail.Error)
}
return errors.Join(fails...)
}
func (r *Result) addError(rcpt string, err error) int {
r.Failures = append(r.Failures, Failure{
Rcpts: []string{rcpt},
Error: err,
})
return len(r.Failures) - 1
}
func (r *Result) addErrorRcpt(index int, rcpt string) int {
r.Failures[index].Rcpts = append(r.Failures[index].Rcpts, rcpt)
return len(r.Failures) - 1
}
func (r *Result) addServer(rcpt string, addresses [][]string) int {
for i, server := range r.Servers {
if sliceEqual(addresses, server.Addresses) {
r.Servers[i].Rcpts = append(r.Servers[i].Rcpts, rcpt)
return i
}
}
r.Servers = append(r.Servers, Server{Rcpts: []string{rcpt}, Addresses: addresses})
return len(r.Servers) - 1
}
func (r *Result) addServerRcpt(i int, rcpt string) {
r.Servers[i].Rcpts = append(r.Servers[i].Rcpts, rcpt)
}
type cache struct {
index int
err error
}
// Recipients resolves the smtp servers for specific recipients and groups them.
// If an error returns, then there was an error resolving the MX-Record for the domains of the recipients.
// For example if the network is offline or the dns server isn't responding.
// Failures inside the result are permanent errors like no mx record could be found or
// the domain could not be extracted.
func (r *Resolver) Recipients(ctx context.Context, rcpts []string) (Result, error) {
res := Result{}
rcptToDomain := map[string]string{}
domainToServer := map[string]cache{}
for _, rcpt := range rcpts {
domainIndex := strings.LastIndex(rcpt, "@")
if domainIndex == -1 {
res.addError(rcpt, fmt.Errorf("couldn't extract domain part of %s", rcpt))
continue
}
domain := rcpt[domainIndex+1:]
rcptToDomain[rcpt] = domain
c, ok := domainToServer[domain]
if ok {
if c.err != nil {
res.addErrorRcpt(c.index, rcpt)
} else {
res.addServerRcpt(c.index, rcpt)
}
continue
}
addresses, err := r.Lookup(ctx, domain)
if err != nil {
return res, err
}
if len(addresses) == 0 {
err = fmt.Errorf("domain resolve failed, no mx record found for %s", domain)
domainToServer[domain] = cache{
index: res.addError(rcpt, err),
err: err,
}
continue
}
domainToServer[domain] = cache{
index: res.addServer(rcpt, addresses),
}
}
return res, nil
}
// Lookup returns prioritized server addresses for a specific domains.
// It returns nil,nil if no mx record is found and an error if the dns request didn't worked.
func (r *Resolver) Lookup(ctx context.Context, domain string) ([][]string, error) {
// LookupMX returns the DNS MX records for the given domain name sorted by preference.
// => We can assume it is sorted and just need
mxs, err := r.resolver.LookupMX(ctx, domain)
if err != nil {
netErr := &net.DNSError{}
if errors.As(err, &netErr) && netErr.IsNotFound {
return nil, nil
}
return nil, err
}
res := [][]string{}
var prio uint16
for i := range mxs {
host := net.JoinHostPort(strings.TrimSuffix(mxs[i].Host, "."), "25")
if mxs[i].Pref > prio || i == 0 {
prio = mxs[i].Pref
res = append(res, []string{
host,
})
continue
}
res[len(res)-1] = append(
res[len(res)-1],
host,
)
}
return res, nil
}
func sliceEqual(a [][]string, b [][]string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if len(a[i]) != len(b[i]) {
return false
}
}
for i := range a {
if !slices.Equal(a[i], b[i]) {
return false
}
}
return true
}
package smtp
import (
"regexp"
)
/*
https://github.com/moisseev/rspamd/blob/master/rules/misc.lua
Detect PRVS/BATV addresses to avoid FORGED_SENDER
https://en.wikipedia.org/wiki/Bounce_Address_Tag_Validation
Signature syntax:
prvs=TAG=USER@example.com BATV draft (https://tools.ietf.org/html/draft-levine-smtp-batv-01)
prvs=USER=TAG@example.com
btv1==TAG==USER@example.com Barracuda appliance
msprvs1=TAG=USER@example.com Sparkpost email delivery service
*/
const (
regexpBATV = "^(?:(?:prvs|msprvs1)=[^=]+=|btv1==[^=]+==)([^@]+@(?:[^@]+))$"
regexpSRS = "^([^+]+)\\+SRS=[^=]+=[^=]+=[^=]+=[^@]+@([^@]+)$"
)
var (
compiledRegexpBATV = regexp.MustCompile(regexpBATV)
compiledRegexpSRS = regexp.MustCompile(regexpSRS)
)
// ParseBATV parses src to extract a BATV address.
// When BATV extration is not possible/needed src is returned.
func ParseBATV(src string) string {
res := compiledRegexpBATV.FindStringSubmatch(src)
if len(res) == 2 {
return res[1]
}
return src
}
// ParseSRS parses src to extract the forwarding sender from SRS (Exchange Online).
// When SRS extration is not possible/needed src is returned.
func ParseSRS(src string) string {
res := compiledRegexpSRS.FindStringSubmatch(src)
if len(res) == 3 {
return res[1] + "@" + res[2]
}
return src
}
// ParseSender combines ParseSRS and ParseBATV.
func ParseSender(src string) string {
return ParseBATV(ParseSRS(src))
}
package server
import (
"context"
"crypto/tls"
"io"
"log/slog"
"github.com/uponusolutions/go-sasl"
"github.com/uponusolutions/go-smtp"
)
// Backend is a SMTP server backend.
type Backend interface {
NewSession(ctx context.Context, c *Conn) (context.Context, Session, error)
}
// BackendFunc is an adapter to allow the use of an ordinary function as a
// Backend.
type BackendFunc func(ctx context.Context, c *Conn) (context.Context, Session, error)
// NewSession calls f(c).
// The returning context is used in the session.
func (f BackendFunc) NewSession(ctx context.Context, c *Conn) (context.Context, Session, error) {
return f(ctx, c)
}
// Session is used by servers to respond to an SMTP client.
//
// The methods are called when the remote client issues the matching command.
type Session interface {
// Discard currently processed message.
// The returning context replaces the context used in the current session.
// Upgrade is true when the reset is called after a tls upgrade.
Reset(ctx context.Context, upgrade bool) (context.Context, error)
// Free all resources associated with session.
// Error is set if an error occurred during session or connection.
// Close is always called after the session is done.
Close(ctx context.Context, err error)
// Returns logger to use when an error occurs inside a session.
// If no logger is returned the default *slog.Logger is used.
Logger(ctx context.Context) *slog.Logger
// Set return path for currently processed message.
Mail(ctx context.Context, from string, opts *smtp.MailOptions) error
// Add recipient for currently processed message.
Rcpt(ctx context.Context, to string, opts *smtp.RcptOptions) error
// Verify checks the validity of an email address on the server.
// If error is nil then smtp code 252 is send
// if error is smtp status then the smtp status is send
// else internal server error is returned and connection is closed
Verify(ctx context.Context, addr string, opts *smtp.VrfyOptions) error
// Set currently processed message contents and send it.
// If r is called then the data must be consumed completely before returning.
// The queuedid must not be unique.
Data(ctx context.Context, r func() io.Reader) (queueid string, err error)
// AuthMechanisms returns valid auth mechanism.
// Nil or an empty list means no authentication mechanism is allowed.
AuthMechanisms(ctx context.Context) []string
// Auth returns a matching sasl server for the given mech.
Auth(ctx context.Context, mech string) (sasl.Server, error)
// STARTTLS returns a valid *tls.Config.
// Is called with the default tls config and the returned tls config is used in the tls upgrade.
// If the tls.Config is nil or an error is returned, the tls upgrade is aborted and the connection closed.
// The *tls.Config received must not be changed.
STARTTLS(ctx context.Context, tls *tls.Config) (*tls.Config, error)
}
package server
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net"
"slices"
"strconv"
"strings"
"time"
"github.com/uponusolutions/go-smtp"
"github.com/uponusolutions/go-smtp/internal/parse"
"github.com/uponusolutions/go-smtp/internal/smtpreader"
"github.com/uponusolutions/go-smtp/internal/smtpwriter"
)
type state int32
const (
stateInit state = 0
stateUpgrade state = 1
stateEnforceAuthentication state = 2
stateEnforceSecureConnection state = 3
stateGreeted state = 4
stateMail state = 5
)
// Conn is a connection inside a smtp server.
type Conn struct {
ctx context.Context
conn net.Conn
receiver *smtpreader.Receiver
sender *smtpwriter.Sender
state state
server *Server
session Session
binarymime bool
esmtp bool // set in helo / ehlo
helo string // set in helo / ehlo
mechanisms []string // seh in helo / ehlo
recipients int // count recipients
didAuth bool
}
// run loops until an error occurs (quit for example)
func (c *Conn) run() (err error) {
var (
cmd string
arg string
)
c.greet()
for {
cmd, arg, err = c.nextCommand()
if err != nil {
return err
}
err = c.handle(cmd, arg)
if err != nil {
// if error is a smtp status it isn't necessary to close the connection
if smtpErr, ok := err.(*smtp.Status); ok {
// Service closing transmission channel, after quit
if smtpErr.Code == 221 {
return smtpErr
}
// ToDo: close connection on repeated errors (e.g. authentication tries)
c.writeStatus(smtpErr)
continue
}
return err
}
}
}
// nextCommand reads the next line and parses it.
// The command is always returned upper case.
func (c *Conn) nextCommand() (cmd string, arg string, err error) {
line, err := c.readLine()
if err != nil {
return "", "", err
}
return parse.Cmd(line)
}
// Commands are dispatched to the appropriate handler functions.
func (c *Conn) handle(cmd string, arg string) error {
if cmd == "" {
return smtp.NewStatusS(500, smtp.EnhancedCode{5, 5, 2}, "Error: bad syntax")
}
switch c.state {
case stateInit, stateUpgrade:
return c.handleStateInit(cmd, arg)
case stateEnforceSecureConnection:
return c.handleStateEnforceSecureConnection(cmd, arg)
case stateEnforceAuthentication:
return c.handleStateEnforceAuthentication(cmd, arg)
case stateGreeted:
return c.handleStateGreeted(cmd, arg)
case stateMail:
return c.handleStateMail(cmd, arg)
default:
return fmt.Errorf("unsupported state %d, how?", c.state)
}
}
func (c *Conn) handleStateInit(cmd string, arg string) error {
switch cmd {
case "HELO", "EHLO":
return c.handleGreet(cmd == "EHLO", arg)
case "NOOP":
return smtp.Noop
case "VRFY":
return c.handleVrfy(arg)
case "RSET": // Reset session
return c.handleRSET()
case "QUIT":
return smtp.Quit
default:
return c.commandUnknown(cmd)
}
}
func (c *Conn) handleStateEnforceAuthentication(cmd string, arg string) error {
switch cmd {
case "HELO", "EHLO":
return c.handleGreet(cmd == "EHLO", arg)
case "NOOP":
return smtp.Noop
case "VRFY":
return c.handleVrfy(arg)
case "RSET": // Reset session
return c.handleRSET()
case "QUIT":
return smtp.Quit
case "AUTH":
// there is always a mechanism, as it is an enforce authentication precondition
return c.handleAuth(arg)
case "STARTTLS":
return c.handleStartTLS()
// RFC 5321, always consume bdat data if capabilities were sent
case "BDAT":
return c.handleBdatDiscard(arg)
default:
return smtp.NewStatusS(530, smtp.EnhancedCode{5, 7, 0}, "Authentication required")
}
}
func (c *Conn) handleStateGreeted(cmd string, arg string) error {
switch cmd {
case "HELO", "EHLO":
return c.handleGreet(cmd == "EHLO", arg)
case "MAIL":
return c.handleMail(arg)
case "NOOP":
return smtp.Noop
case "VRFY":
return c.handleVrfy(arg)
case "RSET": // Reset session
return c.handleRSET()
case "QUIT":
return smtp.Quit
case "AUTH":
if len(c.mechanisms) > 0 {
return c.handleAuth(arg)
}
return smtp.ErrAuthUnsupported
case "STARTTLS":
return c.handleStartTLS()
// RFC 5321, always consume bdat data if capabilities were sent
case "BDAT":
return c.handleBdatDiscard(arg)
default:
return c.commandUnknown(cmd)
}
}
func (c *Conn) handleStateMail(cmd string, arg string) error {
switch cmd {
case "HELO", "EHLO":
return c.handleGreet(cmd == "EHLO", arg)
case "RCPT":
return c.handleRcpt(arg)
case "NOOP":
return smtp.Noop
case "VRFY":
return c.handleVrfy(arg)
case "RSET": // Reset session
return c.handleRSET()
case "BDAT":
if !c.server.enableCHUNKING {
return c.handleBdatDiscard(arg)
}
return c.handleBdat(arg)
case "DATA":
return c.handleData(arg)
case "QUIT":
return smtp.Quit
case "STARTTLS":
return c.handleStartTLS()
default:
return c.commandUnknown(cmd)
}
}
func (c *Conn) handleStateEnforceSecureConnection(cmd string, arg string) error {
switch cmd {
case "HELO", "EHLO":
return c.handleGreet(cmd == "EHLO", arg)
case "NOOP":
return smtp.Noop
case "VRFY":
return c.handleVrfy(arg)
case "STARTTLS":
return c.handleStartTLS()
case "QUIT":
return smtp.Quit
// RFC 5321, always consume bdat data if capabilities were sent
case "BDAT":
return c.handleBdatDiscard(arg)
default:
return smtp.NewStatusS(530, smtp.EnhancedCode{5, 7, 0}, "Must issue a STARTTLS command first")
}
}
func (c *Conn) commandUnknown(cmd string) *smtp.Status {
return smtp.NewStatusS(502, smtp.EnhancedCode{5, 5, 1}, fmt.Sprintf("%s command unknown, state %d", cmd, c.state))
}
// Server returns the server this connection comes from.
func (c *Conn) Server() *Server {
return c.server
}
// Close closes the connection.
func (c *Conn) Close(err error) {
c.logger().DebugContext(c.ctx, "connection is closing")
// flush any pending data from writer before closing connection
_ = c.sender.Flush()
closeErr := c.conn.Close()
if closeErr != nil {
if err == nil {
err = closeErr
} else {
err = errors.Join(err, closeErr)
}
}
if err != nil {
c.logger().ErrorContext(c.ctx, "close error", slog.Any("err", err))
}
if c.session != nil {
c.session.Close(c.ctx, err)
c.session = nil
}
}
// TLSConnectionState returns the connection's TLS connection state.
// Zero values are returned if the connection doesn't use TLS.
func (c *Conn) TLSConnectionState() (tls.ConnectionState, bool) {
tc, ok := c.conn.(*tls.Conn)
if !ok {
return tls.ConnectionState{}, ok
}
return tc.ConnectionState(), true
}
// IsTLS returns if the connection is encrypted by tls.
func (c *Conn) IsTLS() bool {
_, ok := c.conn.(*tls.Conn)
return ok
}
// Hostname returns the name of the connected client.
func (c *Conn) Hostname() string {
return c.helo
}
// Esmtp returns true if esmtp was used (helo instead of ehlo).
func (c *Conn) Esmtp() bool {
return c.esmtp
}
// Mechanisms returns the allowed auth mechanism for this connection.
func (c *Conn) Mechanisms() []string {
return c.mechanisms
}
// Conn returns the connection.
func (c *Conn) Conn() net.Conn {
return c.conn
}
func (c *Conn) handleRSET() error {
err := c.reset()
if err != nil {
return err
}
c.writeStatus(smtp.Reset)
return nil
}
// GREET state -> waiting for HELO
func (c *Conn) handleGreet(esmtp bool, arg string) error {
domain, err := parse.HelloArgument(arg)
if err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 2}, "Domain/address argument required for HELO")
}
// c.helo is populated before NewSession so
// NewSession can access it via Conn.Esmtp.
c.esmtp = esmtp
// c.helo is populated before NewSession so
// NewSession can access it via Conn.Hostname.
c.helo = domain
// RFC 5321: "An EHLO command MAY be issued by a client later in the session"
// RFC 5321: "... the SMTP server MUST clear all buffers
// and reset the state exactly as if a RSET command has been issued."
if c.state != stateInit && c.state != stateEnforceSecureConnection && c.state != stateEnforceAuthentication {
err := c.reset()
if err != nil {
return err
}
}
if c.server.enforceSecureConnection && !c.IsTLS() {
c.state = stateEnforceSecureConnection
} else if c.server.enforceAuthentication && !c.didAuth {
c.state = stateEnforceAuthentication
} else {
c.state = stateGreeted
}
if !esmtp {
return smtp.NewStatusS(250, smtp.NoEnhancedCode, c.server.hostname+" greets "+c.helo)
}
c.mechanisms = c.session.AuthMechanisms(c.ctx)
if len(c.mechanisms) == 0 && c.server.enforceAuthentication {
// without any auth mechanism, no authentication can happen => deadlock
return c.newStatusError(451, smtp.EnhancedCode{4, 0, 0},
"No auth mechanism available but authentication enforced", err)
}
return c.handleGreetResponse()
}
func (c *Conn) handleGreetResponse() *smtp.Status {
isTLS := c.IsTLS()
// 17 lines is the current maximum
lines := make([]string, 0, 17)
lines = append(lines, c.server.hostname+" greets "+c.helo)
lines = append(lines, "PIPELINING")
lines = append(lines, "8BITMIME")
lines = append(lines, "ENHANCEDSTATUSCODES")
if c.server.enableCHUNKING {
lines = append(lines, "CHUNKING")
}
if !isTLS && c.server.tlsConfig != nil {
lines = append(lines, "STARTTLS")
}
if len(c.mechanisms) > 0 {
lines = append(lines, "AUTH "+strings.Join(c.mechanisms, " "))
}
if c.server.enableSMTPUTF8 {
lines = append(lines, "SMTPUTF8")
}
// We explicit allow require tls to be set if the connection is unencrypted
// because if the server runs in a fully trusted zone
// then the encryption is only relevant when it leaves this zone.
if c.server.enableREQUIRETLS {
lines = append(lines, "REQUIRETLS")
}
if c.server.enableBINARYMIME {
lines = append(lines, "BINARYMIME")
}
if c.server.enableDSN {
lines = append(lines, "DSN")
}
if c.server.enableXOORG {
lines = append(lines, "XOORG")
}
if c.server.maxMessageBytes > 0 {
lines = append(lines, "SIZE "+strconv.FormatInt(c.server.maxMessageBytes, 10))
} else {
lines = append(lines, "SIZE")
}
if c.server.maxRecipients > 0 {
lines = append(lines, "LIMITS RCPTMAX="+strconv.Itoa(c.server.maxRecipients))
}
if c.server.enableRRVS {
lines = append(lines, "RRVS")
}
if c.server.enableDELIVERBY {
// maximum of nine digits
if c.server.minimumDeliverInSeconds > 0 && c.server.minimumDeliverInSeconds < 999999999 {
lines = append(lines, "DELIVERBY "+strconv.Itoa(c.server.minimumDeliverInSeconds))
} else {
lines = append(lines, "DELIVERBY")
}
}
if c.server.enableMTPRIORITY {
if c.server.mtPriorityProfile != smtp.PriorityUnspecified {
lines = append(lines, "MT-PRIORITY "+string(c.server.mtPriorityProfile))
} else {
lines = append(lines, "MT-PRIORITY")
}
}
return smtp.NewStatusM(250, smtp.NoEnhancedCode, lines)
}
// handleError handles error and closes the connection afterwards.
func (c *Conn) handleError(err error) {
if err == io.EOF || errors.Is(err, net.ErrClosed) {
c.Close(fmt.Errorf("connection closed unexpectedly: %w", err))
return
}
if neterr, ok := err.(net.Error); ok && neterr.Timeout() {
c.writeStatus(smtp.NewStatusS(421, smtp.EnhancedCode{4, 4, 2}, "Idle timeout, bye bye"))
c.Close(fmt.Errorf("idle timeout: %w", err))
return
}
if smtpErr, ok := err.(*smtp.Status); ok {
c.writeStatus(smtpErr)
if smtpErr.Code != 221 {
c.Close(fmt.Errorf("smtp error: %w", err))
} else {
c.Close(nil)
}
return
}
if err == smtp.ErrTooLongLine {
c.writeStatus(smtp.NewStatusS(500, smtp.EnhancedCode{5, 4, 0}, "Too long line"))
c.Close(errors.New("line too long"))
return
}
c.writeStatus(smtp.ErrConnection)
c.Close(fmt.Errorf("unknown error: %w", err))
}
func (c *Conn) logger() *slog.Logger {
// Fallback if the connection couldn't be created or is already closed.
if c.session == nil {
return slog.Default()
}
logger := c.session.Logger(c.ctx)
if logger == nil {
return slog.Default()
}
return logger
}
// READY state -> waiting for MAIL
// nolint: revive
func (c *Conn) handleMail(arg string) error {
arg, ok := parse.CutPrefixFold(arg, "FROM:")
if !ok {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 2}, "Was expecting MAIL arg syntax of FROM:<address>")
}
p := parse.Parser{S: strings.TrimSpace(arg)}
from, err := p.ReversePath()
if err != nil {
return c.newStatusError(501, smtp.EnhancedCode{5, 5, 2}, "Was expecting MAIL arg syntax of FROM:<address>", err)
}
args, err := parse.Args(p.S)
if err != nil {
return c.newStatusError(501, smtp.EnhancedCode{5, 5, 4}, "Unable to parse MAIL ESMTP parameters", err)
}
opts := &smtp.MailOptions{}
c.binarymime = false
// This is where the Conn may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
for _, arg := range args {
switch arg.Key {
case "SIZE":
size, err := strconv.ParseUint(arg.Value, 10, 32)
if err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Unable to parse SIZE as an integer")
}
if c.server.maxMessageBytes > 0 && int64(size) > c.server.maxMessageBytes {
return smtp.ErrDataTooLarge
}
opts.Size = int64(size)
case "XOORG":
value, err := decodeXtext(arg.Value)
if err != nil || value == "" {
return smtp.NewStatusS(500, smtp.EnhancedCode{5, 5, 4}, "Malformed XOORG parameter value")
}
if !c.server.enableXOORG {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "EnableXOORG is not implemented")
}
opts.XOORG = &value
case "SMTPUTF8":
if !c.server.enableSMTPUTF8 {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "SMTPUTF8 is not implemented")
}
opts.UTF8 = true
case "REQUIRETLS":
if !c.server.enableREQUIRETLS {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "REQUIRETLS is not implemented")
}
opts.RequireTLS = true
case "BODY":
value := strings.ToUpper(arg.Value)
switch smtp.BodyType(value) {
case smtp.BodyBinaryMIME:
if !c.server.enableBINARYMIME {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "BINARYMIME is not implemented")
}
c.binarymime = true
case smtp.Body7Bit, smtp.Body8BitMIME:
// This space is intentionally left blank
default:
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Unknown BODY value")
}
opts.Body = smtp.BodyType(value)
case "RET":
if !c.server.enableDSN {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "RET is not implemented")
}
value := strings.ToUpper(arg.Value)
switch smtp.DSNReturn(value) {
case smtp.DSNReturnFull, smtp.DSNReturnHeaders:
// This space is intentionally left blank
default:
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Unknown RET value")
}
opts.Return = smtp.DSNReturn(value)
case "ENVID":
if !c.server.enableDSN {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "ENVID is not implemented")
}
value, err := decodeXtext(arg.Value)
if err != nil || value == "" || !parse.IsPrintableASCII(value) {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed ENVID parameter value")
}
opts.EnvelopeID = value
case "AUTH":
value, err := decodeXtext(arg.Value)
if err != nil || value == "" {
return smtp.NewStatusS(500, smtp.EnhancedCode{5, 5, 4}, "Malformed AUTH parameter value")
}
if value == "<>" {
value = ""
} else {
p := parse.Parser{S: value}
value, err = p.Mailbox()
if err != nil || p.S != "" {
return smtp.NewStatusS(500, smtp.EnhancedCode{5, 5, 4}, "Malformed AUTH parameter mailbox")
}
}
opts.Auth = &value
case "BY":
if err := handleMailBY(c.server, opts, arg.Value); err != nil {
return err
}
case "MT-PRIORITY":
if err := handleMailMTPRIORITY(c.server, opts, arg.Value); err != nil {
return err
}
default:
return smtp.NewStatusS(500, smtp.EnhancedCode{5, 5, 4}, "Unknown MAIL FROM argument")
}
}
if err := c.session.Mail(c.ctx, from, opts); err != nil {
if smtpErr, ok := err.(*smtp.Status); ok {
// a positive response also counts as a success
if smtpErr.Positive() {
c.state = stateMail
}
return smtpErr
}
return c.newStatusError(451, smtp.EnhancedCode{4, 0, 0}, "Mail not accepted", err)
}
c.state = stateMail
return smtp.NewStatusS(250, smtp.EnhancedCode{2, 0, 0}, fmt.Sprintf("Roger, accepting mail from <%v>", from))
}
func handleMailBY(server *Server, opts *smtp.MailOptions, value string) error {
if !server.enableDELIVERBY {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "DELIVERBY is not implemented")
}
deliverBy := parseDeliverByArgument(value)
if deliverBy == nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed BY parameter value")
}
if server.minimumDeliverInSeconds != 0 &&
deliverBy.Mode == smtp.DeliverByReturn &&
deliverBy.Seconds < server.minimumDeliverInSeconds {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "BY parameter is below server minimum")
}
opts.DeliverBy = deliverBy
return nil
}
func handleMailMTPRIORITY(server *Server, opts *smtp.MailOptions, value string) error {
if !server.enableMTPRIORITY {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "MT-PRIORITY is not implemented")
}
mtPriority, err := strconv.Atoi(value)
if err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed MT-PRIORITY parameter value")
}
if mtPriority < -9 || mtPriority > 9 {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "MT-PRIORITY is outside valid range")
}
opts.MTPriority = &mtPriority
return nil
}
// MAIL state -> waiting for RCPTs followed by DATA
func (c *Conn) handleRcpt(arg string) error {
arg, ok := parse.CutPrefixFold(arg, "TO:")
if !ok {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 2}, "Was expecting RCPT arg syntax of TO:<address>")
}
p := parse.Parser{S: strings.TrimSpace(arg)}
recipient, err := p.Path()
if err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 2}, "Was expecting RCPT arg syntax of TO:<address>")
}
if c.server.maxRecipients > 0 && c.recipients >= c.server.maxRecipients {
return smtp.NewStatusS(452, smtp.EnhancedCode{4, 5, 3},
fmt.Sprintf("Maximum limit of %v recipients reached", c.server.maxRecipients),
)
}
args, err := parse.Args(p.S)
if err != nil {
return c.newStatusError(501, smtp.EnhancedCode{5, 5, 4}, "Unable to parse RCPT ESMTP parameters", err)
}
opts := &smtp.RcptOptions{}
for _, arg := range args {
switch arg.Key {
case "NOTIFY":
if err := handleRcptNotify(c.server, opts, arg.Value); err != nil {
return err
}
case "ORCPT":
if err := handleRcptORCPT(c.server, opts, arg.Value); err != nil {
return err
}
case "RRVS":
if err := handleRcptRRVS(c.server, opts, arg.Value); err != nil {
return err
}
default:
return smtp.NewStatusS(500, smtp.EnhancedCode{5, 5, 4}, "Unknown RCPT TO argument")
}
}
if err := c.session.Rcpt(c.ctx, recipient, opts); err != nil {
if smtpErr, ok := err.(*smtp.Status); ok {
// a positive response also counts as a success
if smtpErr.Positive() {
c.recipients++
}
return smtpErr
}
return c.newStatusError(451, smtp.EnhancedCode{4, 0, 0}, "Recipient not accepted", err)
}
c.recipients++
return smtp.NewStatusS(250, smtp.EnhancedCode{2, 0, 0}, fmt.Sprintf("I'll make sure <%v> gets this", recipient))
}
func handleRcptNotify(server *Server, opts *smtp.RcptOptions, value string) error {
if !server.enableDSN {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "NOTIFY is not implemented")
}
notify := []smtp.DSNNotify{}
for val := range strings.SplitSeq(value, ",") {
notify = append(notify, smtp.DSNNotify(strings.ToUpper(val)))
}
if err := parse.CheckNotifySet(notify); err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed NOTIFY parameter value")
}
opts.Notify = notify
return nil
}
func handleRcptORCPT(server *Server, opts *smtp.RcptOptions, value string) error {
if !server.enableDSN {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "ORCPT is not implemented")
}
aType, aAddr, err := decodeTypedAddress(value)
if err != nil || aAddr == "" {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed ORCPT parameter value")
}
opts.OriginalRecipientType = aType
opts.OriginalRecipient = aAddr
return nil
}
func handleRcptRRVS(server *Server, opts *smtp.RcptOptions, value string) error {
if !server.enableRRVS {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "RRVS is not implemented")
}
value, _, _ = strings.Cut(value, ";") // discard the no-support action
rrvsTime, err := time.Parse(time.RFC3339, value)
if err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "Malformed RRVS parameter value")
}
opts.RequireRecipientValidSince = rrvsTime
return nil
}
func (c *Conn) handleVrfy(arg string) error {
p := parse.Parser{S: strings.TrimSpace(arg)}
vrfy, err := p.Path()
if err != nil {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 2}, "Was expecting <address>")
}
args, err := parse.Args(p.S)
if err != nil {
return c.newStatusError(501, smtp.EnhancedCode{5, 5, 4}, "Unable to parse VRFY ESMTP parameters", err)
}
opts := &smtp.VrfyOptions{}
for _, arg := range args {
if arg.Key == "SMTPUTF8" {
if !c.server.enableSMTPUTF8 {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "SMTPUTF8 is not implemented")
}
opts.UTF8 = true
}
}
res := c.session.Verify(c.ctx, vrfy, opts)
if res == nil {
return smtp.VRFY
}
return res
}
func (c *Conn) handleAuth(arg string) error {
if c.didAuth {
return smtp.NewStatusS(503, smtp.EnhancedCode{5, 5, 1}, "Already authenticated")
}
parts := strings.Fields(arg)
if len(parts) == 0 {
return smtp.NewStatusS(502, smtp.EnhancedCode{5, 5, 4}, "Missing parameter")
}
mechanism := strings.ToUpper(parts[0])
// Is mechanism allowed?
if !slices.Contains(c.mechanisms, mechanism) {
return smtp.NewStatusS(502, smtp.EnhancedCode{5, 5, 4}, "Invalid mechanism")
}
// Parse client initial response if there is one
var ir []byte
if len(parts) > 1 {
var err error
ir, err = decodeSASLResponse(parts[1])
if err != nil {
return smtp.NewStatusS(454, smtp.EnhancedCode{4, 7, 0}, "Invalid base64 data")
}
}
sasl, err := c.session.Auth(c.ctx, mechanism)
if err != nil {
return c.newStatusError(454, smtp.EnhancedCode{4, 7, 0}, "Authentication failed", err)
}
if sasl == nil {
return c.newStatusError(451, smtp.EnhancedCode{4, 0, 0}, "No auth handler received, but mechanism seems valid.", err)
}
response := ir
for {
challenge, done, err := sasl.Next(response)
if err != nil {
return c.newStatusError(454, smtp.EnhancedCode{4, 7, 0}, "Authentication failed", err)
}
if done {
break
}
encoded := ""
if len(challenge) > 0 {
encoded = base64.StdEncoding.EncodeToString(challenge)
}
c.writeStatus(smtp.NewStatusS(334, smtp.NoEnhancedCode, encoded))
encoded, err = c.readLine()
if err != nil {
return err
}
if encoded == "*" {
// https://tools.ietf.org/html/rfc4954#page-4
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 0, 0}, "Negotiation cancelled")
}
response, err = decodeSASLResponse(encoded)
if err != nil {
return smtp.NewStatusS(454, smtp.EnhancedCode{4, 7, 0}, "Invalid base64 data")
}
}
c.didAuth = true
if c.state == stateEnforceAuthentication {
c.state = stateGreeted
}
return smtp.NewStatusS(235, smtp.EnhancedCode{2, 0, 0}, "Authentication succeeded")
}
func (c *Conn) handleStartTLS() error {
if _, isTLS := c.TLSConnectionState(); isTLS {
return smtp.NewStatusS(502, smtp.EnhancedCode{5, 5, 1}, "Already running in TLS")
}
if c.server.tlsConfig == nil {
return smtp.NewStatusS(502, smtp.EnhancedCode{5, 5, 1}, "TLS not supported")
}
// allow the session to change tlsConfig
tlsConfig, err := c.session.STARTTLS(c.ctx, c.server.tlsConfig)
if err != nil {
return c.newStatusError(451, smtp.EnhancedCode{4, 0, 0}, "TLS config retrieval failed", err)
}
if tlsConfig == nil {
return smtp.NewStatusS(451, smtp.EnhancedCode{4, 0, 0}, "TLS config retrieval nil returned")
}
c.writeStatus(smtp.NewStatusS(220, smtp.EnhancedCode{2, 0, 0}, "Ready to start TLS"))
// Upgrade to TLS
tlsConn := tls.Server(c.conn, tlsConfig)
if err := tlsConn.HandshakeContext(c.ctx); err != nil {
// err is not a smtp error and will terminate the connection
return err
}
c.conn = tlsConn
c.receiver.Reset(tlsConn)
c.sender.Reset(tlsConn)
c.state = stateUpgrade // same as StateInit but calls logout/reset on ehlo/helo
return nil
}
// DATA
func (c *Conn) handleData(arg string) error {
// at least a single recipient needs to be set
if c.recipients == 0 {
return smtp.ErrNoRecipients
}
if arg != "" {
return smtp.NewStatusS(501, smtp.EnhancedCode{5, 5, 4}, "DATA command should not have any arguments")
}
if c.binarymime {
return smtp.NewStatusS(502, smtp.EnhancedCode{5, 5, 1}, "DATA not allowed for BINARYMIME messages")
}
var r io.Reader
rstart := func() io.Reader {
if r != nil {
return r
}
// We have recipients, go to accept data
c.writeStatus(smtp.NewStatusS(354, smtp.NoEnhancedCode, "Go ahead. End your data with <CR><LF>.<CR><LF>"))
// r gets exposed to be able to discard the rest of the message
r = smtpreader.NewDot(c.receiver.Reader, c.server.maxMessageBytes)
return r
}
uuid, err := c.session.Data(c.ctx, rstart)
if err != nil {
// an error which isn't a smtp status error will always terminate the connection
// if it is an smtp status then we need to make sure the stream ist read to the end
if _, ok := err.(*smtp.Status); ok && r != nil {
_, _ = io.Copy(io.Discard, r)
}
return err
}
// Make sure all the data has been consumed
if r != nil {
_, _ = io.Copy(io.Discard, r)
}
if err = c.reset(); err != nil {
return err
}
return c.accepted(uuid)
}
func (c *Conn) handleBdatDiscard(arg string) error {
size, _, err := smtpreader.BdatArg(arg)
if err != nil {
return err
}
if _, err = c.receiver.Discard(int(size)); err != nil {
return err
}
if !c.server.enableCHUNKING {
return smtp.NewStatusS(504, smtp.EnhancedCode{5, 5, 4}, "CHUNKING is not implemented")
}
return smtp.ErrBadSequence
}
func (c *Conn) handleBdat(arg string) error {
size, last, err := smtpreader.BdatArg(arg)
if err != nil {
return err
}
// at least a single recipient needs to be set
if c.recipients == 0 {
if _, err = c.receiver.Discard(int(size)); err != nil {
return err
}
return smtp.ErrNoRecipients
}
closed := false
data := smtpreader.NewBdat(size, last, c.server.maxMessageBytes, c.receiver, func() (string, string, error) {
// if bdat is closed (error occurred)
if closed {
return "", "", io.EOF
}
c.writeStatus(smtp.NewStatusS(250, smtp.EnhancedCode{2, 0, 0}, "Continue"))
return c.nextCommand()
})
queueid, err := c.session.Data(c.ctx, func() io.Reader {
return data
})
if err != nil {
if smtpErr, ok := err.(*smtp.Status); ok {
// read anything left to continue after this failure, ignore any read error
// https://www.rfc-editor.org/rfc/rfc3030.html
// If a 5XX or 4XX code is received by the sender-SMTP in response to a BDAT
// chunk, the transaction should be considered failed and the sender-
// SMTP MUST NOT send any additional BDAT segments. If the receiver-
// SMTP has declared support for command pipelining [PIPE], the receiver
// SMTP MUST be prepared to accept and discard additional BDAT chunks
// already in the pipeline after the failed BDAT.
closed = true
_, _ = io.Copy(io.Discard, data)
// write down error after data is discarded to prevent deadlock because of pipelining
c.writeStatus(smtpErr)
return c.reset()
}
// an error which isn't a SMTPStatus error will always terminate the connection
return err
}
// Make sure all the data has been consumed
_, _ = io.Copy(io.Discard, data)
if err = c.reset(); err != nil {
return err
}
return c.accepted(queueid)
}
func (*Conn) accepted(queueid string) *smtp.Status {
if queueid != "" {
// limit length if queueid is too long (< 1000)
if len(queueid) > 977 {
queueid = queueid[:974] + "..."
}
return smtp.NewStatusS(250, smtp.EnhancedCode{2, 0, 0}, "OK: queued as "+queueid)
}
return smtp.NewStatusS(250, smtp.EnhancedCode{2, 0, 0}, "OK: queued")
}
func (c *Conn) greet() {
protocol := "ESMTP"
c.writeStatus(
smtp.NewStatusS(220, smtp.NoEnhancedCode, fmt.Sprintf("%v %s Service Ready", c.server.hostname, protocol)),
)
}
func (c *Conn) writeStatus(status *smtp.Status) {
c.logger().DebugContext(
c.ctx, "write status", slog.Any("status", status),
)
// TODO: error handling
if c.server.writeTimeout != 0 {
_ = c.conn.SetWriteDeadline(time.Now().Add(c.server.writeTimeout))
}
// TODO: error handling
_, _ = status.WriteTo(c.sender)
// PIPELINE support
// If there is something buffered in c.text.R then we can assume another command is following.
// This means the client is doing pipelining and we don't need to respond just now.
if c.receiver.Buffered() == 0 {
_ = c.sender.Flush()
}
}
func (c *Conn) newStatusError(code int, enhCode smtp.EnhancedCode, msg string, err error) *smtp.Status {
if smtpErr, ok := err.(*smtp.Status); ok {
return smtpErr
}
c.logger().ErrorContext(c.ctx, msg, slog.Any("err", err))
return smtp.NewStatusS(code, enhCode, msg)
}
// Reads a line of input
func (c *Conn) readLine() (string, error) {
if c.server.readTimeout != 0 {
_ = c.conn.SetReadDeadline(time.Now().Add(c.server.readTimeout))
}
line, err := c.receiver.ReadFullLine()
if err == nil {
c.logger().DebugContext(c.ctx, "read", slog.String("line", line))
}
return line, err
}
func (c *Conn) reset() error {
// Reset state to Greeted
if c.state == stateMail {
c.state = stateGreeted
}
c.recipients = 0
upgrade := c.state == stateUpgrade
// Authentication is only revoked if starttls is used.
if upgrade {
c.didAuth = false
}
ctx, err := c.session.Reset(c.ctx, upgrade)
c.ctx = ctx
return err
}
package server
import (
"encoding/base64"
"errors"
"regexp"
"strconv"
"strings"
"github.com/uponusolutions/go-smtp"
"github.com/uponusolutions/go-smtp/internal/parse"
)
// Parses the BY argument defined in RFC2852 section 4.
// Returns pointer to options or nil if invalid.
func parseDeliverByArgument(arg string) *smtp.DeliverByOptions {
secondsStr, modeStr, ok := strings.Cut(arg, ";")
if !ok {
return nil
}
modeStr, traceValue := strings.CutSuffix(modeStr, "T")
if modeStr != string(smtp.DeliverByNotify) && modeStr != string(smtp.DeliverByReturn) {
return nil
}
modeValue := smtp.DeliverByMode(modeStr)
secondsValue, err := strconv.Atoi(secondsStr)
if err != nil ||
(modeValue == smtp.DeliverByReturn && (secondsValue < 1 || secondsValue > 999999999)) ||
(modeValue == smtp.DeliverByNotify && (secondsValue < -999999999 || secondsValue > 999999999)) {
return nil
}
return &smtp.DeliverByOptions{
Seconds: secondsValue,
Mode: modeValue,
Trace: traceValue,
}
}
func decodeSASLResponse(s string) ([]byte, error) {
if s == "=" {
return []byte{}, nil
}
return base64.StdEncoding.DecodeString(s)
}
// This regexp matches 'hexchar' token defined in
// https://tools.ietf.org/html/rfc4954#section-8 however it is intentionally
// relaxed by requiring only '+' to be present. It allows us to detect
// malformed values such as +A or +HH and report them appropriately.
var hexcharRe = regexp.MustCompile(`\+[0-9A-F]?[0-9A-F]?`)
func decodeXtext(val string) (string, error) {
if !strings.Contains(val, "+") {
return val, nil
}
var replaceErr error
decoded := hexcharRe.ReplaceAllStringFunc(val, func(match string) string {
if len(match) != 3 {
replaceErr = errors.New("incomplete hexchar")
return ""
}
char, err := strconv.ParseInt(match, 16, 8)
if err != nil {
replaceErr = err
return ""
}
return string(rune(char))
})
if replaceErr != nil {
return "", replaceErr
}
return decoded, nil
}
// This regexp matches 'EmbeddedUnicodeChar' token defined in
// https://datatracker.ietf.org/doc/html/rfc6533.html#section-3
// however it is intentionally relaxed by requiring only '\x{HEX}' to be
// present. It also matches disallowed characters in QCHAR and QUCHAR defined
// in above.
// So it allows us to detect malformed values and report them appropriately.
var eUOrDCharRe = regexp.MustCompile(`\\x[{][0-9A-F]+[}]|[[:cntrl:] \\+=]`)
// Decodes the utf-8-addr-xtext or the utf-8-addr-unitext form.
func decodeUTF8AddrXtext(val string) (string, error) {
var replaceErr error
decoded := eUOrDCharRe.ReplaceAllStringFunc(val, func(match string) string {
if len(match) == 1 {
replaceErr = errors.New("disallowed character:" + match)
return ""
}
hexpoint := match[3 : len(match)-1]
char, err := strconv.ParseUint(hexpoint, 16, 21)
if err != nil {
replaceErr = err
return ""
}
switch len(hexpoint) {
case 2:
switch {
// all xtext-specials
case 0x01 <= char && char <= 0x09 ||
0x11 <= char && char <= 0x19 ||
char == 0x10 || char == 0x20 ||
char == 0x2B || char == 0x3D || char == 0x7F:
// 2-digit forms
case char == 0x5C || 0x80 <= char && char <= 0xFF:
// This space is intentionally left blank
default:
replaceErr = errors.New("illegal hexpoint:" + hexpoint)
return ""
}
// 3-digit forms
case 3:
switch {
case 0x100 <= char && char <= 0xFFF:
// This space is intentionally left blank
default:
replaceErr = errors.New("illegal hexpoint:" + hexpoint)
return ""
}
// 4-digit forms excluding surrogate
case 4:
switch {
case 0x1000 <= char && char <= 0xD7FF:
case 0xE000 <= char && char <= 0xFFFF:
// This space is intentionally left blank
default:
replaceErr = errors.New("illegal hexpoint:" + hexpoint)
return ""
}
// 5-digit forms
case 5:
switch {
case 0x1_0000 <= char && char <= 0xF_FFFF:
// This space is intentionally left blank
default:
replaceErr = errors.New("illegal hexpoint:" + hexpoint)
return ""
}
// 6-digit forms
case 6:
switch {
case 0x10_0000 <= char && char <= 0x10_FFFF:
// This space is intentionally left blank
default:
replaceErr = errors.New("illegal hexpoint:" + hexpoint)
return ""
}
// the other invalid forms
default:
replaceErr = errors.New("illegal hexpoint:" + hexpoint)
return ""
}
return string(rune(char))
})
if replaceErr != nil {
return "", replaceErr
}
return decoded, nil
}
func decodeTypedAddress(val string) (smtp.DSNAddressType, string, error) {
tv := strings.SplitN(val, ";", 2)
if len(tv) != 2 || tv[0] == "" || tv[1] == "" {
return "", "", errors.New("bad address")
}
aType, aAddr := strings.ToUpper(tv[0]), tv[1]
var err error
switch smtp.DSNAddressType(aType) {
case smtp.DSNAddressTypeRFC822:
aAddr, err = decodeXtext(aAddr)
if err == nil && !parse.IsPrintableASCII(aAddr) {
err = errors.New("illegal address:" + aAddr)
}
case smtp.DSNAddressTypeUTF8:
aAddr, err = decodeUTF8AddrXtext(aAddr)
default:
err = errors.New("unknown address type:" + aType)
}
if err != nil {
return "", "", err
}
return smtp.DSNAddressType(aType), aAddr, nil
}
// Package smtp implements the server of the Simple Mail Transfer Protocol as defined in RFC 5321.
//
// It also implements the following extensions:
//
// - 8BITMIME (RFC 1652)
// - AUTH (RFC 2554)
// - STARTTLS (RFC 3207)
// - ENHANCEDSTATUSCODES (RFC 2034)
// - SMTPUTF8 (RFC 6531)
// - REQUIRETLS (RFC 8689)
// - CHUNKING (RFC 3030)
// - BINARYMIME (RFC 3030)
// - DSN (RFC 3461, RFC 6533)
//
// Additional extensions may be handled by other packages.
package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"runtime/debug"
"time"
"github.com/uponusolutions/go-smtp"
"github.com/uponusolutions/go-smtp/internal/smtpreader"
"github.com/uponusolutions/go-smtp/internal/smtpwriter"
)
// Serve accepts incoming connections on the Listener l.
func (s *Server) Serve(ctx context.Context, l net.Listener) error {
s.locker.Lock()
s.listeners = append(s.listeners, l)
s.locker.Unlock()
var tempDelay time.Duration // how long to sleep on accept failure
for {
c, err := l.Accept()
if err != nil {
select {
case <-s.done:
// we called Close()
return nil
default:
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if maxDelay := 1 * time.Second; tempDelay > maxDelay {
tempDelay = maxDelay
}
s.logger.ErrorContext(
ctx,
"accept error, retrying",
slog.Any("err", err),
slog.Any("temp_delay", tempDelay),
)
time.Sleep(tempDelay)
continue
}
return err
}
s.wg.Add(1)
go s.handleConn(ctx, c)
}
}
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
ctx, cancel := context.WithCancel(ctx)
c := &Conn{
ctx: ctx,
server: s,
conn: conn,
sender: smtpwriter.NewSender(conn, s.writerSize),
receiver: smtpreader.NewReceiver(conn, s.readerSize, s.maxLineLength),
}
s.locker.Lock()
s.conns[c] = struct{}{}
s.locker.Unlock()
var err error
defer func() {
if err := recover(); err != nil {
c.writeStatus(smtp.NewStatusS(421, smtp.EnhancedCode{4, 0, 0}, "Internal server error"))
stack := debug.Stack()
c.logger().ErrorContext(
c.ctx,
"panic serving",
slog.Any("err", err),
slog.Any("stack", string(stack)),
)
c.Close(errors.New("recovered from panic inside handleConn"))
}
s.locker.Lock()
delete(s.conns, c)
s.locker.Unlock()
s.wg.Done()
cancel()
}()
sctx, session, err := s.backend.NewSession(ctx, c)
if err != nil {
c.Close(fmt.Errorf("couldn't create connection wrapper: %w", err))
return
}
// update ctx and set session
c.ctx = sctx
c.session = session
c.logger().DebugContext(c.ctx, "connection is opened")
// explicit tls handshake call
if tlsConn, ok := c.conn.(*tls.Conn); ok {
if d := s.readTimeout; d != 0 {
_ = c.conn.SetReadDeadline(time.Now().Add(d))
}
if d := s.writeTimeout; d != 0 {
_ = c.conn.SetWriteDeadline(time.Now().Add(d))
}
if err := tlsConn.Handshake(); err != nil {
c.handleError(err)
return
}
}
// run always returns an error when finished
c.handleError(c.run())
}
// Listen listens on the network address s.Addr
// to handle requests on incoming connections.
//
// If s.Addr is blank and LMTP is disabled, ":smtp" is used.
func (s *Server) Listen() (net.Listener, error) {
network := s.network
if network == "" {
network = "tcp"
}
addr := s.addr
if addr == "" {
addr = ":smtp"
}
var l net.Listener
var err error
if s.implicitTLS {
l, err = tls.Listen(network, addr, s.tlsConfig)
} else {
l, err = net.Listen(network, addr)
}
if err != nil {
return nil, err
}
return l, nil
}
// ListenAndServe listens on the network address s.Addr and then calls Serve
// to handle requests on incoming connections.
//
// If s.Addr is blank and LMTP is disabled, ":smtp" is used.
func (s *Server) ListenAndServe(ctx context.Context) error {
network := s.network
if network == "" {
network = "tcp"
}
addr := s.addr
if addr == "" {
addr = ":smtp"
}
var l net.Listener
var err error
if s.implicitTLS {
l, err = tls.Listen(network, addr, s.tlsConfig)
} else {
l, err = net.Listen(network, addr)
}
if err != nil {
return err
}
return s.Serve(ctx, l)
}
// Close immediately closes all active listeners and connections.
//
// Close returns any error returned from closing the server's underlying
// listener(s).
func (s *Server) Close() error {
select {
case <-s.done:
return ErrServerClosed
default:
close(s.done)
}
var err error
s.locker.Lock()
for _, l := range s.listeners {
if lerr := l.Close(); lerr != nil && err == nil {
err = lerr
}
}
for conn := range s.conns {
// directly close underlying connection
_ = conn.conn.Close()
}
s.locker.Unlock()
return err
}
// Shutdown gracefully shuts down the server without interrupting any
// active connections. Shutdown works by first closing all open
// listeners and then waiting indefinitely for connections to return to
// idle and then shut down.
// If the provided context expires before the shutdown is complete,
// Shutdown returns the context's error, otherwise it returns any
// error returned from closing the Server's underlying Listener(s).
func (s *Server) Shutdown(ctx context.Context) error {
select {
case <-s.done:
return ErrServerClosed
default:
close(s.done)
}
var err error
s.locker.Lock()
for _, l := range s.listeners {
if lerr := l.Close(); lerr != nil && err == nil {
err = lerr
}
}
s.locker.Unlock()
connDone := make(chan struct{})
go func() {
defer close(connDone)
s.wg.Wait()
}()
select {
case <-ctx.Done():
return ctx.Err()
case <-connDone:
return err
}
}
// Hostname returns the configured hostname used in ehlo/helo
func (s *Server) Hostname() string {
return s.hostname
}
package server
import (
"crypto/tls"
"errors"
"log/slog"
"net"
"sync"
"time"
"github.com/uponusolutions/go-smtp"
)
// ErrServerClosed occurs if a server is already closed.
var ErrServerClosed = errors.New("smtp: server already closed")
// Server implements a SMTP server.
type Server struct {
// The type of network, "tcp" or "unix".
network string
// TCP or Unix address to listen on.
addr string
// The server TLS configuration.
tlsConfig *tls.Config
hostname string
maxRecipients int
// Max line length for every command except data and bdat.
maxLineLength int
// Maximum size when receiving data and bdat.
maxMessageBytes int64
// Reader buffer size.
readerSize int
// Writer buffer size.
writerSize int
readTimeout time.Duration
writeTimeout time.Duration
implicitTLS bool
// Enforces usage of implicit tls or starttls before accepting commands except NOOP, EHLO, STARTTLS, or QUIT.
enforceSecureConnection bool
// Enforces usage of authentication.
enforceAuthentication bool
// Advertise SMTPUTF8 (RFC 6531) capability.
// Should be used only if backend supports it.
enableSMTPUTF8 bool
// Advertise REQUIRETLS (RFC 8689) capability.
// Should be used only if backend supports it.
enableREQUIRETLS bool
// Advertise CHUNKING (RFC 1830) capability.
enableCHUNKING bool
// Advertise BINARYMIME (RFC 3030) capability.
// Should be used only if backend supports it.
enableBINARYMIME bool
// Advertise DSN (RFC 3461) capability.
// Should be used only if backend supports it.
enableDSN bool
// Advertise XOORG capability.
// Should be used only if backend supports it.
enableXOORG bool
// Advertise RRVS (RFC 7293) capability.
// Should be used only if backend supports it.
enableRRVS bool
// Advertise DELIVERBY (RFC 2852) capability.
// Should be used only if backend supports it.
enableDELIVERBY bool
// The minimum time, with seconds precision, that a client
// may specify in the BY argument with return mode.
// A zero value indicates no set minimum.
// Only use if DELIVERBY is enabled.
// 99999999999 is the maximum and 1 the minimum value allowed.
minimumDeliverInSeconds int
// Advertise MT-PRIORITY (RFC 6710) capability.
// Should only be used if backend supports it.
enableMTPRIORITY bool
// The priority profile mapping as defined
// in RFC 6710 section 10.2.
//
// Default value of NONE to advertise no specific profile.
mtPriorityProfile smtp.PriorityProfile
// The server backend.
backend Backend
logger *slog.Logger
wg sync.WaitGroup
done chan struct{}
locker sync.Mutex
listeners []net.Listener
conns map[*Conn]struct{}
}
// Backend returns the servers Backend.
func (s *Server) Backend() Backend {
return s.backend
}
// Option is an option for the server.
type Option func(*Server)
// New creates a new SMTP server.
func New(opts ...Option) *Server {
s := &Server{
done: make(chan struct{}, 1),
conns: make(map[*Conn]struct{}),
hostname: "localhost",
}
for _, o := range opts {
o(s)
}
if s.logger == nil {
s.logger = slog.Default()
}
return s
}
// WithLogger sets the backend.
func WithLogger(logger *slog.Logger) Option {
return func(s *Server) {
s.logger = logger
}
}
// WithBackend sets the backend.
func WithBackend(backend Backend) Option {
return func(s *Server) {
s.backend = backend
}
}
// WithNetwork sets the network.
func WithNetwork(network string) Option {
return func(s *Server) {
s.network = network
}
}
// WithReadTimeout sets the read timeout.
func WithReadTimeout(readTimeout time.Duration) Option {
return func(s *Server) {
s.readTimeout = readTimeout
}
}
// WithWriteTimeout sets the write timeout.
func WithWriteTimeout(writeTimeout time.Duration) Option {
return func(s *Server) {
s.writeTimeout = writeTimeout
}
}
// WithMaxMessageBytes sets the max message size.
func WithMaxMessageBytes(maxMessageBytes int64) Option {
return func(s *Server) {
s.maxMessageBytes = maxMessageBytes
}
}
// WithMaxLineLength sets the max length per line.
func WithMaxLineLength(maxLineLength int) Option {
return func(s *Server) {
s.maxLineLength = maxLineLength
}
}
// WithMaxRecipients sets the max recipients per mail.
func WithMaxRecipients(maxRecipients int) Option {
return func(s *Server) {
s.maxRecipients = maxRecipients
}
}
// WithAddr sets addr.
func WithAddr(addr string) Option {
return func(s *Server) {
s.addr = addr
}
}
// WithEnableXOORG enables xoorg.
func WithEnableXOORG(enableXOORG bool) Option {
return func(s *Server) {
s.enableXOORG = enableXOORG
}
}
// WithEnableBINARYMIME sets EnableBINARYMIME.
func WithEnableBINARYMIME(enableBINARYMIME bool) Option {
return func(s *Server) {
s.enableBINARYMIME = enableBINARYMIME
}
}
// WithEnableREQUIRETLS sets EnableREQUIRETLS.
func WithEnableREQUIRETLS(enableREQUIRETLS bool) Option {
return func(s *Server) {
s.enableREQUIRETLS = enableREQUIRETLS
}
}
// WithEnableCHUNKING sets EnableCHUNKING.
func WithEnableCHUNKING(enableCHUNKING bool) Option {
return func(s *Server) {
s.enableCHUNKING = enableCHUNKING
}
}
// WithEnableSMTPUTF8 sets EnableSMTPUTF8.
func WithEnableSMTPUTF8(enableSMTPUTF8 bool) Option {
return func(s *Server) {
s.enableSMTPUTF8 = enableSMTPUTF8
}
}
// WithEnableDSN sets EnableDSN.
func WithEnableDSN(enableDSN bool) Option {
return func(s *Server) {
s.enableDSN = enableDSN
}
}
// WithImplicitTLS sets implicitTLS.
func WithImplicitTLS(implicitTLS bool) Option {
return func(s *Server) {
s.implicitTLS = implicitTLS
}
}
// WithHostname sets the domain.
func WithHostname(hostname string) Option {
return func(s *Server) {
s.hostname = hostname
}
}
// WithTLSConfig sets certificate.
func WithTLSConfig(tlsConfig *tls.Config) Option {
return func(s *Server) {
s.tlsConfig = tlsConfig
}
}
// WithEnforceSecureConnection enforces implicit TLS or STARTTLS.
func WithEnforceSecureConnection(enforceSecureConnection bool) Option {
return func(s *Server) {
s.enforceSecureConnection = enforceSecureConnection
}
}
// WithEnforceAuthentication enforces authentication before mail usage.
func WithEnforceAuthentication(enforceAuthentication bool) Option {
return func(s *Server) {
s.enforceAuthentication = enforceAuthentication
}
}
// WithReaderSize sets ReaderSize.
func WithReaderSize(readerSize int) Option {
return func(s *Server) {
s.readerSize = readerSize
}
}
// WithWriterSize sets WriterSize.
func WithWriterSize(writerSize int) Option {
return func(s *Server) {
s.writerSize = writerSize
}
}
// WithEnableRRVS advertises RRVS (RFC 7293) capability.
// Should be used only if backend supports it.
func WithEnableRRVS(enableRRVS bool) Option {
return func(s *Server) {
s.enableRRVS = enableRRVS
}
}
// WithEnableDELIVERBY advertises DELIVERBY (RFC 2852) capability.
// Should be used only if backend supports it.
func WithEnableDELIVERBY(enableDELIVERBY bool) Option {
return func(s *Server) {
s.enableDELIVERBY = enableDELIVERBY
}
}
// WithMinimumDeliverInSeconds defines the minimum time, with seconds precision, that a client
// may specify in the BY argument with return mode.
// A zero value indicates no set minimum.
// Only use if DELIVERBY is enabled.
func WithMinimumDeliverInSeconds(minimumDeliverInSeconds int) Option {
return func(s *Server) {
s.minimumDeliverInSeconds = minimumDeliverInSeconds
}
}
// WithEnableMTPRIORITY advertises MT-PRIORITY (RFC 6710) capability.
// Should only be used if backend supports it.
func WithEnableMTPRIORITY(enableMTPRIORITY bool) Option {
return func(s *Server) {
s.enableMTPRIORITY = enableMTPRIORITY
}
}
// WithMtPriorityProfile sets the priority profile mapping as defined
// in RFC 6710 section 10.2.
//
// Default value of NONE to advertise no specific profile.
func WithMtPriorityProfile(mtPriorityProfile smtp.PriorityProfile) Option {
return func(s *Server) {
s.mtPriorityProfile = mtPriorityProfile
}
}
package tester
import (
"io"
)
// A Buffer is a variable-sized buffer of bytes with [Buffer.Read] and [Buffer.Write] methods.
// The zero value for Buffer is an empty buffer ready to use.
type Buffer struct {
buf []byte // contents are the bytes buf[off : len(buf)]
off int // read at &buf[off], write at &buf[len(buf)]
}
// empty reports whether the unread portion of the buffer is empty.
func (b *Buffer) empty() bool { return len(b.buf) <= b.off }
// Reset resets the buffer to be empty,
// but it retains the underlying storage for use by future writes.
// Reset is the same as [Buffer.Truncate](0).
func (b *Buffer) Reset() {
b.buf = b.buf[:0]
b.off = 0
}
// Read reads the next len(p) bytes from the buffer or until the buffer
// is drained. The return value n is the number of bytes read. If the
// buffer has no data to return, err is [io.EOF] (unless len(p) is zero);
// otherwise it is nil.
func (b *Buffer) Read(p []byte) (n int, err error) {
if b.empty() {
// Buffer is empty, reset to recover space.
b.Reset()
if len(p) == 0 {
return 0, nil
}
return 0, io.EOF
}
n = copy(p, b.buf[b.off:])
b.off += n
return n, nil
}
// NewBuffer creates and initializes a new [Buffer] using buf as its
// initial contents. The new [Buffer] takes ownership of buf, and the
// caller should not use buf after this call. NewBuffer is intended to
// prepare a [Buffer] to read existing data.
//
// In most cases, new([Buffer]) (or just declaring a [Buffer] variable) is
// sufficient to initialize a [Buffer].
//
// Buffer just implements a reader without any extra functionality.
func NewBuffer(buf []byte) io.Reader { return &Buffer{buf: buf} }
package tester
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"math/big"
"time"
)
// GenX509KeyPair generates a self signed smtp server certificate with the given domain.
func GenX509KeyPair(domain string) (tls.Certificate, error) {
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(now.Unix()),
Subject: pkix.Name{
CommonName: domain,
Country: []string{"DE"},
Organization: []string{"Testcompany"},
OrganizationalUnit: []string{"Development"},
},
NotBefore: now.AddDate(0, 0, -1),
NotAfter: now.AddDate(999, 0, 0),
SubjectKeyId: []byte{113, 117, 105, 99, 107, 115, 101, 114, 118, 101},
BasicConstraintsValid: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
DNSNames: []string{domain},
}
extSubjectAltName := pkix.Extension{}
extSubjectAltName.Id = asn1.ObjectIdentifier{2, 5, 29, 17}
extSubjectAltName.Critical = false
var err error
extSubjectAltName.Value, err = asn1.Marshal([]string{`dns:` + domain})
if err != nil {
return tls.Certificate{}, err
}
template.ExtraExtensions = []pkix.Extension{extSubjectAltName}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
cert, err := x509.CreateCertificate(rand.Reader, template, template,
priv.Public(), priv)
if err != nil {
return tls.Certificate{}, err
}
var outCert tls.Certificate
outCert.Certificate = append(outCert.Certificate, cert)
outCert.PrivateKey = priv
return outCert, nil
}
package tester
import (
"bytes"
"embed"
"io"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func getAllFilenames(fs *embed.FS, path string) (out []string, err error) {
if len(path) == 0 {
path = "."
}
entries, err := fs.ReadDir(path)
if err != nil {
return nil, err
}
for _, entry := range entries {
fp := filepath.Join(path, entry.Name())
if entry.IsDir() {
res, err := getAllFilenames(fs, fp)
if err != nil {
return nil, err
}
out = append(out, res...)
continue
}
out = append(out, fp)
}
return out, err
}
func chunkSlice(slice []byte, chunkSize int) [][]byte {
var chunks [][]byte
for len(slice) != 0 {
// necessary check to avoid slicing beyond
// slice capacity
if len(slice) < chunkSize {
chunkSize = len(slice)
}
chunks = append(chunks, slice[0:chunkSize])
slice = slice[chunkSize:]
}
return chunks
}
func checkExpectedBufferAgainsActual(t *testing.T, b []byte, expected func(io.Writer) io.WriteCloser, actual func(io.Writer) io.WriteCloser) {
var buf bytes.Buffer
var err error
f := expected(&buf)
_, err = f.Write(b)
require.NoError(t, err)
require.NoError(t, f.Close())
size := 1
for size < 4048 && len(b) >= size {
bsplitted := chunkSlice(b, size)
var buf1 bytes.Buffer
f := actual(&buf1)
for _, b := range bsplitted {
_, err = f.Write(b)
require.NoError(t, err)
}
require.NoError(t, f.Close())
require.Equal(t, buf, buf1)
size++
}
}
// WriterCompareTest reads all files out of fs[path] and compares the expected func against the actual func.
// To simulate differences of Write calls of different sizes it slices the files in increasing sizes up to 4048.
func WriterCompareTest(t *testing.T, fs *embed.FS, path string, expected func(io.Writer) io.WriteCloser, actual func(io.Writer) io.WriteCloser) {
files, err := getAllFilenames(fs, path)
require.NoError(t, err)
for _, file := range files {
dat, err := fs.ReadFile(file)
require.NoError(t, err)
checkExpectedBufferAgainsActual(t, []byte(dat), expected, actual)
}
}
func checkRaderExpectedAgainsActual(t *testing.T, b []byte, expected func(io.Reader) ([]byte, error), actual func(io.Reader) ([]byte, error)) {
pr, pw := io.Pipe()
go func() {
_, err := pw.Write(b)
require.NoError(t, err)
err = pw.Close()
require.NoError(t, err)
}()
buf, err := expected(pr)
size := 1
for size < 4048 && len(b) >= size {
bsplitted := chunkSlice(b, size)
pr, pw = io.Pipe()
writeInGoroutine(t, bsplitted, pw)
buf1, err1 := actual(pr)
require.Equal(t, err, err1)
// print(string(buf), string(buf1))
require.Equal(t, buf, buf1)
size++
}
}
func writeInGoroutine(t *testing.T, bsplitted [][]byte, pw *io.PipeWriter) {
go func() {
var err error
for _, b := range bsplitted {
_, err = pw.Write(b)
require.NoError(t, err)
}
err = pw.Close()
require.NoError(t, err)
}()
}
// ReaderCompareTest reads all files out of fs[path] and
// compares the result of the expected func against the actual func.
// To simulate differences of Read calls with different sizes it slices the files in increasing sizes up to 4048.
func ReaderCompareTest(t *testing.T, fs *embed.FS, path string, expected func(io.Reader) ([]byte, error), actual func(io.Reader) ([]byte, error)) {
files, err := getAllFilenames(fs, path)
require.NoError(t, err)
for _, file := range files {
dat, err := fs.ReadFile(file)
require.NoError(t, err)
checkRaderExpectedAgainsActual(t, []byte(dat), expected, actual)
}
}
package tester
import (
"bytes"
"io"
"net"
"strings"
"time"
)
// FakeConn fakes a conn for testing.
type FakeConn struct {
io.ReadWriter
RemoteAddrReturn net.Addr
}
// NewFakeConnStream creates a new FakeConn with a stream as a input.
func NewFakeConnStream(in io.Reader, out *bytes.Buffer) *FakeConn {
rw := struct {
io.Reader
io.Writer
}{
Reader: in,
Writer: out,
}
return &FakeConn{
ReadWriter: rw,
}
}
// NewFakeConn creates a new FakeConn with a string as a input.
func NewFakeConn(in string, out *bytes.Buffer) *FakeConn {
rw := struct {
io.Reader
io.Writer
}{
Reader: strings.NewReader(in),
Writer: out,
}
return &FakeConn{
ReadWriter: rw,
}
}
// Close always returns nil.
func (FakeConn) Close() error { return nil }
// LocalAddr always returns nil.
func (FakeConn) LocalAddr() net.Addr { return nil }
// RemoteAddr always returns RemoteAddrReturn.
func (f FakeConn) RemoteAddr() net.Addr { return f.RemoteAddrReturn }
// SetDeadline always returns nil and does nothing.
func (FakeConn) SetDeadline(time.Time) error { return nil }
// SetReadDeadline always returns nil and does nothing.
func (FakeConn) SetReadDeadline(time.Time) error { return nil }
// SetWriteDeadline always returns nil and does nothing.
func (FakeConn) SetWriteDeadline(time.Time) error { return nil }
package tester
import "strings"
// Mail is one mail received by SMTP server.
type Mail struct {
From string
Recipients []string
Data []byte
}
// LookupKey call LookupKey for current mail.
func (m *Mail) LookupKey() string {
return m.From + "+" + strings.Join(m.Recipients, "+")
}
// LookupKey returns a key of the format:
//
// m.From+m.Recipient_1+m.Recipient_2...
func LookupKey(f string, r []string) string {
return f + "+" + strings.Join(r, "+")
}
// Package smtptester implements a simple SMTP server for testing. All
// received mails are saved in a sync.Map with a key:
//
// From+Recipient1+Recipient2
//
// Mails to the same sender and recipients will overwrite a previous
// received mail, when the recipients slice has the same order as
// in the mail received before.
package testserver
import (
"context"
"crypto/tls"
"io"
"log/slog"
"sync"
"time"
"github.com/uponusolutions/go-sasl"
"github.com/uponusolutions/go-smtp"
"github.com/uponusolutions/go-smtp/server"
"github.com/uponusolutions/go-smtp/tester"
)
// Standard returns a standard SMTP server listening on a random Port.
func Standard(opts ...server.Option) *server.Server {
defaultOpts := []server.Option{
server.WithAddr(":0"),
server.WithReadTimeout(10 * time.Second),
server.WithWriteTimeout(10 * time.Second),
server.WithMaxMessageBytes(1024 * 1024),
server.WithMaxRecipients(100),
server.WithBackend(NewBackend()),
}
return server.New(
append(defaultOpts, opts...)...,
)
}
///////////////////////////////////////////////////////////////////////////
// Backend
///////////////////////////////////////////////////////////////////////////
// Backend is the backend for out test server.
// It contains a sync.Map with all mails received.
type Backend struct {
Mails sync.Map
Mail func(ctx context.Context, from string, options *smtp.MailOptions) error
Rcpt func(ctx context.Context, to string, options *smtp.RcptOptions) error
}
// NewBackend returns a new Backend with an empty (not nil) Mails map.
func NewBackend() *Backend {
return &Backend{Mails: sync.Map{}}
}
// NewSession returns a new Session.
func (b *Backend) NewSession(ctx context.Context, _ *server.Conn) (context.Context, server.Session, error) {
return ctx, newSession(b), nil
}
// GetBackend returns the concrete type *Backend from SMTP server.
func GetBackend(s *server.Server) *Backend {
if s.Backend() == nil {
return nil
}
b, ok := s.Backend().(*Backend)
if !ok {
return nil
}
return b
}
// Add adds mail to backends map.
func (b *Backend) Add(m *tester.Mail) {
b.Mails.Store(m.LookupKey(), m)
}
// Load loads mail from 'from' to recipients 'recipients'. The ok
// result indicates whether value was found in the map.
func (b *Backend) Load(from string, recipients []string) (*tester.Mail, bool) {
i, ok := b.Mails.Load(tester.LookupKey(from, recipients))
if !ok {
return nil, ok
}
return i.(*tester.Mail), ok //nolint
}
///////////////////////////////////////////////////////////////////////////
// Session
///////////////////////////////////////////////////////////////////////////
// A Session is returned after successful login.
type Session struct {
backend *Backend
mail *tester.Mail
}
func newSession(b *Backend) *Session {
return &Session{
backend: b,
mail: &tester.Mail{},
}
}
// Reset implements Reset interface.
func (s *Session) Reset(ctx context.Context, _ bool) (context.Context, error) {
s.mail = &tester.Mail{}
return ctx, nil
}
// Close implements the Close interface.
func (s *Session) Close(_ context.Context, _ error) {
s.mail = &tester.Mail{}
}
// Logger implements the Logger interface.
func (Session) Logger(_ context.Context) *slog.Logger {
return nil
}
// Verify implements the Verify interface.
func (Session) Verify(_ context.Context, _ string, _ *smtp.VrfyOptions) error {
return nil
}
// Mail implements the Mail interface.
func (s *Session) Mail(ctx context.Context, from string, options *smtp.MailOptions) error {
if s.backend.Mail != nil {
if err := s.backend.Mail(ctx, from, options); err != nil {
return err
}
}
s.mail.From = from
return nil
}
// Rcpt implements the Rcpt interface.
func (s *Session) Rcpt(ctx context.Context, to string, options *smtp.RcptOptions) error {
if s.backend.Rcpt != nil {
if err := s.backend.Rcpt(ctx, to, options); err != nil {
return err
}
}
s.mail.Recipients = append(s.mail.Recipients, to)
return nil
}
// Data implements the Data interface.
func (s *Session) Data(_ context.Context, r func() io.Reader) (string, error) {
var err error
if s.mail.Data, err = io.ReadAll(r()); err != nil {
return "", err
}
s.backend.Add(s.mail)
return "", nil
}
// AuthMechanisms implements the AuthMechanisms interface.
func (Session) AuthMechanisms(_ context.Context) []string {
return nil
}
// Auth implements the Auth interface.
func (Session) Auth(_ context.Context, _ string) (sasl.Server, error) {
return nil, nil
}
// STARTTLS implements the STARTTLS interface.
func (Session) STARTTLS(_ context.Context, config *tls.Config) (*tls.Config, error) {
return config, nil
}
package upstream
import (
"bufio"
"io"
"github.com/uponusolutions/go-smtp"
)
// Copied and integrated over from
// https://github.com/emersion/go-smtp/blob/8d5af0d9db3ace5e4fdc0e8b427f5b157b0c6c44/data.go
// to serve as the upstream comparison.
// DotReader implements a smtp dot reader.
type DotReader struct {
r *bufio.Reader
state int
limited bool
n int64 // Maximum bytes remaining
}
// NewDotReader creates a new smtp dot reader.
func NewDotReader(r *bufio.Reader, maxMessageBytes int) *DotReader {
dr := &DotReader{
r: r,
}
if maxMessageBytes > 0 {
dr.limited = true
dr.n = int64(maxMessageBytes)
}
return dr
}
func (r *DotReader) Read(b []byte) (n int, err error) {
if r.limited {
if r.n <= 0 {
return 0, smtp.ErrDataTooLarge
}
if int64(len(b)) > r.n {
b = b[0:r.n]
}
}
// Code below is taken from net/textproto with only one modification to
// not rewrite CRLF -> LF.
// Run data through a simple state machine to
// elide leading dots and detect End-of-Data (<CR><LF>.<CR><LF>) line.
const (
stateBeginLine = iota // beginning of line; initial state; must be zero
stateDot // read . at beginning of line
stateDotCR // read .\r at beginning of line
stateCR // read \r (possibly at end of line)
stateData // reading data in middle of line
stateEOF // reached .\r\n end marker line
)
for n < len(b) && r.state != stateEOF {
var c byte
c, err = r.r.ReadByte()
if err != nil {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
break
}
switch r.state {
case stateBeginLine:
if c == '.' {
r.state = stateDot
continue
}
if c == '\r' {
r.state = stateCR
break
}
r.state = stateData
case stateDot:
if c == '\r' {
r.state = stateDotCR
continue
}
r.state = stateData
case stateDotCR:
if c == '\n' {
r.state = stateEOF
continue
}
r.state = stateData
case stateCR:
if c == '\n' {
r.state = stateBeginLine
break
}
r.state = stateData
case stateData:
if c == '\r' {
r.state = stateCR
}
default:
}
b[n] = c
n++
}
if err == nil && r.state == stateEOF {
err = io.EOF
}
if r.limited {
r.n -= int64(n)
}
return n, err
}
package smtp
import (
"net"
"time"
)
// Timeout sets a timeout by deadline to the connection and relieves it when returning func is used.
func Timeout(conn net.Conn, duration time.Duration) func() {
_ = conn.SetDeadline(time.Now().Add(duration))
return func() {
_ = conn.SetDeadline(time.Time{})
}
}