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:
Mechiel Lukkien 2025-03-29 18:10:23 +01:00
parent d6e55b5f36
commit a5d74eb718
No known key found for this signature in database
8 changed files with 591 additions and 394 deletions

View File

@ -72,7 +72,7 @@ func xcmdExport(mbox, single bool, args []string, c *cmd) {
}() }()
a := store.DirArchiver{Dir: dst} a := store.DirArchiver{Dir: dst}
err = store.ExportMessages(context.Background(), c.log, db, accountDir, a, !mbox, mailbox, !single) err = store.ExportMessages(context.Background(), c.log, db, accountDir, a, !mbox, mailbox, nil, !single)
xcheckf(err, "exporting messages") xcheckf(err, "exporting messages")
err = a.Close() err = a.Close()
xcheckf(err, "closing archiver") xcheckf(err, "closing archiver")

View File

@ -122,16 +122,22 @@ func (a *MboxArchiver) Close() error {
return nil return nil
} }
// ExportMessages writes messages to archiver. Either in maildir format, or otherwise in // ExportMessages writes messages to archiver. Either in maildir format, or
// mbox. If mailboxOpt is empty, all mailboxes are exported, otherwise only the // otherwise in mbox. If mailboxOpt is non-empty, all messages from that mailbox
// named mailbox. // are exported. If messageIDsOpt is non-empty, only those message IDs are exported.
// If both are empty, all mailboxes and all messages are exported. mailboxOpt
// and messageIDsOpt cannot both be non-empty.
// //
// Some errors are not fatal and result in skipped messages. In that happens, a // Some errors are not fatal and result in skipped messages. In that happens, a
// file "errors.txt" is added to the archive describing the errors. The goal is to // file "errors.txt" is added to the archive describing the errors. The goal is to
// let users export (hopefully) most messages even in the face of errors. // let users export (hopefully) most messages even in the face of errors.
func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir string, archiver Archiver, maildir bool, mailboxOpt string, recursive bool) error { func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir string, archiver Archiver, maildir bool, mailboxOpt string, messageIDsOpt []int64, recursive bool) error {
// todo optimize: should prepare next file to add to archive (can be an mbox with many messages) while writing a file to the archive (which typically compresses, which takes time). // todo optimize: should prepare next file to add to archive (can be an mbox with many messages) while writing a file to the archive (which typically compresses, which takes time).
if mailboxOpt != "" && len(messageIDsOpt) != 0 {
return fmt.Errorf("cannot have both mailbox and message ids")
}
// Start transaction without closure, we are going to close it early, but don't // Start transaction without closure, we are going to close it early, but don't
// want to deal with declaring many variables now to be able to assign them in a // want to deal with declaring many variables now to be able to assign them in a
// closure and use them afterwards. // closure and use them afterwards.
@ -153,6 +159,13 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir
// continue with useless work. // continue with useless work.
var errors string var errors string
if messageIDsOpt != nil {
var err error
errors, err = exportMessages(log, tx, accountDir, messageIDsOpt, archiver, maildir, start)
if err != nil {
return fmt.Errorf("exporting messages: %v", err)
}
} else {
// Process mailboxes sorted by name, so submaildirs come after their parent. // Process mailboxes sorted by name, so submaildirs come after their parent.
prefix := mailboxOpt + "/" prefix := mailboxOpt + "/"
var trimPrefix string var trimPrefix string
@ -181,6 +194,7 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir
if err != nil { if err != nil {
return fmt.Errorf("query mailboxes: %w", err) return fmt.Errorf("query mailboxes: %w", err)
} }
}
if errors != "" { if errors != "" {
w, err := archiver.Create("errors.txt", int64(len(errors)), time.Now()) w, err := archiver.Create("errors.txt", int64(len(errors)), time.Now())
@ -201,121 +215,161 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir
return nil return nil
} }
func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int64, mailboxName string, archiver Archiver, maildir bool, start time.Time) (string, error) { func exportMessages(log mlog.Log, tx *bstore.Tx, accountDir string, messageIDs []int64, archiver Archiver, maildir bool, start time.Time) (string, error) {
var errors string mbe, err := newMailboxExport(log, "Export", accountDir, archiver, start, maildir)
if err != nil {
var mboxtmp *os.File return "", err
var mboxwriter *bufio.Writer }
defer func() { defer mbe.Cleanup()
if mboxtmp != nil {
CloseRemoveTempFile(log, mboxtmp, "mbox") for _, id := range messageIDs {
m := Message{ID: id}
if err := tx.Get(&m); err != nil {
mbe.errors += fmt.Sprintf("get message with id %d: %v\n", id, err)
continue
} else if m.Expunged {
mbe.errors += fmt.Sprintf("message with id %d is expunged\n", id)
continue
}
if err := mbe.ExportMessage(m); err != nil {
return mbe.errors, err
}
}
err = mbe.Finish()
return mbe.errors, err
}
func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int64, mailboxName string, archiver Archiver, maildir bool, start time.Time) (string, error) {
mbe, err := newMailboxExport(log, mailboxName, accountDir, archiver, start, maildir)
if err != nil {
return "", err
}
defer mbe.Cleanup()
// Fetch all messages for mailbox.
q := bstore.QueryTx[Message](tx)
q.FilterNonzero(Message{MailboxID: mailboxID})
q.FilterEqual("Expunged", false)
q.SortAsc("Received", "ID")
err = q.ForEach(func(m Message) error {
return mbe.ExportMessage(m)
})
if err != nil {
return mbe.errors, err
}
err = mbe.Finish()
return mbe.errors, err
} }
}()
// For dovecot-keyword-style flags not in standard maildir. // For dovecot-keyword-style flags not in standard maildir.
maildirFlags := map[string]int{} type maildirFlags struct {
var maildirFlaglist []string Map map[string]int
maildirFlag := func(flag string) string { List []string
i, ok := maildirFlags[flag] }
func newMaildirFlags() *maildirFlags {
return &maildirFlags{map[string]int{}, nil}
}
func (f *maildirFlags) Flag(flag string) string {
i, ok := f.Map[flag]
if !ok { if !ok {
if len(maildirFlags) >= 26 { if len(f.Map) >= 26 {
// Max 26 flag characters. // Max 26 flag characters.
return "" return ""
} }
i = len(maildirFlags) i = len(f.Map)
maildirFlags[flag] = i f.Map[flag] = i
maildirFlaglist = append(maildirFlaglist, flag) f.List = append(f.List, flag)
} }
return string(rune('a' + i)) return string(rune('a' + i))
} }
finishMailbox := func() error { func (f *maildirFlags) Empty() bool {
return len(f.Map) == 0
}
type mailboxExport struct {
log mlog.Log
mailboxName string
accountDir string
archiver Archiver
start time.Time
maildir bool
maildirFlags *maildirFlags
mboxtmp *os.File
mboxwriter *bufio.Writer
errors string
}
func (e *mailboxExport) Cleanup() {
if e.mboxtmp != nil {
CloseRemoveTempFile(e.log, e.mboxtmp, "mbox")
}
}
func newMailboxExport(log mlog.Log, mailboxName, accountDir string, archiver Archiver, start time.Time, maildir bool) (*mailboxExport, error) {
mbe := mailboxExport{
log: log,
mailboxName: mailboxName,
accountDir: accountDir,
archiver: archiver,
start: start,
maildir: maildir,
}
if maildir { if maildir {
if len(maildirFlags) == 0 { // Create the directories that show this is a maildir.
return nil mbe.maildirFlags = newMaildirFlags()
if _, err := archiver.Create(mailboxName+"/new/", 0, start); err != nil {
return nil, fmt.Errorf("adding maildir new directory: %v", err)
}
if _, err := archiver.Create(mailboxName+"/cur/", 0, start); err != nil {
return nil, fmt.Errorf("adding maildir cur directory: %v", err)
}
if _, err := archiver.Create(mailboxName+"/tmp/", 0, start); err != nil {
return nil, fmt.Errorf("adding maildir tmp directory: %v", err)
}
} else {
var err error
mbe.mboxtmp, err = os.CreateTemp("", "mox-mail-export-mbox")
if err != nil {
return nil, fmt.Errorf("creating temp mbox file: %v", err)
}
mbe.mboxwriter = bufio.NewWriter(mbe.mboxtmp)
} }
var b bytes.Buffer return &mbe, nil
for i, flag := range maildirFlaglist {
if _, err := fmt.Fprintf(&b, "%d %s\n", i, flag); err != nil {
return err
}
}
w, err := archiver.Create(mailboxName+"/dovecot-keywords", int64(b.Len()), start)
if err != nil {
return fmt.Errorf("adding dovecot-keywords: %v", err)
}
if _, err := w.Write(b.Bytes()); err != nil {
xerr := w.Close()
log.Check(xerr, "closing dovecot-keywords file after closing")
return fmt.Errorf("writing dovecot-keywords: %v", err)
}
maildirFlags = map[string]int{}
maildirFlaglist = nil
return w.Close()
} }
if err := mboxwriter.Flush(); err != nil { func (e *mailboxExport) ExportMessage(m Message) error {
return fmt.Errorf("flush mbox writer: %v", err) mp := filepath.Join(e.accountDir, "msg", MessagePath(m.ID))
}
fi, err := mboxtmp.Stat()
if err != nil {
return fmt.Errorf("stat temporary mbox file: %v", err)
}
if _, err := mboxtmp.Seek(0, 0); err != nil {
return fmt.Errorf("seek to start of temporary mbox file")
}
w, err := archiver.Create(mailboxName+".mbox", fi.Size(), fi.ModTime())
if err != nil {
return fmt.Errorf("add mbox to archive: %v", err)
}
if _, err := io.Copy(w, mboxtmp); err != nil {
xerr := w.Close()
log.Check(xerr, "closing mbox message file after error")
return fmt.Errorf("copying temp mbox file to archive: %v", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("closing message file: %v", err)
}
name := mboxtmp.Name()
err = mboxtmp.Close()
log.Check(err, "closing temporary mbox file")
err = os.Remove(name)
log.Check(err, "removing temporary mbox file", slog.String("path", name))
mboxwriter = nil
mboxtmp = nil
return nil
}
exportMessage := func(m Message) error {
mp := filepath.Join(accountDir, "msg", MessagePath(m.ID))
var mr io.ReadCloser var mr io.ReadCloser
if m.Size == int64(len(m.MsgPrefix)) { if m.Size == int64(len(m.MsgPrefix)) {
mr = io.NopCloser(bytes.NewReader(m.MsgPrefix)) mr = io.NopCloser(bytes.NewReader(m.MsgPrefix))
} else { } else {
mf, err := os.Open(mp) mf, err := os.Open(mp)
if err != nil { if err != nil {
errors += fmt.Sprintf("open message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err) e.errors += fmt.Sprintf("open message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err)
return nil return nil
} }
defer func() { defer func() {
err := mf.Close() err := mf.Close()
log.Check(err, "closing message file after export") e.log.Check(err, "closing message file after export")
}() }()
st, err := mf.Stat() st, err := mf.Stat()
if err != nil { if err != nil {
errors += fmt.Sprintf("stat message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err) e.errors += fmt.Sprintf("stat message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err)
return nil return nil
} }
size := st.Size() + int64(len(m.MsgPrefix)) size := st.Size() + int64(len(m.MsgPrefix))
if size != m.Size { if size != m.Size {
errors += fmt.Sprintf("message size mismatch for message id %d, database has %d, size is %d+%d=%d, using calculated size\n", m.ID, m.Size, len(m.MsgPrefix), st.Size(), size) e.errors += fmt.Sprintf("message size mismatch for message id %d, database has %d, size is %d+%d=%d, using calculated size\n", m.ID, m.Size, len(m.MsgPrefix), st.Size(), size)
} }
mr = FileMsgReader(m.MsgPrefix, mf) mr = FileMsgReader(m.MsgPrefix, mf)
} }
if maildir { if e.maildir {
p := mailboxName p := e.mailboxName
if m.Flags.Seen { if m.Flags.Seen {
p = filepath.Join(p, "cur") p = filepath.Join(p, "cur")
} else { } else {
@ -342,19 +396,19 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
// Non-standard flag. We set them with a dovecot-keywords file. // Non-standard flag. We set them with a dovecot-keywords file.
if m.Flags.Forwarded { if m.Flags.Forwarded {
name += maildirFlag("$Forwarded") name += e.maildirFlags.Flag("$Forwarded")
} }
if m.Flags.Junk { if m.Flags.Junk {
name += maildirFlag("$Junk") name += e.maildirFlags.Flag("$Junk")
} }
if m.Flags.Notjunk { if m.Flags.Notjunk {
name += maildirFlag("$NotJunk") name += e.maildirFlags.Flag("$NotJunk")
} }
if m.Flags.Phishing { if m.Flags.Phishing {
name += maildirFlag("$Phishing") name += e.maildirFlags.Flag("$Phishing")
} }
if m.Flags.MDNSent { if m.Flags.MDNSent {
name += maildirFlag("$MDNSent") name += e.maildirFlags.Flag("$MDNSent")
} }
p = filepath.Join(p, name) p = filepath.Join(p, name)
@ -367,7 +421,7 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
for { for {
line, rerr := r.ReadBytes('\n') line, rerr := r.ReadBytes('\n')
if rerr != io.EOF && rerr != nil { if rerr != io.EOF && rerr != nil {
errors += fmt.Sprintf("reading from message for id %d: %v (message skipped)\n", m.ID, rerr) e.errors += fmt.Sprintf("reading from message for id %d: %v (message skipped)\n", m.ID, rerr)
return nil return nil
} }
if len(line) > 0 { if len(line) > 0 {
@ -384,13 +438,13 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
} }
} }
size := int64(dst.Len()) size := int64(dst.Len())
w, err := archiver.Create(p, size, m.Received) w, err := e.archiver.Create(p, size, m.Received)
if err != nil { if err != nil {
return fmt.Errorf("adding message to archive: %v", err) return fmt.Errorf("adding message to archive: %v", err)
} }
if _, err := io.Copy(w, &dst); err != nil { if _, err := io.Copy(w, &dst); err != nil {
xerr := w.Close() xerr := w.Close()
log.Check(xerr, "closing message") e.log.Check(xerr, "closing message")
return fmt.Errorf("copying message to archive: %v", err) return fmt.Errorf("copying message to archive: %v", err)
} }
return w.Close() return w.Close()
@ -401,13 +455,13 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
mailfrom = m.MailFrom mailfrom = m.MailFrom
} }
// ../rfc/4155:80 // ../rfc/4155:80
if _, err := fmt.Fprintf(mboxwriter, "From %s %s\n", mailfrom, m.Received.Format(time.ANSIC)); err != nil { if _, err := fmt.Fprintf(e.mboxwriter, "From %s %s\n", mailfrom, m.Received.Format(time.ANSIC)); err != nil {
return fmt.Errorf("write message line to mbox temp file: %v", err) return fmt.Errorf("write message line to mbox temp file: %v", err)
} }
// Write message flags in the three headers that mbox consumers may (or may not) understand. // Write message flags in the three headers that mbox consumers may (or may not) understand.
if m.Seen { if m.Seen {
if _, err := fmt.Fprintf(mboxwriter, "Status: R\n"); err != nil { if _, err := fmt.Fprintf(e.mboxwriter, "Status: R\n"); err != nil {
return fmt.Errorf("writing status header: %v", err) return fmt.Errorf("writing status header: %v", err)
} }
} }
@ -425,7 +479,7 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
xstatus += "D" xstatus += "D"
} }
if xstatus != "" { if xstatus != "" {
if _, err := fmt.Fprintf(mboxwriter, "X-Status: %s\n", xstatus); err != nil { if _, err := fmt.Fprintf(e.mboxwriter, "X-Status: %s\n", xstatus); err != nil {
return fmt.Errorf("writing x-status header: %v", err) return fmt.Errorf("writing x-status header: %v", err)
} }
} }
@ -446,7 +500,7 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
xkeywords = append(xkeywords, "$MDNSent") xkeywords = append(xkeywords, "$MDNSent")
} }
if len(xkeywords) > 0 { if len(xkeywords) > 0 {
if _, err := fmt.Fprintf(mboxwriter, "X-Keywords: %s\n", strings.Join(xkeywords, ",")); err != nil { if _, err := fmt.Fprintf(e.mboxwriter, "X-Keywords: %s\n", strings.Join(xkeywords, ",")); err != nil {
return fmt.Errorf("writing x-keywords header: %v", err) return fmt.Errorf("writing x-keywords header: %v", err)
} }
} }
@ -479,11 +533,11 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
} }
// ../rfc/4155:119 // ../rfc/4155:119
if bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) { if bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) {
if _, err := fmt.Fprint(mboxwriter, ">"); err != nil { if _, err := fmt.Fprint(e.mboxwriter, ">"); err != nil {
return fmt.Errorf("writing escaping >: %v", err) return fmt.Errorf("writing escaping >: %v", err)
} }
} }
if _, err := mboxwriter.Write(line); err != nil { if _, err := e.mboxwriter.Write(line); err != nil {
return fmt.Errorf("writing line: %v", err) return fmt.Errorf("writing line: %v", err)
} }
} }
@ -492,46 +546,64 @@ func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int
} }
} }
// ../rfc/4155:75 // ../rfc/4155:75
if _, err := fmt.Fprint(mboxwriter, "\n"); err != nil { if _, err := fmt.Fprint(e.mboxwriter, "\n"); err != nil {
return fmt.Errorf("writing end of message newline: %v", err) return fmt.Errorf("writing end of message newline: %v", err)
} }
return nil return nil
} }
if maildir { func (e *mailboxExport) Finish() error {
// Create the directories that show this is a maildir. if e.maildir {
if _, err := archiver.Create(mailboxName+"/new/", 0, start); err != nil { if e.maildirFlags.Empty() {
return errors, fmt.Errorf("adding maildir new directory: %v", err) return nil
}
if _, err := archiver.Create(mailboxName+"/cur/", 0, start); err != nil {
return errors, fmt.Errorf("adding maildir cur directory: %v", err)
}
if _, err := archiver.Create(mailboxName+"/tmp/", 0, start); err != nil {
return errors, fmt.Errorf("adding maildir tmp directory: %v", err)
}
} else {
var err error
mboxtmp, err = os.CreateTemp("", "mox-mail-export-mbox")
if err != nil {
return errors, fmt.Errorf("creating temp mbox file: %v", err)
}
mboxwriter = bufio.NewWriter(mboxtmp)
} }
// Fetch all messages for mailbox. var b bytes.Buffer
q := bstore.QueryTx[Message](tx) for i, flag := range e.maildirFlags.List {
q.FilterNonzero(Message{MailboxID: mailboxID}) if _, err := fmt.Fprintf(&b, "%d %s\n", i, flag); err != nil {
q.FilterEqual("Expunged", false) return err
q.SortAsc("Received", "ID")
err := q.ForEach(func(m Message) error {
return exportMessage(m)
})
if err != nil {
return errors, err
} }
if err := finishMailbox(); err != nil { }
return errors, err w, err := e.archiver.Create(e.mailboxName+"/dovecot-keywords", int64(b.Len()), e.start)
if err != nil {
return fmt.Errorf("adding dovecot-keywords: %v", err)
}
if _, err := w.Write(b.Bytes()); err != nil {
xerr := w.Close()
e.log.Check(xerr, "closing dovecot-keywords file after closing")
return fmt.Errorf("writing dovecot-keywords: %v", err)
}
return w.Close()
} }
return errors, nil if err := e.mboxwriter.Flush(); err != nil {
return fmt.Errorf("flush mbox writer: %v", err)
}
fi, err := e.mboxtmp.Stat()
if err != nil {
return fmt.Errorf("stat temporary mbox file: %v", err)
}
if _, err := e.mboxtmp.Seek(0, 0); err != nil {
return fmt.Errorf("seek to start of temporary mbox file")
}
w, err := e.archiver.Create(e.mailboxName+".mbox", fi.Size(), fi.ModTime())
if err != nil {
return fmt.Errorf("add mbox to archive: %v", err)
}
if _, err := io.Copy(w, e.mboxtmp); err != nil {
xerr := w.Close()
e.log.Check(xerr, "closing mbox message file after error")
return fmt.Errorf("copying temp mbox file to archive: %v", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("closing message file: %v", err)
}
name := e.mboxtmp.Name()
err = e.mboxtmp.Close()
e.log.Check(err, "closing temporary mbox file")
err = os.Remove(name)
e.log.Check(err, "removing temporary mbox file", slog.String("path", name))
e.mboxwriter = nil
e.mboxtmp = nil
return nil
} }

View File

@ -41,8 +41,9 @@ func TestExport(t *testing.T) {
_, err = msgFile.Write([]byte(msg)) _, err = msgFile.Write([]byte(msg))
tcheck(t, err, "write message") tcheck(t, err, "write message")
var m Message
acc.WithWLock(func() { acc.WithWLock(func() {
m := Message{Received: time.Now(), Size: int64(len(msg))} m = Message{Received: time.Now(), Size: int64(len(msg))}
err = acc.DeliverMailbox(pkglog, "Inbox", &m, msgFile) err = acc.DeliverMailbox(pkglog, "Inbox", &m, msgFile)
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
@ -53,9 +54,9 @@ func TestExport(t *testing.T) {
var maildirZip, maildirTar, mboxZip, mboxTar bytes.Buffer var maildirZip, maildirTar, mboxZip, mboxTar bytes.Buffer
archive := func(archiver Archiver, maildir bool) { archive := func(archiver Archiver, mailbox string, messageIDs []int64, maildir bool) {
t.Helper() t.Helper()
err = ExportMessages(ctxbg, log, acc.DB, acc.Dir, archiver, maildir, "", true) err = ExportMessages(ctxbg, log, acc.DB, acc.Dir, archiver, maildir, mailbox, messageIDs, true)
tcheck(t, err, "export messages") tcheck(t, err, "export messages")
err = archiver.Close() err = archiver.Close()
tcheck(t, err, "archiver close") tcheck(t, err, "archiver close")
@ -64,12 +65,14 @@ func TestExport(t *testing.T) {
os.RemoveAll("../testdata/exportmaildir") os.RemoveAll("../testdata/exportmaildir")
os.RemoveAll("../testdata/exportmbox") os.RemoveAll("../testdata/exportmbox")
archive(ZipArchiver{zip.NewWriter(&maildirZip)}, true) archive(ZipArchiver{zip.NewWriter(&maildirZip)}, "", nil, true)
archive(ZipArchiver{zip.NewWriter(&mboxZip)}, false) archive(ZipArchiver{zip.NewWriter(&mboxZip)}, "", nil, false)
archive(TarArchiver{tar.NewWriter(&maildirTar)}, true) archive(TarArchiver{tar.NewWriter(&maildirTar)}, "", nil, true)
archive(TarArchiver{tar.NewWriter(&mboxTar)}, false) archive(TarArchiver{tar.NewWriter(&mboxTar)}, "", nil, false)
archive(DirArchiver{filepath.FromSlash("../testdata/exportmaildir")}, true) archive(TarArchiver{tar.NewWriter(&mboxTar)}, "Inbox", nil, false)
archive(DirArchiver{filepath.FromSlash("../testdata/exportmbox")}, false) archive(TarArchiver{tar.NewWriter(&mboxTar)}, "", []int64{m.ID}, false)
archive(DirArchiver{filepath.FromSlash("../testdata/exportmaildir")}, "", nil, true)
archive(DirArchiver{filepath.FromSlash("../testdata/exportmbox")}, "", nil, false)
const defaultMailboxes = 6 // Inbox, Drafts, etc const defaultMailboxes = 6 // Inbox, Drafts, etc
if r, err := zip.NewReader(bytes.NewReader(maildirZip.Bytes()), int64(maildirZip.Len())); err != nil { if r, err := zip.NewReader(bytes.NewReader(maildirZip.Bytes()), int64(maildirZip.Len())); err != nil {

View File

@ -618,25 +618,42 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt
err = zw.Close() err = zw.Close()
log.Check(err, "final write to zip file") log.Check(err, "final write to zip file")
// Raw display of a message, as text/plain. // Raw display or download of a message, as text/plain.
case len(t) == 2 && t[1] == "raw": case len(t) == 2 && (t[1] == "raw" || t[1] == "rawdl"):
_, _, _, msgr, p, cleanup, ok := xprepare() _, _, m, msgr, p, cleanup, ok := xprepare()
if !ok { if !ok {
return return
} }
defer cleanup() defer cleanup()
headers(false, false, false, false)
// We intentially use text/plain. We certainly don't want to return a format that // 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 // 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 // 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... // not, there is not much we could do better...
headers(false, false, false, false)
ct := "text/plain" ct := "text/plain"
params := map[string]string{} 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 params["charset"] = charset
} }
h.Set("Content-Type", mime.FormatMediaType(ct, params)) 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") h.Set("Cache-Control", "no-store, max-age=0")
_, err := io.Copy(w, &moxio.AtReader{R: msgr}) _, err := io.Copy(w, &moxio.AtReader{R: msgr})

View File

@ -3806,6 +3806,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
} }
window.open('msg/' + m.ID + '/viewtext/' + [0, ...path].join('.'), '_blank'); window.open('msg/' + m.ID + '/viewtext/' + [0, ...path].join('.'), '_blank');
}; };
const cmdDownloadRaw = async () => { window.open('msg/' + m.ID + '/rawdl', '_blank'); };
const cmdViewAttachments = async () => { const cmdViewAttachments = async () => {
if (attachments.length > 0) { if (attachments.length > 0) {
view(attachments[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('Mute thread', clickCmd(msglistView.cmdMute, shortcuts)),
dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)), dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)),
dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, 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 raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)),
dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)), dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)),
dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, 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))); 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) { }), ' ', dom.clickbutton('Labels...', attr.title('Add/remove labels ...'), function click(e) {
labelsPopover(e, effselected.map(miv => miv.messageitem.Message), possibleLabels); 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(); setLocationHash();
}; };
@ -5471,11 +5478,37 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash,
}; };
return mlv; return mlv;
}; };
const popoverExport = (reference, mailboxName) => { // Export messages to maildir/mbox in tar/tgz/zip/no container. Either all
const removeExport = popover(reference, {}, dom.h1('Export ', mailboxName || 'all mailboxes'), dom.form(function submit() { // 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. // If we would remove the popup immediately, the form would be deleted too and never submitted.
window.setTimeout(() => removeExport(), 100); 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 newMailboxView = (xmb, mailboxlistView, otherMailbox) => {
const plusbox = '⊞'; const plusbox = '⊞';
@ -5559,8 +5592,8 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => {
await withStatus('Marking mailbox as special use', client.MailboxSetSpecialUse(mb)); 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; }); })))); 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() { })), dom.div(dom.clickbutton('Export as...', function click() {
popoverExport(actionBtn, mbv.mailbox.Name); popoverExport(actionBtn, mbv.mailbox.Name, null);
remove(); 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')))); }, fieldset = dom.fieldset(dom.label('Name ', name = dom.input(attr.required('yes'), focusPlaceholder('Lists/Go/Nuts'))), ' ', dom.submitbutton('Create'))));
remove(); remove();
name.focus(); name.focus();
})), dom.div(dom.clickbutton('Export', function click(e) { })), dom.div(dom.clickbutton('Export as...', function click(e) {
const ref = e.target; const ref = e.target;
popoverExport(ref, ''); popoverExport(ref, '', null);
remove(); remove();
})))); }))));
})), mailboxesElem)); })), mailboxesElem));

View File

@ -3059,6 +3059,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
} }
window.open('msg/'+m.ID+'/viewtext/'+[0, ...path].join('.'), '_blank') window.open('msg/'+m.ID+'/viewtext/'+[0, ...path].join('.'), '_blank')
} }
const cmdDownloadRaw = async () => { window.open('msg/'+m.ID+'/rawdl', '_blank') }
const cmdViewAttachments = async () => { const cmdViewAttachments = async () => {
if (attachments.length > 0) { if (attachments.length > 0) {
view(attachments[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('Mute thread', clickCmd(msglistView.cmdMute, shortcuts)),
dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)), dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)),
dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, 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 raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)),
dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)), dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)),
dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, 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 Read', clickCmd(cmdMarkRead, shortcuts)), ' ',
dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ', dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ',
dom.clickbutton('Mute thread', clickCmd(cmdMute, 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 setKeywords: (keywords: string[]) => void
} }
const popoverExport = (reference: HTMLElement, mailboxName: string) => { // 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, {}, const removeExport = popover(reference, {},
dom.h1('Export ', mailboxName || 'all mailboxes'), dom.h1('Export'),
dom.form( dom.form(
function submit() { function submit() {
// If we would remove the popup immediately, the form would be deleted too and never submitted. // 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'), 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('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')),
dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), 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'}), 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.div(
dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', !mailboxName && !messageIDs ? 'Mbox ' : mboxbtn=dom.submitbutton('Mbox', attr.title('Export as mbox file, not wrapped in an archive.'), function click() {
dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox'), 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.div(
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ', 'Maildir ',
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ', dom.submitbutton('zip', function click() {
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ', format.value = 'maildir'
dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None'), 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.div(
dom.clickbutton('Export', function click() { dom.clickbutton('Export as...', function click() {
popoverExport(actionBtn, mbv.mailbox.Name) popoverExport(actionBtn, mbv.mailbox.Name, null)
remove() remove()
}), }),
), ),
@ -5609,9 +5648,9 @@ const newMailboxlistView = (msglistView: MsglistView, requestNewView: requestNew
}), }),
), ),
dom.div( dom.div(
dom.clickbutton('Export', function click(e: MouseEvent) { dom.clickbutton('Export as...', function click(e: MouseEvent) {
const ref = e.target! as HTMLElement const ref = e.target! as HTMLElement
popoverExport(ref, '') popoverExport(ref, '', null)
remove() remove()
}), }),
), ),

View File

@ -185,7 +185,7 @@ var (
msgText = Message{ msgText = Message{
From: "mjl <mjl@mox.example>", From: "mjl <mjl@mox.example>",
To: "mox <mox@other.example>", To: "mox <mox@other.example>",
Subject: "text message", Subject: "text message",
Part: Part{Type: "text/plain; charset=utf-8", Content: "the body"}, Part: Part{Type: "text/plain; charset=utf-8", Content: "the body"},
} }
msgHTML = Message{ msgHTML = Message{
@ -383,6 +383,8 @@ func TestWebmail(t *testing.T) {
ctTextNoCharset := [2]string{"Content-Type", "text/plain"} ctTextNoCharset := [2]string{"Content-Type", "text/plain"}
ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"} ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"}
ctJSON := [2]string{"Content-Type", "application/json; 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} cookieOK := &http.Cookie{Name: "webmailsession", Value: sessionCookie.Value}
cookieBad := &http.Cookie{Name: "webmailsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"} 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) testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil) testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
testHTTPAuthREST("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, 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 // HTTP message: parsedmessage.js
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil) testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil)

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"mime" "mime"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -23,7 +24,25 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
return return
} }
// We
mailbox := r.FormValue("mailbox") // Empty means all. mailbox := r.FormValue("mailbox") // Empty means all.
messageIDstr := r.FormValue("messageids")
var messageIDs []int64
if messageIDstr != "" {
for _, s := range strings.Split(messageIDstr, ",") {
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
http.Error(w, fmt.Sprintf("400 - bad request - bad message id %q: %v", s, err), http.StatusBadRequest)
return
}
messageIDs = append(messageIDs, id)
}
}
if mailbox != "" && len(messageIDs) > 0 {
http.Error(w, "400 - bad request - cannot specify both mailbox and message ids", http.StatusBadRequest)
return
}
format := r.FormValue("format") format := r.FormValue("format")
archive := r.FormValue("archive") archive := r.FormValue("archive")
recursive := r.FormValue("recursive") != "" recursive := r.FormValue("recursive") != ""
@ -43,6 +62,10 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
http.Error(w, "400 - bad request - archive none can only be used with non-recursive mbox", http.StatusBadRequest) http.Error(w, "400 - bad request - archive none can only be used with non-recursive mbox", http.StatusBadRequest)
return return
} }
if len(messageIDs) > 0 && recursive {
http.Error(w, "400 - bad request - cannot export message ids recursively", http.StatusBadRequest)
return
}
acc, err := store.OpenAccount(log, accName, false) acc, err := store.OpenAccount(log, accName, false)
if err != nil { if err != nil {
@ -55,11 +78,15 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
log.Check(err, "closing account") log.Check(err, "closing account")
}() }()
name := strings.ReplaceAll(mailbox, "/", "-") var name string
if name == "" { if mailbox != "" {
name = "all" name = "-" + strings.ReplaceAll(mailbox, "/", "-")
} else if len(messageIDs) > 1 {
name = "-selection"
} else if len(messageIDs) == 0 {
name = "-all"
} }
filename := fmt.Sprintf("mailexport-%s-%s", name, time.Now().Format("20060102-150405")) filename := fmt.Sprintf("mailexport%s-%s", name, time.Now().Format("20060102-150405"))
filename += "." + format filename += "." + format
var archiver store.Archiver var archiver store.Archiver
if archive == "none" { if archive == "none" {
@ -90,7 +117,7 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
log.Check(err, "exporting mail close") log.Check(err, "exporting mail close")
}() }()
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
if err := store.ExportMessages(r.Context(), log, acc.DB, acc.Dir, archiver, format == "maildir", mailbox, recursive); err != nil { if err := store.ExportMessages(r.Context(), log, acc.DB, acc.Dir, archiver, format == "maildir", mailbox, messageIDs, recursive); err != nil {
log.Errorx("exporting mail", err) log.Errorx("exporting mail", err)
} }
} }