From 93c52b01a02d9f4558a0c9eb29b8accc21b6da66 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 10 Feb 2024 17:55:56 +0100 Subject: [PATCH] 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. --- dsn/dsn.go | 10 ++++++ dsn/dsn_test.go | 7 ++-- queue/dsn.go | 5 +-- queue/queue.go | 19 ++++++---- rfc/index.txt | 4 ++- smtp/codes.go | 22 ++++++------ smtpserver/parse.go | 73 +++++++++++++++++++++++++++++++-------- smtpserver/server.go | 59 +++++++++++++++++++++++++++---- smtpserver/server_test.go | 72 ++++++++++++++++++++++++++++++++++++++ webadmin/admin.js | 2 +- webadmin/api.json | 7 ++++ webadmin/api.ts | 3 +- webmail/api.go | 20 ++++++++--- webmail/api.json | 8 +++++ webmail/api.ts | 3 +- webmail/msg.js | 2 +- webmail/text.js | 2 +- webmail/webmail.js | 52 ++++++++++++++++++++++++++-- webmail/webmail.ts | 66 +++++++++++++++++++++++++++++++++++ 19 files changed, 382 insertions(+), 54 deletions(-) diff --git a/dsn/dsn.go b/dsn/dsn.go index d36e429..abcea3e 100644 --- a/dsn/dsn.go +++ b/dsn/dsn.go @@ -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 diff --git a/dsn/dsn_test.go b/dsn/dsn_test.go index 4f57d17..f990528 100644 --- a/dsn/dsn_test.go +++ b/dsn/dsn_test.go @@ -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{ { diff --git a/queue/dsn.go b/queue/dsn.go index 1c451c7..ad3c0a1 100644 --- a/queue/dsn.go +++ b/queue/dsn.go @@ -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{ { diff --git a/queue/queue.go b/queue/queue.go index 0f7114f..ef9c079 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -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 == "" { diff --git a/rfc/index.txt b/rfc/index.txt index 01868b8..411d120 100644 --- a/rfc/index.txt +++ b/rfc/index.txt @@ -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 diff --git a/smtp/codes.go b/smtp/codes.go index 963c240..315e565 100644 --- a/smtp/codes.go +++ b/smtp/codes.go @@ -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 ) diff --git a/smtpserver/parse.go b/smtpserver/parse.go index c076471..cc09edb 100644 --- a/smtpserver/parse.go +++ b/smtpserver/parse.go @@ -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 == '_') }) } diff --git a/smtpserver/server.go b/smtpserver/server.go index 827dd8e..a3ec423 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -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... diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 3c0b175..6f0eb95 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -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:%s\r\n", mailtoMore)) + readPrefixLine(expResponsePrefix) + if expResponsePrefix != "2" { + return + } + write("RCPT TO:\r\n") + readPrefixLine("2") + + write("DATA\r\n") + readPrefixLine("3") + write("From: \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. +} diff --git a/webadmin/admin.js b/webadmin/admin.js index 93fb6a3..338a1ee 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -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"] }] }, diff --git a/webadmin/api.json b/webadmin/api.json index 14ea31d..8dfc050 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -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" + ] } ] }, diff --git a/webadmin/api.ts b/webadmin/api.ts index cffacdc..3b62f5c 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -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"]}]}, diff --git a/webmail/api.go b/webmail/api.go index 4627500..c9ecaba 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -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() diff --git a/webmail/api.json b/webmail/api.json index 5e72024..d7e6c73 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -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" + ] } ] }, diff --git a/webmail/api.ts b/webmail/api.ts index 93fd574..07922d7 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -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"]}]}, diff --git a/webmail/msg.js b/webmail/msg.js index 094d8ce..c89bfaa 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -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"] }] }, diff --git a/webmail/text.js b/webmail/text.js index e7dacc3..f7a73a4 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -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"] }] }, diff --git a/webmail/webmail.js b/webmail/webmail.js index ac24e72..23f190b 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -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); })); diff --git a/webmail/webmail.ts b/webmail/webmail.ts index e4a3be7..17759da 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -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'),