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