mirror of
https://github.com/mjl-/mox.git
synced 2024-12-25 16:03:48 +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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
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",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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
|
||||
// <user@host>".
|
||||
|
@ -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<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
|
||||
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
|
||||
// Bcc message header.
|
||||
|
|
|
@ -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 <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
|
||||
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 <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>",
|
||||
UserAgent: "moxwebmail/dev",
|
||||
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>",
|
||||
UserAgent: "moxwebmail/dev",
|
||||
DraftMessageID: draftID,
|
||||
})
|
||||
// todo: check delivery of 6 messages to inbox, 1 to sent
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<void>
|
||||
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<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()
|
||||
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<api.File[]>((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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
146
webops/xops.go
146
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.
|
||||
|
|
Loading…
Reference in a new issue