mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:44:35 +03:00
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:
@ -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 == '_')
|
||||
})
|
||||
}
|
||||
|
@ -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...
|
||||
|
@ -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.
|
||||
}
|
||||
|
Reference in New Issue
Block a user