mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
webmail: store composed message as draft until send, ask about unsaved changes when closing compose window
This commit is contained in:
parent
e8bbaa451b
commit
9529ae0bd4
10 changed files with 976 additions and 129 deletions
284
webmail/api.go
284
webmail/api.go
|
@ -177,6 +177,268 @@ func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
|
||||||
|
// of the message in storage. Used when opening a previously saved draft message
|
||||||
|
// for editing again.
|
||||||
|
// If no message is find, zero is returned, not an error.
|
||||||
|
func (Webmail) MessageFindMessageID(ctx context.Context, messageID string) (id int64) {
|
||||||
|
log := pkglog.WithContext(ctx)
|
||||||
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||||
|
acc, err := store.OpenAccount(log, reqInfo.AccountName)
|
||||||
|
xcheckf(ctx, err, "open account")
|
||||||
|
defer func() {
|
||||||
|
err := acc.Close()
|
||||||
|
log.Check(err, "closing account")
|
||||||
|
}()
|
||||||
|
|
||||||
|
messageID, _, _ = message.MessageIDCanonical(messageID)
|
||||||
|
if messageID == "" {
|
||||||
|
xcheckuserf(ctx, errors.New("empty message-id"), "parsing message-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
xdbread(ctx, acc, func(tx *bstore.Tx) {
|
||||||
|
m, err := bstore.QueryTx[store.Message](tx).FilterNonzero(store.Message{MessageID: messageID}).Get()
|
||||||
|
if err == bstore.ErrAbsent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xcheckf(ctx, err, "looking up message by message-id")
|
||||||
|
id = m.ID
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeMessage is a message to be composed, for saving draft messages.
|
||||||
|
type ComposeMessage struct {
|
||||||
|
From string
|
||||||
|
To []string
|
||||||
|
Cc []string
|
||||||
|
Bcc []string
|
||||||
|
ReplyTo string // If non-empty, Reply-To header to add to message.
|
||||||
|
Subject string
|
||||||
|
TextBody string
|
||||||
|
ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
|
||||||
|
DraftMessageID int64 // If set, previous draft message that will be removed after composing new message.
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageCompose composes a message and saves it to the mailbox. Used for
|
||||||
|
// saving draft messages.
|
||||||
|
func (w Webmail) MessageCompose(ctx context.Context, m ComposeMessage, mailboxID int64) (id int64) {
|
||||||
|
// Prevent any accidental control characters, or attempts at getting bare \r or \n
|
||||||
|
// into messages.
|
||||||
|
for _, l := range [][]string{m.To, m.Cc, m.Bcc, {m.From, m.Subject, m.ReplyTo}} {
|
||||||
|
for _, s := range l {
|
||||||
|
for _, c := range s {
|
||||||
|
if c < 0x20 {
|
||||||
|
xcheckuserf(ctx, errors.New("control characters not allowed"), "checking header values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||||
|
log := pkglog.WithContext(ctx).With(slog.String("account", reqInfo.AccountName))
|
||||||
|
acc, err := store.OpenAccount(log, reqInfo.AccountName)
|
||||||
|
xcheckf(ctx, err, "open account")
|
||||||
|
defer func() {
|
||||||
|
err := acc.Close()
|
||||||
|
log.Check(err, "closing account")
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Debug("message compose")
|
||||||
|
|
||||||
|
fromAddr, err := parseAddress(m.From)
|
||||||
|
xcheckuserf(ctx, err, "parsing From address")
|
||||||
|
|
||||||
|
var replyTo *message.NameAddress
|
||||||
|
if m.ReplyTo != "" {
|
||||||
|
addr, err := parseAddress(m.ReplyTo)
|
||||||
|
xcheckuserf(ctx, err, "parsing Reply-To address")
|
||||||
|
replyTo = &addr
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipients []smtp.Address
|
||||||
|
|
||||||
|
var toAddrs []message.NameAddress
|
||||||
|
for _, s := range m.To {
|
||||||
|
addr, err := parseAddress(s)
|
||||||
|
xcheckuserf(ctx, err, "parsing To address")
|
||||||
|
toAddrs = append(toAddrs, addr)
|
||||||
|
recipients = append(recipients, addr.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccAddrs []message.NameAddress
|
||||||
|
for _, s := range m.Cc {
|
||||||
|
addr, err := parseAddress(s)
|
||||||
|
xcheckuserf(ctx, err, "parsing Cc address")
|
||||||
|
ccAddrs = append(ccAddrs, addr)
|
||||||
|
recipients = append(recipients, addr.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bccAddrs []message.NameAddress
|
||||||
|
for _, s := range m.Bcc {
|
||||||
|
addr, err := parseAddress(s)
|
||||||
|
xcheckuserf(ctx, err, "parsing Bcc address")
|
||||||
|
bccAddrs = append(bccAddrs, addr)
|
||||||
|
recipients = append(recipients, addr.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
|
||||||
|
smtputf8 := false
|
||||||
|
for _, a := range recipients {
|
||||||
|
if a.Localpart.IsInternational() {
|
||||||
|
smtputf8 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !smtputf8 && fromAddr.Address.Localpart.IsInternational() {
|
||||||
|
// todo: may want to warn user that they should consider sending with a ascii-only localpart, in case receiver doesn't support smtputf8.
|
||||||
|
smtputf8 = true
|
||||||
|
}
|
||||||
|
if !smtputf8 && replyTo != nil && replyTo.Address.Localpart.IsInternational() {
|
||||||
|
smtputf8 = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file to compose message into.
|
||||||
|
dataFile, err := store.CreateMessageTemp(log, "webmail-compose")
|
||||||
|
xcheckf(ctx, err, "creating temporary file for compose message")
|
||||||
|
defer store.CloseRemoveTempFile(log, dataFile, "compose message")
|
||||||
|
|
||||||
|
// If writing to the message file fails, we abort immediately.
|
||||||
|
xc := message.NewComposer(dataFile, w.maxMessageSize, smtputf8)
|
||||||
|
defer func() {
|
||||||
|
x := recover()
|
||||||
|
if x == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err, ok := x.(error); ok && errors.Is(err, message.ErrMessageSize) {
|
||||||
|
xcheckuserf(ctx, err, "making message")
|
||||||
|
} else if ok && errors.Is(err, message.ErrCompose) {
|
||||||
|
xcheckf(ctx, err, "making message")
|
||||||
|
}
|
||||||
|
panic(x)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Outer message headers.
|
||||||
|
xc.HeaderAddrs("From", []message.NameAddress{fromAddr})
|
||||||
|
if replyTo != nil {
|
||||||
|
xc.HeaderAddrs("Reply-To", []message.NameAddress{*replyTo})
|
||||||
|
}
|
||||||
|
xc.HeaderAddrs("To", toAddrs)
|
||||||
|
xc.HeaderAddrs("Cc", ccAddrs)
|
||||||
|
xc.HeaderAddrs("Bcc", bccAddrs)
|
||||||
|
if m.Subject != "" {
|
||||||
|
xc.Subject(m.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add In-Reply-To and References headers.
|
||||||
|
if m.ResponseMessageID > 0 {
|
||||||
|
xdbread(ctx, acc, func(tx *bstore.Tx) {
|
||||||
|
rm := xmessageID(ctx, tx, m.ResponseMessageID)
|
||||||
|
msgr := acc.MessageReader(rm)
|
||||||
|
defer func() {
|
||||||
|
err := msgr.Close()
|
||||||
|
log.Check(err, "closing message reader")
|
||||||
|
}()
|
||||||
|
rp, err := rm.LoadPart(msgr)
|
||||||
|
xcheckf(ctx, err, "load parsed message")
|
||||||
|
h, err := rp.Header()
|
||||||
|
xcheckf(ctx, err, "parsing header")
|
||||||
|
|
||||||
|
if rp.Envelope == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rp.Envelope.MessageID != "" {
|
||||||
|
xc.Header("In-Reply-To", rp.Envelope.MessageID)
|
||||||
|
}
|
||||||
|
refs := h.Values("References")
|
||||||
|
if len(refs) == 0 && rp.Envelope.InReplyTo != "" {
|
||||||
|
refs = []string{rp.Envelope.InReplyTo}
|
||||||
|
}
|
||||||
|
if rp.Envelope.MessageID != "" {
|
||||||
|
refs = append(refs, rp.Envelope.MessageID)
|
||||||
|
}
|
||||||
|
if len(refs) > 0 {
|
||||||
|
xc.Header("References", strings.Join(refs, "\r\n\t"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
xc.Header("MIME-Version", "1.0")
|
||||||
|
textBody, ct, cte := xc.TextPart("plain", m.TextBody)
|
||||||
|
xc.Header("Content-Type", ct)
|
||||||
|
xc.Header("Content-Transfer-Encoding", cte)
|
||||||
|
xc.Line()
|
||||||
|
xc.Write([]byte(textBody))
|
||||||
|
xc.Flush()
|
||||||
|
|
||||||
|
var nm store.Message
|
||||||
|
|
||||||
|
// Remove previous draft message, append message to destination mailbox.
|
||||||
|
acc.WithRLock(func() {
|
||||||
|
var changes []store.Change
|
||||||
|
|
||||||
|
xdbwrite(ctx, acc, func(tx *bstore.Tx) {
|
||||||
|
var modseq store.ModSeq // Only set if needed.
|
||||||
|
|
||||||
|
if m.DraftMessageID > 0 {
|
||||||
|
var nchanges []store.Change
|
||||||
|
modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
|
||||||
|
changes = append(changes, nchanges...)
|
||||||
|
// On-disk file is removed after lock.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find mailbox to write to.
|
||||||
|
mb := store.Mailbox{ID: mailboxID}
|
||||||
|
err := tx.Get(&mb)
|
||||||
|
if err == bstore.ErrAbsent {
|
||||||
|
xcheckuserf(ctx, err, "looking up mailbox")
|
||||||
|
}
|
||||||
|
xcheckf(ctx, err, "looking up mailbox")
|
||||||
|
|
||||||
|
if modseq == 0 {
|
||||||
|
modseq, err = acc.NextModSeq(tx)
|
||||||
|
xcheckf(ctx, err, "next modseq")
|
||||||
|
}
|
||||||
|
|
||||||
|
nm = store.Message{
|
||||||
|
CreateSeq: modseq,
|
||||||
|
ModSeq: modseq,
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
MailboxOrigID: mb.ID,
|
||||||
|
Flags: store.Flags{Notjunk: true},
|
||||||
|
Size: xc.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, maxSize, err := acc.CanAddMessageSize(tx, nm.Size); err != nil {
|
||||||
|
xcheckf(ctx, err, "checking quota")
|
||||||
|
} else if !ok {
|
||||||
|
xcheckuserf(ctx, fmt.Errorf("account over maximum total message size %d", maxSize), "checking quota")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mailbox before delivery, which changes uidnext.
|
||||||
|
mb.Add(nm.MailboxCounts())
|
||||||
|
err = tx.Update(&mb)
|
||||||
|
xcheckf(ctx, err, "updating sent mailbox for counts")
|
||||||
|
|
||||||
|
err = acc.DeliverMessage(log, tx, &nm, dataFile, true, false, false, true)
|
||||||
|
xcheckf(ctx, err, "storing message in mailbox")
|
||||||
|
|
||||||
|
changes = append(changes, nm.ChangeAddUID(), mb.ChangeCounts())
|
||||||
|
})
|
||||||
|
|
||||||
|
store.BroadcastChanges(acc, changes)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove on-disk file for removed draft message.
|
||||||
|
if m.DraftMessageID > 0 {
|
||||||
|
p := acc.MessagePath(m.DraftMessageID)
|
||||||
|
err := os.Remove(p)
|
||||||
|
log.Check(err, "removing draft message file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nm.ID
|
||||||
|
}
|
||||||
|
|
||||||
// Attachment is a MIME part is an existing message that is not intended as
|
// Attachment is a MIME part is an existing message that is not intended as
|
||||||
// viewable text or HTML part.
|
// viewable text or HTML part.
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
|
@ -197,17 +459,18 @@ type SubmitMessage struct {
|
||||||
To []string
|
To []string
|
||||||
Cc []string
|
Cc []string
|
||||||
Bcc []string
|
Bcc []string
|
||||||
|
ReplyTo string // If non-empty, Reply-To header to add to message.
|
||||||
Subject string
|
Subject string
|
||||||
TextBody string
|
TextBody string
|
||||||
Attachments []File
|
Attachments []File
|
||||||
ForwardAttachments ForwardAttachments
|
ForwardAttachments ForwardAttachments
|
||||||
IsForward bool
|
IsForward bool
|
||||||
ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
|
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.
|
UserAgent string // User-Agent header added if not empty.
|
||||||
RequireTLS *bool // For "Require TLS" extension during delivery.
|
RequireTLS *bool // For "Require TLS" extension during delivery.
|
||||||
FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
|
FutureRelease *time.Time // If set, time (in the future) when message should be delivered from queue.
|
||||||
ArchiveThread bool // If set, thread is archived after sending message.
|
ArchiveThread bool // If set, thread is archived after sending message.
|
||||||
|
DraftMessageID int64 // If set, draft message that will be removed after sending.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForwardAttachments references attachments by a list of message.Part paths.
|
// ForwardAttachments references attachments by a list of message.Part paths.
|
||||||
|
@ -705,7 +968,8 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
|
||||||
|
|
||||||
var modseq store.ModSeq // Only set if needed.
|
var modseq store.ModSeq // Only set if needed.
|
||||||
|
|
||||||
// Append message to Sent mailbox and mark original messages as answered/forwarded.
|
// Append message to Sent mailbox, mark original messages as answered/forwarded,
|
||||||
|
// remove any draft message.
|
||||||
acc.WithRLock(func() {
|
acc.WithRLock(func() {
|
||||||
var changes []store.Change
|
var changes []store.Change
|
||||||
|
|
||||||
|
@ -719,6 +983,13 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
xdbwrite(ctx, acc, func(tx *bstore.Tx) {
|
xdbwrite(ctx, acc, func(tx *bstore.Tx) {
|
||||||
|
if m.DraftMessageID > 0 {
|
||||||
|
var nchanges []store.Change
|
||||||
|
modseq, nchanges = xops.MessageDeleteTx(ctx, log, tx, acc, []int64{m.DraftMessageID}, modseq)
|
||||||
|
changes = append(changes, nchanges...)
|
||||||
|
// On-disk file is removed after lock.
|
||||||
|
}
|
||||||
|
|
||||||
if m.ResponseMessageID > 0 {
|
if m.ResponseMessageID > 0 {
|
||||||
rm := xmessageID(ctx, tx, m.ResponseMessageID)
|
rm := xmessageID(ctx, tx, m.ResponseMessageID)
|
||||||
oflags := rm.Flags
|
oflags := rm.Flags
|
||||||
|
@ -759,7 +1030,7 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
|
||||||
xcheckf(ctx, err, "listing messages in thread to archive")
|
xcheckf(ctx, err, "listing messages in thread to archive")
|
||||||
if len(msgIDs) > 0 {
|
if len(msgIDs) > 0 {
|
||||||
var nchanges []store.Change
|
var nchanges []store.Change
|
||||||
modseq, nchanges = xops.MessageMoveMailbox(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
|
modseq, nchanges = xops.MessageMoveTx(ctx, log, acc, tx, msgIDs, mbArchive, modseq)
|
||||||
changes = append(changes, nchanges...)
|
changes = append(changes, nchanges...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -821,6 +1092,13 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
|
||||||
|
|
||||||
store.BroadcastChanges(acc, changes)
|
store.BroadcastChanges(acc, changes)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Remove on-disk file for removed draft message.
|
||||||
|
if m.DraftMessageID > 0 {
|
||||||
|
p := acc.MessagePath(m.DraftMessageID)
|
||||||
|
err := os.Remove(p)
|
||||||
|
log.Check(err, "removing draft message file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageMove moves messages to another mailbox. If the message is already in
|
// MessageMove moves messages to another mailbox. If the message is already in
|
||||||
|
|
139
webmail/api.json
139
webmail/api.json
|
@ -99,6 +99,52 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "MessageFindMessageID",
|
||||||
|
"Docs": "MessageFindMessageID looks up a message by Message-Id header, and returns the ID\nof the message in storage. Used when opening a previously saved draft message\nfor editing again.\nIf no message is find, zero is returned, not an error.",
|
||||||
|
"Params": [
|
||||||
|
{
|
||||||
|
"Name": "messageID",
|
||||||
|
"Typewords": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Returns": [
|
||||||
|
{
|
||||||
|
"Name": "id",
|
||||||
|
"Typewords": [
|
||||||
|
"int64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "MessageCompose",
|
||||||
|
"Docs": "MessageCompose composes a message and saves it to the mailbox. Used for\nsaving draft messages.",
|
||||||
|
"Params": [
|
||||||
|
{
|
||||||
|
"Name": "m",
|
||||||
|
"Typewords": [
|
||||||
|
"ComposeMessage"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "mailboxID",
|
||||||
|
"Typewords": [
|
||||||
|
"int64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Returns": [
|
||||||
|
{
|
||||||
|
"Name": "id",
|
||||||
|
"Typewords": [
|
||||||
|
"int64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "MessageSubmit",
|
"Name": "MessageSubmit",
|
||||||
"Docs": "MessageSubmit sends a message by submitting it the outgoing email queue. The\nmessage is sent to all addresses listed in the To, Cc and Bcc addresses, without\nBcc message header.\n\nIf a Sent mailbox is configured, messages are added to it after submitting\nto the delivery queue. If Bcc addresses were present, a header is prepended\nto the message stored in the Sent mailbox.",
|
"Docs": "MessageSubmit sends a message by submitting it the outgoing email queue. The\nmessage is sent to all addresses listed in the To, Cc and Bcc addresses, without\nBcc message header.\n\nIf a Sent mailbox is configured, messages are added to it after submitting\nto the delivery queue. If Bcc addresses were present, a header is prepended\nto the message stored in the Sent mailbox.",
|
||||||
|
@ -1083,6 +1129,78 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "ComposeMessage",
|
||||||
|
"Docs": "ComposeMessage is a message to be composed, for saving draft messages.",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "From",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "To",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"[]",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Cc",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"[]",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Bcc",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"[]",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "ReplyTo",
|
||||||
|
"Docs": "If non-empty, Reply-To header to add to message.",
|
||||||
|
"Typewords": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Subject",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "TextBody",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "ResponseMessageID",
|
||||||
|
"Docs": "If set, this was a reply or forward, based on IsForward.",
|
||||||
|
"Typewords": [
|
||||||
|
"int64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "DraftMessageID",
|
||||||
|
"Docs": "If set, previous draft message that will be removed after composing new message.",
|
||||||
|
"Typewords": [
|
||||||
|
"int64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "SubmitMessage",
|
"Name": "SubmitMessage",
|
||||||
"Docs": "SubmitMessage is an email message to be sent to one or more recipients.\nAddresses are formatted as just email address, or with a name like \"name\n\u003cuser@host\u003e\".",
|
"Docs": "SubmitMessage is an email message to be sent to one or more recipients.\nAddresses are formatted as just email address, or with a name like \"name\n\u003cuser@host\u003e\".",
|
||||||
|
@ -1118,6 +1236,13 @@
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "ReplyTo",
|
||||||
|
"Docs": "If non-empty, Reply-To header to add to message.",
|
||||||
|
"Typewords": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "Subject",
|
"Name": "Subject",
|
||||||
"Docs": "",
|
"Docs": "",
|
||||||
|
@ -1161,13 +1286,6 @@
|
||||||
"int64"
|
"int64"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"Name": "ReplyTo",
|
|
||||||
"Docs": "If non-empty, Reply-To header to add to message.",
|
|
||||||
"Typewords": [
|
|
||||||
"string"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"Name": "UserAgent",
|
"Name": "UserAgent",
|
||||||
"Docs": "User-Agent header added if not empty.",
|
"Docs": "User-Agent header added if not empty.",
|
||||||
|
@ -1197,6 +1315,13 @@
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"bool"
|
"bool"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "DraftMessageID",
|
||||||
|
"Docs": "If set, draft message that will be removed after sending.",
|
||||||
|
"Typewords": [
|
||||||
|
"int64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -127,6 +127,19 @@ export interface Domain {
|
||||||
Unicode: string // Name as U-labels, in Unicode NFC. Empty if this is an ASCII-only domain. No trailing dot.
|
Unicode: string // Name as U-labels, in Unicode NFC. Empty if this is an ASCII-only domain. No trailing dot.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ComposeMessage is a message to be composed, for saving draft messages.
|
||||||
|
export interface ComposeMessage {
|
||||||
|
From: string
|
||||||
|
To?: string[] | null
|
||||||
|
Cc?: string[] | null
|
||||||
|
Bcc?: string[] | null
|
||||||
|
ReplyTo: string // If non-empty, Reply-To header to add to message.
|
||||||
|
Subject: string
|
||||||
|
TextBody: string
|
||||||
|
ResponseMessageID: number // If set, this was a reply or forward, based on IsForward.
|
||||||
|
DraftMessageID: number // If set, previous draft message that will be removed after composing new message.
|
||||||
|
}
|
||||||
|
|
||||||
// SubmitMessage is an email message to be sent to one or more recipients.
|
// SubmitMessage is an email message to be sent to one or more recipients.
|
||||||
// Addresses are formatted as just email address, or with a name like "name
|
// Addresses are formatted as just email address, or with a name like "name
|
||||||
// <user@host>".
|
// <user@host>".
|
||||||
|
@ -135,17 +148,18 @@ export interface SubmitMessage {
|
||||||
To?: string[] | null
|
To?: string[] | null
|
||||||
Cc?: string[] | null
|
Cc?: string[] | null
|
||||||
Bcc?: string[] | null
|
Bcc?: string[] | null
|
||||||
|
ReplyTo: string // If non-empty, Reply-To header to add to message.
|
||||||
Subject: string
|
Subject: string
|
||||||
TextBody: string
|
TextBody: string
|
||||||
Attachments?: File[] | null
|
Attachments?: File[] | null
|
||||||
ForwardAttachments: ForwardAttachments
|
ForwardAttachments: ForwardAttachments
|
||||||
IsForward: boolean
|
IsForward: boolean
|
||||||
ResponseMessageID: number // If set, this was a reply or forward, based on IsForward.
|
ResponseMessageID: number // 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.
|
UserAgent: string // User-Agent header added if not empty.
|
||||||
RequireTLS?: boolean | null // For "Require TLS" extension during delivery.
|
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.
|
FutureRelease?: Date | null // If set, time (in the future) when message should be delivered from queue.
|
||||||
ArchiveThread: boolean // If set, thread is archived after sending message.
|
ArchiveThread: boolean // If set, thread is archived after sending message.
|
||||||
|
DraftMessageID: number // If set, draft message that will be removed after sending.
|
||||||
}
|
}
|
||||||
|
|
||||||
// File is a new attachment (not from an existing message that is being
|
// File is a new attachment (not from an existing message that is being
|
||||||
|
@ -537,7 +551,7 @@ export enum Quoting {
|
||||||
// Localparts are in Unicode NFC.
|
// Localparts are in Unicode NFC.
|
||||||
export type Localpart = string
|
export type Localpart = string
|
||||||
|
|
||||||
export const structTypes: {[typename: string]: boolean} = {"Address":true,"Attachment":true,"ChangeMailboxAdd":true,"ChangeMailboxCounts":true,"ChangeMailboxKeywords":true,"ChangeMailboxRemove":true,"ChangeMailboxRename":true,"ChangeMailboxSpecialUse":true,"ChangeMsgAdd":true,"ChangeMsgFlags":true,"ChangeMsgRemove":true,"ChangeMsgThread":true,"Domain":true,"DomainAddressConfig":true,"Envelope":true,"EventStart":true,"EventViewChanges":true,"EventViewErr":true,"EventViewMsgs":true,"EventViewReset":true,"File":true,"Filter":true,"Flags":true,"ForwardAttachments":true,"Mailbox":true,"Message":true,"MessageAddress":true,"MessageEnvelope":true,"MessageItem":true,"NotFilter":true,"Page":true,"ParsedMessage":true,"Part":true,"Query":true,"RecipientSecurity":true,"Request":true,"Settings":true,"SpecialUse":true,"SubmitMessage":true}
|
export const structTypes: {[typename: string]: boolean} = {"Address":true,"Attachment":true,"ChangeMailboxAdd":true,"ChangeMailboxCounts":true,"ChangeMailboxKeywords":true,"ChangeMailboxRemove":true,"ChangeMailboxRename":true,"ChangeMailboxSpecialUse":true,"ChangeMsgAdd":true,"ChangeMsgFlags":true,"ChangeMsgRemove":true,"ChangeMsgThread":true,"ComposeMessage":true,"Domain":true,"DomainAddressConfig":true,"Envelope":true,"EventStart":true,"EventViewChanges":true,"EventViewErr":true,"EventViewMsgs":true,"EventViewReset":true,"File":true,"Filter":true,"Flags":true,"ForwardAttachments":true,"Mailbox":true,"Message":true,"MessageAddress":true,"MessageEnvelope":true,"MessageItem":true,"NotFilter":true,"Page":true,"ParsedMessage":true,"Part":true,"Query":true,"RecipientSecurity":true,"Request":true,"Settings":true,"SpecialUse":true,"SubmitMessage":true}
|
||||||
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"CSRFToken":true,"Localpart":true,"Quoting":true,"SecurityResult":true,"ThreadMode":true}
|
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"CSRFToken":true,"Localpart":true,"Quoting":true,"SecurityResult":true,"ThreadMode":true}
|
||||||
export const intsTypes: {[typename: string]: boolean} = {"ModSeq":true,"UID":true,"Validation":true}
|
export const intsTypes: {[typename: string]: boolean} = {"ModSeq":true,"UID":true,"Validation":true}
|
||||||
export const types: TypenameMap = {
|
export const types: TypenameMap = {
|
||||||
|
@ -552,7 +566,8 @@ export const types: TypenameMap = {
|
||||||
"Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["string"]}]},
|
"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"]}]},
|
"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"]}]},
|
"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"]},{"Name":"FutureRelease","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"ArchiveThread","Docs":"","Typewords":["bool"]}]},
|
"ComposeMessage": {"Name":"ComposeMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"DraftMessageID","Docs":"","Typewords":["int64"]}]},
|
||||||
|
"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":"ReplyTo","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":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"Name":"FutureRelease","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"ArchiveThread","Docs":"","Typewords":["bool"]},{"Name":"DraftMessageID","Docs":"","Typewords":["int64"]}]},
|
||||||
"File": {"Name":"File","Docs":"","Fields":[{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DataURI","Docs":"","Typewords":["string"]}]},
|
"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"]}]},
|
"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"]}]},
|
"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"]}]},
|
||||||
|
@ -603,6 +618,7 @@ export const parser = {
|
||||||
Address: (v: any) => parse("Address", v) as Address,
|
Address: (v: any) => parse("Address", v) as Address,
|
||||||
MessageAddress: (v: any) => parse("MessageAddress", v) as MessageAddress,
|
MessageAddress: (v: any) => parse("MessageAddress", v) as MessageAddress,
|
||||||
Domain: (v: any) => parse("Domain", v) as Domain,
|
Domain: (v: any) => parse("Domain", v) as Domain,
|
||||||
|
ComposeMessage: (v: any) => parse("ComposeMessage", v) as ComposeMessage,
|
||||||
SubmitMessage: (v: any) => parse("SubmitMessage", v) as SubmitMessage,
|
SubmitMessage: (v: any) => parse("SubmitMessage", v) as SubmitMessage,
|
||||||
File: (v: any) => parse("File", v) as File,
|
File: (v: any) => parse("File", v) as File,
|
||||||
ForwardAttachments: (v: any) => parse("ForwardAttachments", v) as ForwardAttachments,
|
ForwardAttachments: (v: any) => parse("ForwardAttachments", v) as ForwardAttachments,
|
||||||
|
@ -732,6 +748,28 @@ export class Client {
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ParsedMessage
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ParsedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
|
||||||
|
// of the message in storage. Used when opening a previously saved draft message
|
||||||
|
// for editing again.
|
||||||
|
// If no message is find, zero is returned, not an error.
|
||||||
|
async MessageFindMessageID(messageID: string): Promise<number> {
|
||||||
|
const fn: string = "MessageFindMessageID"
|
||||||
|
const paramTypes: string[][] = [["string"]]
|
||||||
|
const returnTypes: string[][] = [["int64"]]
|
||||||
|
const params: any[] = [messageID]
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageCompose composes a message and saves it to the mailbox. Used for
|
||||||
|
// saving draft messages.
|
||||||
|
async MessageCompose(m: ComposeMessage, mailboxID: number): Promise<number> {
|
||||||
|
const fn: string = "MessageCompose"
|
||||||
|
const paramTypes: string[][] = [["ComposeMessage"],["int64"]]
|
||||||
|
const returnTypes: string[][] = [["int64"]]
|
||||||
|
const params: any[] = [m, mailboxID]
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
|
||||||
|
}
|
||||||
|
|
||||||
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
||||||
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
||||||
// Bcc message header.
|
// Bcc message header.
|
||||||
|
|
|
@ -175,14 +175,14 @@ func TestAPI(t *testing.T) {
|
||||||
tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) })
|
tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) })
|
||||||
|
|
||||||
// MailboxSetSpecialUse
|
// MailboxSetSpecialUse
|
||||||
var inbox, archive, sent, testbox1 store.Mailbox
|
var inbox, archive, sent, drafts, testbox1 store.Mailbox
|
||||||
err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
|
err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
|
||||||
get := func(k string, v any) store.Mailbox {
|
get := func(k string, v any) store.Mailbox {
|
||||||
mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual(k, v).Get()
|
mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual(k, v).Get()
|
||||||
tcheck(t, err, "get special-use mailbox")
|
tcheck(t, err, "get special-use mailbox")
|
||||||
return mb
|
return mb
|
||||||
}
|
}
|
||||||
get("Draft", true)
|
drafts = get("Draft", true)
|
||||||
sent = get("Sent", true)
|
sent = get("Sent", true)
|
||||||
archive = get("Archive", true)
|
archive = get("Archive", true)
|
||||||
get("Trash", true)
|
get("Trash", true)
|
||||||
|
@ -273,6 +273,32 @@ func TestAPI(t *testing.T) {
|
||||||
tdeliver(t, acc, testbox1Alt)
|
tdeliver(t, acc, testbox1Alt)
|
||||||
tdeliver(t, acc, inboxAltRel)
|
tdeliver(t, acc, inboxAltRel)
|
||||||
|
|
||||||
|
// MessageCompose
|
||||||
|
draftID := api.MessageCompose(ctx, ComposeMessage{
|
||||||
|
From: "mjl@mox.example",
|
||||||
|
To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
|
||||||
|
Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
|
||||||
|
Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
|
||||||
|
Subject: "test email",
|
||||||
|
TextBody: "this is the content\n\ncheers,\nmox",
|
||||||
|
ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
|
||||||
|
}, drafts.ID)
|
||||||
|
// Replace draft.
|
||||||
|
draftID = api.MessageCompose(ctx, ComposeMessage{
|
||||||
|
From: "mjl@mox.example",
|
||||||
|
To: []string{"mjl+to@mox.example", "mjl to2 <mjl+to2@mox.example>"},
|
||||||
|
Cc: []string{"mjl+cc@mox.example", "mjl cc2 <mjl+cc2@mox.example>"},
|
||||||
|
Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 <mjl+bcc2@mox.example>"},
|
||||||
|
Subject: "test email",
|
||||||
|
TextBody: "this is the content\n\ncheers,\nmox",
|
||||||
|
ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
|
||||||
|
DraftMessageID: draftID,
|
||||||
|
}, drafts.ID)
|
||||||
|
|
||||||
|
// MessageFindMessageID
|
||||||
|
msgID := api.MessageFindMessageID(ctx, "<absent@localhost>")
|
||||||
|
tcompare(t, msgID, int64(0))
|
||||||
|
|
||||||
// MessageSubmit
|
// MessageSubmit
|
||||||
queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
|
queue.Localserve = true // Deliver directly to us instead attempting actual delivery.
|
||||||
err = queue.Init()
|
err = queue.Init()
|
||||||
|
@ -286,6 +312,7 @@ func TestAPI(t *testing.T) {
|
||||||
TextBody: "this is the content\n\ncheers,\nmox",
|
TextBody: "this is the content\n\ncheers,\nmox",
|
||||||
ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
|
ReplyTo: "mjl replyto <mjl+replyto@mox.example>",
|
||||||
UserAgent: "moxwebmail/dev",
|
UserAgent: "moxwebmail/dev",
|
||||||
|
DraftMessageID: draftID,
|
||||||
})
|
})
|
||||||
// todo: check delivery of 6 messages to inbox, 1 to sent
|
// todo: check delivery of 6 messages to inbox, 1 to sent
|
||||||
|
|
||||||
|
|
|
@ -281,7 +281,7 @@ var api;
|
||||||
Quoting["Bottom"] = "bottom";
|
Quoting["Bottom"] = "bottom";
|
||||||
Quoting["Top"] = "top";
|
Quoting["Top"] = "top";
|
||||||
})(Quoting = api.Quoting || (api.Quoting = {}));
|
})(Quoting = api.Quoting || (api.Quoting = {}));
|
||||||
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "Settings": true, "SpecialUse": true, "SubmitMessage": true };
|
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "ComposeMessage": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "Settings": true, "SpecialUse": true, "SubmitMessage": true };
|
||||||
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "Quoting": true, "SecurityResult": true, "ThreadMode": true };
|
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "Quoting": true, "SecurityResult": true, "ThreadMode": true };
|
||||||
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
|
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
|
||||||
api.types = {
|
api.types = {
|
||||||
|
@ -296,7 +296,8 @@ var api;
|
||||||
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
|
"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"] }] },
|
"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"] }] },
|
"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"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "ArchiveThread", "Docs": "", "Typewords": ["bool"] }] },
|
"ComposeMessage": { "Name": "ComposeMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "DraftMessageID", "Docs": "", "Typewords": ["int64"] }] },
|
||||||
|
"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": "ReplyTo", "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": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "ArchiveThread", "Docs": "", "Typewords": ["bool"] }, { "Name": "DraftMessageID", "Docs": "", "Typewords": ["int64"] }] },
|
||||||
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
|
"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"] }] },
|
"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"] }] },
|
"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"] }] },
|
||||||
|
@ -346,6 +347,7 @@ var api;
|
||||||
Address: (v) => api.parse("Address", v),
|
Address: (v) => api.parse("Address", v),
|
||||||
MessageAddress: (v) => api.parse("MessageAddress", v),
|
MessageAddress: (v) => api.parse("MessageAddress", v),
|
||||||
Domain: (v) => api.parse("Domain", v),
|
Domain: (v) => api.parse("Domain", v),
|
||||||
|
ComposeMessage: (v) => api.parse("ComposeMessage", v),
|
||||||
SubmitMessage: (v) => api.parse("SubmitMessage", v),
|
SubmitMessage: (v) => api.parse("SubmitMessage", v),
|
||||||
File: (v) => api.parse("File", v),
|
File: (v) => api.parse("File", v),
|
||||||
ForwardAttachments: (v) => api.parse("ForwardAttachments", v),
|
ForwardAttachments: (v) => api.parse("ForwardAttachments", v),
|
||||||
|
@ -463,6 +465,26 @@ var api;
|
||||||
const params = [msgID];
|
const params = [msgID];
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
}
|
}
|
||||||
|
// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
|
||||||
|
// of the message in storage. Used when opening a previously saved draft message
|
||||||
|
// for editing again.
|
||||||
|
// If no message is find, zero is returned, not an error.
|
||||||
|
async MessageFindMessageID(messageID) {
|
||||||
|
const fn = "MessageFindMessageID";
|
||||||
|
const paramTypes = [["string"]];
|
||||||
|
const returnTypes = [["int64"]];
|
||||||
|
const params = [messageID];
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
|
}
|
||||||
|
// MessageCompose composes a message and saves it to the mailbox. Used for
|
||||||
|
// saving draft messages.
|
||||||
|
async MessageCompose(m, mailboxID) {
|
||||||
|
const fn = "MessageCompose";
|
||||||
|
const paramTypes = [["ComposeMessage"], ["int64"]];
|
||||||
|
const returnTypes = [["int64"]];
|
||||||
|
const params = [m, mailboxID];
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
|
}
|
||||||
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
||||||
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
||||||
// Bcc message header.
|
// Bcc message header.
|
||||||
|
|
|
@ -281,7 +281,7 @@ var api;
|
||||||
Quoting["Bottom"] = "bottom";
|
Quoting["Bottom"] = "bottom";
|
||||||
Quoting["Top"] = "top";
|
Quoting["Top"] = "top";
|
||||||
})(Quoting = api.Quoting || (api.Quoting = {}));
|
})(Quoting = api.Quoting || (api.Quoting = {}));
|
||||||
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "Settings": true, "SpecialUse": true, "SubmitMessage": true };
|
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "ComposeMessage": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "Settings": true, "SpecialUse": true, "SubmitMessage": true };
|
||||||
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "Quoting": true, "SecurityResult": true, "ThreadMode": true };
|
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "Quoting": true, "SecurityResult": true, "ThreadMode": true };
|
||||||
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
|
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
|
||||||
api.types = {
|
api.types = {
|
||||||
|
@ -296,7 +296,8 @@ var api;
|
||||||
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
|
"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"] }] },
|
"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"] }] },
|
"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"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "ArchiveThread", "Docs": "", "Typewords": ["bool"] }] },
|
"ComposeMessage": { "Name": "ComposeMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "DraftMessageID", "Docs": "", "Typewords": ["int64"] }] },
|
||||||
|
"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": "ReplyTo", "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": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "ArchiveThread", "Docs": "", "Typewords": ["bool"] }, { "Name": "DraftMessageID", "Docs": "", "Typewords": ["int64"] }] },
|
||||||
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
|
"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"] }] },
|
"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"] }] },
|
"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"] }] },
|
||||||
|
@ -346,6 +347,7 @@ var api;
|
||||||
Address: (v) => api.parse("Address", v),
|
Address: (v) => api.parse("Address", v),
|
||||||
MessageAddress: (v) => api.parse("MessageAddress", v),
|
MessageAddress: (v) => api.parse("MessageAddress", v),
|
||||||
Domain: (v) => api.parse("Domain", v),
|
Domain: (v) => api.parse("Domain", v),
|
||||||
|
ComposeMessage: (v) => api.parse("ComposeMessage", v),
|
||||||
SubmitMessage: (v) => api.parse("SubmitMessage", v),
|
SubmitMessage: (v) => api.parse("SubmitMessage", v),
|
||||||
File: (v) => api.parse("File", v),
|
File: (v) => api.parse("File", v),
|
||||||
ForwardAttachments: (v) => api.parse("ForwardAttachments", v),
|
ForwardAttachments: (v) => api.parse("ForwardAttachments", v),
|
||||||
|
@ -463,6 +465,26 @@ var api;
|
||||||
const params = [msgID];
|
const params = [msgID];
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
}
|
}
|
||||||
|
// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
|
||||||
|
// of the message in storage. Used when opening a previously saved draft message
|
||||||
|
// for editing again.
|
||||||
|
// If no message is find, zero is returned, not an error.
|
||||||
|
async MessageFindMessageID(messageID) {
|
||||||
|
const fn = "MessageFindMessageID";
|
||||||
|
const paramTypes = [["string"]];
|
||||||
|
const returnTypes = [["int64"]];
|
||||||
|
const params = [messageID];
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
|
}
|
||||||
|
// MessageCompose composes a message and saves it to the mailbox. Used for
|
||||||
|
// saving draft messages.
|
||||||
|
async MessageCompose(m, mailboxID) {
|
||||||
|
const fn = "MessageCompose";
|
||||||
|
const paramTypes = [["ComposeMessage"], ["int64"]];
|
||||||
|
const returnTypes = [["int64"]];
|
||||||
|
const params = [m, mailboxID];
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
|
}
|
||||||
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
||||||
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
||||||
// Bcc message header.
|
// Bcc message header.
|
||||||
|
|
|
@ -18,9 +18,9 @@ fieldset { border: 0; }
|
||||||
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
|
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
|
||||||
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
|
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
|
||||||
.button { display: inline-block; }
|
.button { display: inline-block; }
|
||||||
button, .button { border-radius: .15em; background-color: #eee; border: 1px solid #888; padding: 0 .15em; color: inherit; /* for ipad, which shows blue or white text */ }
|
button, .button, select { border-radius: .15em; background-color: #eee; border: 1px solid #888; padding: 0 .15em; color: inherit; /* for ipad, which shows blue or white text */ }
|
||||||
button.active, .button.active, button.active:hover, .button.active:hover { background-color: gold; }
|
button.active, .button.active, button.active:hover, .button.active:hover { background-color: gold; }
|
||||||
button:hover, .button:hover { background-color: #ddd; }
|
button:hover, .button:hover, select:hover { background-color: #ddd; }
|
||||||
button.keyword:hover { background-color: #ffbd21; }
|
button.keyword:hover { background-color: #ffbd21; }
|
||||||
button.keyword { cursor: pointer; }
|
button.keyword { cursor: pointer; }
|
||||||
.btngroup button, .btngroup .button { border-radius: 0; border-right-width: 0; }
|
.btngroup button, .btngroup .button { border-radius: 0; border-right-width: 0; }
|
||||||
|
|
|
@ -281,7 +281,7 @@ var api;
|
||||||
Quoting["Bottom"] = "bottom";
|
Quoting["Bottom"] = "bottom";
|
||||||
Quoting["Top"] = "top";
|
Quoting["Top"] = "top";
|
||||||
})(Quoting = api.Quoting || (api.Quoting = {}));
|
})(Quoting = api.Quoting || (api.Quoting = {}));
|
||||||
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "Settings": true, "SpecialUse": true, "SubmitMessage": true };
|
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "ComposeMessage": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "Settings": true, "SpecialUse": true, "SubmitMessage": true };
|
||||||
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "Quoting": true, "SecurityResult": true, "ThreadMode": true };
|
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "Quoting": true, "SecurityResult": true, "ThreadMode": true };
|
||||||
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
|
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
|
||||||
api.types = {
|
api.types = {
|
||||||
|
@ -296,7 +296,8 @@ var api;
|
||||||
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
|
"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"] }] },
|
"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"] }] },
|
"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"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "ArchiveThread", "Docs": "", "Typewords": ["bool"] }] },
|
"ComposeMessage": { "Name": "ComposeMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "DraftMessageID", "Docs": "", "Typewords": ["int64"] }] },
|
||||||
|
"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": "ReplyTo", "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": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "ArchiveThread", "Docs": "", "Typewords": ["bool"] }, { "Name": "DraftMessageID", "Docs": "", "Typewords": ["int64"] }] },
|
||||||
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
|
"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"] }] },
|
"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"] }] },
|
"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"] }] },
|
||||||
|
@ -346,6 +347,7 @@ var api;
|
||||||
Address: (v) => api.parse("Address", v),
|
Address: (v) => api.parse("Address", v),
|
||||||
MessageAddress: (v) => api.parse("MessageAddress", v),
|
MessageAddress: (v) => api.parse("MessageAddress", v),
|
||||||
Domain: (v) => api.parse("Domain", v),
|
Domain: (v) => api.parse("Domain", v),
|
||||||
|
ComposeMessage: (v) => api.parse("ComposeMessage", v),
|
||||||
SubmitMessage: (v) => api.parse("SubmitMessage", v),
|
SubmitMessage: (v) => api.parse("SubmitMessage", v),
|
||||||
File: (v) => api.parse("File", v),
|
File: (v) => api.parse("File", v),
|
||||||
ForwardAttachments: (v) => api.parse("ForwardAttachments", v),
|
ForwardAttachments: (v) => api.parse("ForwardAttachments", v),
|
||||||
|
@ -463,6 +465,26 @@ var api;
|
||||||
const params = [msgID];
|
const params = [msgID];
|
||||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
}
|
}
|
||||||
|
// MessageFindMessageID looks up a message by Message-Id header, and returns the ID
|
||||||
|
// of the message in storage. Used when opening a previously saved draft message
|
||||||
|
// for editing again.
|
||||||
|
// If no message is find, zero is returned, not an error.
|
||||||
|
async MessageFindMessageID(messageID) {
|
||||||
|
const fn = "MessageFindMessageID";
|
||||||
|
const paramTypes = [["string"]];
|
||||||
|
const returnTypes = [["int64"]];
|
||||||
|
const params = [messageID];
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
|
}
|
||||||
|
// MessageCompose composes a message and saves it to the mailbox. Used for
|
||||||
|
// saving draft messages.
|
||||||
|
async MessageCompose(m, mailboxID) {
|
||||||
|
const fn = "MessageCompose";
|
||||||
|
const paramTypes = [["ComposeMessage"], ["int64"]];
|
||||||
|
const returnTypes = [["int64"]];
|
||||||
|
const params = [m, mailboxID];
|
||||||
|
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||||
|
}
|
||||||
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
// MessageSubmit sends a message by submitting it the outgoing email queue. The
|
||||||
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
||||||
// Bcc message header.
|
// Bcc message header.
|
||||||
|
@ -2198,6 +2220,7 @@ const cmdHelp = async () => {
|
||||||
['r', 'reply or list reply'],
|
['r', 'reply or list reply'],
|
||||||
['R', 'reply all'],
|
['R', 'reply all'],
|
||||||
['f', 'forward message'],
|
['f', 'forward message'],
|
||||||
|
['e', 'edit draft'],
|
||||||
['v', 'view attachments'],
|
['v', 'view attachments'],
|
||||||
['t', 'view text version'],
|
['t', 'view text version'],
|
||||||
['T', 'view HTML version'],
|
['T', 'view HTML version'],
|
||||||
|
@ -2312,11 +2335,109 @@ const compose = (opts, listMailboxes) => {
|
||||||
let toRow, replyToRow, ccRow, bccRow; // We show/hide rows as needed.
|
let toRow, replyToRow, ccRow, bccRow; // We show/hide rows as needed.
|
||||||
let toViews = [], replytoViews = [], ccViews = [], bccViews = [];
|
let toViews = [], replytoViews = [], ccViews = [], bccViews = [];
|
||||||
let forwardAttachmentViews = [];
|
let forwardAttachmentViews = [];
|
||||||
|
// todo future: upload attachments with draft messages. would mean we let users remove them again too.
|
||||||
|
// We automatically save drafts 1m after a change. When closing window, we ask to
|
||||||
|
// save unsaved change to draft.
|
||||||
|
let draftMessageID = opts.draftMessageID || 0;
|
||||||
|
let draftSaveTimer = 0;
|
||||||
|
let draftSavePromise = Promise.resolve(0);
|
||||||
|
let draftLastText = opts.body;
|
||||||
|
const draftCancelSave = () => {
|
||||||
|
if (draftSaveTimer) {
|
||||||
|
window.clearTimeout(draftSaveTimer);
|
||||||
|
draftSaveTimer = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const draftScheduleSave = () => {
|
||||||
|
if (draftSaveTimer || body.value === draftLastText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftSaveTimer = window.setTimeout(async () => {
|
||||||
|
draftSaveTimer = 0;
|
||||||
|
await withStatus('Saving draft', draftSave());
|
||||||
|
draftScheduleSave();
|
||||||
|
}, 60 * 1000);
|
||||||
|
};
|
||||||
|
const draftSave = async () => {
|
||||||
|
draftCancelSave();
|
||||||
|
let replyTo = '';
|
||||||
|
if (replytoViews && replytoViews.length === 1 && replytoViews[0].input.value) {
|
||||||
|
replyTo = replytoViews[0].input.value;
|
||||||
|
}
|
||||||
|
const cm = {
|
||||||
|
From: customFrom ? customFrom.value : from.value,
|
||||||
|
To: toViews.map(v => v.input.value).filter(s => s),
|
||||||
|
Cc: ccViews.map(v => v.input.value).filter(s => s),
|
||||||
|
Bcc: bccViews.map(v => v.input.value).filter(s => s),
|
||||||
|
ReplyTo: replyTo,
|
||||||
|
Subject: subject.value,
|
||||||
|
TextBody: body.value,
|
||||||
|
ResponseMessageID: opts.responseMessageID || 0,
|
||||||
|
DraftMessageID: draftMessageID,
|
||||||
|
};
|
||||||
|
const mbdrafts = listMailboxes().find(mb => mb.Draft);
|
||||||
|
if (!mbdrafts) {
|
||||||
|
throw new Error('no designated drafts mailbox');
|
||||||
|
}
|
||||||
|
draftSavePromise = client.MessageCompose(cm, mbdrafts.ID);
|
||||||
|
draftMessageID = await draftSavePromise;
|
||||||
|
draftLastText = cm.TextBody;
|
||||||
|
};
|
||||||
|
// todo future: on visibilitychange with visibilityState "hidden", use navigator.sendBeacon to save latest modified draft message?
|
||||||
|
// When window is closed, ask user to cancel due to unsaved changes.
|
||||||
|
const unsavedChanges = () => opts.body !== body.value && (!draftMessageID || draftLastText !== body.value);
|
||||||
|
// In Firefox, ctrl-w doesn't seem interceptable when focus is on a button. It is
|
||||||
|
// when focus is on a textarea or not any specific UI element. So this isn't always
|
||||||
|
// triggered. But we still have the beforeunload handler that checks for
|
||||||
|
// unsavedChanges to protect the user in such cases.
|
||||||
const cmdCancel = async () => {
|
const cmdCancel = async () => {
|
||||||
|
draftCancelSave();
|
||||||
|
await draftSavePromise;
|
||||||
|
if (unsavedChanges()) {
|
||||||
|
const action = await new Promise((resolve) => {
|
||||||
|
const remove = popup(dom.p('Message has unsaved changes.'), dom.br(), dom.div(dom.clickbutton('Save draft', function click() {
|
||||||
|
resolve('save');
|
||||||
|
remove();
|
||||||
|
}), ' ', dom.clickbutton('Remove draft', function click() {
|
||||||
|
resolve('remove');
|
||||||
|
remove();
|
||||||
|
}), ' ', dom.clickbutton('Cancel', function click() {
|
||||||
|
resolve('cancel');
|
||||||
|
remove();
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
if (action === 'save') {
|
||||||
|
await withStatus('Saving draft', draftSave());
|
||||||
|
}
|
||||||
|
else if (action === 'remove') {
|
||||||
|
if (draftMessageID) {
|
||||||
|
await withStatus('Removing draft', client.MessageDelete([draftMessageID]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
composeElem.remove();
|
composeElem.remove();
|
||||||
composeView = null;
|
composeView = null;
|
||||||
};
|
};
|
||||||
|
const cmdClose = async () => {
|
||||||
|
draftCancelSave();
|
||||||
|
await draftSavePromise;
|
||||||
|
if (unsavedChanges()) {
|
||||||
|
await withStatus('Saving draft', draftSave());
|
||||||
|
}
|
||||||
|
composeElem.remove();
|
||||||
|
composeView = null;
|
||||||
|
};
|
||||||
|
const cmdSave = async () => {
|
||||||
|
draftCancelSave();
|
||||||
|
await draftSavePromise;
|
||||||
|
await withStatus('Saving draft', draftSave());
|
||||||
|
};
|
||||||
const submit = async (archive) => {
|
const submit = async (archive) => {
|
||||||
|
draftCancelSave();
|
||||||
|
await draftSavePromise;
|
||||||
const files = await new Promise((resolve, reject) => {
|
const files = await new Promise((resolve, reject) => {
|
||||||
const l = [];
|
const l = [];
|
||||||
if (attachments.files && attachments.files.length === 0) {
|
if (attachments.files && attachments.files.length === 0) {
|
||||||
|
@ -2358,9 +2479,11 @@ const compose = (opts, listMailboxes) => {
|
||||||
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
|
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
|
||||||
FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null,
|
FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null,
|
||||||
ArchiveThread: archive,
|
ArchiveThread: archive,
|
||||||
|
DraftMessageID: draftMessageID,
|
||||||
};
|
};
|
||||||
await client.MessageSubmit(message);
|
await client.MessageSubmit(message);
|
||||||
cmdCancel();
|
composeElem.remove();
|
||||||
|
composeView = null;
|
||||||
};
|
};
|
||||||
const cmdSend = async () => {
|
const cmdSend = async () => {
|
||||||
await withStatus('Sending email', submit(false), fieldset);
|
await withStatus('Sending email', submit(false), fieldset);
|
||||||
|
@ -2388,6 +2511,8 @@ const compose = (opts, listMailboxes) => {
|
||||||
'ctrl C': cmdAddCc,
|
'ctrl C': cmdAddCc,
|
||||||
'ctrl B': cmdAddBcc,
|
'ctrl B': cmdAddBcc,
|
||||||
'ctrl Y': cmdReplyTo,
|
'ctrl Y': cmdReplyTo,
|
||||||
|
'ctrl s': cmdSave,
|
||||||
|
'ctrl S': cmdClose,
|
||||||
// ctrl Backspace and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add.
|
// ctrl Backspace and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add.
|
||||||
};
|
};
|
||||||
const newAddrView = (addr, isRecipient, views, btn, cell, row, single) => {
|
const newAddrView = (addr, isRecipient, views, btn, cell, row, single) => {
|
||||||
|
@ -2648,7 +2773,10 @@ const compose = (opts, listMailboxes) => {
|
||||||
flexGrow: '1',
|
flexGrow: '1',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}), dom.table(style({ width: '100%' }), dom.tr(dom.td(style({ textAlign: 'right', color: '#555' }), dom.span('From:')), dom.td(dom.clickbutton('Cancel', style({ float: 'right', marginLeft: '1em', marginTop: '.15em' }), attr.title('Close window, discarding message.'), clickCmd(cmdCancel, shortcuts)), from = dom.select(attr.required(''), style({ width: 'auto' }), fromOptions), ' ', toBtn = dom.clickbutton('To', clickCmd(cmdAddTo, shortcuts)), ' ', ccBtn = dom.clickbutton('Cc', clickCmd(cmdAddCc, shortcuts)), ' ', bccBtn = dom.clickbutton('Bcc', clickCmd(cmdAddBcc, shortcuts)), ' ', replyToBtn = dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ', customFromBtn = dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts)))), toRow = dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555' })), toCell = dom.td(style({ lineHeight: '1.5' }))), replyToRow = dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555' })), replyToCell = dom.td(style({ lineHeight: '1.5' }))), ccRow = dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555' })), ccCell = dom.td(style({ lineHeight: '1.5' }))), bccRow = dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555' })), bccCell = dom.td(style({ lineHeight: '1.5' }))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555' })), dom.td(subjectAutosize = dom.span(dom._class('autosize'), style({ width: '100%' }), // Without 100% width, the span takes minimal width for input, we want the full table cell.
|
}), dom.table(style({ width: '100%' }), dom.tr(dom.td(style({ textAlign: 'right', color: '#555' }), dom.span('From:')), dom.td(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(from = dom.select(attr.required(''), style({ width: 'auto' }), fromOptions), ' ', toBtn = dom.clickbutton('To', clickCmd(cmdAddTo, shortcuts)), ' ', ccBtn = dom.clickbutton('Cc', clickCmd(cmdAddCc, shortcuts)), ' ', bccBtn = dom.clickbutton('Bcc', clickCmd(cmdAddBcc, shortcuts)), ' ', replyToBtn = dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ', customFromBtn = dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts))), dom.div(listMailboxes().find(mb => mb.Draft) ? [
|
||||||
|
dom.clickbutton('Save', attr.title('Save draft message.'), clickCmd(cmdSave, shortcuts)), ' ',
|
||||||
|
dom.clickbutton('Close', attr.title('Close window, saving draft message if body has changed or a draft was saved earlier.'), clickCmd(cmdClose, shortcuts)), ' ',
|
||||||
|
] : [], dom.clickbutton('Cancel', attr.title('Close window, discarding (draft) message.'), clickCmd(cmdCancel, shortcuts)))))), toRow = dom.tr(dom.td('To:', style({ textAlign: 'right', color: '#555' })), toCell = dom.td(style({ lineHeight: '1.5' }))), replyToRow = dom.tr(dom.td('Reply-To:', style({ textAlign: 'right', color: '#555' })), replyToCell = dom.td(style({ lineHeight: '1.5' }))), ccRow = dom.tr(dom.td('Cc:', style({ textAlign: 'right', color: '#555' })), ccCell = dom.td(style({ lineHeight: '1.5' }))), bccRow = dom.tr(dom.td('Bcc:', style({ textAlign: 'right', color: '#555' })), bccCell = dom.td(style({ lineHeight: '1.5' }))), dom.tr(dom.td('Subject:', style({ textAlign: 'right', color: '#555' })), dom.td(subjectAutosize = dom.span(dom._class('autosize'), style({ width: '100%' }), // Without 100% width, the span takes minimal width for input, we want the full table cell.
|
||||||
subject = dom.input(style({ width: '100%' }), attr.value(opts.subject || ''), attr.required(''), focusPlaceholder('subject...'), function input() {
|
subject = dom.input(style({ width: '100%' }), attr.value(opts.subject || ''), attr.required(''), focusPlaceholder('subject...'), function input() {
|
||||||
subjectAutosize.dataset.value = subject.value;
|
subjectAutosize.dataset.value = subject.value;
|
||||||
}))))), body = dom.textarea(dom._class('mono'), style({
|
}))))), body = dom.textarea(dom._class('mono'), style({
|
||||||
|
@ -2661,6 +2789,8 @@ const compose = (opts, listMailboxes) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
checkAttachments();
|
checkAttachments();
|
||||||
}
|
}
|
||||||
|
}, !listMailboxes().find(mb => mb.Draft) ? [] : function input() {
|
||||||
|
draftScheduleSave();
|
||||||
}), !(opts.attachmentsMessageItem && opts.attachmentsMessageItem.Attachments && opts.attachmentsMessageItem.Attachments.length > 0) ? [] : dom.div(style({ margin: '.5em 0' }), 'Forward attachments: ', forwardAttachmentViews = (opts.attachmentsMessageItem?.Attachments || []).map(a => {
|
}), !(opts.attachmentsMessageItem && opts.attachmentsMessageItem.Attachments && opts.attachmentsMessageItem.Attachments.length > 0) ? [] : dom.div(style({ margin: '.5em 0' }), 'Forward attachments: ', forwardAttachmentViews = (opts.attachmentsMessageItem?.Attachments || []).map(a => {
|
||||||
const filename = a.Filename || '(unnamed)';
|
const filename = a.Filename || '(unnamed)';
|
||||||
const size = formatSize(a.Part.DecodedSize);
|
const size = formatSize(a.Part.DecodedSize);
|
||||||
|
@ -2739,6 +2869,7 @@ const compose = (opts, listMailboxes) => {
|
||||||
composeView = {
|
composeView = {
|
||||||
root: composeElem,
|
root: composeElem,
|
||||||
key: keyHandler(shortcuts),
|
key: keyHandler(shortcuts),
|
||||||
|
unsavedChanges: unsavedChanges,
|
||||||
};
|
};
|
||||||
return composeView;
|
return composeView;
|
||||||
};
|
};
|
||||||
|
@ -3283,6 +3414,31 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
|
||||||
view(attachments[0]);
|
view(attachments[0]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const cmdComposeDraft = async () => {
|
||||||
|
// Compose based on message. Most information is available, we just need to find
|
||||||
|
// the ID of the stored message this is a reply/forward to, based in In-Reply-To
|
||||||
|
// header.
|
||||||
|
const env = mi.Envelope;
|
||||||
|
let refMsgID = 0;
|
||||||
|
if (env.InReplyTo) {
|
||||||
|
refMsgID = await withStatus('Looking up referenced message', client.MessageFindMessageID(env.InReplyTo));
|
||||||
|
}
|
||||||
|
const pm = await parsedMessagePromise;
|
||||||
|
const isForward = !!env.Subject.match(/^\[?fwd?:/i) || !!env.Subject.match(/\(fwd\)[ \t]*$/i);
|
||||||
|
const opts = {
|
||||||
|
from: (env.From || []),
|
||||||
|
to: (env.To || []).map(a => formatAddress(a)),
|
||||||
|
cc: (env.CC || []).map(a => formatAddress(a)),
|
||||||
|
bcc: (env.BCC || []).map(a => formatAddress(a)),
|
||||||
|
replyto: env.ReplyTo && env.ReplyTo.length > 0 ? formatAddress(env.ReplyTo[0]) : '',
|
||||||
|
subject: env.Subject,
|
||||||
|
isForward: isForward,
|
||||||
|
body: pm.Texts && pm.Texts.length > 0 ? pm.Texts[0].replace(/\r/g, '') : '',
|
||||||
|
responseMessageID: refMsgID,
|
||||||
|
draftMessageID: m.ID,
|
||||||
|
};
|
||||||
|
compose(opts, listMailboxes);
|
||||||
|
};
|
||||||
const cmdToggleHeaders = async () => {
|
const cmdToggleHeaders = async () => {
|
||||||
settingsPut({ ...settings, showAllHeaders: !settings.showAllHeaders });
|
settingsPut({ ...settings, showAllHeaders: !settings.showAllHeaders });
|
||||||
const pm = await parsedMessagePromise;
|
const pm = await parsedMessagePromise;
|
||||||
|
@ -3336,6 +3492,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
|
||||||
const cmdHome = async () => { msgscrollElem.scrollTo({ top: 0 }); };
|
const cmdHome = async () => { msgscrollElem.scrollTo({ top: 0 }); };
|
||||||
const cmdEnd = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollHeight }); };
|
const cmdEnd = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollHeight }); };
|
||||||
const shortcuts = {
|
const shortcuts = {
|
||||||
|
e: cmdComposeDraft,
|
||||||
I: cmdShowInternals,
|
I: cmdShowInternals,
|
||||||
o: cmdOpenNewTab,
|
o: cmdOpenNewTab,
|
||||||
O: cmdOpenRaw,
|
O: cmdOpenRaw,
|
||||||
|
@ -3374,7 +3531,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
|
||||||
const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID;
|
const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID;
|
||||||
// Initially called with potentially null pm, once loaded called again with pm set.
|
// Initially called with potentially null pm, once loaded called again with pm set.
|
||||||
const loadButtons = (pm) => {
|
const loadButtons = (pm) => {
|
||||||
dom._kids(msgbuttonElem, dom.div(dom._class('pad'), (!pm || !pm.ListReplyAddress) ? [] : dom.clickbutton('Reply to list', attr.title('Compose a reply to this mailing list.'), clickCmd(cmdReplyList, shortcuts)), ' ', (pm && pm.ListReplyAddress && formatEmail(pm.ListReplyAddress) === fromAddress) ? [] : dom.clickbutton('Reply', attr.title('Compose a reply to the sender of this message.'), clickCmd(cmdReply, shortcuts)), ' ', (mi.Envelope.To || []).length <= 1 && (mi.Envelope.CC || []).length === 0 && (mi.Envelope.BCC || []).length === 0 ? [] :
|
dom._kids(msgbuttonElem, dom.div(dom._class('pad'), !listMailboxes().find(mb => mb.Draft) ? [] : dom.clickbutton('Edit', attr.title('Continue editing this draft message.'), clickCmd(cmdComposeDraft, shortcuts)), ' ', (!pm || !pm.ListReplyAddress) ? [] : dom.clickbutton('Reply to list', attr.title('Compose a reply to this mailing list.'), clickCmd(cmdReplyList, shortcuts)), ' ', (pm && pm.ListReplyAddress && formatEmail(pm.ListReplyAddress) === fromAddress) ? [] : dom.clickbutton('Reply', attr.title('Compose a reply to the sender of this message.'), clickCmd(cmdReply, shortcuts)), ' ', (mi.Envelope.To || []).length <= 1 && (mi.Envelope.CC || []).length === 0 && (mi.Envelope.BCC || []).length === 0 ? [] :
|
||||||
dom.clickbutton('Reply all', attr.title('Compose a reply to all participants of this message.'), clickCmd(cmdReplyAll, shortcuts)), ' ', dom.clickbutton('Forward', attr.title('Compose a forwarding message, optionally including attachments.'), clickCmd(cmdForward, shortcuts)), ' ', dom.clickbutton('Archive', attr.title('Move to the Archive mailbox.'), clickCmd(msglistView.cmdArchive, shortcuts)), ' ', m.MailboxID === trashMailboxID ?
|
dom.clickbutton('Reply all', attr.title('Compose a reply to all participants of this message.'), clickCmd(cmdReplyAll, shortcuts)), ' ', dom.clickbutton('Forward', attr.title('Compose a forwarding message, optionally including attachments.'), clickCmd(cmdForward, shortcuts)), ' ', dom.clickbutton('Archive', attr.title('Move to the Archive mailbox.'), clickCmd(msglistView.cmdArchive, shortcuts)), ' ', m.MailboxID === trashMailboxID ?
|
||||||
dom.clickbutton('Delete', attr.title('Permanently delete message.'), clickCmd(msglistView.cmdDelete, shortcuts)) :
|
dom.clickbutton('Delete', attr.title('Permanently delete message.'), clickCmd(msglistView.cmdDelete, shortcuts)) :
|
||||||
dom.clickbutton('Trash', attr.title('Move to the Trash mailbox.'), clickCmd(msglistView.cmdTrash, shortcuts)), ' ', dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdJunk, shortcuts)), ' ', dom.clickbutton('Move to...', function click(e) {
|
dom.clickbutton('Trash', attr.title('Move to the Trash mailbox.'), clickCmd(msglistView.cmdTrash, shortcuts)), ' ', dom.clickbutton('Junk', attr.title('Move to Junk mailbox, marking as junk and causing this message to be used in spam classification of new incoming messages.'), clickCmd(msglistView.cmdJunk, shortcuts)), ' ', dom.clickbutton('Move to...', function click(e) {
|
||||||
|
@ -6091,7 +6248,6 @@ const init = async () => {
|
||||||
}
|
}
|
||||||
// Prevent many regular key presses from being processed, some possibly unintended.
|
// Prevent many regular key presses from being processed, some possibly unintended.
|
||||||
if ((e.target instanceof window.HTMLInputElement || e.target instanceof window.HTMLTextAreaElement || e.target instanceof window.HTMLSelectElement) && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
if ((e.target instanceof window.HTMLInputElement || e.target instanceof window.HTMLTextAreaElement || e.target instanceof window.HTMLSelectElement) && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
// log('skipping key without modifiers on input/textarea')
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let l = [];
|
let l = [];
|
||||||
|
@ -6281,13 +6437,18 @@ const init = async () => {
|
||||||
let noreconnectTimer = 0; // Timer ID for resetting noreconnect.
|
let noreconnectTimer = 0; // Timer ID for resetting noreconnect.
|
||||||
// Don't show disconnection just before user navigates away.
|
// Don't show disconnection just before user navigates away.
|
||||||
let leaving = false;
|
let leaving = false;
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
if (composeView && composeView.unsavedChanges()) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
else {
|
||||||
leaving = true;
|
leaving = true;
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
sseID = 0;
|
sseID = 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// On chromium, we may get restored when user hits the back button ("bfcache"). We
|
// On chromium, we may get restored when user hits the back button ("bfcache"). We
|
||||||
// have left, closed the connection, so we should restore it.
|
// have left, closed the connection, so we should restore it.
|
||||||
|
|
|
@ -1214,6 +1214,7 @@ const cmdHelp = async () => {
|
||||||
['r', 'reply or list reply'],
|
['r', 'reply or list reply'],
|
||||||
['R', 'reply all'],
|
['R', 'reply all'],
|
||||||
['f', 'forward message'],
|
['f', 'forward message'],
|
||||||
|
['e', 'edit draft'],
|
||||||
['v', 'view attachments'],
|
['v', 'view attachments'],
|
||||||
['t', 'view text version'],
|
['t', 'view text version'],
|
||||||
['T', 'view HTML version'],
|
['T', 'view HTML version'],
|
||||||
|
@ -1359,11 +1360,13 @@ type ComposeOptions = {
|
||||||
// Whether message is to a list, due to List-Id header.
|
// Whether message is to a list, due to List-Id header.
|
||||||
isList?: boolean
|
isList?: boolean
|
||||||
editOffset?: number // For cursor, default at start.
|
editOffset?: number // For cursor, default at start.
|
||||||
|
draftMessageID?: number // For composing for existing draft message, to be removed when message is sent.
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposeView {
|
interface ComposeView {
|
||||||
root: HTMLElement
|
root: HTMLElement
|
||||||
key: (k: string, e: KeyboardEvent) => Promise<void>
|
key: (k: string, e: KeyboardEvent) => Promise<void>
|
||||||
|
unsavedChanges: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let composeView: ComposeView | null = null
|
let composeView: ComposeView | null = null
|
||||||
|
@ -1405,12 +1408,125 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
let toViews: AddrView[] = [], replytoViews: AddrView[] = [], ccViews: AddrView[] = [], bccViews: AddrView[] = []
|
let toViews: AddrView[] = [], replytoViews: AddrView[] = [], ccViews: AddrView[] = [], bccViews: AddrView[] = []
|
||||||
let forwardAttachmentViews: ForwardAttachmentView[] = []
|
let forwardAttachmentViews: ForwardAttachmentView[] = []
|
||||||
|
|
||||||
|
// todo future: upload attachments with draft messages. would mean we let users remove them again too.
|
||||||
|
|
||||||
|
// We automatically save drafts 1m after a change. When closing window, we ask to
|
||||||
|
// save unsaved change to draft.
|
||||||
|
let draftMessageID = opts.draftMessageID || 0
|
||||||
|
let draftSaveTimer = 0
|
||||||
|
let draftSavePromise = Promise.resolve(0)
|
||||||
|
let draftLastText = opts.body
|
||||||
|
|
||||||
|
const draftCancelSave = () => {
|
||||||
|
if (draftSaveTimer) {
|
||||||
|
window.clearTimeout(draftSaveTimer)
|
||||||
|
draftSaveTimer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftScheduleSave = () => {
|
||||||
|
if (draftSaveTimer || body.value === draftLastText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftSaveTimer = window.setTimeout(async () => {
|
||||||
|
draftSaveTimer = 0
|
||||||
|
await withStatus('Saving draft', draftSave())
|
||||||
|
draftScheduleSave()
|
||||||
|
}, 60*1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftSave = async () => {
|
||||||
|
draftCancelSave()
|
||||||
|
let replyTo = ''
|
||||||
|
if (replytoViews && replytoViews.length === 1 && replytoViews[0].input.value) {
|
||||||
|
replyTo = replytoViews[0].input.value
|
||||||
|
}
|
||||||
|
const cm: api.ComposeMessage = {
|
||||||
|
From: customFrom ? customFrom.value : from.value,
|
||||||
|
To: toViews.map(v => v.input.value).filter(s => s),
|
||||||
|
Cc: ccViews.map(v => v.input.value).filter(s => s),
|
||||||
|
Bcc: bccViews.map(v => v.input.value).filter(s => s),
|
||||||
|
ReplyTo: replyTo,
|
||||||
|
Subject: subject.value,
|
||||||
|
TextBody: body.value,
|
||||||
|
ResponseMessageID: opts.responseMessageID || 0,
|
||||||
|
DraftMessageID: draftMessageID,
|
||||||
|
}
|
||||||
|
const mbdrafts = listMailboxes().find(mb => mb.Draft)
|
||||||
|
if (!mbdrafts) {
|
||||||
|
throw new Error('no designated drafts mailbox')
|
||||||
|
}
|
||||||
|
draftSavePromise = client.MessageCompose(cm, mbdrafts.ID)
|
||||||
|
draftMessageID = await draftSavePromise
|
||||||
|
draftLastText = cm.TextBody
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo future: on visibilitychange with visibilityState "hidden", use navigator.sendBeacon to save latest modified draft message?
|
||||||
|
|
||||||
|
// When window is closed, ask user to cancel due to unsaved changes.
|
||||||
|
const unsavedChanges = () => opts.body !== body.value && (!draftMessageID || draftLastText !== body.value)
|
||||||
|
|
||||||
|
// In Firefox, ctrl-w doesn't seem interceptable when focus is on a button. It is
|
||||||
|
// when focus is on a textarea or not any specific UI element. So this isn't always
|
||||||
|
// triggered. But we still have the beforeunload handler that checks for
|
||||||
|
// unsavedChanges to protect the user in such cases.
|
||||||
const cmdCancel = async () => {
|
const cmdCancel = async () => {
|
||||||
|
draftCancelSave()
|
||||||
|
await draftSavePromise
|
||||||
|
if (unsavedChanges()) {
|
||||||
|
const action = await new Promise<string>((resolve) => {
|
||||||
|
const remove = popup(
|
||||||
|
dom.p('Message has unsaved changes.'),
|
||||||
|
dom.br(),
|
||||||
|
dom.div(
|
||||||
|
dom.clickbutton('Save draft', function click() {
|
||||||
|
resolve('save')
|
||||||
|
remove()
|
||||||
|
}), ' ',
|
||||||
|
dom.clickbutton('Remove draft', function click() {
|
||||||
|
resolve('remove')
|
||||||
|
remove()
|
||||||
|
}), ' ',
|
||||||
|
dom.clickbutton('Cancel', function click() {
|
||||||
|
resolve('cancel')
|
||||||
|
remove()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (action === 'save') {
|
||||||
|
await withStatus('Saving draft', draftSave())
|
||||||
|
} else if (action === 'remove') {
|
||||||
|
if (draftMessageID) {
|
||||||
|
await withStatus('Removing draft', client.MessageDelete([draftMessageID]))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
composeElem.remove()
|
composeElem.remove()
|
||||||
composeView = null
|
composeView = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cmdClose = async () => {
|
||||||
|
draftCancelSave()
|
||||||
|
await draftSavePromise
|
||||||
|
if (unsavedChanges()) {
|
||||||
|
await withStatus('Saving draft', draftSave())
|
||||||
|
}
|
||||||
|
composeElem.remove()
|
||||||
|
composeView = null
|
||||||
|
}
|
||||||
|
const cmdSave = async () => {
|
||||||
|
draftCancelSave()
|
||||||
|
await draftSavePromise
|
||||||
|
await withStatus('Saving draft', draftSave())
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (archive: boolean) => {
|
const submit = async (archive: boolean) => {
|
||||||
|
draftCancelSave()
|
||||||
|
await draftSavePromise
|
||||||
|
|
||||||
const files = await new Promise<api.File[]>((resolve, reject) => {
|
const files = await new Promise<api.File[]>((resolve, reject) => {
|
||||||
const l: api.File[] = []
|
const l: api.File[] = []
|
||||||
if (attachments.files && attachments.files.length === 0) {
|
if (attachments.files && attachments.files.length === 0) {
|
||||||
|
@ -1455,9 +1571,11 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
|
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
|
||||||
FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null,
|
FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null,
|
||||||
ArchiveThread: archive,
|
ArchiveThread: archive,
|
||||||
|
DraftMessageID: draftMessageID,
|
||||||
}
|
}
|
||||||
await client.MessageSubmit(message)
|
await client.MessageSubmit(message)
|
||||||
cmdCancel()
|
composeElem.remove()
|
||||||
|
composeView = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmdSend = async () => {
|
const cmdSend = async () => {
|
||||||
|
@ -1488,6 +1606,8 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
'ctrl C': cmdAddCc,
|
'ctrl C': cmdAddCc,
|
||||||
'ctrl B': cmdAddBcc,
|
'ctrl B': cmdAddBcc,
|
||||||
'ctrl Y': cmdReplyTo,
|
'ctrl Y': cmdReplyTo,
|
||||||
|
'ctrl s': cmdSave,
|
||||||
|
'ctrl S': cmdClose,
|
||||||
// ctrl Backspace and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add.
|
// ctrl Backspace and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1806,7 +1926,9 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
dom.span('From:'),
|
dom.span('From:'),
|
||||||
),
|
),
|
||||||
dom.td(
|
dom.td(
|
||||||
dom.clickbutton('Cancel', style({float: 'right', marginLeft: '1em', marginTop: '.15em'}), attr.title('Close window, discarding message.'), clickCmd(cmdCancel, shortcuts)),
|
dom.div(
|
||||||
|
style({display: 'flex', gap: '1em'}),
|
||||||
|
dom.div(
|
||||||
from=dom.select(
|
from=dom.select(
|
||||||
attr.required(''),
|
attr.required(''),
|
||||||
style({width: 'auto'}),
|
style({width: 'auto'}),
|
||||||
|
@ -1819,6 +1941,15 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
replyToBtn=dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ',
|
replyToBtn=dom.clickbutton('ReplyTo', clickCmd(cmdReplyTo, shortcuts)), ' ',
|
||||||
customFromBtn=dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts)),
|
customFromBtn=dom.clickbutton('From', attr.title('Set custom From address/name.'), clickCmd(cmdCustomFrom, shortcuts)),
|
||||||
),
|
),
|
||||||
|
dom.div(
|
||||||
|
listMailboxes().find(mb => mb.Draft) ? [
|
||||||
|
dom.clickbutton('Save', attr.title('Save draft message.'), clickCmd(cmdSave, shortcuts)), ' ',
|
||||||
|
dom.clickbutton('Close', attr.title('Close window, saving draft message if body has changed or a draft was saved earlier.'), clickCmd(cmdClose, shortcuts)), ' ',
|
||||||
|
] : [],
|
||||||
|
dom.clickbutton('Cancel', attr.title('Close window, discarding (draft) message.'), clickCmd(cmdCancel, shortcuts)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
toRow=dom.tr(
|
toRow=dom.tr(
|
||||||
dom.td('To:', style({textAlign: 'right', color: '#555'})),
|
dom.td('To:', style({textAlign: 'right', color: '#555'})),
|
||||||
|
@ -1871,6 +2002,9 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
checkAttachments()
|
checkAttachments()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
!listMailboxes().find(mb => mb.Draft) ? [] : function input() {
|
||||||
|
draftScheduleSave()
|
||||||
|
},
|
||||||
),
|
),
|
||||||
!(opts.attachmentsMessageItem && opts.attachmentsMessageItem.Attachments && opts.attachmentsMessageItem.Attachments.length > 0) ? [] : dom.div(
|
!(opts.attachmentsMessageItem && opts.attachmentsMessageItem.Attachments && opts.attachmentsMessageItem.Attachments.length > 0) ? [] : dom.div(
|
||||||
style({margin: '.5em 0'}),
|
style({margin: '.5em 0'}),
|
||||||
|
@ -1997,6 +2131,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => {
|
||||||
composeView = {
|
composeView = {
|
||||||
root: composeElem,
|
root: composeElem,
|
||||||
key: keyHandler(shortcuts),
|
key: keyHandler(shortcuts),
|
||||||
|
unsavedChanges: unsavedChanges,
|
||||||
}
|
}
|
||||||
return composeView
|
return composeView
|
||||||
}
|
}
|
||||||
|
@ -2706,6 +2841,32 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
|
||||||
view(attachments[0])
|
view(attachments[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const cmdComposeDraft = async () => {
|
||||||
|
// Compose based on message. Most information is available, we just need to find
|
||||||
|
// the ID of the stored message this is a reply/forward to, based in In-Reply-To
|
||||||
|
// header.
|
||||||
|
const env = mi.Envelope
|
||||||
|
let refMsgID = 0
|
||||||
|
if (env.InReplyTo) {
|
||||||
|
refMsgID = await withStatus('Looking up referenced message', client.MessageFindMessageID(env.InReplyTo))
|
||||||
|
}
|
||||||
|
|
||||||
|
const pm = await parsedMessagePromise
|
||||||
|
const isForward = !!env.Subject.match(/^\[?fwd?:/i) || !!env.Subject.match(/\(fwd\)[ \t]*$/i)
|
||||||
|
const opts: ComposeOptions = {
|
||||||
|
from: (env.From || []),
|
||||||
|
to: (env.To || []).map(a => formatAddress(a)),
|
||||||
|
cc: (env.CC || []).map(a => formatAddress(a)),
|
||||||
|
bcc: (env.BCC || []).map(a => formatAddress(a)),
|
||||||
|
replyto: env.ReplyTo && env.ReplyTo.length > 0 ? formatAddress(env.ReplyTo[0]) : '',
|
||||||
|
subject: env.Subject,
|
||||||
|
isForward: isForward,
|
||||||
|
body: pm.Texts && pm.Texts.length > 0 ? pm.Texts[0].replace(/\r/g, '') : '',
|
||||||
|
responseMessageID: refMsgID,
|
||||||
|
draftMessageID: m.ID,
|
||||||
|
}
|
||||||
|
compose(opts, listMailboxes)
|
||||||
|
}
|
||||||
|
|
||||||
const cmdToggleHeaders = async () => {
|
const cmdToggleHeaders = async () => {
|
||||||
settingsPut({...settings, showAllHeaders: !settings.showAllHeaders})
|
settingsPut({...settings, showAllHeaders: !settings.showAllHeaders})
|
||||||
|
@ -2775,6 +2936,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
|
||||||
const cmdEnd = async () => { msgscrollElem.scrollTo({top: msgscrollElem.scrollHeight}) }
|
const cmdEnd = async () => { msgscrollElem.scrollTo({top: msgscrollElem.scrollHeight}) }
|
||||||
|
|
||||||
const shortcuts: {[key: string]: command} = {
|
const shortcuts: {[key: string]: command} = {
|
||||||
|
e: cmdComposeDraft,
|
||||||
I: cmdShowInternals,
|
I: cmdShowInternals,
|
||||||
o: cmdOpenNewTab,
|
o: cmdOpenNewTab,
|
||||||
O: cmdOpenRaw,
|
O: cmdOpenRaw,
|
||||||
|
@ -2838,6 +3000,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
|
||||||
const loadButtons = (pm: api.ParsedMessage | null) => {
|
const loadButtons = (pm: api.ParsedMessage | null) => {
|
||||||
dom._kids(msgbuttonElem,
|
dom._kids(msgbuttonElem,
|
||||||
dom.div(dom._class('pad'),
|
dom.div(dom._class('pad'),
|
||||||
|
!listMailboxes().find(mb => mb.Draft) ? [] : dom.clickbutton('Edit', attr.title('Continue editing this draft message.'), clickCmd(cmdComposeDraft, shortcuts)), ' ',
|
||||||
(!pm || !pm.ListReplyAddress) ? [] : dom.clickbutton('Reply to list', attr.title('Compose a reply to this mailing list.'), clickCmd(cmdReplyList, shortcuts)), ' ',
|
(!pm || !pm.ListReplyAddress) ? [] : dom.clickbutton('Reply to list', attr.title('Compose a reply to this mailing list.'), clickCmd(cmdReplyList, shortcuts)), ' ',
|
||||||
(pm && pm.ListReplyAddress && formatEmail(pm.ListReplyAddress) === fromAddress) ? [] : dom.clickbutton('Reply', attr.title('Compose a reply to the sender of this message.'), clickCmd(cmdReply, shortcuts)), ' ',
|
(pm && pm.ListReplyAddress && formatEmail(pm.ListReplyAddress) === fromAddress) ? [] : dom.clickbutton('Reply', attr.title('Compose a reply to the sender of this message.'), clickCmd(cmdReply, shortcuts)), ' ',
|
||||||
(mi.Envelope.To || []).length <= 1 && (mi.Envelope.CC || []).length === 0 && (mi.Envelope.BCC || []).length === 0 ? [] :
|
(mi.Envelope.To || []).length <= 1 && (mi.Envelope.CC || []).length === 0 && (mi.Envelope.BCC || []).length === 0 ? [] :
|
||||||
|
@ -6390,7 +6553,6 @@ const init = async () => {
|
||||||
|
|
||||||
// Prevent many regular key presses from being processed, some possibly unintended.
|
// Prevent many regular key presses from being processed, some possibly unintended.
|
||||||
if ((e.target instanceof window.HTMLInputElement || e.target instanceof window.HTMLTextAreaElement || e.target instanceof window.HTMLSelectElement) && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
if ((e.target instanceof window.HTMLInputElement || e.target instanceof window.HTMLTextAreaElement || e.target instanceof window.HTMLSelectElement) && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
// log('skipping key without modifiers on input/textarea')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let l = []
|
let l = []
|
||||||
|
@ -6622,13 +6784,17 @@ const init = async () => {
|
||||||
|
|
||||||
// Don't show disconnection just before user navigates away.
|
// Don't show disconnection just before user navigates away.
|
||||||
let leaving = false
|
let leaving = false
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
|
||||||
|
if (composeView && composeView.unsavedChanges()) {
|
||||||
|
e.preventDefault()
|
||||||
|
} else {
|
||||||
leaving = true
|
leaving = true
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close()
|
eventSource.close()
|
||||||
eventSource = null
|
eventSource = null
|
||||||
sseID = 0
|
sseID = 0
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// On chromium, we may get restored when user hits the back button ("bfcache"). We
|
// On chromium, we may get restored when user hits the back button ("bfcache"). We
|
||||||
|
|
|
@ -57,11 +57,26 @@ func (x XOps) messageID(ctx context.Context, tx *bstore.Tx, messageID int64) sto
|
||||||
|
|
||||||
func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64) {
|
func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64) {
|
||||||
acc.WithWLock(func() {
|
acc.WithWLock(func() {
|
||||||
|
var changes []store.Change
|
||||||
|
|
||||||
|
x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
|
||||||
|
_, changes = x.MessageDeleteTx(ctx, log, tx, acc, messageIDs, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
store.BroadcastChanges(acc, changes)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, mID := range messageIDs {
|
||||||
|
p := acc.MessagePath(mID)
|
||||||
|
err := os.Remove(p)
|
||||||
|
log.Check(err, "removing message file for expunge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x XOps) MessageDeleteTx(ctx context.Context, log mlog.Log, tx *bstore.Tx, acc *store.Account, messageIDs []int64, modseq store.ModSeq) (store.ModSeq, []store.Change) {
|
||||||
removeChanges := map[int64]store.ChangeRemoveUIDs{}
|
removeChanges := map[int64]store.ChangeRemoveUIDs{}
|
||||||
changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
|
changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts
|
||||||
|
|
||||||
x.DBWrite(ctx, acc, func(tx *bstore.Tx) {
|
|
||||||
var modseq store.ModSeq
|
|
||||||
var mb store.Mailbox
|
var mb store.Mailbox
|
||||||
remove := make([]store.Message, 0, len(messageIDs))
|
remove := make([]store.Message, 0, len(messageIDs))
|
||||||
|
|
||||||
|
@ -120,7 +135,6 @@ func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Accoun
|
||||||
}
|
}
|
||||||
err = acc.RetrainMessages(ctx, log, tx, remove, true)
|
err = acc.RetrainMessages(ctx, log, tx, remove, true)
|
||||||
x.Checkf(ctx, err, "untraining deleted messages")
|
x.Checkf(ctx, err, "untraining deleted messages")
|
||||||
})
|
|
||||||
|
|
||||||
for _, ch := range removeChanges {
|
for _, ch := range removeChanges {
|
||||||
sort.Slice(ch.UIDs, func(i, j int) bool {
|
sort.Slice(ch.UIDs, func(i, j int) bool {
|
||||||
|
@ -128,14 +142,8 @@ func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Accoun
|
||||||
})
|
})
|
||||||
changes = append(changes, ch)
|
changes = append(changes, ch)
|
||||||
}
|
}
|
||||||
store.BroadcastChanges(acc, changes)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, mID := range messageIDs {
|
return modseq, changes
|
||||||
p := acc.MessagePath(mID)
|
|
||||||
err := os.Remove(p)
|
|
||||||
log.Check(err, "removing message file for expunge")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x XOps) MessageFlagsAdd(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) {
|
func (x XOps) MessageFlagsAdd(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) {
|
||||||
|
@ -301,14 +309,14 @@ func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, changes = x.MessageMoveMailbox(ctx, log, acc, tx, messageIDs, mbDst, 0)
|
_, changes = x.MessageMoveTx(ctx, log, acc, tx, messageIDs, mbDst, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
store.BroadcastChanges(acc, changes)
|
store.BroadcastChanges(acc, changes)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x XOps) MessageMoveMailbox(ctx context.Context, log mlog.Log, acc *store.Account, tx *bstore.Tx, messageIDs []int64, mbDst store.Mailbox, modseq store.ModSeq) (store.ModSeq, []store.Change) {
|
func (x XOps) MessageMoveTx(ctx context.Context, log mlog.Log, acc *store.Account, tx *bstore.Tx, messageIDs []int64, mbDst store.Mailbox, modseq store.ModSeq) (store.ModSeq, []store.Change) {
|
||||||
retrain := make([]store.Message, 0, len(messageIDs))
|
retrain := make([]store.Message, 0, len(messageIDs))
|
||||||
removeChanges := map[int64]store.ChangeRemoveUIDs{}
|
removeChanges := map[int64]store.ChangeRemoveUIDs{}
|
||||||
// n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
|
// n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.
|
||||||
|
|
Loading…
Reference in a new issue