From a5d74eb7185ccf7fc4f578aed9830473b3837312 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien <mechiel@ueber.net> Date: Sat, 29 Mar 2025 18:10:23 +0100 Subject: [PATCH] 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. --- export.go | 2 +- store/export.go | 770 ++++++++++++++++++++++------------------ store/export_test.go | 21 +- webmail/webmail.go | 27 +- webmail/webmail.js | 49 ++- webmail/webmail.ts | 71 +++- webmail/webmail_test.go | 8 +- webops/export.go | 37 +- 8 files changed, 591 insertions(+), 394 deletions(-) diff --git a/export.go b/export.go index 1e250b4..424c2f6 100644 --- a/export.go +++ b/export.go @@ -72,7 +72,7 @@ func xcmdExport(mbox, single bool, args []string, c *cmd) { }() 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") err = a.Close() xcheckf(err, "closing archiver") diff --git a/store/export.go b/store/export.go index 3152e88..4ab955a 100644 --- a/store/export.go +++ b/store/export.go @@ -122,16 +122,22 @@ func (a *MboxArchiver) Close() error { return nil } -// ExportMessages writes messages to archiver. Either in maildir format, or otherwise in -// mbox. If mailboxOpt is empty, all mailboxes are exported, otherwise only the -// named mailbox. +// ExportMessages writes messages to archiver. Either in maildir format, or +// otherwise in mbox. If mailboxOpt is non-empty, all messages from that 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 // 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. -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). + 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 // want to deal with declaring many variables now to be able to assign them in a // closure and use them afterwards. @@ -153,33 +159,41 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir // continue with useless work. var errors string - // Process mailboxes sorted by name, so submaildirs come after their parent. - prefix := mailboxOpt + "/" - var trimPrefix string - if mailboxOpt != "" { - // If exporting a specific mailbox, trim its parent path from stored file names. - trimPrefix = mox.ParentMailboxName(mailboxOpt) + "/" - } - q := bstore.QueryTx[Mailbox](tx) - q.FilterEqual("Expunged", false) - q.FilterFn(func(mb Mailbox) bool { - return mailboxOpt == "" || mb.Name == mailboxOpt || recursive && strings.HasPrefix(mb.Name, prefix) - }) - q.SortAsc("Name") - err = q.ForEach(func(mb Mailbox) error { - mailboxName := mb.Name - if trimPrefix != "" { - mailboxName = strings.TrimPrefix(mailboxName, trimPrefix) - } - errmsgs, err := exportMailbox(log, tx, accountDir, mb.ID, mailboxName, archiver, maildir, start) + if messageIDsOpt != nil { + var err error + errors, err = exportMessages(log, tx, accountDir, messageIDsOpt, archiver, maildir, start) if err != nil { - return err + return fmt.Errorf("exporting messages: %v", err) + } + } else { + // Process mailboxes sorted by name, so submaildirs come after their parent. + prefix := mailboxOpt + "/" + var trimPrefix string + if mailboxOpt != "" { + // If exporting a specific mailbox, trim its parent path from stored file names. + trimPrefix = mox.ParentMailboxName(mailboxOpt) + "/" + } + q := bstore.QueryTx[Mailbox](tx) + q.FilterEqual("Expunged", false) + q.FilterFn(func(mb Mailbox) bool { + return mailboxOpt == "" || mb.Name == mailboxOpt || recursive && strings.HasPrefix(mb.Name, prefix) + }) + q.SortAsc("Name") + err = q.ForEach(func(mb Mailbox) error { + mailboxName := mb.Name + if trimPrefix != "" { + mailboxName = strings.TrimPrefix(mailboxName, trimPrefix) + } + errmsgs, err := exportMailbox(log, tx, accountDir, mb.ID, mailboxName, archiver, maildir, start) + if err != nil { + return err + } + errors += errmsgs + return nil + }) + if err != nil { + return fmt.Errorf("query mailboxes: %w", err) } - errors += errmsgs - return nil - }) - if err != nil { - return fmt.Errorf("query mailboxes: %w", err) } if errors != "" { @@ -201,337 +215,395 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir return nil } +func exportMessages(log mlog.Log, tx *bstore.Tx, accountDir string, messageIDs []int64, archiver Archiver, maildir bool, start time.Time) (string, error) { + mbe, err := newMailboxExport(log, "Export", accountDir, archiver, start, maildir) + if err != nil { + return "", err + } + defer mbe.Cleanup() + + 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) { - var errors string - - var mboxtmp *os.File - var mboxwriter *bufio.Writer - defer func() { - if mboxtmp != nil { - CloseRemoveTempFile(log, mboxtmp, "mbox") - } - }() - - // For dovecot-keyword-style flags not in standard maildir. - maildirFlags := map[string]int{} - var maildirFlaglist []string - maildirFlag := func(flag string) string { - i, ok := maildirFlags[flag] - if !ok { - if len(maildirFlags) >= 26 { - // Max 26 flag characters. - return "" - } - i = len(maildirFlags) - maildirFlags[flag] = i - maildirFlaglist = append(maildirFlaglist, flag) - } - return string(rune('a' + i)) - } - - finishMailbox := func() error { - if maildir { - if len(maildirFlags) == 0 { - return nil - } - - var b bytes.Buffer - 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 { - return fmt.Errorf("flush mbox writer: %v", err) - } - 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 - if m.Size == int64(len(m.MsgPrefix)) { - mr = io.NopCloser(bytes.NewReader(m.MsgPrefix)) - } else { - mf, err := os.Open(mp) - if err != nil { - errors += fmt.Sprintf("open message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err) - return nil - } - defer func() { - err := mf.Close() - log.Check(err, "closing message file after export") - }() - st, err := mf.Stat() - if err != nil { - errors += fmt.Sprintf("stat message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err) - return nil - } - size := st.Size() + int64(len(m.MsgPrefix)) - 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) - } - mr = FileMsgReader(m.MsgPrefix, mf) - } - - if maildir { - p := mailboxName - if m.Flags.Seen { - p = filepath.Join(p, "cur") - } else { - p = filepath.Join(p, "new") - } - name := fmt.Sprintf("%d.%d.mox:2,", m.Received.Unix(), m.ID) - - // Standard flags. May need to be sorted. - if m.Flags.Draft { - name += "D" - } - if m.Flags.Flagged { - name += "F" - } - if m.Flags.Answered { - name += "R" - } - if m.Flags.Seen { - name += "S" - } - if m.Flags.Deleted { - name += "T" - } - - // Non-standard flag. We set them with a dovecot-keywords file. - if m.Flags.Forwarded { - name += maildirFlag("$Forwarded") - } - if m.Flags.Junk { - name += maildirFlag("$Junk") - } - if m.Flags.Notjunk { - name += maildirFlag("$NotJunk") - } - if m.Flags.Phishing { - name += maildirFlag("$Phishing") - } - if m.Flags.MDNSent { - name += maildirFlag("$MDNSent") - } - - p = filepath.Join(p, name) - - // We store messages with \r\n, maildir needs without. But we need to know the - // final size. So first convert, then create file with size, and write from buffer. - // todo: for large messages, we should go through a temporary file instead of memory. - var dst bytes.Buffer - r := bufio.NewReader(mr) - for { - line, rerr := r.ReadBytes('\n') - if rerr != io.EOF && rerr != nil { - errors += fmt.Sprintf("reading from message for id %d: %v (message skipped)\n", m.ID, rerr) - return nil - } - if len(line) > 0 { - if bytes.HasSuffix(line, []byte("\r\n")) { - line = line[:len(line)-1] - line[len(line)-1] = '\n' - } - if _, err := dst.Write(line); err != nil { - return fmt.Errorf("writing message: %v", err) - } - } - if rerr == io.EOF { - break - } - } - size := int64(dst.Len()) - w, err := archiver.Create(p, size, m.Received) - if err != nil { - return fmt.Errorf("adding message to archive: %v", err) - } - if _, err := io.Copy(w, &dst); err != nil { - xerr := w.Close() - log.Check(xerr, "closing message") - return fmt.Errorf("copying message to archive: %v", err) - } - return w.Close() - } - - mailfrom := "mox" - if m.MailFrom != "" { - mailfrom = m.MailFrom - } - // ../rfc/4155:80 - if _, err := fmt.Fprintf(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) - } - - // Write message flags in the three headers that mbox consumers may (or may not) understand. - if m.Seen { - if _, err := fmt.Fprintf(mboxwriter, "Status: R\n"); err != nil { - return fmt.Errorf("writing status header: %v", err) - } - } - xstatus := "" - if m.Answered { - xstatus += "A" - } - if m.Flagged { - xstatus += "F" - } - if m.Draft { - xstatus += "T" - } - if m.Deleted { - xstatus += "D" - } - if xstatus != "" { - if _, err := fmt.Fprintf(mboxwriter, "X-Status: %s\n", xstatus); err != nil { - return fmt.Errorf("writing x-status header: %v", err) - } - } - var xkeywords []string - if m.Forwarded { - xkeywords = append(xkeywords, "$Forwarded") - } - if m.Junk && !m.Notjunk { - xkeywords = append(xkeywords, "$Junk") - } - if m.Notjunk && !m.Junk { - xkeywords = append(xkeywords, "$NotJunk") - } - if m.Phishing { - xkeywords = append(xkeywords, "$Phishing") - } - if m.MDNSent { - xkeywords = append(xkeywords, "$MDNSent") - } - if len(xkeywords) > 0 { - if _, err := fmt.Fprintf(mboxwriter, "X-Keywords: %s\n", strings.Join(xkeywords, ",")); err != nil { - return fmt.Errorf("writing x-keywords header: %v", err) - } - } - - // ../rfc/4155:365 todo: rewrite messages to be 7-bit. still useful nowadays? - - header := true - r := bufio.NewReader(mr) - for { - line, rerr := r.ReadBytes('\n') - if rerr != io.EOF && rerr != nil { - return fmt.Errorf("reading message: %v", rerr) - } - if len(line) > 0 { - // ../rfc/4155:354 - if bytes.HasSuffix(line, []byte("\r\n")) { - line = line[:len(line)-1] - line[len(line)-1] = '\n' - } - if header && len(line) == 1 { - header = false - } - if header { - // Skip any previously stored flag-holding or now incorrect content-length headers. - // This assumes these headers are just a single line. - switch strings.ToLower(string(bytes.SplitN(line, []byte(":"), 2)[0])) { - case "status", "x-status", "x-keywords", "content-length": - continue - } - } - // ../rfc/4155:119 - if bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) { - if _, err := fmt.Fprint(mboxwriter, ">"); err != nil { - return fmt.Errorf("writing escaping >: %v", err) - } - } - if _, err := mboxwriter.Write(line); err != nil { - return fmt.Errorf("writing line: %v", err) - } - } - if rerr == io.EOF { - break - } - } - // ../rfc/4155:75 - if _, err := fmt.Fprint(mboxwriter, "\n"); err != nil { - return fmt.Errorf("writing end of message newline: %v", err) - } - return nil - } - - if maildir { - // Create the directories that show this is a maildir. - if _, err := archiver.Create(mailboxName+"/new/", 0, start); err != nil { - return errors, fmt.Errorf("adding maildir new directory: %v", err) - } - 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) + 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 exportMessage(m) + err = q.ForEach(func(m Message) error { + return mbe.ExportMessage(m) }) if err != nil { - return errors, err + return mbe.errors, err } - if err := finishMailbox(); err != nil { - return errors, err + err = mbe.Finish() + return mbe.errors, err +} + +// For dovecot-keyword-style flags not in standard maildir. +type maildirFlags struct { + Map map[string]int + List []string +} + +func newMaildirFlags() *maildirFlags { + return &maildirFlags{map[string]int{}, nil} +} + +func (f *maildirFlags) Flag(flag string) string { + i, ok := f.Map[flag] + if !ok { + if len(f.Map) >= 26 { + // Max 26 flag characters. + return "" + } + i = len(f.Map) + f.Map[flag] = i + f.List = append(f.List, flag) + } + return string(rune('a' + i)) +} + +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 { + // Create the directories that show this is a maildir. + 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) } - return errors, nil + return &mbe, nil +} + +func (e *mailboxExport) ExportMessage(m Message) error { + mp := filepath.Join(e.accountDir, "msg", MessagePath(m.ID)) + var mr io.ReadCloser + if m.Size == int64(len(m.MsgPrefix)) { + mr = io.NopCloser(bytes.NewReader(m.MsgPrefix)) + } else { + mf, err := os.Open(mp) + if err != nil { + e.errors += fmt.Sprintf("open message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err) + return nil + } + defer func() { + err := mf.Close() + e.log.Check(err, "closing message file after export") + }() + st, err := mf.Stat() + if err != nil { + e.errors += fmt.Sprintf("stat message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err) + return nil + } + size := st.Size() + int64(len(m.MsgPrefix)) + if size != m.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) + } + + if e.maildir { + p := e.mailboxName + if m.Flags.Seen { + p = filepath.Join(p, "cur") + } else { + p = filepath.Join(p, "new") + } + name := fmt.Sprintf("%d.%d.mox:2,", m.Received.Unix(), m.ID) + + // Standard flags. May need to be sorted. + if m.Flags.Draft { + name += "D" + } + if m.Flags.Flagged { + name += "F" + } + if m.Flags.Answered { + name += "R" + } + if m.Flags.Seen { + name += "S" + } + if m.Flags.Deleted { + name += "T" + } + + // Non-standard flag. We set them with a dovecot-keywords file. + if m.Flags.Forwarded { + name += e.maildirFlags.Flag("$Forwarded") + } + if m.Flags.Junk { + name += e.maildirFlags.Flag("$Junk") + } + if m.Flags.Notjunk { + name += e.maildirFlags.Flag("$NotJunk") + } + if m.Flags.Phishing { + name += e.maildirFlags.Flag("$Phishing") + } + if m.Flags.MDNSent { + name += e.maildirFlags.Flag("$MDNSent") + } + + p = filepath.Join(p, name) + + // We store messages with \r\n, maildir needs without. But we need to know the + // final size. So first convert, then create file with size, and write from buffer. + // todo: for large messages, we should go through a temporary file instead of memory. + var dst bytes.Buffer + r := bufio.NewReader(mr) + for { + line, rerr := r.ReadBytes('\n') + if rerr != io.EOF && rerr != nil { + e.errors += fmt.Sprintf("reading from message for id %d: %v (message skipped)\n", m.ID, rerr) + return nil + } + if len(line) > 0 { + if bytes.HasSuffix(line, []byte("\r\n")) { + line = line[:len(line)-1] + line[len(line)-1] = '\n' + } + if _, err := dst.Write(line); err != nil { + return fmt.Errorf("writing message: %v", err) + } + } + if rerr == io.EOF { + break + } + } + size := int64(dst.Len()) + w, err := e.archiver.Create(p, size, m.Received) + if err != nil { + return fmt.Errorf("adding message to archive: %v", err) + } + if _, err := io.Copy(w, &dst); err != nil { + xerr := w.Close() + e.log.Check(xerr, "closing message") + return fmt.Errorf("copying message to archive: %v", err) + } + return w.Close() + } + + mailfrom := "mox" + if m.MailFrom != "" { + mailfrom = m.MailFrom + } + // ../rfc/4155:80 + 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) + } + + // Write message flags in the three headers that mbox consumers may (or may not) understand. + if m.Seen { + if _, err := fmt.Fprintf(e.mboxwriter, "Status: R\n"); err != nil { + return fmt.Errorf("writing status header: %v", err) + } + } + xstatus := "" + if m.Answered { + xstatus += "A" + } + if m.Flagged { + xstatus += "F" + } + if m.Draft { + xstatus += "T" + } + if m.Deleted { + xstatus += "D" + } + if xstatus != "" { + if _, err := fmt.Fprintf(e.mboxwriter, "X-Status: %s\n", xstatus); err != nil { + return fmt.Errorf("writing x-status header: %v", err) + } + } + var xkeywords []string + if m.Forwarded { + xkeywords = append(xkeywords, "$Forwarded") + } + if m.Junk && !m.Notjunk { + xkeywords = append(xkeywords, "$Junk") + } + if m.Notjunk && !m.Junk { + xkeywords = append(xkeywords, "$NotJunk") + } + if m.Phishing { + xkeywords = append(xkeywords, "$Phishing") + } + if m.MDNSent { + xkeywords = append(xkeywords, "$MDNSent") + } + if len(xkeywords) > 0 { + if _, err := fmt.Fprintf(e.mboxwriter, "X-Keywords: %s\n", strings.Join(xkeywords, ",")); err != nil { + return fmt.Errorf("writing x-keywords header: %v", err) + } + } + + // ../rfc/4155:365 todo: rewrite messages to be 7-bit. still useful nowadays? + + header := true + r := bufio.NewReader(mr) + for { + line, rerr := r.ReadBytes('\n') + if rerr != io.EOF && rerr != nil { + return fmt.Errorf("reading message: %v", rerr) + } + if len(line) > 0 { + // ../rfc/4155:354 + if bytes.HasSuffix(line, []byte("\r\n")) { + line = line[:len(line)-1] + line[len(line)-1] = '\n' + } + if header && len(line) == 1 { + header = false + } + if header { + // Skip any previously stored flag-holding or now incorrect content-length headers. + // This assumes these headers are just a single line. + switch strings.ToLower(string(bytes.SplitN(line, []byte(":"), 2)[0])) { + case "status", "x-status", "x-keywords", "content-length": + continue + } + } + // ../rfc/4155:119 + if bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) { + if _, err := fmt.Fprint(e.mboxwriter, ">"); err != nil { + return fmt.Errorf("writing escaping >: %v", err) + } + } + if _, err := e.mboxwriter.Write(line); err != nil { + return fmt.Errorf("writing line: %v", err) + } + } + if rerr == io.EOF { + break + } + } + // ../rfc/4155:75 + if _, err := fmt.Fprint(e.mboxwriter, "\n"); err != nil { + return fmt.Errorf("writing end of message newline: %v", err) + } + return nil +} + +func (e *mailboxExport) Finish() error { + if e.maildir { + if e.maildirFlags.Empty() { + return nil + } + + var b bytes.Buffer + for i, flag := range e.maildirFlags.List { + if _, err := fmt.Fprintf(&b, "%d %s\n", i, flag); err != nil { + return 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() + } + + 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 } diff --git a/store/export_test.go b/store/export_test.go index 951092b..25c2a92 100644 --- a/store/export_test.go +++ b/store/export_test.go @@ -41,8 +41,9 @@ func TestExport(t *testing.T) { _, err = msgFile.Write([]byte(msg)) tcheck(t, err, "write message") + var m Message 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) tcheck(t, err, "deliver") @@ -53,9 +54,9 @@ func TestExport(t *testing.T) { 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() - 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") err = archiver.Close() tcheck(t, err, "archiver close") @@ -64,12 +65,14 @@ func TestExport(t *testing.T) { os.RemoveAll("../testdata/exportmaildir") os.RemoveAll("../testdata/exportmbox") - archive(ZipArchiver{zip.NewWriter(&maildirZip)}, true) - archive(ZipArchiver{zip.NewWriter(&mboxZip)}, false) - archive(TarArchiver{tar.NewWriter(&maildirTar)}, true) - archive(TarArchiver{tar.NewWriter(&mboxTar)}, false) - archive(DirArchiver{filepath.FromSlash("../testdata/exportmaildir")}, true) - archive(DirArchiver{filepath.FromSlash("../testdata/exportmbox")}, false) + archive(ZipArchiver{zip.NewWriter(&maildirZip)}, "", nil, true) + archive(ZipArchiver{zip.NewWriter(&mboxZip)}, "", nil, false) + archive(TarArchiver{tar.NewWriter(&maildirTar)}, "", nil, true) + archive(TarArchiver{tar.NewWriter(&mboxTar)}, "", nil, false) + archive(TarArchiver{tar.NewWriter(&mboxTar)}, "Inbox", nil, 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 if r, err := zip.NewReader(bytes.NewReader(maildirZip.Bytes()), int64(maildirZip.Len())); err != nil { diff --git a/webmail/webmail.go b/webmail/webmail.go index 43cf6a3..44433a2 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -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}) diff --git a/webmail/webmail.js b/webmail/webmail.js index 70a770e..1b70055 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -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)); diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 0118356..63057c0 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -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() }), ), diff --git a/webmail/webmail_test.go b/webmail/webmail_test.go index 56485b9..fbab9e3 100644 --- a/webmail/webmail_test.go +++ b/webmail/webmail_test.go @@ -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) diff --git a/webops/export.go b/webops/export.go index c69f659..45adde8 100644 --- a/webops/export.go +++ b/webops/export.go @@ -7,6 +7,7 @@ import ( "fmt" "mime" "net/http" + "strconv" "strings" "time" @@ -23,7 +24,25 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request return } + // We 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") archive := r.FormValue("archive") 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) 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) 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") }() - name := strings.ReplaceAll(mailbox, "/", "-") - if name == "" { - name = "all" + var name string + if mailbox != "" { + 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 var archiver store.Archiver 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") }() 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) } }