From 3484651691cb3a78062e5c19d5ac7046a5dfba7b Mon Sep 17 00:00:00 2001
From: Laurent Meunier <laurent@deltalima.net>
Date: Thu, 28 Mar 2024 17:47:11 +0100
Subject: [PATCH] do not require the SMTPUTF8 extension when not needed

fix #145
---
 smtpserver/server.go      | 31 +++++++++++++++++++++++-
 smtpserver/server_test.go | 50 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 80 insertions(+), 1 deletion(-)

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)
+}