From 5229d01601ad110f30ee4b29039ae9c87bce3f08 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 19 Apr 2024 21:03:18 +0200 Subject: [PATCH] webmail: for replies/forwards, add button "send and archive thread" next to the "send" button, and give it a control+shift+Enter shortcut the regular send shortcut is control+Enter. the shift enables "archive thread". there is no configuration option, you'll always get the button, but only for reply/forward, not for new compose. we may do "send and move thread to thrash", but let's wait until people want it. for github issue #135 by mattfbacon --- webmail/api.go | 23 +++++ webmail/api.json | 7 ++ webmail/api.ts | 3 +- webmail/msg.js | 2 +- webmail/text.js | 2 +- webmail/webmail.js | 29 ++++-- webmail/webmail.ts | 27 ++++-- webops/xops.go | 220 +++++++++++++++++++++++---------------------- 8 files changed, 188 insertions(+), 125 deletions(-) diff --git a/webmail/api.go b/webmail/api.go index d28e7d8..87e194f 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -207,6 +207,7 @@ type SubmitMessage struct { 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. } // ForwardAttachments references attachments by a list of message.Part paths. @@ -740,6 +741,28 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { err = acc.RetrainMessages(ctx, log, tx, []store.Message{rm}, false) xcheckf(ctx, err, "retraining messages after reply/forward") } + + // Move messages from this thread still in this mailbox to the designated Archive + // mailbox. + if m.ArchiveThread { + mbArchive, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Archive", true).Get() + if err == bstore.ErrAbsent { + xcheckuserf(ctx, errors.New("not configured"), "looking up designated archive mailbox") + } + xcheckf(ctx, err, "looking up designated archive mailbox") + + var msgIDs []int64 + q := bstore.QueryTx[store.Message](tx) + q.FilterNonzero(store.Message{ThreadID: rm.ThreadID, MailboxID: rm.MailboxID}) + q.FilterEqual("Expunged", false) + err = q.IDs(&msgIDs) + 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) + changes = append(changes, nchanges...) + } + } } sentmb, err := bstore.QueryTx[store.Mailbox](tx).FilterEqual("Sent", true).Get() diff --git a/webmail/api.json b/webmail/api.json index a5952d0..cbb816e 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -1190,6 +1190,13 @@ "nullable", "timestamp" ] + }, + { + "Name": "ArchiveThread", + "Docs": "If set, thread is archived after sending message.", + "Typewords": [ + "bool" + ] } ] }, diff --git a/webmail/api.ts b/webmail/api.ts index bd8d655..e8112e7 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -145,6 +145,7 @@ export interface SubmitMessage { 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. } // File is a new attachment (not from an existing message that is being @@ -551,7 +552,7 @@ export const types: TypenameMap = { "Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["string"]}]}, "MessageAddress": {"Name":"MessageAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]}, - "SubmitMessage": {"Name":"SubmitMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"Attachments","Docs":"","Typewords":["[]","File"]},{"Name":"ForwardAttachments","Docs":"","Typewords":["ForwardAttachments"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]},{"Name":"FutureRelease","Docs":"","Typewords":["nullable","timestamp"]}]}, + "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"]}]}, "File": {"Name":"File","Docs":"","Fields":[{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DataURI","Docs":"","Typewords":["string"]}]}, "ForwardAttachments": {"Name":"ForwardAttachments","Docs":"","Fields":[{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"Paths","Docs":"","Typewords":["[]","[]","int32"]}]}, "Mailbox": {"Name":"Mailbox","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"UIDValidity","Docs":"","Typewords":["uint32"]},{"Name":"UIDNext","Docs":"","Typewords":["UID"]},{"Name":"Archive","Docs":"","Typewords":["bool"]},{"Name":"Draft","Docs":"","Typewords":["bool"]},{"Name":"Junk","Docs":"","Typewords":["bool"]},{"Name":"Sent","Docs":"","Typewords":["bool"]},{"Name":"Trash","Docs":"","Typewords":["bool"]},{"Name":"Keywords","Docs":"","Typewords":["[]","string"]},{"Name":"HaveCounts","Docs":"","Typewords":["bool"]},{"Name":"Total","Docs":"","Typewords":["int64"]},{"Name":"Deleted","Docs":"","Typewords":["int64"]},{"Name":"Unread","Docs":"","Typewords":["int64"]},{"Name":"Unseen","Docs":"","Typewords":["int64"]},{"Name":"Size","Docs":"","Typewords":["int64"]}]}, diff --git a/webmail/msg.js b/webmail/msg.js index 0519685..050b744 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -296,7 +296,7 @@ var api; "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, - "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }] }, + "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"] }] }, "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, diff --git a/webmail/text.js b/webmail/text.js index 30add86..2004277 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -296,7 +296,7 @@ var api; "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, - "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }] }, + "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"] }] }, "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, diff --git a/webmail/webmail.js b/webmail/webmail.js index 7ec317d..a08b7d4 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -296,7 +296,7 @@ var api; "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, - "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }, { "Name": "FutureRelease", "Docs": "", "Typewords": ["nullable", "timestamp"] }] }, + "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"] }] }, "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"] }] }, @@ -2178,6 +2178,7 @@ const cmdHelp = async () => { ['→', 'expand thread'], ].map(t => dom.tr(dom.td(t[0]), dom.td(t[1]))))), dom.div(style({ width: '40em' }), dom.table(dom.tr(dom.td(attr.colspan('2'), dom.h2('Compose', style({ margin: '0' })))), [ ['ctrl Enter', 'send message'], + ['ctrl shift Enter', 'send message and archive thread'], ['ctrl w', 'cancel message'], ['ctrl O', 'add To'], ['ctrl C', 'add Cc'], @@ -2283,7 +2284,7 @@ const cmdTooltip = async () => { })); }; let composeView = null; -const compose = (opts) => { +const compose = (opts, listMailboxes) => { log('compose', opts); if (composeView) { // todo: should allow multiple @@ -2307,7 +2308,7 @@ const compose = (opts) => { composeElem.remove(); composeView = null; }; - const submit = async () => { + const submit = async (archive) => { const files = await new Promise((resolve, reject) => { const l = []; if (attachments.files && attachments.files.length === 0) { @@ -2348,12 +2349,16 @@ const compose = (opts) => { ResponseMessageID: opts.responseMessageID || 0, RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes', FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null, + ArchiveThread: archive, }; await client.MessageSubmit(message); cmdCancel(); }; const cmdSend = async () => { - await withStatus('Sending email', submit(), fieldset); + await withStatus('Sending email', submit(false), fieldset); + }; + const cmdSendArchive = async () => { + await withStatus('Sending email and archive', submit(true), fieldset); }; const cmdAddTo = async () => { newAddrView('', true, toViews, toBtn, toCell, toRow); }; const cmdAddCc = async () => { newAddrView('', true, ccViews, ccBtn, ccCell, ccRow); }; @@ -2369,6 +2374,7 @@ const compose = (opts) => { }; const shortcuts = { 'ctrl Enter': cmdSend, + 'ctrl shift Enter': cmdSendArchive, 'ctrl w': cmdCancel, 'ctrl O': cmdAddTo, 'ctrl C': cmdAddCc, @@ -2659,7 +2665,7 @@ const compose = (opts) => { scheduleTime.value = ''; }), dom.div(style({ marginTop: '1ex' }), scheduleTime = dom.input(attr.type('datetime-local'), function change() { scheduleTimeChanged(); - }), ' in local timezone ' + (Intl.DateTimeFormat().resolvedOptions().timeZone || '') + ', ', scheduleWeekday = dom.span()))), dom.div(style({ margin: '3ex 0 1ex 0', display: 'block' }), dom.submitbutton('Send'))), async function submit(e) { + }), ' in local timezone ' + (Intl.DateTimeFormat().resolvedOptions().timeZone || '') + ', ', scheduleWeekday = dom.span()))), dom.div(style({ margin: '3ex 0 1ex 0', display: 'block' }), dom.submitbutton('Send'), ' ', opts.responseMessageID && listMailboxes().find(mb => mb.Archive) ? dom.clickbutton('Send and archive thread', clickCmd(cmdSendArchive, shortcuts)) : [])), async function submit(e) { e.preventDefault(); shortcutCmd(cmdSend, shortcuts); })); @@ -3174,7 +3180,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad isList: m.IsMailingList, editOffset: editOffset, }; - compose(opts); + compose(opts, listMailboxes); }; const reply = async (all) => { const contains = (l, a) => !!l.find(e => equalAddress(e, a)); @@ -5925,7 +5931,7 @@ const init = async () => { if (sig) { body += '\n\n' + sig; } - compose({ body: body, editOffset: 0 }); + compose({ body: body, editOffset: 0 }, listMailboxes); }; const cmdOpenInbox = async () => { const mb = mailboxlistView.findMailboxByName('Inbox'); @@ -6054,6 +6060,11 @@ const init = async () => { if (e.metaKey) { l.push('meta'); } + // Assume regular keys generate a 1 character e.key, and others are special for + // which we may want to treat shift specially too. + if (e.key.length > 1 && e.shiftKey) { + l.push('shift'); + } l.push(e.key); const k = l.join(' '); if (attachmentView) { @@ -6199,7 +6210,7 @@ const init = async () => { if (opts.subject && opts.subject.includes('=?')) { opts.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(opts.subject)); } - compose(opts); + compose(opts, listMailboxes); })(); } catch (err) { @@ -6433,7 +6444,7 @@ const init = async () => { if (openComposeOptions.subject && openComposeOptions.subject.includes('=?')) { openComposeOptions.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(openComposeOptions.subject)); } - compose(openComposeOptions); + compose(openComposeOptions, listMailboxes); openComposeOptions = undefined; })(); } diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 472d310..8c5492f 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1191,6 +1191,7 @@ const cmdHelp = async () => { dom.tr(dom.td(attr.colspan('2'), dom.h2('Compose', style({margin: '0'})))), [ ['ctrl Enter', 'send message'], + ['ctrl shift Enter', 'send message and archive thread'], ['ctrl w', 'cancel message'], ['ctrl O', 'add To'], ['ctrl C', 'add Cc'], @@ -1359,7 +1360,7 @@ interface ComposeView { let composeView: ComposeView | null = null -const compose = (opts: ComposeOptions) => { +const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { log('compose', opts) if (composeView) { @@ -1401,7 +1402,7 @@ const compose = (opts: ComposeOptions) => { composeView = null } - const submit = async () => { + const submit = async (archive: boolean) => { const files = await new Promise((resolve, reject) => { const l: api.File[] = [] if (attachments.files && attachments.files.length === 0) { @@ -1445,13 +1446,17 @@ const compose = (opts: ComposeOptions) => { ResponseMessageID: opts.responseMessageID || 0, RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes', FutureRelease: scheduleTime.value ? new Date(scheduleTime.value) : null, + ArchiveThread: archive, } await client.MessageSubmit(message) cmdCancel() } const cmdSend = async () => { - await withStatus('Sending email', submit(), fieldset) + await withStatus('Sending email', submit(false), fieldset) + } + const cmdSendArchive = async () => { + await withStatus('Sending email and archive', submit(true), fieldset) } const cmdAddTo = async () => { newAddrView('', true, toViews, toBtn, toCell, toRow) } @@ -1469,6 +1474,7 @@ const compose = (opts: ComposeOptions) => { const shortcuts: {[key: string]: command} = { 'ctrl Enter': cmdSend, + 'ctrl shift Enter': cmdSendArchive, 'ctrl w': cmdCancel, 'ctrl O': cmdAddTo, 'ctrl C': cmdAddCc, @@ -1896,6 +1902,8 @@ const compose = (opts: ComposeOptions) => { dom.div( style({margin: '3ex 0 1ex 0', display: 'block'}), dom.submitbutton('Send'), + ' ', + opts.responseMessageID && listMailboxes().find(mb => mb.Archive) ? dom.clickbutton('Send and archive thread', clickCmd(cmdSendArchive, shortcuts)) : [], ), ), async function submit(e: SubmitEvent) { @@ -2583,7 +2591,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l isList: m.IsMailingList, editOffset: editOffset, } - compose(opts) + compose(opts, listMailboxes) } const reply = async (all: boolean) => { @@ -6123,7 +6131,7 @@ const init = async () => { if (sig) { body += '\n\n' + sig } - compose({body: body, editOffset: 0}) + compose({body: body, editOffset: 0}, listMailboxes) } const cmdOpenInbox = async () => { const mb = mailboxlistView.findMailboxByName('Inbox') @@ -6339,6 +6347,11 @@ const init = async () => { if (e.metaKey) { l.push('meta') } + // Assume regular keys generate a 1 character e.key, and others are special for + // which we may want to treat shift specially too. + if (e.key.length > 1 && e.shiftKey) { + l.push('shift') + } l.push(e.key) const k = l.join(' ') @@ -6523,7 +6536,7 @@ const init = async () => { if (opts.subject && opts.subject.includes('=?')) { opts.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(opts.subject)) } - compose(opts) + compose(opts, listMailboxes) })() } catch (err) { window.alert('Error parsing compose mailto URL: '+errmsg(err)) @@ -6782,7 +6795,7 @@ const init = async () => { if (openComposeOptions.subject && openComposeOptions.subject.includes('=?')) { openComposeOptions.subject = await withStatus('Decoding MIME words for subject', client.DecodeMIMEWords(openComposeOptions.subject)) } - compose(openComposeOptions) + compose(openComposeOptions, listMailboxes) openComposeOptions = undefined })() } diff --git a/webops/xops.go b/webops/xops.go index b6dff5a..b46e28f 100644 --- a/webops/xops.go +++ b/webops/xops.go @@ -282,15 +282,9 @@ func (x XOps) MessageFlagsClear(ctx context.Context, log mlog.Log, acc *store.Ac // MessageMove moves messages to the mailbox represented by mailboxName, or to mailboxID if mailboxName is empty. func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account, messageIDs []int64, mailboxName string, mailboxID int64) { acc.WithRLock(func() { - 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. - changes := make([]store.Change, 0, len(messageIDs)+3) + var changes []store.Change x.DBWrite(ctx, acc, func(tx *bstore.Tx) { - var mbSrc store.Mailbox - var modseq store.ModSeq - if mailboxName != "" { mb, err := acc.MailboxFind(tx, mailboxName) x.Checkf(ctx, err, "looking up mailbox name") @@ -307,111 +301,125 @@ func (x XOps) MessageMove(ctx context.Context, log mlog.Log, acc *store.Account, return } - keywords := map[string]struct{}{} - - for _, mid := range messageIDs { - m := x.messageID(ctx, tx, mid) - - // We may have loaded this mailbox in the previous iteration of this loop. - if m.MailboxID != mbSrc.ID { - if mbSrc.ID != 0 { - err := tx.Update(&mbSrc) - x.Checkf(ctx, err, "updating source mailbox counts") - changes = append(changes, mbSrc.ChangeCounts()) - } - mbSrc = x.mailboxID(ctx, tx, m.MailboxID) - } - - if mbSrc.ID == mailboxID { - // Client should filter out messages that are already in mailbox. - x.Checkuserf(ctx, errors.New("already in destination mailbox"), "moving message") - } - - var err error - if modseq == 0 { - modseq, err = acc.NextModSeq(tx) - x.Checkf(ctx, err, "assigning next modseq") - } - - ch := removeChanges[m.MailboxID] - ch.UIDs = append(ch.UIDs, m.UID) - ch.ModSeq = modseq - ch.MailboxID = m.MailboxID - removeChanges[m.MailboxID] = ch - - // Copy of message record that we'll insert when UID is freed up. - om := m - om.PrepareExpunge() - om.ID = 0 // Assign new ID. - om.ModSeq = modseq - - mbSrc.Sub(m.MailboxCounts()) - - if mbDst.Trash { - m.Seen = true - } - conf, _ := acc.Conf() - m.MailboxID = mbDst.ID - if m.IsReject && m.MailboxDestinedID != 0 { - // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message - // is used for reputation calculation during future deliveries. - m.MailboxOrigID = m.MailboxDestinedID - m.IsReject = false - m.Seen = false - } - m.UID = mbDst.UIDNext - m.ModSeq = modseq - mbDst.UIDNext++ - m.JunkFlagsForMailbox(mbDst, conf) - err = tx.Update(&m) - x.Checkf(ctx, err, "updating moved message in database") - - // Now that UID is unused, we can insert the old record again. - err = tx.Insert(&om) - x.Checkf(ctx, err, "inserting record for expunge after moving message") - - mbDst.Add(m.MailboxCounts()) - - changes = append(changes, m.ChangeAddUID()) - retrain = append(retrain, m) - - for _, kw := range m.Keywords { - keywords[kw] = struct{}{} - } - } - - err := tx.Update(&mbSrc) - x.Checkf(ctx, err, "updating source mailbox counts") - - changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts()) - - // Ensure destination mailbox has keywords of the moved messages. - var mbKwChanged bool - mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords)) - if mbKwChanged { - changes = append(changes, mbDst.ChangeKeywords()) - } - - err = tx.Update(&mbDst) - x.Checkf(ctx, err, "updating mailbox with uidnext") - - err = acc.RetrainMessages(ctx, log, tx, retrain, false) - x.Checkf(ctx, err, "retraining messages after move") + _, changes = x.MessageMoveMailbox(ctx, log, acc, tx, messageIDs, mbDst, 0) }) - // Ensure UIDs of the removed message are in increasing order. It is quite common - // for all messages to be from a single source mailbox, meaning this is just one - // change, for which we preallocated space. - 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) }) } +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) { + 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. + changes := make([]store.Change, 0, len(messageIDs)+3) + + var mbSrc store.Mailbox + + keywords := map[string]struct{}{} + + for _, mid := range messageIDs { + m := x.messageID(ctx, tx, mid) + + // We may have loaded this mailbox in the previous iteration of this loop. + if m.MailboxID != mbSrc.ID { + if mbSrc.ID != 0 { + err := tx.Update(&mbSrc) + x.Checkf(ctx, err, "updating source mailbox counts") + changes = append(changes, mbSrc.ChangeCounts()) + } + mbSrc = x.mailboxID(ctx, tx, m.MailboxID) + } + + if mbSrc.ID == mbDst.ID { + // Client should filter out messages that are already in mailbox. + x.Checkuserf(ctx, errors.New("already in destination mailbox"), "moving message") + } + + var err error + if modseq == 0 { + modseq, err = acc.NextModSeq(tx) + x.Checkf(ctx, err, "assigning next modseq") + } + + ch := removeChanges[m.MailboxID] + ch.UIDs = append(ch.UIDs, m.UID) + ch.ModSeq = modseq + ch.MailboxID = m.MailboxID + removeChanges[m.MailboxID] = ch + + // Copy of message record that we'll insert when UID is freed up. + om := m + om.PrepareExpunge() + om.ID = 0 // Assign new ID. + om.ModSeq = modseq + + mbSrc.Sub(m.MailboxCounts()) + + if mbDst.Trash { + m.Seen = true + } + conf, _ := acc.Conf() + m.MailboxID = mbDst.ID + if m.IsReject && m.MailboxDestinedID != 0 { + // Incorrectly delivered to Rejects mailbox. Adjust MailboxOrigID so this message + // is used for reputation calculation during future deliveries. + m.MailboxOrigID = m.MailboxDestinedID + m.IsReject = false + m.Seen = false + } + m.UID = mbDst.UIDNext + m.ModSeq = modseq + mbDst.UIDNext++ + m.JunkFlagsForMailbox(mbDst, conf) + err = tx.Update(&m) + x.Checkf(ctx, err, "updating moved message in database") + + // Now that UID is unused, we can insert the old record again. + err = tx.Insert(&om) + x.Checkf(ctx, err, "inserting record for expunge after moving message") + + mbDst.Add(m.MailboxCounts()) + + changes = append(changes, m.ChangeAddUID()) + retrain = append(retrain, m) + + for _, kw := range m.Keywords { + keywords[kw] = struct{}{} + } + } + + err := tx.Update(&mbSrc) + x.Checkf(ctx, err, "updating source mailbox counts") + + changes = append(changes, mbSrc.ChangeCounts(), mbDst.ChangeCounts()) + + // Ensure destination mailbox has keywords of the moved messages. + var mbKwChanged bool + mbDst.Keywords, mbKwChanged = store.MergeKeywords(mbDst.Keywords, maps.Keys(keywords)) + if mbKwChanged { + changes = append(changes, mbDst.ChangeKeywords()) + } + + err = tx.Update(&mbDst) + x.Checkf(ctx, err, "updating mailbox with uidnext") + + err = acc.RetrainMessages(ctx, log, tx, retrain, false) + x.Checkf(ctx, err, "retraining messages after move") + + // Ensure UIDs of the removed message are in increasing order. It is quite common + // for all messages to be from a single source mailbox, meaning this is just one + // change, for which we preallocated space. + 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 isText(p message.Part) bool { return p.MediaType == "" && p.MediaSubType == "" || p.MediaType == "TEXT" && p.MediaSubType == "PLAIN" }