diff --git a/smtpserver/server.go b/smtpserver/server.go index 6477020..6bd8dfa 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -26,6 +26,7 @@ import ( "strings" "sync" "time" + "unicode" "golang.org/x/exp/maps" "golang.org/x/text/unicode/norm" @@ -331,7 +332,7 @@ type conn struct { futureRelease time.Time // MAIL FROM with HOLDFOR or HOLDUNTIL. futureReleaseRequest string // For use in DSNs, either "for;" or "until;" plus original value. ../rfc/4865:305 has8bitmime bool // If MAIL FROM parameter BODY=8BITMIME was sent. Required for SMTPUTF8. - smtputf8 bool // todo future: we should keep track of this per recipient. perhaps only a specific recipient requires smtputf8, e.g. due to a utf8 localpart. we should decide ourselves if the message needs smtputf8, e.g. due to utf8 header values. + smtputf8 bool // todo future: we should keep track of this per recipient. perhaps only a specific recipient requires smtputf8, e.g. due to a utf8 localpart. recipients []rcptAccount } @@ -1899,6 +1900,34 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "must match authenticated user") } + // Check if the message contains non-ascii characters. If no such characters are found, + // the SMTPUTF8 extension is not required. + // ../rfc/6531:497 + isASCII := func(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + return true + } + c.smtputf8 = !isASCII(c.mailFrom.Localpart.String()) + for _, rcpt := range c.recipients { + if !isASCII(rcpt.rcptTo.Localpart.String()) { + c.smtputf8 = true + break + } + } + for _, values := range header { + for _, value := range values { + if !isASCII(value) { + c.smtputf8 = true + break + } + } + + } + // TLS-Required: No header makes us not enforce recipient domain's TLS policy. // ../rfc/8689:206 // Only when requiretls smtp extension wasn't used. ../rfc/8689:246 diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index d49df9d..eacc691 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -1767,3 +1767,53 @@ func TestFutureRelease(t *testing.T) { test(" HOLDFOR=1 HOLDFOR=1", "501") // Duplicate. test(" HOLDFOR=1 HOLDUNTIL="+time.Now().Add(time.Hour).UTC().Format(time.RFC3339), "501") // Duplicate. } + +// Test SMTPUTF8 +func TestSMTPUTF8(t *testing.T) { + ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{}) + defer ts.close() + + ts.user = "mjl@mox.example" + ts.pass = password0 + ts.submission = true + + test := func(mailFrom string, rcptTo string, headerValue string, clientSmtputf8 bool, expectedSmtputf8 bool, expErr *smtpclient.Error) { + t.Helper() + + ts.run(func(_ error, client *smtpclient.Client) { + t.Helper() + msg := strings.ReplaceAll(fmt.Sprintf(`From: <%s> +To: <%s> +Subject: test +X-Custom-Test-Header: %s + +test email +`, mailFrom, rcptTo, headerValue), "\n", "\r\n") + + err := client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, clientSmtputf8, false) + var cerr smtpclient.Error + if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) { + t.Fatalf("got err %#v, expected %#v", err, expErr) + } + if err != nil { + return + } + + msgs, _ := queue.List(ctxbg, queue.Filter{}) + queuedMsg := msgs[len(msgs)-1] + if queuedMsg.SMTPUTF8 != expectedSmtputf8 { + t.Fatalf("[%s / %s / %s] got SMTPUTF8 %t, expected %t", mailFrom, rcptTo, headerValue, queuedMsg.SMTPUTF8, expectedSmtputf8) + } + }) + } + + test(`mjl@mox.example`, `remote@example.org`, "ascii", false, false, nil) + test(`mjl@mox.example`, `remote@example.org`, "ascii", true, false, nil) + test(`mjl@mox.example`, `🙂@example.org`, "ascii", true, true, nil) + test(`mjl@mox.example`, `🙂@example.org`, "ascii", false, true, &smtpclient.Error{Permanent: true, Code: smtp.C553BadMailbox, Secode: smtp.SeMsg6NonASCIIAddrNotPermitted7}) + test(`Ω@mox.example`, `remote@example.org`, "ascii", true, true, nil) + test(`Ω@mox.example`, `remote@example.org`, "ascii", false, true, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SeMsg6NonASCIIAddrNotPermitted7}) + test(`mjl@mox.example`, `remote@example.org`, "non-ascii-😍", false, true, nil) + test(`mjl@mox.example`, `remote@example.org`, "non-ascii-😍", true, true, nil) + test(`Ω@mox.example`, `🙂@example.org`, "non-ascii-😍", true, true, nil) +}