// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC
// 5321. It extends net/smtp as follows:
//
// - Supports SMTPUTF8, via MailAndRcpt.
// - Adds IsPermanent.
package smtp
import (
"bufio"
"io"
"net"
"net/smtp"
"net/textproto"
"unicode"
"blitiri.com.ar/go/chasquid/internal/envelope"
"golang.org/x/net/idna"
)
// A Client represents a client connection to an SMTP server.
type Client struct {
*smtp.Client
}
// NewClient uses the given connection to create a new Client.
func NewClient(conn net.Conn, host string) (*Client, error) {
c, err := smtp.NewClient(conn, host)
if err != nil {
return nil, err
}
// Wrap the textproto.Conn reader so we are not exposed to a memory
// exhaustion DoS on very long replies from the server.
// Limit to 2 MiB total (all replies through the lifetime of the client),
// which should be plenty for our uses of SMTP.
lr := &io.LimitedReader{R: c.Text.Reader.R, N: 2 * 1024 * 1024}
c.Text.Reader.R = bufio.NewReader(lr)
return &Client{c}, nil
}
// cmd sends a command and returns the response over the text connection.
// Based on Go's method of the same name.
func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
id, err := c.Text.Cmd(format, args...)
if err != nil {
return 0, "", err
}
c.Text.StartResponse(id)
defer c.Text.EndResponse(id)
return c.Text.ReadResponse(expectCode)
}
// MailAndRcpt issues MAIL FROM and RCPT TO commands, in sequence.
// It will check the addresses, decide if SMTPUTF8 is needed, and apply the
// necessary transformations.
func (c *Client) MailAndRcpt(from string, to string) error {
from, fromNeeds, err := c.prepareForSMTPUTF8(from)
if err != nil {
return err
}
to, toNeeds, err := c.prepareForSMTPUTF8(to)
if err != nil {
return err
}
smtputf8Needed := fromNeeds || toNeeds
cmdStr := "MAIL FROM:<%s>"
if ok, _ := c.Extension("8BITMIME"); ok {
cmdStr += " BODY=8BITMIME"
}
if smtputf8Needed {
cmdStr += " SMTPUTF8"
}
_, _, err = c.cmd(250, cmdStr, from)
if err != nil {
return err
}
_, _, err = c.cmd(25, "RCPT TO:<%s>", to)
return err
}
// prepareForSMTPUTF8 prepares the address for SMTPUTF8.
// It returns:
// - The address to use. It is based on addr, and possibly modified to make
// it not need the extension, if the server does not support it.
// - Whether the address needs the extension or not.
// - An error if the address needs the extension, but the client does not
// support it.
func (c *Client) prepareForSMTPUTF8(addr string) (string, bool, error) {
// ASCII address pass through.
if isASCII(addr) {
return addr, false, nil
}
// Non-ASCII address also pass through if the server supports the
// extension.
// Note there's a chance the server wants the domain in IDNA anyway, but
// it could also require it to be UTF8. We assume that if it supports
// SMTPUTF8 then it knows what its doing.
if ok, _ := c.Extension("SMTPUTF8"); ok {
return addr, true, nil
}
// Something is not ASCII, and the server does not support SMTPUTF8:
// - If it's the local part, there's no way out and is required.
// - If it's the domain, use IDNA.
user, domain := envelope.Split(addr)
if !isASCII(user) {
return addr, true, &textproto.Error{Code: 599,
Msg: "local part is not ASCII but server does not support SMTPUTF8"}
}
// If it's only the domain, convert to IDNA and move on.
domain, err := idna.ToASCII(domain)
if err != nil {
// The domain is not IDNA compliant, which is odd.
// Fail with a permanent error, not ideal but this should not
// happen.
return addr, true, &textproto.Error{
Code: 599, Msg: "non-ASCII domain is not IDNA safe"}
}
return user + "@" + domain, false, nil
}
// isASCII returns true if all the characters in s are ASCII, false otherwise.
func isASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {
return false
}
}
return true
}
// IsPermanent returns true if the error is permanent, and false otherwise.
// If it can't tell, it returns false.
func IsPermanent(err error) bool {
terr, ok := err.(*textproto.Error)
if !ok {
return false
}
// Error codes 5yz are permanent.
// https://tools.ietf.org/html/rfc5321#section-4.2.1
if terr.Code >= 500 && terr.Code < 600 {
return true
}
return false
}