implement "future release"

the smtp extension, rfc 4865.
also implement in the webmail.
the queueing/delivery part hardly required changes: we just set the first
delivery time in the future instead of immediately.

still have to find the first client that implements it.
This commit is contained in:
Mechiel Lukkien
2024-02-10 17:55:56 +01:00
parent 17734196e3
commit 93c52b01a0
19 changed files with 382 additions and 54 deletions

View File

@ -6,6 +6,7 @@ import (
"net"
"strconv"
"strings"
"time"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
@ -137,7 +138,7 @@ func (p *parser) peekchar() rune {
return -1
}
func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
func (p *parser) xtakefn1(what string, fn func(c rune, i int) bool) string {
if p.empty() {
p.xerrorf("need at least one char for %s", what)
}
@ -152,7 +153,7 @@ func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
return p.remainder()
}
func (p *parser) takefn1case(what string, fn func(c rune, i int) bool) string {
func (p *parser) xtakefn1case(what string, fn func(c rune, i int) bool) string {
if p.empty() {
p.xerrorf("need at least one char for %s", what)
}
@ -167,7 +168,7 @@ func (p *parser) takefn1case(what string, fn func(c rune, i int) bool) string {
return p.remainder()
}
func (p *parser) takefn(fn func(c rune, i int) bool) string {
func (p *parser) xtakefn(fn func(c rune, i int) bool) string {
for i, c := range p.upper[p.o:] {
if !fn(c, i) {
return p.xtaken(i)
@ -183,7 +184,7 @@ func (p *parser) takefn(fn func(c rune, i int) bool) string {
// ../rfc/5321:2260
func (p *parser) xrawReversePath() string {
p.xtake("<")
s := p.takefn(func(c rune, i int) bool {
s := p.xtakefn(func(c rune, i int) bool {
return c != '>'
})
p.xtake(">")
@ -261,7 +262,7 @@ func (p *parser) xdomain() dns.Domain {
// ../rfc/5321:2303
// ../rfc/5321:2303 ../rfc/6531:411
func (p *parser) xsubdomain() string {
return p.takefn1("subdomain", func(c rune, i int) bool {
return p.xtakefn1("subdomain", func(c rune, i int) bool {
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || i > 0 && c == '-' || c > 0x7f && p.smtputf8
})
}
@ -275,7 +276,7 @@ func (p *parser) xmailbox() smtp.Path {
// ../rfc/5321:2307
func (p *parser) xldhstr() string {
return p.takefn1("ldh-str", func(c rune, i int) bool {
return p.xtakefn1("ldh-str", func(c rune, i int) bool {
return c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || i == 0 && c == '-'
})
}
@ -295,7 +296,7 @@ func (p *parser) xipdomain(isehlo bool) dns.IPDomain {
}
ipv6 = true
}
ipaddr := p.takefn1("address literal", func(c rune, i int) bool {
ipaddr := p.xtakefn1("address literal", func(c rune, i int) bool {
return c != ']'
})
p.take("]")
@ -402,7 +403,7 @@ func (p *parser) xchar() rune {
// ../rfc/5321:2320 ../rfc/6531:414
func (p *parser) xatom(islocalpart bool) string {
return p.takefn1("atom", func(c rune, i int) bool {
return p.xtakefn1("atom", func(c rune, i int) bool {
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
return true
@ -424,23 +425,23 @@ func (p *parser) xstring() string {
// ../rfc/5321:2279
func (p *parser) xparamKeyword() string {
return p.takefn1("parameter keyword", func(c rune, i int) bool {
return p.xtakefn1("parameter keyword", func(c rune, i int) bool {
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || (i > 0 && c == '-')
})
}
// ../rfc/5321:2281 ../rfc/6531:422
func (p *parser) xparamValue() string {
return p.takefn1("parameter value", func(c rune, i int) bool {
return p.xtakefn1("parameter value", func(c rune, i int) bool {
return c > ' ' && c < 0x7f && c != '=' || (c > 0x7f && p.smtputf8)
})
}
// for smtp parameters that take a numeric parameter with specified number of
// digits, eg SIZE=... for MAIL FROM.
func (p *parser) xnumber(maxDigits int) int64 {
s := p.takefn1("number", func(c rune, i int) bool {
return c >= '0' && c <= '9' && i < maxDigits
func (p *parser) xnumber(maxDigits int, allowZero bool) int64 {
s := p.xtakefn1("number", func(c rune, i int) bool {
return (c >= '1' && c <= '9' || c == '0' && (i > 0 || allowZero)) && i < maxDigits
})
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
@ -449,10 +450,54 @@ func (p *parser) xnumber(maxDigits int) int64 {
return v
}
// parse date-time in UTC form. ../rfc/4865:147 ../rfc/4865-eid2040
func (p *parser) xdatetimeutc() (time.Time, string) {
// ../rfc/3339:422
xdash := func() string {
p.xtake("-")
return "-"
}
xcolon := func() string {
p.xtake(":")
return ":"
}
xdigits := func(n int) string {
s := p.xtakefn1("digits", func(c rune, i int) bool {
return c >= '0' && c <= '9' && i < n
})
if len(s) != n {
p.xerrorf("parsing date-time: got %d digits, need %d", len(s), n)
}
return s
}
s := xdigits(4) + xdash() + xdigits(2) + xdash() + xdigits(2)
if !p.hasPrefix("T") {
p.xerrorf("expected T for date-time separator")
}
s += p.xtaken(1) + xdigits(2) + xcolon() + xdigits(2) + xcolon() + xdigits(2)
layout := time.RFC3339
if p.take(".") {
layout = time.RFC3339Nano
s += "." + p.xtakefn1("digits", func(c rune, i int) bool {
return c >= '0' && c <= '9'
})
}
if !p.hasPrefix("Z") {
p.xerrorf("expected Z for date-time utc timezone")
}
s += p.xtaken(1)
t, err := time.Parse(layout, s)
if err != nil {
p.xerrorf("bad utc date-time %q: %s", s, err)
}
return t, s
}
// sasl mechanism, for AUTH command.
// ../rfc/4422:436
func (p *parser) xsaslMech() string {
return p.takefn1case("sasl-mech", func(c rune, i int) bool {
return p.xtakefn1case("sasl-mech", func(c rune, i int) bool {
return i < 20 && (c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_')
})
}

View File

@ -321,11 +321,13 @@ type conn struct {
transactionBad int
// Message transaction.
mailFrom *smtp.Path
requireTLS *bool // MAIL FROM with REQUIRETLS set.
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.
recipients []rcptAccount
mailFrom *smtp.Path
requireTLS *bool // MAIL FROM with REQUIRETLS set.
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.
recipients []rcptAccount
}
type rcptAccount struct {
@ -361,6 +363,8 @@ func (c *conn) reset() {
func (c *conn) rset() {
c.mailFrom = nil
c.requireTLS = nil
c.futureRelease = time.Time{}
c.futureReleaseRequest = ""
c.has8bitmime = false
c.smtputf8 = false
c.recipients = nil
@ -878,6 +882,9 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
} else {
c.bwritelinef("250-AUTH ")
}
// ../rfc/4865:127
t := time.Now().Add(queue.FutureReleaseIntervalMax).UTC() // ../rfc/4865:98
c.bwritelinef("250-FUTURERELEASE %d %s", queue.FutureReleaseIntervalMax/time.Second, t.Format(time.RFC3339))
}
c.bwritelinef("250-ENHANCEDSTATUSCODES") // ../rfc/2034:71
// todo future? c.writelinef("250-DSN")
@ -1306,7 +1313,7 @@ func (c *conn) cmdAuth(p *parser) {
func (c *conn) cmdMail(p *parser) {
// requirements for maximum line length:
// ../rfc/5321:3500 (base max of 512 including crlf) ../rfc/4954:134 (+500) ../rfc/1870:92 (+26) ../rfc/6152:90 (none specified) ../rfc/6531:231 (+10)
// todo future: enforce?
// todo future: enforce? doesn't really seem worth it...
if c.transactionBad > 10 && c.transactionGood == 0 {
// If we get many bad transactions, it's probably a spammer that is guessing user names.
@ -1354,7 +1361,7 @@ func (c *conn) cmdMail(p *parser) {
switch K {
case "SIZE":
p.xtake("=")
size := p.xnumber(20) // ../rfc/1870:90
size := p.xnumber(20, true) // ../rfc/1870:90
if size > c.maxMessageSize {
// ../rfc/1870:136 ../rfc/3463:382
ecode := smtp.SeSys3MsgLimitExceeded4
@ -1402,6 +1409,39 @@ func (c *conn) cmdMail(p *parser) {
}
v := true
c.requireTLS = &v
case "HOLDFOR", "HOLDUNTIL":
// Only for submission ../rfc/4865:163
if !c.submission {
xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "unrecognized parameter %q", key)
}
if K == "HOLDFOR" && paramSeen["HOLDUNTIL"] || K == "HOLDUNTIL" && paramSeen["HOLDFOR"] {
// ../rfc/4865:260
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "cannot use both HOLDUNTIL and HOLFOR")
}
p.xtake("=")
// ../rfc/4865:263 ../rfc/4865:267 We are not following the advice of treating
// semantic errors as syntax errors
if K == "HOLDFOR" {
n := p.xnumber(9, false) // ../rfc/4865:92
if n > int64(queue.FutureReleaseIntervalMax/time.Second) {
// ../rfc/4865:250
xsmtpUserErrorf(smtp.C554TransactionFailed, smtp.SeProto5BadParams4, "future release interval too far in the future")
}
c.futureRelease = time.Now().Add(time.Duration(n) * time.Second)
c.futureReleaseRequest = fmt.Sprintf("for;%d", n)
} else {
t, s := p.xdatetimeutc()
ival := time.Until(t)
if ival <= 0 {
// Likely a mistake by the user.
xsmtpUserErrorf(smtp.C554TransactionFailed, smtp.SeProto5BadParams4, "requested future release time is in the past")
} else if ival > queue.FutureReleaseIntervalMax {
// ../rfc/4865:255
xsmtpUserErrorf(smtp.C554TransactionFailed, smtp.SeProto5BadParams4, "requested future release time is too far in the future")
}
c.futureRelease = t
c.futureReleaseRequest = "until;" + s
}
default:
// ../rfc/5321:2230
xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "unrecognized parameter %q", key)
@ -1938,6 +1978,11 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
msgSize := int64(len(xmsgPrefix)) + msgWriter.Size
qm := queue.MakeMsg(c.account.Name, *c.mailFrom, rcptAcc.rcptTo, msgWriter.Has8bit, c.smtputf8, msgSize, messageID, xmsgPrefix, c.requireTLS)
if !c.futureRelease.IsZero() {
qm.NextAttempt = c.futureRelease
qm.FutureReleaseRequest = c.futureReleaseRequest
}
// todo: it would be good to have a limit on messages (count and total size) a user has in the queue. also/especially with futurerelease. ../rfc/4865:387
if err := queue.Add(ctx, c.log, &qm, dataFile); err != nil {
// Aborting the transaction is not great. But continuing and generating DSNs will
// probably result in errors as well...

View File

@ -1658,3 +1658,75 @@ func TestSmuggle(t *testing.T) {
test("\r.\r")
test("\n.\r\n")
}
func TestFutureRelease(t *testing.T) {
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{})
ts.tlsmode = smtpclient.TLSSkip
ts.user = "mjl@mox.example"
ts.pass = "testtest"
ts.submission = true
defer ts.close()
ts.auth = func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
return sasl.NewClientPlain(ts.user, ts.pass), nil
}
test := func(mailtoMore, expResponsePrefix string) {
t.Helper()
ts.runRaw(func(conn net.Conn) {
t.Helper()
ourHostname := mox.Conf.Static.HostnameDomain
remoteHostname := dns.Domain{ASCII: "mox.example"}
opts := smtpclient.Opts{Auth: ts.auth}
log := pkglog.WithCid(ts.cid - 1)
_, err := smtpclient.New(ctxbg, log.Logger, conn, ts.tlsmode, false, ourHostname, remoteHostname, opts)
tcheck(t, err, "smtpclient")
defer conn.Close()
write := func(s string) {
_, err := conn.Write([]byte(s))
tcheck(t, err, "write")
}
readPrefixLine := func(prefix string) string {
t.Helper()
buf := make([]byte, 512)
n, err := conn.Read(buf)
tcheck(t, err, "read")
s := strings.TrimRight(string(buf[:n]), "\r\n")
if !strings.HasPrefix(s, prefix) {
t.Fatalf("got smtp response %q, expected line with prefix %q", s, prefix)
}
return s
}
write(fmt.Sprintf("MAIL FROM:<mjl@mox.example>%s\r\n", mailtoMore))
readPrefixLine(expResponsePrefix)
if expResponsePrefix != "2" {
return
}
write("RCPT TO:<mjl@mox.example>\r\n")
readPrefixLine("2")
write("DATA\r\n")
readPrefixLine("3")
write("From: <mjl@mox.example>\r\n\r\nbody\r\n\r\n.\r\n")
readPrefixLine("2")
})
}
test(" HOLDFOR=1", "2")
test(" HOLDUNTIL="+time.Now().Add(time.Minute).UTC().Format(time.RFC3339), "2")
test(" HOLDUNTIL="+time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), "2")
test(" HOLDFOR=0", "501") // 0 is invalid syntax.
test(fmt.Sprintf(" HOLDFOR=%d", int64((queue.FutureReleaseIntervalMax+time.Minute)/time.Second)), "554") // Too far in the future.
test(" HOLDUNTIL="+time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), "554") // In the past.
test(" HOLDUNTIL="+time.Now().Add(queue.FutureReleaseIntervalMax+time.Minute).UTC().Format(time.RFC3339), "554") // Too far in the future.
test(" HOLDUNTIL=2024-02-10T17:28:00+00:00", "501") // "Z" required.
test(" HOLDUNTIL=24-02-10T17:28:00Z", "501") // Invalid.
test(" HOLDFOR=1 HOLDFOR=1", "501") // Duplicate.
test(" HOLDFOR=1 HOLDUNTIL="+time.Now().Add(time.Hour).UTC().Format(time.RFC3339), "501") // Duplicate.
}