Skip to content

Commit

Permalink
implement "future release"
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mjl- committed Feb 10, 2024
1 parent 1773419 commit 93c52b0
Show file tree
Hide file tree
Showing 19 changed files with 382 additions and 54 deletions.
10 changes: 10 additions & 0 deletions dsn/dsn.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ type Message struct {
// mail user-agents will thread the DSN with the original message.
References string

// For message submitted with FUTURERELEASE SMTP extension. Value is either "for;"
// plus original interval in seconds or "until;" plus original UTC RFC3339
// date-time.
FutureReleaseRequest string
// ../rfc/4865:315

// Human-readable text explaining the failure. Line endings should be
// bare newlines, not \r\n. They are converted to \r\n when composing.
TextBody string
Expand Down Expand Up @@ -230,6 +236,10 @@ func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
status("Received-From-MTA", fmt.Sprintf("dns;%s (%s)", m.ReceivedFromMTA.Name, smtp.AddressLiteral(m.ReceivedFromMTA.ConnIP)))
}
status("Arrival-Date", m.ArrivalDate.Format(message.RFC5322Z)) // ../rfc/3464:758
if m.FutureReleaseRequest != "" {
// ../rfc/4865:320
status("Future-Release-Request", m.FutureReleaseRequest)
}

// Then per-recipient fields. ../rfc/3464:769
// todo: should also handle other address types. at least recognize "unknown". Probably just store this field. ../rfc/3464:819
Expand Down
7 changes: 4 additions & 3 deletions dsn/dsn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ func TestDSN(t *testing.T) {
MessageID: "test@localhost",
TextBody: "delivery failure\n",

ReportingMTA: "mox.example",
ReceivedFromMTA: smtp.Ehlo{Name: xparseIPDomain("relay.example"), ConnIP: net.ParseIP("10.10.10.10")},
ArrivalDate: now,
ReportingMTA: "mox.example",
ReceivedFromMTA: smtp.Ehlo{Name: xparseIPDomain("relay.example"), ConnIP: net.ParseIP("10.10.10.10")},
ArrivalDate: now,
FutureReleaseRequest: "for;123",

Recipients: []Recipient{
{
Expand Down
5 changes: 3 additions & 2 deletions queue/dsn.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ func deliverDSN(ctx context.Context, log mlog.Log, m Msg, remoteMTA dsn.NameIP,
References: m.MessageID,
TextBody: textBody,

ReportingMTA: mox.Conf.Static.HostnameDomain.ASCII,
ArrivalDate: m.Queued,
ReportingMTA: mox.Conf.Static.HostnameDomain.ASCII,
ArrivalDate: m.Queued,
FutureReleaseRequest: m.FutureReleaseRequest,

Recipients: []dsn.Recipient{
{
Expand Down
19 changes: 13 additions & 6 deletions queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ var jitter = mox.NewPseudoRand()
var DBTypes = []any{Msg{}} // Types stored in DB.
var DB *bstore.DB // Exported for making backups.

// Allow requesting delivery starting from up to this interval from time of submission.
const FutureReleaseIntervalMax = 60 * 24 * time.Hour

// Set for mox localserve, to prevent queueing.
var Localserve bool

Expand Down Expand Up @@ -122,6 +125,12 @@ type Msg struct {
// i.e. falling back to SMTP delivery with unverified STARTTLS or plain text.
RequireTLS *bool
// ../rfc/8689:250

// For DSNs, where the original FUTURERELEASE value must be included as per-message
// field. This field should be of the form "for;" plus interval, or "until;" plus
// utc date-time.
FutureReleaseRequest string
// ../rfc/4865:305
}

// Sender of message as used in MAIL FROM.
Expand Down Expand Up @@ -200,6 +209,7 @@ func Count(ctx context.Context) (int, error) {

// MakeMsg is a convenience function that sets the commonly used fields for a Msg.
func MakeMsg(senderAccount string, sender, recipient smtp.Path, has8bit, smtputf8 bool, size int64, messageID string, prefix []byte, requireTLS *bool) Msg {
now := time.Now()
return Msg{
SenderAccount: mox.Conf.Static.Postmaster.Account,
SenderLocalpart: sender.Localpart,
Expand All @@ -212,6 +222,9 @@ func MakeMsg(senderAccount string, sender, recipient smtp.Path, has8bit, smtputf
MessageID: messageID,
MsgPrefix: prefix,
RequireTLS: requireTLS,
Queued: now,
NextAttempt: now,
RecipientDomainStr: formatIPDomain(recipient.IPDomain),
}
}

Expand All @@ -228,12 +241,6 @@ func Add(ctx context.Context, log mlog.Log, qm *Msg, msgFile *os.File) error {
if qm.ID != 0 {
return fmt.Errorf("id of queued message must be 0")
}
qm.Queued = time.Now()
qm.DialedIPs = nil
qm.NextAttempt = qm.Queued
qm.LastAttempt = nil
qm.LastError = ""
qm.RecipientDomainStr = formatIPDomain(qm.RecipientDomain)

if Localserve {
if qm.SenderAccount == "" {
Expand Down
4 changes: 3 additions & 1 deletion rfc/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ https://www.iana.org/assignments/message-headers/message-headers.xhtml
3974 - - SMTP Operational Experience in Mixed IPv4/v6 Environments
4409 - Obs (RFC 6409) Message Submission for Mail
4468 Roadmap - Message Submission BURL Extension
4865 Roadmap - SMTP Submission Service Extension for Future Message Release
4865 Yes - SMTP Submission Service Extension for Future Message Release
4865-eid2040 - errata: Internet-style-date-time-UTC -> date-time from rfc 3339
4954 Yes - SMTP Service Extension for Authentication
5068 - - Email Submission Operations: Access and Accountability Requirements
5248 - - A Registry for SMTP Enhanced Mail System Status Codes
Expand All @@ -83,6 +84,7 @@ https://www.iana.org/assignments/message-headers/message-headers.xhtml
6532 Yes - Internationalized Email Headers
6533 Yes - Internationalized Delivery Status and Disposition Notifications
6647 Partial - Email Greylisting: An Applicability Statement for SMTP
6710 No - Simple Mail Transfer Protocol Extension for Message Transfer Priorities
6729 No - Indicating Email Handling States in Trace Fields
6857 No - Post-Delivery Message Downgrading for Internationalized Email Messages
7293 No - The Require-Recipient-Valid-Since Header Field and SMTP Service Extension
Expand Down
22 changes: 12 additions & 10 deletions smtp/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,16 @@ var (
SePol7PasswdTransitionReq12 = "7.12" // ../rfc/4954:578
SePol7AccountDisabled13 = "7.13" // ../rfc/5248:399
SePol7TrustReq14 = "7.14" // ../rfc/5248:418
SePol7NoDKIMPass20 = "7.20" // ../rfc/7372:137
SePol7NoDKIMAccept21 = "7.21" // ../rfc/7372:148
SePol7NoDKIMAuthorMatch22 = "7.22" // ../rfc/7372:175
SePol7SPFResultFail23 = "7.23" // ../rfc/7372:192
SePol7SPFError24 = "7.24" // ../rfc/7372:204
SePol7RevDNSFail25 = "7.25" // ../rfc/7372:233
SePol7MultiAuthFails26 = "7.26" // ../rfc/7372:246
SePol7SenderHasNullMX27 = "7.27" // ../rfc/7505:246
SePol7ARCFail = "7.29" // ../rfc/8617:1438
SePol7MissingReqTLS = "7.30" // ../rfc/8689:448
// todo spec: duplicate spec of 7.16 ../rfc/4865:412 ../rfc/6710:878
// todo spec: duplicate spec of 7.17 ../rfc/4865:418 ../rfc/7293:1137
SePol7NoDKIMPass20 = "7.20" // ../rfc/7372:137
SePol7NoDKIMAccept21 = "7.21" // ../rfc/7372:148
SePol7NoDKIMAuthorMatch22 = "7.22" // ../rfc/7372:175
SePol7SPFResultFail23 = "7.23" // ../rfc/7372:192
SePol7SPFError24 = "7.24" // ../rfc/7372:204
SePol7RevDNSFail25 = "7.25" // ../rfc/7372:233
SePol7MultiAuthFails26 = "7.26" // ../rfc/7372:246
SePol7SenderHasNullMX27 = "7.27" // ../rfc/7505:246
SePol7ARCFail = "7.29" // ../rfc/8617:1438
SePol7MissingReqTLS = "7.30" // ../rfc/8689:448
)
73 changes: 59 additions & 14 deletions smtpserver/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net"
"strconv"
"strings"
"time"

"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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(">")
Expand Down Expand Up @@ -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
})
}
Expand All @@ -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 == '-'
})
}
Expand All @@ -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("]")
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 == '_')
})
}
Expand Down

0 comments on commit 93c52b0

Please sign in to comment.