mirror of
https://github.com/mjl-/mox.git
synced 2025-07-14 22:54:37 +03:00
webmail: add buttons to download a message as eml, and export 1 or more messages as mbox/maildir in zip/tgz/tar, like for entire mailboxes
Download as eml is useful with firefox, because opening the raw message in a new tab, and then downloading it, causes firefox to request the url without cookies, causing it to save a "403 - forbidden" response. Exporting a selection is useful during all kinds of testing. Makes it easy to an entire thread, or just some messages. The export popover now has buttons for each combination of mbox/maildir vs zip/tgz/tar. Before you may have had to select the email format and archive format first, followed by a click. Now it's just a click.
This commit is contained in:
@ -618,25 +618,42 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt
|
||||
err = zw.Close()
|
||||
log.Check(err, "final write to zip file")
|
||||
|
||||
// Raw display of a message, as text/plain.
|
||||
case len(t) == 2 && t[1] == "raw":
|
||||
_, _, _, msgr, p, cleanup, ok := xprepare()
|
||||
// Raw display or download of a message, as text/plain.
|
||||
case len(t) == 2 && (t[1] == "raw" || t[1] == "rawdl"):
|
||||
_, _, m, msgr, p, cleanup, ok := xprepare()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
headers(false, false, false, false)
|
||||
|
||||
// We intentially use text/plain. We certainly don't want to return a format that
|
||||
// browsers or users would think of executing. We do set the charset if available
|
||||
// on the outer part. If present, we assume it may be relevant for other parts. If
|
||||
// not, there is not much we could do better...
|
||||
headers(false, false, false, false)
|
||||
ct := "text/plain"
|
||||
params := map[string]string{}
|
||||
if charset := p.ContentTypeParams["charset"]; charset != "" {
|
||||
|
||||
if t[1] == "rawdl" {
|
||||
ct = "message/rfc822"
|
||||
if smtputf8, err := p.NeedsSMTPUTF8(); err != nil {
|
||||
log.Errorx("checking for smtputf8 for content-type", err, slog.Int64("msgid", m.ID))
|
||||
http.Error(w, "500 - server error - checking message for content-type: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
} else if smtputf8 {
|
||||
ct = "message/global"
|
||||
params["charset"] = "utf-8"
|
||||
}
|
||||
} else if charset := p.ContentTypeParams["charset"]; charset != "" {
|
||||
params["charset"] = charset
|
||||
}
|
||||
h.Set("Content-Type", mime.FormatMediaType(ct, params))
|
||||
if t[1] == "rawdl" {
|
||||
filename := fmt.Sprintf("email-%d-%s.eml", m.ID, m.Received.Format("20060102-150405"))
|
||||
cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
|
||||
h.Set("Content-Disposition", cd)
|
||||
}
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
|
||||
_, err := io.Copy(w, &moxio.AtReader{R: msgr})
|
||||
|
@ -3806,6 +3806,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
|
||||
}
|
||||
window.open('msg/' + m.ID + '/viewtext/' + [0, ...path].join('.'), '_blank');
|
||||
};
|
||||
const cmdDownloadRaw = async () => { window.open('msg/' + m.ID + '/rawdl', '_blank'); };
|
||||
const cmdViewAttachments = async () => {
|
||||
if (attachments.length > 0) {
|
||||
view(attachments[0]);
|
||||
@ -3956,6 +3957,10 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
|
||||
dom.clickbutton('Mute thread', clickCmd(msglistView.cmdMute, shortcuts)),
|
||||
dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)),
|
||||
dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, shortcuts)),
|
||||
dom.clickbutton('Download raw original message', clickCmd(cmdDownloadRaw, shortcuts)),
|
||||
dom.clickbutton('Export as ...', function click(e) {
|
||||
popoverExport(e.target, '', [m.ID]);
|
||||
}),
|
||||
dom.clickbutton('Show raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)),
|
||||
dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)),
|
||||
dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, shortcuts)),
|
||||
@ -4538,7 +4543,9 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash,
|
||||
movePopover(e, listMailboxes(), effselected.map(miv => miv.messageitem.Message).filter(m => effselected.length === 1 || !sentMailboxID || m.MailboxID !== sentMailboxID || !otherMailbox(sentMailboxID)));
|
||||
}), ' ', dom.clickbutton('Labels...', attr.title('Add/remove labels ...'), function click(e) {
|
||||
labelsPopover(e, effselected.map(miv => miv.messageitem.Message), possibleLabels);
|
||||
}), ' ', dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdMarkNotJunk, shortcuts)), ' ', dom.clickbutton('Mark Read', clickCmd(cmdMarkRead, shortcuts)), ' ', dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ', dom.clickbutton('Mute thread', clickCmd(cmdMute, shortcuts)), ' ', dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts))))));
|
||||
}), ' ', dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdMarkNotJunk, shortcuts)), ' ', dom.clickbutton('Mark Read', clickCmd(cmdMarkRead, shortcuts)), ' ', dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ', dom.clickbutton('Mute thread', clickCmd(cmdMute, shortcuts)), ' ', dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts)), ' ', dom.clickbutton('Export as...', function click(e) {
|
||||
popoverExport(e.target, '', effselected.map(miv => miv.messageitem.Message.ID));
|
||||
})))));
|
||||
}
|
||||
setLocationHash();
|
||||
};
|
||||
@ -5471,11 +5478,37 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash,
|
||||
};
|
||||
return mlv;
|
||||
};
|
||||
const popoverExport = (reference, mailboxName) => {
|
||||
const removeExport = popover(reference, {}, dom.h1('Export ', mailboxName || 'all mailboxes'), dom.form(function submit() {
|
||||
// Export messages to maildir/mbox in tar/tgz/zip/no container. Either all
|
||||
// messages, messages in from 1 mailbox, or explicit message ids.
|
||||
const popoverExport = (reference, mailboxName, messageIDs) => {
|
||||
let format;
|
||||
let archive;
|
||||
let mboxbtn;
|
||||
const removeExport = popover(reference, {}, dom.h1('Export'), dom.form(function submit() {
|
||||
// If we would remove the popup immediately, the form would be deleted too and never submitted.
|
||||
window.setTimeout(() => removeExport(), 100);
|
||||
}, attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), dom.div(css('exportFields', { display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox')), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None')), dom.div(dom.label(dom.input(attr.type('checkbox'), attr.checked(''), attr.name('recursive'), attr.value('on')), ' Recursive')), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Export')))));
|
||||
}, attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), dom.input(attr.type('hidden'), attr.name('messageids'), attr.value((messageIDs || []).join(','))), format = dom.input(attr.type('hidden'), attr.name('format')), archive = dom.input(attr.type('hidden'), attr.name('archive')), dom.div(css('exportFields', { display: 'flex', flexDirection: 'column', gap: '.5ex' }), mailboxName ? dom.div(dom.label(dom.input(attr.type('checkbox'), attr.name('recursive'), attr.value('on'), function change(e) { mboxbtn.disabled = e.target.checked; }), ' Recursive')) : [], dom.div(!mailboxName && !messageIDs ? 'Mbox ' : mboxbtn = dom.submitbutton('Mbox', attr.title('Export as mbox file, not wrapped in an archive.'), function click() {
|
||||
format.value = 'mbox';
|
||||
archive.value = 'none';
|
||||
}), ' ', dom.submitbutton('zip', function click() {
|
||||
format.value = 'mbox';
|
||||
archive.value = 'zip';
|
||||
}), ' ', dom.submitbutton('tgz', function click() {
|
||||
format.value = 'mbox';
|
||||
archive.value = 'tgz';
|
||||
}), ' ', dom.submitbutton('tar', function click() {
|
||||
format.value = 'mbox';
|
||||
archive.value = 'tar';
|
||||
})), dom.div('Maildir ', dom.submitbutton('zip', function click() {
|
||||
format.value = 'maildir';
|
||||
archive.value = 'zip';
|
||||
}), ' ', dom.submitbutton('tgz', function click() {
|
||||
format.value = 'maildir';
|
||||
archive.value = 'tgz';
|
||||
}), ' ', dom.submitbutton('tar', function click() {
|
||||
format.value = 'maildir';
|
||||
archive.value = 'tar';
|
||||
})))));
|
||||
};
|
||||
const newMailboxView = (xmb, mailboxlistView, otherMailbox) => {
|
||||
const plusbox = '⊞';
|
||||
@ -5559,8 +5592,8 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => {
|
||||
await withStatus('Marking mailbox as special use', client.MailboxSetSpecialUse(mb));
|
||||
};
|
||||
popover(actionBtn, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Archive', async function click() { await setUse((mb) => { mb.Archive = true; }); })), dom.div(dom.clickbutton('Draft', async function click() { await setUse((mb) => { mb.Draft = true; }); })), dom.div(dom.clickbutton('Junk', async function click() { await setUse((mb) => { mb.Junk = true; }); })), dom.div(dom.clickbutton('Sent', async function click() { await setUse((mb) => { mb.Sent = true; }); })), dom.div(dom.clickbutton('Trash', async function click() { await setUse((mb) => { mb.Trash = true; }); }))));
|
||||
})), dom.div(dom.clickbutton('Export', function click() {
|
||||
popoverExport(actionBtn, mbv.mailbox.Name);
|
||||
})), dom.div(dom.clickbutton('Export as...', function click() {
|
||||
popoverExport(actionBtn, mbv.mailbox.Name, null);
|
||||
remove();
|
||||
}))));
|
||||
};
|
||||
@ -5806,9 +5839,9 @@ const newMailboxlistView = (msglistView, requestNewView, updatePageTitle, setLoc
|
||||
}, fieldset = dom.fieldset(dom.label('Name ', name = dom.input(attr.required('yes'), focusPlaceholder('Lists/Go/Nuts'))), ' ', dom.submitbutton('Create'))));
|
||||
remove();
|
||||
name.focus();
|
||||
})), dom.div(dom.clickbutton('Export', function click(e) {
|
||||
})), dom.div(dom.clickbutton('Export as...', function click(e) {
|
||||
const ref = e.target;
|
||||
popoverExport(ref, '');
|
||||
popoverExport(ref, '', null);
|
||||
remove();
|
||||
}))));
|
||||
})), mailboxesElem));
|
||||
|
@ -3059,6 +3059,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
|
||||
}
|
||||
window.open('msg/'+m.ID+'/viewtext/'+[0, ...path].join('.'), '_blank')
|
||||
}
|
||||
const cmdDownloadRaw = async () => { window.open('msg/'+m.ID+'/rawdl', '_blank') }
|
||||
const cmdViewAttachments = async () => {
|
||||
if (attachments.length > 0) {
|
||||
view(attachments[0])
|
||||
@ -3271,6 +3272,10 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
|
||||
dom.clickbutton('Mute thread', clickCmd(msglistView.cmdMute, shortcuts)),
|
||||
dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)),
|
||||
dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, shortcuts)),
|
||||
dom.clickbutton('Download raw original message', clickCmd(cmdDownloadRaw, shortcuts)),
|
||||
dom.clickbutton('Export as ...', function click(e: {target: HTMLElement}) {
|
||||
popoverExport(e.target, '', [m.ID])
|
||||
}),
|
||||
dom.clickbutton('Show raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)),
|
||||
dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)),
|
||||
dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, shortcuts)),
|
||||
@ -4111,7 +4116,11 @@ const newMsglistView = (msgElem: HTMLElement, activeMailbox: () => api.Mailbox |
|
||||
dom.clickbutton('Mark Read', clickCmd(cmdMarkRead, shortcuts)), ' ',
|
||||
dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ',
|
||||
dom.clickbutton('Mute thread', clickCmd(cmdMute, shortcuts)), ' ',
|
||||
dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts)),
|
||||
dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts)), ' ',
|
||||
dom.clickbutton('Export as...', function click(e: {target: HTMLElement}) {
|
||||
popoverExport(e.target, '', effselected.map(miv => miv.messageitem.Message.ID))
|
||||
}),
|
||||
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -5096,9 +5105,14 @@ interface MailboxView {
|
||||
setKeywords: (keywords: string[]) => void
|
||||
}
|
||||
|
||||
const popoverExport = (reference: HTMLElement, mailboxName: string) => {
|
||||
const removeExport=popover(reference, {},
|
||||
dom.h1('Export ', mailboxName || 'all mailboxes'),
|
||||
// Export messages to maildir/mbox in tar/tgz/zip/no container. Either all
|
||||
// messages, messages in from 1 mailbox, or explicit message ids.
|
||||
const popoverExport = (reference: HTMLElement, mailboxName: string, messageIDs: number[] | null) => {
|
||||
let format: HTMLInputElement
|
||||
let archive: HTMLInputElement
|
||||
let mboxbtn: HTMLButtonElement
|
||||
const removeExport = popover(reference, {},
|
||||
dom.h1('Export'),
|
||||
dom.form(
|
||||
function submit() {
|
||||
// If we would remove the popup immediately, the form would be deleted too and never submitted.
|
||||
@ -5107,20 +5121,45 @@ const popoverExport = (reference: HTMLElement, mailboxName: string) => {
|
||||
attr.target('_blank'), attr.method('POST'), attr.action('export'),
|
||||
dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')),
|
||||
dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)),
|
||||
dom.input(attr.type('hidden'), attr.name('messageids'), attr.value((messageIDs || []).join(','))),
|
||||
format=dom.input(attr.type('hidden'), attr.name('format')),
|
||||
archive=dom.input(attr.type('hidden'), attr.name('archive')),
|
||||
|
||||
dom.div(css('exportFields', {display: 'flex', flexDirection: 'column', gap: '.5ex'}),
|
||||
mailboxName ? dom.div(dom.label(dom.input(attr.type('checkbox'), attr.name('recursive'), attr.value('on'), function change(e: {target: HTMLInputElement}) { mboxbtn.disabled = e.target.checked }), ' Recursive')) : [],
|
||||
dom.div(
|
||||
dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ',
|
||||
dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox'),
|
||||
!mailboxName && !messageIDs ? 'Mbox ' : mboxbtn=dom.submitbutton('Mbox', attr.title('Export as mbox file, not wrapped in an archive.'), function click() {
|
||||
format.value = 'mbox'
|
||||
archive.value = 'none'
|
||||
}), ' ',
|
||||
dom.submitbutton('zip', function click() {
|
||||
format.value = 'mbox'
|
||||
archive.value = 'zip'
|
||||
}), ' ',
|
||||
dom.submitbutton('tgz', function click() {
|
||||
format.value = 'mbox'
|
||||
archive.value = 'tgz'
|
||||
}), ' ',
|
||||
dom.submitbutton('tar', function click() {
|
||||
format.value = 'mbox'
|
||||
archive.value = 'tar'
|
||||
}),
|
||||
),
|
||||
dom.div(
|
||||
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ',
|
||||
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ',
|
||||
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ',
|
||||
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None'),
|
||||
'Maildir ',
|
||||
dom.submitbutton('zip', function click() {
|
||||
format.value = 'maildir'
|
||||
archive.value = 'zip'
|
||||
}), ' ',
|
||||
dom.submitbutton('tgz', function click() {
|
||||
format.value = 'maildir'
|
||||
archive.value = 'tgz'
|
||||
}), ' ',
|
||||
dom.submitbutton('tar', function click() {
|
||||
format.value = 'maildir'
|
||||
archive.value = 'tar'
|
||||
}),
|
||||
),
|
||||
dom.div(dom.label(dom.input(attr.type('checkbox'), attr.checked(''), attr.name('recursive'), attr.value('on')), ' Recursive')),
|
||||
dom.div(style({marginTop: '1ex'}), dom.submitbutton('Export')),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -5270,8 +5309,8 @@ const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView, othe
|
||||
}),
|
||||
),
|
||||
dom.div(
|
||||
dom.clickbutton('Export', function click() {
|
||||
popoverExport(actionBtn, mbv.mailbox.Name)
|
||||
dom.clickbutton('Export as...', function click() {
|
||||
popoverExport(actionBtn, mbv.mailbox.Name, null)
|
||||
remove()
|
||||
}),
|
||||
),
|
||||
@ -5609,9 +5648,9 @@ const newMailboxlistView = (msglistView: MsglistView, requestNewView: requestNew
|
||||
}),
|
||||
),
|
||||
dom.div(
|
||||
dom.clickbutton('Export', function click(e: MouseEvent) {
|
||||
dom.clickbutton('Export as...', function click(e: MouseEvent) {
|
||||
const ref = e.target! as HTMLElement
|
||||
popoverExport(ref, '')
|
||||
popoverExport(ref, '', null)
|
||||
remove()
|
||||
}),
|
||||
),
|
||||
|
@ -185,7 +185,7 @@ var (
|
||||
msgText = Message{
|
||||
From: "mjl <mjl@mox.example>",
|
||||
To: "mox <mox@other.example>",
|
||||
Subject: "text message",
|
||||
Subject: "text message ☺",
|
||||
Part: Part{Type: "text/plain; charset=utf-8", Content: "the body"},
|
||||
}
|
||||
msgHTML = Message{
|
||||
@ -383,6 +383,8 @@ func TestWebmail(t *testing.T) {
|
||||
ctTextNoCharset := [2]string{"Content-Type", "text/plain"}
|
||||
ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"}
|
||||
ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
|
||||
ctMessageRFC822 := [2]string{"Content-Type", "message/rfc822"}
|
||||
ctMessageGlobal := [2]string{"Content-Type", "message/global; charset=utf-8"}
|
||||
|
||||
cookieOK := &http.Cookie{Name: "webmailsession", Value: sessionCookie.Value}
|
||||
cookieBad := &http.Cookie{Name: "webmailsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
|
||||
@ -602,6 +604,10 @@ func TestWebmail(t *testing.T) {
|
||||
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
|
||||
testHTTPAuthREST("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, nil)
|
||||
testHTTP("GET", pathInboxAltRel+"/rawdl", httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
testHTTP("GET", pathInboxAltRel+"/rawdl", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
|
||||
testHTTPAuthREST("GET", pathInboxAltRel+"/rawdl", http.StatusOK, httpHeaders{ctMessageRFC822}, nil)
|
||||
testHTTPAuthREST("GET", pathInboxText+"/rawdl", http.StatusOK, httpHeaders{ctMessageGlobal}, nil)
|
||||
|
||||
// HTTP message: parsedmessage.js
|
||||
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil)
|
||||
|
Reference in New Issue
Block a user