Repository: emersion/go-smtp
Pull request #280 (2025-04-05 19:11:44)

Implement RRVS
This pr implements [RRVS](https://www.rfc-editor.org/rfc/rfc7293.html) for both the client and server.


Patch:
diff --git a/client.go b/client.go
index 89ceddd..199512f 100644
--- a/client.go
+++ b/client.go
@@ -81,7 +81,7 @@ func DialTLS(addr string, tlsConfig *tls.Config) (*Client, error) {
 	return client, nil
 }
 
-// DialStartTLS retruns a new Client connected to an SMTP server via STARTTLS
+// DialStartTLS returns a new Client connected to an SMTP server via STARTTLS
 // at addr. The addr must include a port, as in "mail.example.com:smtp".
 //
 // A nil tlsConfig is equivalent to a zero tls.Config.
@@ -530,6 +530,9 @@ func (c *Client) Rcpt(to string, opts *RcptOptions) error {
 			fmt.Fprintf(&sb, " ORCPT=%s;%s", string(opts.OriginalRecipientType), enc)
 		}
 	}
+	if _, ok := c.ext["RRVS"]; ok && opts != nil && !opts.RequireRecipientValidSince.IsZero() {
+		sb.WriteString(fmt.Sprintf(" RRVS=%s", opts.RequireRecipientValidSince.Format(time.RFC3339)))
+	}
 	if _, _, err := c.cmd(25, "%s", sb.String()); err != nil {
 		return err
 	}
diff --git a/conn.go b/conn.go
index 4b88982..54a9ec0 100644
--- a/conn.go
+++ b/conn.go
@@ -291,6 +291,9 @@ func (c *Conn) handleGreet(enhanced bool, arg string) {
 	if c.server.MaxRecipients > 0 {
 		caps = append(caps, fmt.Sprintf("LIMITS RCPTMAX=%v", c.server.MaxRecipients))
 	}
+	if c.server.EnableRRVS {
+		caps = append(caps, "RRVS")
+	}
 
 	args := []string{"Hello " + domain}
 	args = append(args, caps...)
@@ -716,6 +719,18 @@ func (c *Conn) handleRcpt(arg string) {
 			}
 			opts.OriginalRecipientType = aType
 			opts.OriginalRecipient = aAddr
+		case "RRVS":
+			if !c.server.EnableRRVS {
+				c.writeResponse(504, EnhancedCode{5, 5, 4}, "RRVS is not implemented")
+				return
+			}
+			value, _, _ = strings.Cut(value, ";") // discard the no-support action
+			rrvsTime, err := time.Parse(time.RFC3339, value)
+			if err != nil {
+				c.writeResponse(501, EnhancedCode{5, 5, 4}, "Malformed RRVS parameter value")
+				return
+			}
+			opts.RequireRecipientValidSince = rrvsTime
 		default:
 			c.writeResponse(500, EnhancedCode{5, 5, 4}, "Unknown RCPT TO argument")
 			return
diff --git a/server.go b/server.go
index 7c23a31..0891f1f 100644
--- a/server.go
+++ b/server.go
@@ -57,6 +57,10 @@ type Server struct {
 	// Should be used only if backend supports it.
 	EnableDSN bool
 
+	// Advertise RRVS (RFC 7293) capability.
+	// Should be used only if backend supports it.
+	EnableRRVS bool
+
 	// The server backend.
 	Backend Backend
 
diff --git a/smtp.go b/smtp.go
index 9563b11..efc7bdf 100644
--- a/smtp.go
+++ b/smtp.go
@@ -17,6 +17,8 @@
 // Additional extensions may be handled by other packages.
 package smtp
 
+import "time"
+
 type BodyType string
 
 const (
@@ -91,4 +93,8 @@ type RcptOptions struct {
 	// Original recipient set by client.
 	OriginalRecipientType DSNAddressType
 	OriginalRecipient     string
+
+	// Time value of the RRVS= argument
+	// Left as the zero time if unset.
+	RequireRecipientValidSince time.Time
 }


Test patch:
diff --git a/client_test.go b/client_test.go
index b175f23..ba5b01d 100644
--- a/client_test.go
+++ b/client_test.go
@@ -1044,3 +1044,38 @@ func TestClientDSN(t *testing.T) {
 		t.Errorf("wrote %q; want %q", actualcmds, client)
 	}
 }
+
+var rrvsServer = `220 hello world
+250 ok
+250 ok
+`
+
+var rrvsClient = `RCPT TO:<root@nsa.gov> RRVS=2014-04-03T23:01:00Z
+RCPT TO:<root@gchq.gov.uk>
+`
+
+func TestClientRRVS(t *testing.T) {
+	server := strings.Join(strings.Split(rrvsServer, "\n"), "\r\n")
+	client := strings.Join(strings.Split(rrvsClient, "\n"), "\r\n")
+
+	var wrote bytes.Buffer
+	var fake faker
+	fake.ReadWriter = struct {
+		io.Reader
+		io.Writer
+	}{
+		strings.NewReader(server),
+		&wrote,
+	}
+	c := NewClient(fake)
+	c.didHello = true
+	c.ext = map[string]string{"RRVS": ""}
+	c.Rcpt("root@nsa.gov", &RcptOptions{
+		RequireRecipientValidSince: time.Date(2014, time.April, 3, 23, 1, 0, 0, time.UTC),
+	})
+	c.Rcpt("root@gchq.gov.uk", &RcptOptions{})
+	c.Close()
+	if actualcmds := wrote.String(); client != actualcmds {
+		t.Errorf("wrote %q; want %q", actualcmds, client)
+	}
+}
diff --git a/server_test.go b/server_test.go
index 206d336..10e6cf0 100644
--- a/server_test.go
+++ b/server_test.go
@@ -5,6 +5,7 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"fmt"
 	"io"
 	"io/ioutil"
 	"log"
@@ -12,6 +13,7 @@ import (
 	"strings"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/emersion/go-sasl"
 	"github.com/emersion/go-smtp"
@@ -1514,3 +1516,67 @@ func TestServerDSNwithSMTPUTF8(t *testing.T) {
 		t.Fatal("Invalid ORCPT address:", val)
 	}
 }
+
+func TestServerRRVS(t *testing.T) {
+	be, s, c, scanner, caps := testServerEhlo(t,
+		func(s *smtp.Server) {
+			s.EnableRRVS = true
+		})
+	defer s.Close()
+	defer c.Close()
+
+	if _, ok := caps["RRVS"]; !ok {
+		t.Fatal("Missing capability: RRVS")
+	}
+
+	io.WriteString(c, "MAIL FROM:<root@nsa.gov>\r\n")
+	scanner.Scan()
+
+	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk> RRVS=\r\n")
+	scanner.Scan()
+
+	if !strings.HasPrefix(scanner.Text(), "501 5.5.4") {
+		t.Fatal("Unexpected res on malformed RRVS parameter value:", scanner.Text())
+	}
+
+	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk> RRVS=1234\r\n")
+	scanner.Scan()
+
+	if !strings.HasPrefix(scanner.Text(), "501 5.5.4 ") {
+		t.Fatal("Unexpected res on malformed RRVS parameter value:", scanner.Text())
+	}
+
+	io.WriteString(c, "RCPT TO:<root@gchq.gov.uk> RRVS=2014-04-03T23:01:00Z\r\n")
+	scanner.Scan()
+
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid RRVS parameter value:", scanner.Text())
+	}
+
+	io.WriteString(c, "RCPT TO:<root@bnd.bund.de> RRVS=2020-03-19T11:13:00Z;ign0r3.th1s;othr_stuff\r\n")
+	scanner.Scan()
+
+	if !strings.HasPrefix(scanner.Text(), "250 ") {
+		t.Fatal("Invalid RRVS parameter value:", scanner.Text())
+	}
+
+	// complete the transaction
+	io.WriteString(c, "DATA\r\n")
+	scanner.Scan()
+	io.WriteString(c, "Hey <3\r\n")
+	io.WriteString(c, ".\r\n")
+	scanner.Scan()
+
+	opts := be.anonmsgs[0].RcptOpts
+	if opts == nil || len(opts) != 2 {
+		t.Fatal("Invalid number of recipients:", opts)
+	}
+
+	if !opts[0].RequireRecipientValidSince.Equal(time.Date(2014, time.April, 3, 23, 1, 0, 0, time.UTC)) {
+		t.Fatal("Invalid RRVS parameter value:", fmt.Sprintf("%#v", opts[0].RequireRecipientValidSince))
+	}
+
+	if !opts[1].RequireRecipientValidSince.Equal(time.Date(2020, time.March, 19, 11, 13, 0, 0, time.UTC)) {
+		t.Fatal("Invalid RRVS parameter value:", fmt.Sprintf("%#v", opts[1].RequireRecipientValidSince))
+	}
+}