From 9529ae0bd4f7d504fb5d9fa5681aa0a3715262a0 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 20 Apr 2024 17:38:25 +0200 Subject: [PATCH] webmail: store composed message as draft until send, ask about unsaved changes when closing compose window --- webmail/api.go | 284 ++++++++++++++++++++++++++++++++++++++++++- webmail/api.json | 139 +++++++++++++++++++-- webmail/api.ts | 44 ++++++- webmail/api_test.go | 47 +++++-- webmail/msg.js | 26 +++- webmail/text.js | 26 +++- webmail/webmail.html | 4 +- webmail/webmail.js | 185 ++++++++++++++++++++++++++-- webmail/webmail.ts | 204 ++++++++++++++++++++++++++++--- webops/xops.go | 146 +++++++++++----------- 10 files changed, 976 insertions(+), 129 deletions(-) diff --git a/webmail/api.go b/webmail/api.go index 87e194f..5efd0c5 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -177,6 +177,268 @@ func (Webmail) ParsedMessage(ctx context.Context, msgID int64) (pm ParsedMessage 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 // viewable text or HTML part. type Attachment struct { @@ -197,17 +459,18 @@ type SubmitMessage struct { To []string Cc []string Bcc []string + ReplyTo string // If non-empty, Reply-To header to add to message. Subject string TextBody string Attachments []File ForwardAttachments ForwardAttachments IsForward bool ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward. - ReplyTo string // If non-empty, Reply-To header to add to message. UserAgent string // User-Agent header added if not empty. RequireTLS *bool // For "Require TLS" extension during delivery. 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. + DraftMessageID int64 // If set, draft message that will be removed after sending. } // 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. - // 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() { var changes []store.Change @@ -719,6 +983,13 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { } }() 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 { rm := xmessageID(ctx, tx, m.ResponseMessageID) 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") if len(msgIDs) > 0 { 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...) } } @@ -821,6 +1092,13 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { 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 diff --git a/webmail/api.json b/webmail/api.json index cbb816e..6304d11 100644 --- a/webmail/api.json +++ b/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", "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", "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" ] }, + { + "Name": "ReplyTo", + "Docs": "If non-empty, Reply-To header to add to message.", + "Typewords": [ + "string" + ] + }, { "Name": "Subject", "Docs": "", @@ -1161,13 +1286,6 @@ "int64" ] }, - { - "Name": "ReplyTo", - "Docs": "If non-empty, Reply-To header to add to message.", - "Typewords": [ - "string" - ] - }, { "Name": "UserAgent", "Docs": "User-Agent header added if not empty.", @@ -1197,6 +1315,13 @@ "Typewords": [ "bool" ] + }, + { + "Name": "DraftMessageID", + "Docs": "If set, draft message that will be removed after sending.", + "Typewords": [ + "int64" + ] } ] }, diff --git a/webmail/api.ts b/webmail/api.ts index e8112e7..38d4349 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -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. } +// 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. // Addresses are formatted as just email address, or with a name like "name // ". @@ -135,17 +148,18 @@ export interface SubmitMessage { 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 Attachments?: File[] | null ForwardAttachments: ForwardAttachments IsForward: boolean 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. 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. 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 @@ -537,7 +551,7 @@ export enum Quoting { // Localparts are in Unicode NFC. 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 intsTypes: {[typename: string]: boolean} = {"ModSeq":true,"UID":true,"Validation":true} 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"]}]}, "MessageAddress": {"Name":"MessageAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]}, - "SubmitMessage": {"Name":"SubmitMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"Attachments","Docs":"","Typewords":["[]","File"]},{"Name":"ForwardAttachments","Docs":"","Typewords":["ForwardAttachments"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"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"]}]}, "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"]}]}, @@ -603,6 +618,7 @@ export const parser = { Address: (v: any) => parse("Address", v) as Address, MessageAddress: (v: any) => parse("MessageAddress", v) as MessageAddress, Domain: (v: any) => parse("Domain", v) as Domain, + ComposeMessage: (v: any) => parse("ComposeMessage", v) as ComposeMessage, SubmitMessage: (v: any) => parse("SubmitMessage", v) as SubmitMessage, File: (v: any) => parse("File", v) as File, 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 } + // 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 { + 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 { + 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 // message is sent to all addresses listed in the To, Cc and Bcc addresses, without // Bcc message header. diff --git a/webmail/api_test.go b/webmail/api_test.go index f1b551e..e88f919 100644 --- a/webmail/api_test.go +++ b/webmail/api_test.go @@ -175,14 +175,14 @@ func TestAPI(t *testing.T) { tneedError(t, func() { api.FlagsClear(ctx, []int64{inboxText.ID}, []string{`\unknownsystem`}) }) // 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 { get := func(k string, v any) store.Mailbox { mb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual(k, v).Get() tcheck(t, err, "get special-use mailbox") return mb } - get("Draft", true) + drafts = get("Draft", true) sent = get("Sent", true) archive = get("Archive", true) get("Trash", true) @@ -273,19 +273,46 @@ func TestAPI(t *testing.T) { tdeliver(t, acc, testbox1Alt) tdeliver(t, acc, inboxAltRel) + // MessageCompose + draftID := api.MessageCompose(ctx, ComposeMessage{ + From: "mjl@mox.example", + To: []string{"mjl+to@mox.example", "mjl to2 "}, + Cc: []string{"mjl+cc@mox.example", "mjl cc2 "}, + Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 "}, + Subject: "test email", + TextBody: "this is the content\n\ncheers,\nmox", + ReplyTo: "mjl replyto ", + }, drafts.ID) + // Replace draft. + draftID = api.MessageCompose(ctx, ComposeMessage{ + From: "mjl@mox.example", + To: []string{"mjl+to@mox.example", "mjl to2 "}, + Cc: []string{"mjl+cc@mox.example", "mjl cc2 "}, + Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 "}, + Subject: "test email", + TextBody: "this is the content\n\ncheers,\nmox", + ReplyTo: "mjl replyto ", + DraftMessageID: draftID, + }, drafts.ID) + + // MessageFindMessageID + msgID := api.MessageFindMessageID(ctx, "") + tcompare(t, msgID, int64(0)) + // MessageSubmit queue.Localserve = true // Deliver directly to us instead attempting actual delivery. err = queue.Init() tcheck(t, err, "queue init") api.MessageSubmit(ctx, SubmitMessage{ - From: "mjl@mox.example", - To: []string{"mjl+to@mox.example", "mjl to2 "}, - Cc: []string{"mjl+cc@mox.example", "mjl cc2 "}, - Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 "}, - Subject: "test email", - TextBody: "this is the content\n\ncheers,\nmox", - ReplyTo: "mjl replyto ", - UserAgent: "moxwebmail/dev", + From: "mjl@mox.example", + To: []string{"mjl+to@mox.example", "mjl to2 "}, + Cc: []string{"mjl+cc@mox.example", "mjl cc2 "}, + Bcc: []string{"mjl+bcc@mox.example", "mjl bcc2 "}, + Subject: "test email", + TextBody: "this is the content\n\ncheers,\nmox", + ReplyTo: "mjl replyto ", + UserAgent: "moxwebmail/dev", + DraftMessageID: draftID, }) // todo: check delivery of 6 messages to inbox, 1 to sent diff --git a/webmail/msg.js b/webmail/msg.js index 050b744..71d77f1 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -281,7 +281,7 @@ var api; Quoting["Bottom"] = "bottom"; Quoting["Top"] = "top"; })(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.intsTypes = { "ModSeq": true, "UID": true, "Validation": true }; 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"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, - "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "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"] }] }, "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"] }] }, @@ -346,6 +347,7 @@ var api; Address: (v) => api.parse("Address", v), MessageAddress: (v) => api.parse("MessageAddress", v), Domain: (v) => api.parse("Domain", v), + ComposeMessage: (v) => api.parse("ComposeMessage", v), SubmitMessage: (v) => api.parse("SubmitMessage", v), File: (v) => api.parse("File", v), ForwardAttachments: (v) => api.parse("ForwardAttachments", v), @@ -463,6 +465,26 @@ var api; const params = [msgID]; 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 // message is sent to all addresses listed in the To, Cc and Bcc addresses, without // Bcc message header. diff --git a/webmail/text.js b/webmail/text.js index 2004277..c248803 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -281,7 +281,7 @@ var api; Quoting["Bottom"] = "bottom"; Quoting["Top"] = "top"; })(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.intsTypes = { "ModSeq": true, "UID": true, "Validation": true }; 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"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, - "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "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"] }] }, "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"] }] }, @@ -346,6 +347,7 @@ var api; Address: (v) => api.parse("Address", v), MessageAddress: (v) => api.parse("MessageAddress", v), Domain: (v) => api.parse("Domain", v), + ComposeMessage: (v) => api.parse("ComposeMessage", v), SubmitMessage: (v) => api.parse("SubmitMessage", v), File: (v) => api.parse("File", v), ForwardAttachments: (v) => api.parse("ForwardAttachments", v), @@ -463,6 +465,26 @@ var api; const params = [msgID]; 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 // message is sent to all addresses listed in the To, Cc and Bcc addresses, without // Bcc message header. diff --git a/webmail/webmail.html b/webmail/webmail.html index ddc2c38..9154bba 100644 --- a/webmail/webmail.html +++ b/webmail/webmail.html @@ -18,9 +18,9 @@ fieldset { border: 0; } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } .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:hover, .button:hover { background-color: #ddd; } +button:hover, .button:hover, select:hover { background-color: #ddd; } button.keyword:hover { background-color: #ffbd21; } button.keyword { cursor: pointer; } .btngroup button, .btngroup .button { border-radius: 0; border-right-width: 0; } diff --git a/webmail/webmail.js b/webmail/webmail.js index 488bb05..beb23e4 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -281,7 +281,7 @@ var api; Quoting["Bottom"] = "bottom"; Quoting["Top"] = "top"; })(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.intsTypes = { "ModSeq": true, "UID": true, "Validation": true }; 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"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, - "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "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"] }] }, "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"] }] }, @@ -346,6 +347,7 @@ var api; Address: (v) => api.parse("Address", v), MessageAddress: (v) => api.parse("MessageAddress", v), Domain: (v) => api.parse("Domain", v), + ComposeMessage: (v) => api.parse("ComposeMessage", v), SubmitMessage: (v) => api.parse("SubmitMessage", v), File: (v) => api.parse("File", v), ForwardAttachments: (v) => api.parse("ForwardAttachments", v), @@ -463,6 +465,26 @@ var api; const params = [msgID]; 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 // message is sent to all addresses listed in the To, Cc and Bcc addresses, without // Bcc message header. @@ -2198,6 +2220,7 @@ const cmdHelp = async () => { ['r', 'reply or list reply'], ['R', 'reply all'], ['f', 'forward message'], + ['e', 'edit draft'], ['v', 'view attachments'], ['t', 'view text 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 toViews = [], replytoViews = [], ccViews = [], bccViews = []; 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 () => { + 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(); 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) => { + draftCancelSave(); + await draftSavePromise; const files = await new Promise((resolve, reject) => { const l = []; if (attachments.files && attachments.files.length === 0) { @@ -2358,9 +2479,11 @@ const compose = (opts, listMailboxes) => { RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes', FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null, ArchiveThread: archive, + DraftMessageID: draftMessageID, }; await client.MessageSubmit(message); - cmdCancel(); + composeElem.remove(); + composeView = null; }; const cmdSend = async () => { await withStatus('Sending email', submit(false), fieldset); @@ -2388,6 +2511,8 @@ const compose = (opts, listMailboxes) => { 'ctrl C': cmdAddCc, 'ctrl B': cmdAddBcc, '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. }; const newAddrView = (addr, isRecipient, views, btn, cell, row, single) => { @@ -2648,7 +2773,10 @@ const compose = (opts, listMailboxes) => { flexGrow: '1', display: 'flex', 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() { subjectAutosize.dataset.value = subject.value; }))))), body = dom.textarea(dom._class('mono'), style({ @@ -2661,6 +2789,8 @@ const compose = (opts, listMailboxes) => { if (e.key === 'Enter') { 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 => { const filename = a.Filename || '(unnamed)'; const size = formatSize(a.Part.DecodedSize); @@ -2739,6 +2869,7 @@ const compose = (opts, listMailboxes) => { composeView = { root: composeElem, key: keyHandler(shortcuts), + unsavedChanges: unsavedChanges, }; return composeView; }; @@ -3283,6 +3414,31 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad 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 () => { settingsPut({ ...settings, showAllHeaders: !settings.showAllHeaders }); const pm = await parsedMessagePromise; @@ -3336,6 +3492,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad const cmdHome = async () => { msgscrollElem.scrollTo({ top: 0 }); }; const cmdEnd = async () => { msgscrollElem.scrollTo({ top: msgscrollElem.scrollHeight }); }; const shortcuts = { + e: cmdComposeDraft, I: cmdShowInternals, o: cmdOpenNewTab, O: cmdOpenRaw, @@ -3374,7 +3531,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad const trashMailboxID = listMailboxes().find(mb => mb.Trash)?.ID; // Initially called with potentially null pm, once loaded called again with pm set. 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('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) { @@ -6091,7 +6248,6 @@ const init = async () => { } // 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) { - // log('skipping key without modifiers on input/textarea') return; } let l = []; @@ -6281,12 +6437,17 @@ const init = async () => { let noreconnectTimer = 0; // Timer ID for resetting noreconnect. // Don't show disconnection just before user navigates away. let leaving = false; - window.addEventListener('beforeunload', () => { - leaving = true; - if (eventSource) { - eventSource.close(); - eventSource = null; - sseID = 0; + window.addEventListener('beforeunload', (e) => { + if (composeView && composeView.unsavedChanges()) { + e.preventDefault(); + } + else { + leaving = true; + if (eventSource) { + eventSource.close(); + eventSource = null; + sseID = 0; + } } }); // On chromium, we may get restored when user hits the back button ("bfcache"). We diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 55597fb..64b3754 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1214,6 +1214,7 @@ const cmdHelp = async () => { ['r', 'reply or list reply'], ['R', 'reply all'], ['f', 'forward message'], + ['e', 'edit draft'], ['v', 'view attachments'], ['t', 'view text version'], ['T', 'view HTML version'], @@ -1359,11 +1360,13 @@ type ComposeOptions = { // Whether message is to a list, due to List-Id header. isList?: boolean editOffset?: number // For cursor, default at start. + draftMessageID?: number // For composing for existing draft message, to be removed when message is sent. } interface ComposeView { root: HTMLElement key: (k: string, e: KeyboardEvent) => Promise + unsavedChanges: () => boolean } 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 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 () => { + 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() 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) => { + draftCancelSave() + await draftSavePromise + const files = await new Promise((resolve, reject) => { const l: api.File[] = [] if (attachments.files && attachments.files.length === 0) { @@ -1455,9 +1571,11 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes', FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null, ArchiveThread: archive, + DraftMessageID: draftMessageID, } await client.MessageSubmit(message) - cmdCancel() + composeElem.remove() + composeView = null } const cmdSend = async () => { @@ -1488,6 +1606,8 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { 'ctrl C': cmdAddCc, 'ctrl B': cmdAddBcc, '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. } @@ -1806,18 +1926,29 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { 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, + 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)), + ), ), - ' ', - 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( @@ -1871,6 +2002,9 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { 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'}), @@ -1997,6 +2131,7 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { composeView = { root: composeElem, key: keyHandler(shortcuts), + unsavedChanges: unsavedChanges, } return composeView } @@ -2706,6 +2841,32 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l 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 () => { 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 shortcuts: {[key: string]: command} = { + e: cmdComposeDraft, I: cmdShowInternals, o: cmdOpenNewTab, O: cmdOpenRaw, @@ -2838,6 +3000,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l const loadButtons = (pm: api.ParsedMessage | null) => { 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 ? [] : @@ -6390,7 +6553,6 @@ const init = async () => { // 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) { - // log('skipping key without modifiers on input/textarea') return } let l = [] @@ -6622,12 +6784,16 @@ const init = async () => { // Don't show disconnection just before user navigates away. let leaving = false - window.addEventListener('beforeunload', () => { - leaving = true - if (eventSource) { - eventSource.close() - eventSource = null - sseID = 0 + window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => { + if (composeView && composeView.unsavedChanges()) { + e.preventDefault() + } else { + leaving = true + if (eventSource) { + eventSource.close() + eventSource = null + sseID = 0 + } } }) diff --git a/webops/xops.go b/webops/xops.go index b46e28f..7549c02 100644 --- a/webops/xops.go +++ b/webops/xops.go @@ -57,77 +57,12 @@ 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) { acc.WithWLock(func() { - removeChanges := map[int64]store.ChangeRemoveUIDs{} - changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts + var changes []store.Change x.DBWrite(ctx, acc, func(tx *bstore.Tx) { - var modseq store.ModSeq - var mb store.Mailbox - remove := make([]store.Message, 0, len(messageIDs)) - - var totalSize int64 - for _, mid := range messageIDs { - m := x.messageID(ctx, tx, mid) - totalSize += m.Size - - if m.MailboxID != mb.ID { - if mb.ID != 0 { - err := tx.Update(&mb) - x.Checkf(ctx, err, "updating mailbox counts") - changes = append(changes, mb.ChangeCounts()) - } - mb = x.mailboxID(ctx, tx, m.MailboxID) - } - - qmr := bstore.QueryTx[store.Recipient](tx) - qmr.FilterEqual("MessageID", m.ID) - _, err := qmr.Delete() - x.Checkf(ctx, err, "removing message recipients") - - mb.Sub(m.MailboxCounts()) - - if modseq == 0 { - modseq, err = acc.NextModSeq(tx) - x.Checkf(ctx, err, "assigning next modseq") - } - m.Expunged = true - m.ModSeq = modseq - err = tx.Update(&m) - x.Checkf(ctx, err, "marking message as expunged") - - ch := removeChanges[m.MailboxID] - ch.UIDs = append(ch.UIDs, m.UID) - ch.MailboxID = m.MailboxID - ch.ModSeq = modseq - removeChanges[m.MailboxID] = ch - remove = append(remove, m) - } - - if mb.ID != 0 { - err := tx.Update(&mb) - x.Checkf(ctx, err, "updating count in mailbox") - changes = append(changes, mb.ChangeCounts()) - } - - err := acc.AddMessageSize(log, tx, -totalSize) - x.Checkf(ctx, err, "updating disk usage") - - // Mark removed messages as not needing training, then retrain them, so if they - // were trained, they get untrained. - for i := range remove { - remove[i].Junk = false - remove[i].Notjunk = false - } - err = acc.RetrainMessages(ctx, log, tx, remove, true) - x.Checkf(ctx, err, "untraining deleted messages") + _, changes = x.MessageDeleteTx(ctx, log, tx, acc, messageIDs, 0) }) - for _, ch := range removeChanges { - sort.Slice(ch.UIDs, func(i, j int) bool { - return ch.UIDs[i] < ch.UIDs[j] - }) - changes = append(changes, ch) - } store.BroadcastChanges(acc, changes) }) @@ -138,6 +73,79 @@ func (x XOps) MessageDelete(ctx context.Context, log mlog.Log, acc *store.Accoun } } +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{} + changes := make([]store.Change, 0, len(messageIDs)+1) // n remove, 1 mailbox counts + + var mb store.Mailbox + remove := make([]store.Message, 0, len(messageIDs)) + + var totalSize int64 + for _, mid := range messageIDs { + m := x.messageID(ctx, tx, mid) + totalSize += m.Size + + if m.MailboxID != mb.ID { + if mb.ID != 0 { + err := tx.Update(&mb) + x.Checkf(ctx, err, "updating mailbox counts") + changes = append(changes, mb.ChangeCounts()) + } + mb = x.mailboxID(ctx, tx, m.MailboxID) + } + + qmr := bstore.QueryTx[store.Recipient](tx) + qmr.FilterEqual("MessageID", m.ID) + _, err := qmr.Delete() + x.Checkf(ctx, err, "removing message recipients") + + mb.Sub(m.MailboxCounts()) + + if modseq == 0 { + modseq, err = acc.NextModSeq(tx) + x.Checkf(ctx, err, "assigning next modseq") + } + m.Expunged = true + m.ModSeq = modseq + err = tx.Update(&m) + x.Checkf(ctx, err, "marking message as expunged") + + ch := removeChanges[m.MailboxID] + ch.UIDs = append(ch.UIDs, m.UID) + ch.MailboxID = m.MailboxID + ch.ModSeq = modseq + removeChanges[m.MailboxID] = ch + remove = append(remove, m) + } + + if mb.ID != 0 { + err := tx.Update(&mb) + x.Checkf(ctx, err, "updating count in mailbox") + changes = append(changes, mb.ChangeCounts()) + } + + err := acc.AddMessageSize(log, tx, -totalSize) + x.Checkf(ctx, err, "updating disk usage") + + // Mark removed messages as not needing training, then retrain them, so if they + // were trained, they get untrained. + for i := range remove { + remove[i].Junk = false + remove[i].Notjunk = false + } + err = acc.RetrainMessages(ctx, log, tx, remove, true) + x.Checkf(ctx, err, "untraining deleted messages") + + for _, ch := range removeChanges { + sort.Slice(ch.UIDs, func(i, j int) bool { + return ch.UIDs[i] < ch.UIDs[j] + }) + changes = append(changes, ch) + } + + return modseq, changes +} + func (x XOps) MessageFlagsAdd(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, flaglist []string) { flags, keywords, err := store.ParseFlagsKeywords(flaglist) x.Checkuserf(ctx, err, "parsing flags") @@ -301,14 +309,14 @@ func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account, 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) }) } -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)) removeChanges := map[int64]store.ChangeRemoveUIDs{} // n adds, 1 remove, 2 mailboxcounts, optimistic and at least for a single message.