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
No known key found for this signature in database
19 changed files with 382 additions and 54 deletions

View file

@ -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
@ -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

View file

@ -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{
{

View file

@ -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{
{

View file

@ -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
@ -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.
@ -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,
@ -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),
}
}
@ -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 == "" {

View file

@ -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
@ -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

View file

@ -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
)

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.
}

View file

@ -393,7 +393,7 @@ var api;
"Reverse": { "Name": "Reverse", "Docs": "", "Fields": [{ "Name": "Hostnames", "Docs": "", "Typewords": ["[]", "string"] }] },
"ClientConfigs": { "Name": "ClientConfigs", "Docs": "", "Fields": [{ "Name": "Entries", "Docs": "", "Typewords": ["[]", "ClientConfigsEntry"] }] },
"ClientConfigsEntry": { "Name": "ClientConfigsEntry", "Docs": "", "Fields": [{ "Name": "Protocol", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Port", "Docs": "", "Typewords": ["int32"] }, { "Name": "Listener", "Docs": "", "Typewords": ["string"] }, { "Name": "Note", "Docs": "", "Typewords": ["string"] }] },
"Msg": { "Name": "Msg", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Queued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SenderAccount", "Docs": "", "Typewords": ["string"] }, { "Name": "SenderLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "SenderDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Attempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "DialedIPs", "Docs": "", "Typewords": ["{}", "[]", "IP"] }, { "Name": "NextAttempt", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastAttempt", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "LastError", "Docs": "", "Typewords": ["string"] }, { "Name": "Has8bit", "Docs": "", "Typewords": ["bool"] }, { "Name": "SMTPUTF8", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsDMARCReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsTLSReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "DSNUTF8", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"Msg": { "Name": "Msg", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Queued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SenderAccount", "Docs": "", "Typewords": ["string"] }, { "Name": "SenderLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "SenderDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Attempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "DialedIPs", "Docs": "", "Typewords": ["{}", "[]", "IP"] }, { "Name": "NextAttempt", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastAttempt", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "LastError", "Docs": "", "Typewords": ["string"] }, { "Name": "Has8bit", "Docs": "", "Typewords": ["bool"] }, { "Name": "SMTPUTF8", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsDMARCReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsTLSReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "DSNUTF8", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureReleaseRequest", "Docs": "", "Typewords": ["string"] }] },
"IPDomain": { "Name": "IPDomain", "Docs": "", "Fields": [{ "Name": "IP", "Docs": "", "Typewords": ["IP"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"WebserverConfig": { "Name": "WebserverConfig", "Docs": "", "Fields": [{ "Name": "WebDNSDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "Domain"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }] },
"WebHandler": { "Name": "WebHandler", "Docs": "", "Fields": [{ "Name": "LogName", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "PathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "DontRedirectPlainHTTP", "Docs": "", "Typewords": ["bool"] }, { "Name": "Compress", "Docs": "", "Typewords": ["bool"] }, { "Name": "WebStatic", "Docs": "", "Typewords": ["nullable", "WebStatic"] }, { "Name": "WebRedirect", "Docs": "", "Typewords": ["nullable", "WebRedirect"] }, { "Name": "WebForward", "Docs": "", "Typewords": ["nullable", "WebForward"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] },

View file

@ -3489,6 +3489,13 @@
"nullable",
"bool"
]
},
{
"Name": "FutureReleaseRequest",
"Docs": "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.",
"Typewords": [
"string"
]
}
]
},

View file

@ -494,6 +494,7 @@ export interface Msg {
DSNUTF8?: string | null // If set, this message is a DSN and this is a version using utf-8, for the case the remote MTA supports smtputf8. In this case, Size and MsgPrefix are not relevant.
Transport: string // If non-empty, the transport to use for this message. Can be set through cli or admin interface. If empty (the default for a submitted message), regular routing rules apply.
RequireTLS?: boolean | null // RequireTLS influences TLS verification during delivery. If nil, the recipient domain policy is followed (MTA-STS and/or DANE), falling back to optional opportunistic non-verified STARTTLS. If RequireTLS is true (through SMTP REQUIRETLS extension or webmail submit), MTA-STS or DANE is required, as well as REQUIRETLS support by the next hop server. If RequireTLS is false (through messag header "TLS-Required: No"), the recipient domain's policy is ignored if it does not lead to a successful TLS connection, i.e. falling back to SMTP delivery with unverified STARTTLS or plain text.
FutureReleaseRequest: string // 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.
}
// IPDomain is an ip address, a domain, or empty.
@ -836,7 +837,7 @@ export const types: TypenameMap = {
"Reverse": {"Name":"Reverse","Docs":"","Fields":[{"Name":"Hostnames","Docs":"","Typewords":["[]","string"]}]},
"ClientConfigs": {"Name":"ClientConfigs","Docs":"","Fields":[{"Name":"Entries","Docs":"","Typewords":["[]","ClientConfigsEntry"]}]},
"ClientConfigsEntry": {"Name":"ClientConfigsEntry","Docs":"","Fields":[{"Name":"Protocol","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["Domain"]},{"Name":"Port","Docs":"","Typewords":["int32"]},{"Name":"Listener","Docs":"","Typewords":["string"]},{"Name":"Note","Docs":"","Typewords":["string"]}]},
"Msg": {"Name":"Msg","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Queued","Docs":"","Typewords":["timestamp"]},{"Name":"SenderAccount","Docs":"","Typewords":["string"]},{"Name":"SenderLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"SenderDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"RecipientDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientDomainStr","Docs":"","Typewords":["string"]},{"Name":"Attempts","Docs":"","Typewords":["int32"]},{"Name":"MaxAttempts","Docs":"","Typewords":["int32"]},{"Name":"DialedIPs","Docs":"","Typewords":["{}","[]","IP"]},{"Name":"NextAttempt","Docs":"","Typewords":["timestamp"]},{"Name":"LastAttempt","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"LastError","Docs":"","Typewords":["string"]},{"Name":"Has8bit","Docs":"","Typewords":["bool"]},{"Name":"SMTPUTF8","Docs":"","Typewords":["bool"]},{"Name":"IsDMARCReport","Docs":"","Typewords":["bool"]},{"Name":"IsTLSReport","Docs":"","Typewords":["bool"]},{"Name":"Size","Docs":"","Typewords":["int64"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"MsgPrefix","Docs":"","Typewords":["nullable","string"]},{"Name":"DSNUTF8","Docs":"","Typewords":["nullable","string"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]}]},
"Msg": {"Name":"Msg","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Queued","Docs":"","Typewords":["timestamp"]},{"Name":"SenderAccount","Docs":"","Typewords":["string"]},{"Name":"SenderLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"SenderDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"RecipientDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientDomainStr","Docs":"","Typewords":["string"]},{"Name":"Attempts","Docs":"","Typewords":["int32"]},{"Name":"MaxAttempts","Docs":"","Typewords":["int32"]},{"Name":"DialedIPs","Docs":"","Typewords":["{}","[]","IP"]},{"Name":"NextAttempt","Docs":"","Typewords":["timestamp"]},{"Name":"LastAttempt","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"LastError","Docs":"","Typewords":["string"]},{"Name":"Has8bit","Docs":"","Typewords":["bool"]},{"Name":"SMTPUTF8","Docs":"","Typewords":["bool"]},{"Name":"IsDMARCReport","Docs":"","Typewords":["bool"]},{"Name":"IsTLSReport","Docs":"","Typewords":["bool"]},{"Name":"Size","Docs":"","Typewords":["int64"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"MsgPrefix","Docs":"","Typewords":["nullable","string"]},{"Name":"DSNUTF8","Docs":"","Typewords":["nullable","string"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"Name":"FutureReleaseRequest","Docs":"","Typewords":["string"]}]},
"IPDomain": {"Name":"IPDomain","Docs":"","Fields":[{"Name":"IP","Docs":"","Typewords":["IP"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"WebserverConfig": {"Name":"WebserverConfig","Docs":"","Fields":[{"Name":"WebDNSDomainRedirects","Docs":"","Typewords":["[]","[]","Domain"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["[]","[]","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]}]},
"WebHandler": {"Name":"WebHandler","Docs":"","Fields":[{"Name":"LogName","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"PathRegexp","Docs":"","Typewords":["string"]},{"Name":"DontRedirectPlainHTTP","Docs":"","Typewords":["bool"]},{"Name":"Compress","Docs":"","Typewords":["bool"]},{"Name":"WebStatic","Docs":"","Typewords":["nullable","WebStatic"]},{"Name":"WebRedirect","Docs":"","Typewords":["nullable","WebRedirect"]},{"Name":"WebForward","Docs":"","Typewords":["nullable","WebForward"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]},

View file

@ -201,10 +201,11 @@ type SubmitMessage struct {
Attachments []File
ForwardAttachments ForwardAttachments
IsForward bool
ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
ReplyTo string // If non-empty, Reply-To header to add to message.
UserAgent string // User-Agent header added if not empty.
RequireTLS *bool // For "Require TLS" extension during delivery.
ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
ReplyTo string // If non-empty, Reply-To header to add to message.
UserAgent string // User-Agent header added if not empty.
RequireTLS *bool // For "Require TLS" extension during delivery.
FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
}
// ForwardAttachments references attachments by a list of message.Part paths.
@ -635,6 +636,17 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
IPDomain: dns.IPDomain{Domain: rcpt.Domain},
}
qm := queue.MakeMsg(reqInfo.AccountName, fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), m.RequireTLS)
if m.FutureRelease != nil {
ival := time.Until(*m.FutureRelease)
if ival < 0 {
xcheckuserf(ctx, errors.New("date/time is in the past"), "scheduling delivery")
} else if ival > queue.FutureReleaseIntervalMax {
xcheckuserf(ctx, fmt.Errorf("date/time can not be further than %v in the future", queue.FutureReleaseIntervalMax), "scheduling delivery")
}
qm.NextAttempt = *m.FutureRelease
qm.FutureReleaseRequest = "until;" + m.FutureRelease.Format(time.RFC3339)
// todo: possibly add a header to the message stored in the Sent mailbox to indicate it was scheduled for later delivery.
}
err := queue.Add(ctx, log, &qm, dataFile)
if err != nil {
metricSubmission.WithLabelValues("queueerror").Inc()

View file

@ -1169,6 +1169,14 @@
"nullable",
"bool"
]
},
{
"Name": "FutureRelease",
"Docs": "If set, time (in the future) when message should be delivered from queue.",
"Typewords": [
"nullable",
"timestamp"
]
}
]
},

View file

@ -144,6 +144,7 @@ export interface SubmitMessage {
ReplyTo: string // If non-empty, Reply-To header to add to message.
UserAgent: string // User-Agent header added if not empty.
RequireTLS?: boolean | null // For "Require TLS" extension during delivery.
FutureRelease?: Date | null // If set, time (in the future) when message should be delivered from queue.
}
// File is a new attachment (not from an existing message that is being
@ -531,7 +532,7 @@ export const types: TypenameMap = {
"Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["string"]}]},
"MessageAddress": {"Name":"MessageAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]},
"SubmitMessage": {"Name":"SubmitMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"Attachments","Docs":"","Typewords":["[]","File"]},{"Name":"ForwardAttachments","Docs":"","Typewords":["ForwardAttachments"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]}]},
"SubmitMessage": {"Name":"SubmitMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"Attachments","Docs":"","Typewords":["[]","File"]},{"Name":"ForwardAttachments","Docs":"","Typewords":["ForwardAttachments"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"Name":"FutureRelease","Docs":"","Typewords":["nullable","timestamp"]}]},
"File": {"Name":"File","Docs":"","Fields":[{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DataURI","Docs":"","Typewords":["string"]}]},
"ForwardAttachments": {"Name":"ForwardAttachments","Docs":"","Fields":[{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"Paths","Docs":"","Typewords":["[]","[]","int32"]}]},
"Mailbox": {"Name":"Mailbox","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"UIDValidity","Docs":"","Typewords":["uint32"]},{"Name":"UIDNext","Docs":"","Typewords":["UID"]},{"Name":"Archive","Docs":"","Typewords":["bool"]},{"Name":"Draft","Docs":"","Typewords":["bool"]},{"Name":"Junk","Docs":"","Typewords":["bool"]},{"Name":"Sent","Docs":"","Typewords":["bool"]},{"Name":"Trash","Docs":"","Typewords":["bool"]},{"Name":"Keywords","Docs":"","Typewords":["[]","string"]},{"Name":"HaveCounts","Docs":"","Typewords":["bool"]},{"Name":"Total","Docs":"","Typewords":["int64"]},{"Name":"Deleted","Docs":"","Typewords":["int64"]},{"Name":"Unread","Docs":"","Typewords":["int64"]},{"Name":"Unseen","Docs":"","Typewords":["int64"]},{"Name":"Size","Docs":"","Typewords":["int64"]}]},

View file

@ -286,7 +286,7 @@ var api;
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
"MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }] },
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
"ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] },
"Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] },

View file

@ -286,7 +286,7 @@ var api;
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
"MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }] },
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
"ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] },
"Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] },

View file

@ -286,7 +286,7 @@ var api;
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
"MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }] },
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
"ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] },
"Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] },
@ -2287,6 +2287,7 @@ const compose = (opts) => {
IsForward: opts.isForward || false,
ResponseMessageID: opts.responseMessageID || 0,
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null,
};
await client.MessageSubmit(message);
cmdCancel();
@ -2513,6 +2514,18 @@ const compose = (opts) => {
fromOptions.unshift(o);
}
}
let scheduleLink;
let scheduleElem;
let scheduleTime;
let scheduleWeekday;
const pad0 = (v) => v >= 10 ? '' + v : '0' + v;
const localdate = (d) => [d.getFullYear(), pad0(d.getMonth() + 1), pad0(d.getDate())].join('-');
const localdatetime = (d) => localdate(d) + 'T' + pad0(d.getHours()) + ':' + pad0(d.getMinutes()) + ':00';
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const scheduleTimeChanged = () => {
console.log('datetime change', scheduleTime.value);
dom._kids(scheduleWeekday, weekdays[new Date(scheduleTime.value).getDay()]);
};
const composeElem = dom.div(style({
position: 'fixed',
bottom: '1ex',
@ -2548,7 +2561,42 @@ const compose = (opts) => {
return v;
}), dom.label(style({ color: '#666' }), dom.input(attr.type('checkbox'), function change(e) {
forwardAttachmentViews.forEach(v => v.checkbox.checked = e.target.checked);
}), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.label(style({ margin: '1ex 0', display: 'block' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.label(style({ margin: '1ex 0', display: 'block' }), attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), 'TLS ', requiretls = dom.select(dom.option(attr.value(''), 'Default'), dom.option(attr.value('yes'), 'With RequireTLS'), dom.option(attr.value('no'), 'Fallback to insecure'))), dom.div(style({ margin: '3ex 0 1ex 0', display: 'block' }), dom.submitbutton('Send'))), async function submit(e) {
}), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.label(style({ margin: '1ex 0', display: 'block' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.label(style({ margin: '1ex 0', display: 'block' }), attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), 'TLS ', requiretls = dom.select(dom.option(attr.value(''), 'Default'), dom.option(attr.value('yes'), 'With RequireTLS'), dom.option(attr.value('no'), 'Fallback to insecure'))), dom.div(scheduleLink = dom.a(attr.href(''), 'Schedule', function click(e) {
e.preventDefault();
scheduleTime.value = localdatetime(new Date());
scheduleTimeChanged();
scheduleLink.style.display = 'none';
scheduleElem.style.display = '';
scheduleTime.setAttribute('required', '');
}), scheduleElem = dom.div(style({ display: 'none' }), dom.clickbutton('Start of next day', function click(e) {
e.preventDefault();
const d = new Date(scheduleTime.value);
const nextday = new Date(d.getTime() + 24 * 3600 * 1000);
scheduleTime.value = localdate(nextday) + 'T09:00:00';
scheduleTimeChanged();
}), ' ', dom.clickbutton('+1 hour', function click(e) {
e.preventDefault();
const d = new Date(scheduleTime.value);
scheduleTime.value = localdatetime(new Date(d.getTime() + 3600 * 1000));
scheduleTimeChanged();
}), ' ', dom.clickbutton('+1 day', function click(e) {
e.preventDefault();
const d = new Date(scheduleTime.value);
scheduleTime.value = localdatetime(new Date(d.getTime() + 24 * 3600 * 1000));
scheduleTimeChanged();
}), ' ', dom.clickbutton('Now', function click(e) {
e.preventDefault();
scheduleTime.value = localdatetime(new Date());
scheduleTimeChanged();
}), ' ', dom.clickbutton('Cancel', function click(e) {
e.preventDefault();
scheduleLink.style.display = '';
scheduleElem.style.display = 'none';
scheduleTime.removeAttribute('required');
scheduleTime.value = '';
}), dom.div(style({ marginTop: '1ex' }), scheduleTime = dom.input(attr.type('datetime-local'), function change() {
scheduleTimeChanged();
}), ' in local timezone ' + (Intl.DateTimeFormat().resolvedOptions().timeZone || '') + ', ', scheduleWeekday = dom.span()))), dom.div(style({ margin: '3ex 0 1ex 0', display: 'block' }), dom.submitbutton('Send'))), async function submit(e) {
e.preventDefault();
shortcutCmd(cmdSend, shortcuts);
}));

View file

@ -1363,6 +1363,7 @@ const compose = (opts: ComposeOptions) => {
IsForward: opts.isForward || false,
ResponseMessageID: opts.responseMessageID || 0,
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null,
}
await client.MessageSubmit(message)
cmdCancel()
@ -1628,6 +1629,19 @@ const compose = (opts: ComposeOptions) => {
}
}
let scheduleLink: HTMLElement
let scheduleElem: HTMLElement
let scheduleTime: HTMLInputElement
let scheduleWeekday: HTMLElement
const pad0 = (v: number) => v >= 10 ? ''+v : '0'+v
const localdate = (d: Date) => [d.getFullYear(), pad0(d.getMonth()+1), pad0(d.getDate())].join('-')
const localdatetime = (d: Date) => localdate(d) + 'T' + pad0(d.getHours()) + ':' + pad0(d.getMinutes()) + ':00'
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const scheduleTimeChanged = () => {
console.log('datetime change', scheduleTime.value)
dom._kids(scheduleWeekday, weekdays[new Date(scheduleTime.value).getDay()])
}
const composeElem = dom.div(
style({
position: 'fixed',
@ -1743,6 +1757,58 @@ const compose = (opts: ComposeOptions) => {
dom.option(attr.value('no'), 'Fallback to insecure'),
),
),
dom.div(
scheduleLink=dom.a(attr.href(''), 'Schedule', function click(e: MouseEvent) {
e.preventDefault()
scheduleTime.value = localdatetime(new Date())
scheduleTimeChanged()
scheduleLink.style.display = 'none'
scheduleElem.style.display = ''
scheduleTime.setAttribute('required', '')
}),
scheduleElem=dom.div(
style({display: 'none'}),
dom.clickbutton('Start of next day', function click(e: MouseEvent) {
e.preventDefault()
const d = new Date(scheduleTime.value)
const nextday = new Date(d.getTime() + 24*3600*1000)
scheduleTime.value = localdate(nextday) + 'T09:00:00'
scheduleTimeChanged()
}), ' ',
dom.clickbutton('+1 hour', function click(e: MouseEvent) {
e.preventDefault()
const d = new Date(scheduleTime.value)
scheduleTime.value = localdatetime(new Date(d.getTime() + 3600*1000))
scheduleTimeChanged()
}), ' ',
dom.clickbutton('+1 day', function click(e: MouseEvent) {
e.preventDefault()
const d = new Date(scheduleTime.value)
scheduleTime.value = localdatetime(new Date(d.getTime() + 24*3600*1000))
scheduleTimeChanged()
}), ' ',
dom.clickbutton('Now', function click(e: MouseEvent) {
e.preventDefault()
scheduleTime.value = localdatetime(new Date())
scheduleTimeChanged()
}), ' ',
dom.clickbutton('Cancel', function click(e: MouseEvent) {
e.preventDefault()
scheduleLink.style.display = ''
scheduleElem.style.display = 'none'
scheduleTime.removeAttribute('required')
scheduleTime.value = ''
}),
dom.div(
style({marginTop: '1ex'}),
scheduleTime=dom.input(attr.type('datetime-local'), function change() {
scheduleTimeChanged()
}),
' in local timezone ' + (Intl.DateTimeFormat().resolvedOptions().timeZone || '') + ', ',
scheduleWeekday=dom.span(),
),
),
),
dom.div(
style({margin: '3ex 0 1ex 0', display: 'block'}),
dom.submitbutton('Send'),