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,33 +159,41 @@ 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
// Process mailboxes sorted by name, so submaildirs come after their parent. if messageIDsOpt != nil {
prefix := mailboxOpt + "/" var err error
var trimPrefix string errors, err = exportMessages(log, tx, accountDir, messageIDsOpt, archiver, maildir, start)
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 { 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 != "" { if errors != "" {
@ -201,337 +215,395 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir
return nil 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) { 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 mbe, err := newMailboxExport(log, mailboxName, accountDir, archiver, start, maildir)
if err != nil {
var mboxtmp *os.File return "", err
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)
} }
defer mbe.Cleanup()
// Fetch all messages for mailbox. // Fetch all messages for mailbox.
q := bstore.QueryTx[Message](tx) q := bstore.QueryTx[Message](tx)
q.FilterNonzero(Message{MailboxID: mailboxID}) q.FilterNonzero(Message{MailboxID: mailboxID})
q.FilterEqual("Expunged", false) q.FilterEqual("Expunged", false)
q.SortAsc("Received", "ID") q.SortAsc("Received", "ID")
err := q.ForEach(func(m Message) error { err = q.ForEach(func(m Message) error {
return exportMessage(m) return mbe.ExportMessage(m)
}) })
if err != nil { if err != nil {
return errors, err return mbe.errors, err
} }
if err := finishMailbox(); err != nil { err = mbe.Finish()
return errors, err 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
} }

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
const removeExport=popover(reference, {}, // messages, messages in from 1 mailbox, or explicit message ids.
dom.h1('Export ', mailboxName || 'all mailboxes'), 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( 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)
} }
} }