diff --git a/export.go b/export.go
index 1e250b4..424c2f6 100644
--- a/export.go
+++ b/export.go
@@ -72,7 +72,7 @@ func xcmdExport(mbox, single bool, args []string, c *cmd) {
 	}()
 
 	a := store.DirArchiver{Dir: dst}
-	err = store.ExportMessages(context.Background(), c.log, db, accountDir, a, !mbox, mailbox, !single)
+	err = store.ExportMessages(context.Background(), c.log, db, accountDir, a, !mbox, mailbox, nil, !single)
 	xcheckf(err, "exporting messages")
 	err = a.Close()
 	xcheckf(err, "closing archiver")
diff --git a/store/export.go b/store/export.go
index 3152e88..4ab955a 100644
--- a/store/export.go
+++ b/store/export.go
@@ -122,16 +122,22 @@ func (a *MboxArchiver) Close() error {
 	return nil
 }
 
-// ExportMessages writes messages to archiver. Either in maildir format, or otherwise in
-// mbox. If mailboxOpt is empty, all mailboxes are exported, otherwise only the
-// named mailbox.
+// ExportMessages writes messages to archiver. Either in maildir format, or
+// otherwise in mbox. If mailboxOpt is non-empty, all messages from that mailbox
+// are exported. If messageIDsOpt is non-empty, only those message IDs are exported.
+// If both are empty, all mailboxes and all messages are exported. mailboxOpt
+// and messageIDsOpt cannot both be non-empty.
 //
 // Some errors are not fatal and result in skipped messages. In that happens, a
 // file "errors.txt" is added to the archive describing the errors. The goal is to
 // let users export (hopefully) most messages even in the face of errors.
-func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir string, archiver Archiver, maildir bool, mailboxOpt string, recursive bool) error {
+func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir string, archiver Archiver, maildir bool, mailboxOpt string, messageIDsOpt []int64, recursive bool) error {
 	// todo optimize: should prepare next file to add to archive (can be an mbox with many messages) while writing a file to the archive (which typically compresses, which takes time).
 
+	if mailboxOpt != "" && len(messageIDsOpt) != 0 {
+		return fmt.Errorf("cannot have both mailbox and message ids")
+	}
+
 	// Start transaction without closure, we are going to close it early, but don't
 	// want to deal with declaring many variables now to be able to assign them in a
 	// closure and use them afterwards.
@@ -153,33 +159,41 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir
 	// continue with useless work.
 	var errors string
 
-	// Process mailboxes sorted by name, so submaildirs come after their parent.
-	prefix := mailboxOpt + "/"
-	var trimPrefix string
-	if mailboxOpt != "" {
-		// If exporting a specific mailbox, trim its parent path from stored file names.
-		trimPrefix = mox.ParentMailboxName(mailboxOpt) + "/"
-	}
-	q := bstore.QueryTx[Mailbox](tx)
-	q.FilterEqual("Expunged", false)
-	q.FilterFn(func(mb Mailbox) bool {
-		return mailboxOpt == "" || mb.Name == mailboxOpt || recursive && strings.HasPrefix(mb.Name, prefix)
-	})
-	q.SortAsc("Name")
-	err = q.ForEach(func(mb Mailbox) error {
-		mailboxName := mb.Name
-		if trimPrefix != "" {
-			mailboxName = strings.TrimPrefix(mailboxName, trimPrefix)
-		}
-		errmsgs, err := exportMailbox(log, tx, accountDir, mb.ID, mailboxName, archiver, maildir, start)
+	if messageIDsOpt != nil {
+		var err error
+		errors, err = exportMessages(log, tx, accountDir, messageIDsOpt, archiver, maildir, start)
 		if err != nil {
-			return err
+			return fmt.Errorf("exporting messages: %v", err)
+		}
+	} else {
+		// Process mailboxes sorted by name, so submaildirs come after their parent.
+		prefix := mailboxOpt + "/"
+		var trimPrefix string
+		if mailboxOpt != "" {
+			// If exporting a specific mailbox, trim its parent path from stored file names.
+			trimPrefix = mox.ParentMailboxName(mailboxOpt) + "/"
+		}
+		q := bstore.QueryTx[Mailbox](tx)
+		q.FilterEqual("Expunged", false)
+		q.FilterFn(func(mb Mailbox) bool {
+			return mailboxOpt == "" || mb.Name == mailboxOpt || recursive && strings.HasPrefix(mb.Name, prefix)
+		})
+		q.SortAsc("Name")
+		err = q.ForEach(func(mb Mailbox) error {
+			mailboxName := mb.Name
+			if trimPrefix != "" {
+				mailboxName = strings.TrimPrefix(mailboxName, trimPrefix)
+			}
+			errmsgs, err := exportMailbox(log, tx, accountDir, mb.ID, mailboxName, archiver, maildir, start)
+			if err != nil {
+				return err
+			}
+			errors += errmsgs
+			return nil
+		})
+		if err != nil {
+			return fmt.Errorf("query mailboxes: %w", err)
 		}
-		errors += errmsgs
-		return nil
-	})
-	if err != nil {
-		return fmt.Errorf("query mailboxes: %w", err)
 	}
 
 	if errors != "" {
@@ -201,337 +215,395 @@ func ExportMessages(ctx context.Context, log mlog.Log, db *bstore.DB, accountDir
 	return nil
 }
 
+func exportMessages(log mlog.Log, tx *bstore.Tx, accountDir string, messageIDs []int64, archiver Archiver, maildir bool, start time.Time) (string, error) {
+	mbe, err := newMailboxExport(log, "Export", accountDir, archiver, start, maildir)
+	if err != nil {
+		return "", err
+	}
+	defer mbe.Cleanup()
+
+	for _, id := range messageIDs {
+		m := Message{ID: id}
+		if err := tx.Get(&m); err != nil {
+			mbe.errors += fmt.Sprintf("get message with id %d: %v\n", id, err)
+			continue
+		} else if m.Expunged {
+			mbe.errors += fmt.Sprintf("message with id %d is expunged\n", id)
+			continue
+		}
+		if err := mbe.ExportMessage(m); err != nil {
+			return mbe.errors, err
+		}
+	}
+	err = mbe.Finish()
+	return mbe.errors, err
+}
+
 func exportMailbox(log mlog.Log, tx *bstore.Tx, accountDir string, mailboxID int64, mailboxName string, archiver Archiver, maildir bool, start time.Time) (string, error) {
-	var errors string
-
-	var mboxtmp *os.File
-	var mboxwriter *bufio.Writer
-	defer func() {
-		if mboxtmp != nil {
-			CloseRemoveTempFile(log, mboxtmp, "mbox")
-		}
-	}()
-
-	// For dovecot-keyword-style flags not in standard maildir.
-	maildirFlags := map[string]int{}
-	var maildirFlaglist []string
-	maildirFlag := func(flag string) string {
-		i, ok := maildirFlags[flag]
-		if !ok {
-			if len(maildirFlags) >= 26 {
-				// Max 26 flag characters.
-				return ""
-			}
-			i = len(maildirFlags)
-			maildirFlags[flag] = i
-			maildirFlaglist = append(maildirFlaglist, flag)
-		}
-		return string(rune('a' + i))
-	}
-
-	finishMailbox := func() error {
-		if maildir {
-			if len(maildirFlags) == 0 {
-				return nil
-			}
-
-			var b bytes.Buffer
-			for i, flag := range maildirFlaglist {
-				if _, err := fmt.Fprintf(&b, "%d %s\n", i, flag); err != nil {
-					return err
-				}
-			}
-			w, err := archiver.Create(mailboxName+"/dovecot-keywords", int64(b.Len()), start)
-			if err != nil {
-				return fmt.Errorf("adding dovecot-keywords: %v", err)
-			}
-			if _, err := w.Write(b.Bytes()); err != nil {
-				xerr := w.Close()
-				log.Check(xerr, "closing dovecot-keywords file after closing")
-				return fmt.Errorf("writing dovecot-keywords: %v", err)
-			}
-			maildirFlags = map[string]int{}
-			maildirFlaglist = nil
-			return w.Close()
-		}
-
-		if err := mboxwriter.Flush(); err != nil {
-			return fmt.Errorf("flush mbox writer: %v", err)
-		}
-		fi, err := mboxtmp.Stat()
-		if err != nil {
-			return fmt.Errorf("stat temporary mbox file: %v", err)
-		}
-		if _, err := mboxtmp.Seek(0, 0); err != nil {
-			return fmt.Errorf("seek to start of temporary mbox file")
-		}
-		w, err := archiver.Create(mailboxName+".mbox", fi.Size(), fi.ModTime())
-		if err != nil {
-			return fmt.Errorf("add mbox to archive: %v", err)
-		}
-		if _, err := io.Copy(w, mboxtmp); err != nil {
-			xerr := w.Close()
-			log.Check(xerr, "closing mbox message file after error")
-			return fmt.Errorf("copying temp mbox file to archive: %v", err)
-		}
-		if err := w.Close(); err != nil {
-			return fmt.Errorf("closing message file: %v", err)
-		}
-		name := mboxtmp.Name()
-		err = mboxtmp.Close()
-		log.Check(err, "closing temporary mbox file")
-		err = os.Remove(name)
-		log.Check(err, "removing temporary mbox file", slog.String("path", name))
-		mboxwriter = nil
-		mboxtmp = nil
-		return nil
-	}
-
-	exportMessage := func(m Message) error {
-		mp := filepath.Join(accountDir, "msg", MessagePath(m.ID))
-		var mr io.ReadCloser
-		if m.Size == int64(len(m.MsgPrefix)) {
-			mr = io.NopCloser(bytes.NewReader(m.MsgPrefix))
-		} else {
-			mf, err := os.Open(mp)
-			if err != nil {
-				errors += fmt.Sprintf("open message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err)
-				return nil
-			}
-			defer func() {
-				err := mf.Close()
-				log.Check(err, "closing message file after export")
-			}()
-			st, err := mf.Stat()
-			if err != nil {
-				errors += fmt.Sprintf("stat message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err)
-				return nil
-			}
-			size := st.Size() + int64(len(m.MsgPrefix))
-			if size != m.Size {
-				errors += fmt.Sprintf("message size mismatch for message id %d, database has %d, size is %d+%d=%d, using calculated size\n", m.ID, m.Size, len(m.MsgPrefix), st.Size(), size)
-			}
-			mr = FileMsgReader(m.MsgPrefix, mf)
-		}
-
-		if maildir {
-			p := mailboxName
-			if m.Flags.Seen {
-				p = filepath.Join(p, "cur")
-			} else {
-				p = filepath.Join(p, "new")
-			}
-			name := fmt.Sprintf("%d.%d.mox:2,", m.Received.Unix(), m.ID)
-
-			// Standard flags. May need to be sorted.
-			if m.Flags.Draft {
-				name += "D"
-			}
-			if m.Flags.Flagged {
-				name += "F"
-			}
-			if m.Flags.Answered {
-				name += "R"
-			}
-			if m.Flags.Seen {
-				name += "S"
-			}
-			if m.Flags.Deleted {
-				name += "T"
-			}
-
-			// Non-standard flag. We set them with a dovecot-keywords file.
-			if m.Flags.Forwarded {
-				name += maildirFlag("$Forwarded")
-			}
-			if m.Flags.Junk {
-				name += maildirFlag("$Junk")
-			}
-			if m.Flags.Notjunk {
-				name += maildirFlag("$NotJunk")
-			}
-			if m.Flags.Phishing {
-				name += maildirFlag("$Phishing")
-			}
-			if m.Flags.MDNSent {
-				name += maildirFlag("$MDNSent")
-			}
-
-			p = filepath.Join(p, name)
-
-			// We store messages with \r\n, maildir needs without. But we need to know the
-			// final size. So first convert, then create file with size, and write from buffer.
-			// todo: for large messages, we should go through a temporary file instead of memory.
-			var dst bytes.Buffer
-			r := bufio.NewReader(mr)
-			for {
-				line, rerr := r.ReadBytes('\n')
-				if rerr != io.EOF && rerr != nil {
-					errors += fmt.Sprintf("reading from message for id %d: %v (message skipped)\n", m.ID, rerr)
-					return nil
-				}
-				if len(line) > 0 {
-					if bytes.HasSuffix(line, []byte("\r\n")) {
-						line = line[:len(line)-1]
-						line[len(line)-1] = '\n'
-					}
-					if _, err := dst.Write(line); err != nil {
-						return fmt.Errorf("writing message: %v", err)
-					}
-				}
-				if rerr == io.EOF {
-					break
-				}
-			}
-			size := int64(dst.Len())
-			w, err := archiver.Create(p, size, m.Received)
-			if err != nil {
-				return fmt.Errorf("adding message to archive: %v", err)
-			}
-			if _, err := io.Copy(w, &dst); err != nil {
-				xerr := w.Close()
-				log.Check(xerr, "closing message")
-				return fmt.Errorf("copying message to archive: %v", err)
-			}
-			return w.Close()
-		}
-
-		mailfrom := "mox"
-		if m.MailFrom != "" {
-			mailfrom = m.MailFrom
-		}
-		// ../rfc/4155:80
-		if _, err := fmt.Fprintf(mboxwriter, "From %s %s\n", mailfrom, m.Received.Format(time.ANSIC)); err != nil {
-			return fmt.Errorf("write message line to mbox temp file: %v", err)
-		}
-
-		// Write message flags in the three headers that mbox consumers may (or may not) understand.
-		if m.Seen {
-			if _, err := fmt.Fprintf(mboxwriter, "Status: R\n"); err != nil {
-				return fmt.Errorf("writing status header: %v", err)
-			}
-		}
-		xstatus := ""
-		if m.Answered {
-			xstatus += "A"
-		}
-		if m.Flagged {
-			xstatus += "F"
-		}
-		if m.Draft {
-			xstatus += "T"
-		}
-		if m.Deleted {
-			xstatus += "D"
-		}
-		if xstatus != "" {
-			if _, err := fmt.Fprintf(mboxwriter, "X-Status: %s\n", xstatus); err != nil {
-				return fmt.Errorf("writing x-status header: %v", err)
-			}
-		}
-		var xkeywords []string
-		if m.Forwarded {
-			xkeywords = append(xkeywords, "$Forwarded")
-		}
-		if m.Junk && !m.Notjunk {
-			xkeywords = append(xkeywords, "$Junk")
-		}
-		if m.Notjunk && !m.Junk {
-			xkeywords = append(xkeywords, "$NotJunk")
-		}
-		if m.Phishing {
-			xkeywords = append(xkeywords, "$Phishing")
-		}
-		if m.MDNSent {
-			xkeywords = append(xkeywords, "$MDNSent")
-		}
-		if len(xkeywords) > 0 {
-			if _, err := fmt.Fprintf(mboxwriter, "X-Keywords: %s\n", strings.Join(xkeywords, ",")); err != nil {
-				return fmt.Errorf("writing x-keywords header: %v", err)
-			}
-		}
-
-		// ../rfc/4155:365 todo: rewrite messages to be 7-bit. still useful nowadays?
-
-		header := true
-		r := bufio.NewReader(mr)
-		for {
-			line, rerr := r.ReadBytes('\n')
-			if rerr != io.EOF && rerr != nil {
-				return fmt.Errorf("reading message: %v", rerr)
-			}
-			if len(line) > 0 {
-				// ../rfc/4155:354
-				if bytes.HasSuffix(line, []byte("\r\n")) {
-					line = line[:len(line)-1]
-					line[len(line)-1] = '\n'
-				}
-				if header && len(line) == 1 {
-					header = false
-				}
-				if header {
-					// Skip any previously stored flag-holding or now incorrect content-length headers.
-					// This assumes these headers are just a single line.
-					switch strings.ToLower(string(bytes.SplitN(line, []byte(":"), 2)[0])) {
-					case "status", "x-status", "x-keywords", "content-length":
-						continue
-					}
-				}
-				// ../rfc/4155:119
-				if bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) {
-					if _, err := fmt.Fprint(mboxwriter, ">"); err != nil {
-						return fmt.Errorf("writing escaping >: %v", err)
-					}
-				}
-				if _, err := mboxwriter.Write(line); err != nil {
-					return fmt.Errorf("writing line: %v", err)
-				}
-			}
-			if rerr == io.EOF {
-				break
-			}
-		}
-		// ../rfc/4155:75
-		if _, err := fmt.Fprint(mboxwriter, "\n"); err != nil {
-			return fmt.Errorf("writing end of message newline: %v", err)
-		}
-		return nil
-	}
-
-	if maildir {
-		// Create the directories that show this is a maildir.
-		if _, err := archiver.Create(mailboxName+"/new/", 0, start); err != nil {
-			return errors, fmt.Errorf("adding maildir new directory: %v", err)
-		}
-		if _, err := archiver.Create(mailboxName+"/cur/", 0, start); err != nil {
-			return errors, fmt.Errorf("adding maildir cur directory: %v", err)
-		}
-		if _, err := archiver.Create(mailboxName+"/tmp/", 0, start); err != nil {
-			return errors, fmt.Errorf("adding maildir tmp directory: %v", err)
-		}
-	} else {
-		var err error
-		mboxtmp, err = os.CreateTemp("", "mox-mail-export-mbox")
-		if err != nil {
-			return errors, fmt.Errorf("creating temp mbox file: %v", err)
-		}
-		mboxwriter = bufio.NewWriter(mboxtmp)
+	mbe, err := newMailboxExport(log, mailboxName, accountDir, archiver, start, maildir)
+	if err != nil {
+		return "", err
 	}
+	defer mbe.Cleanup()
 
 	// Fetch all messages for mailbox.
 	q := bstore.QueryTx[Message](tx)
 	q.FilterNonzero(Message{MailboxID: mailboxID})
 	q.FilterEqual("Expunged", false)
 	q.SortAsc("Received", "ID")
-	err := q.ForEach(func(m Message) error {
-		return exportMessage(m)
+	err = q.ForEach(func(m Message) error {
+		return mbe.ExportMessage(m)
 	})
 	if err != nil {
-		return errors, err
+		return mbe.errors, err
 	}
-	if err := finishMailbox(); err != nil {
-		return errors, err
+	err = mbe.Finish()
+	return mbe.errors, err
+}
+
+// For dovecot-keyword-style flags not in standard maildir.
+type maildirFlags struct {
+	Map  map[string]int
+	List []string
+}
+
+func newMaildirFlags() *maildirFlags {
+	return &maildirFlags{map[string]int{}, nil}
+}
+
+func (f *maildirFlags) Flag(flag string) string {
+	i, ok := f.Map[flag]
+	if !ok {
+		if len(f.Map) >= 26 {
+			// Max 26 flag characters.
+			return ""
+		}
+		i = len(f.Map)
+		f.Map[flag] = i
+		f.List = append(f.List, flag)
+	}
+	return string(rune('a' + i))
+}
+
+func (f *maildirFlags) Empty() bool {
+	return len(f.Map) == 0
+}
+
+type mailboxExport struct {
+	log          mlog.Log
+	mailboxName  string
+	accountDir   string
+	archiver     Archiver
+	start        time.Time
+	maildir      bool
+	maildirFlags *maildirFlags
+	mboxtmp      *os.File
+	mboxwriter   *bufio.Writer
+	errors       string
+}
+
+func (e *mailboxExport) Cleanup() {
+	if e.mboxtmp != nil {
+		CloseRemoveTempFile(e.log, e.mboxtmp, "mbox")
+	}
+}
+
+func newMailboxExport(log mlog.Log, mailboxName, accountDir string, archiver Archiver, start time.Time, maildir bool) (*mailboxExport, error) {
+	mbe := mailboxExport{
+		log:         log,
+		mailboxName: mailboxName,
+		accountDir:  accountDir,
+		archiver:    archiver,
+		start:       start,
+		maildir:     maildir,
+	}
+	if maildir {
+		// Create the directories that show this is a maildir.
+		mbe.maildirFlags = newMaildirFlags()
+		if _, err := archiver.Create(mailboxName+"/new/", 0, start); err != nil {
+			return nil, fmt.Errorf("adding maildir new directory: %v", err)
+		}
+		if _, err := archiver.Create(mailboxName+"/cur/", 0, start); err != nil {
+			return nil, fmt.Errorf("adding maildir cur directory: %v", err)
+		}
+		if _, err := archiver.Create(mailboxName+"/tmp/", 0, start); err != nil {
+			return nil, fmt.Errorf("adding maildir tmp directory: %v", err)
+		}
+	} else {
+		var err error
+		mbe.mboxtmp, err = os.CreateTemp("", "mox-mail-export-mbox")
+		if err != nil {
+			return nil, fmt.Errorf("creating temp mbox file: %v", err)
+		}
+		mbe.mboxwriter = bufio.NewWriter(mbe.mboxtmp)
 	}
 
-	return errors, nil
+	return &mbe, nil
+}
+
+func (e *mailboxExport) ExportMessage(m Message) error {
+	mp := filepath.Join(e.accountDir, "msg", MessagePath(m.ID))
+	var mr io.ReadCloser
+	if m.Size == int64(len(m.MsgPrefix)) {
+		mr = io.NopCloser(bytes.NewReader(m.MsgPrefix))
+	} else {
+		mf, err := os.Open(mp)
+		if err != nil {
+			e.errors += fmt.Sprintf("open message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err)
+			return nil
+		}
+		defer func() {
+			err := mf.Close()
+			e.log.Check(err, "closing message file after export")
+		}()
+		st, err := mf.Stat()
+		if err != nil {
+			e.errors += fmt.Sprintf("stat message file for id %d, path %s: %v (message skipped)\n", m.ID, mp, err)
+			return nil
+		}
+		size := st.Size() + int64(len(m.MsgPrefix))
+		if size != m.Size {
+			e.errors += fmt.Sprintf("message size mismatch for message id %d, database has %d, size is %d+%d=%d, using calculated size\n", m.ID, m.Size, len(m.MsgPrefix), st.Size(), size)
+		}
+		mr = FileMsgReader(m.MsgPrefix, mf)
+	}
+
+	if e.maildir {
+		p := e.mailboxName
+		if m.Flags.Seen {
+			p = filepath.Join(p, "cur")
+		} else {
+			p = filepath.Join(p, "new")
+		}
+		name := fmt.Sprintf("%d.%d.mox:2,", m.Received.Unix(), m.ID)
+
+		// Standard flags. May need to be sorted.
+		if m.Flags.Draft {
+			name += "D"
+		}
+		if m.Flags.Flagged {
+			name += "F"
+		}
+		if m.Flags.Answered {
+			name += "R"
+		}
+		if m.Flags.Seen {
+			name += "S"
+		}
+		if m.Flags.Deleted {
+			name += "T"
+		}
+
+		// Non-standard flag. We set them with a dovecot-keywords file.
+		if m.Flags.Forwarded {
+			name += e.maildirFlags.Flag("$Forwarded")
+		}
+		if m.Flags.Junk {
+			name += e.maildirFlags.Flag("$Junk")
+		}
+		if m.Flags.Notjunk {
+			name += e.maildirFlags.Flag("$NotJunk")
+		}
+		if m.Flags.Phishing {
+			name += e.maildirFlags.Flag("$Phishing")
+		}
+		if m.Flags.MDNSent {
+			name += e.maildirFlags.Flag("$MDNSent")
+		}
+
+		p = filepath.Join(p, name)
+
+		// We store messages with \r\n, maildir needs without. But we need to know the
+		// final size. So first convert, then create file with size, and write from buffer.
+		// todo: for large messages, we should go through a temporary file instead of memory.
+		var dst bytes.Buffer
+		r := bufio.NewReader(mr)
+		for {
+			line, rerr := r.ReadBytes('\n')
+			if rerr != io.EOF && rerr != nil {
+				e.errors += fmt.Sprintf("reading from message for id %d: %v (message skipped)\n", m.ID, rerr)
+				return nil
+			}
+			if len(line) > 0 {
+				if bytes.HasSuffix(line, []byte("\r\n")) {
+					line = line[:len(line)-1]
+					line[len(line)-1] = '\n'
+				}
+				if _, err := dst.Write(line); err != nil {
+					return fmt.Errorf("writing message: %v", err)
+				}
+			}
+			if rerr == io.EOF {
+				break
+			}
+		}
+		size := int64(dst.Len())
+		w, err := e.archiver.Create(p, size, m.Received)
+		if err != nil {
+			return fmt.Errorf("adding message to archive: %v", err)
+		}
+		if _, err := io.Copy(w, &dst); err != nil {
+			xerr := w.Close()
+			e.log.Check(xerr, "closing message")
+			return fmt.Errorf("copying message to archive: %v", err)
+		}
+		return w.Close()
+	}
+
+	mailfrom := "mox"
+	if m.MailFrom != "" {
+		mailfrom = m.MailFrom
+	}
+	// ../rfc/4155:80
+	if _, err := fmt.Fprintf(e.mboxwriter, "From %s %s\n", mailfrom, m.Received.Format(time.ANSIC)); err != nil {
+		return fmt.Errorf("write message line to mbox temp file: %v", err)
+	}
+
+	// Write message flags in the three headers that mbox consumers may (or may not) understand.
+	if m.Seen {
+		if _, err := fmt.Fprintf(e.mboxwriter, "Status: R\n"); err != nil {
+			return fmt.Errorf("writing status header: %v", err)
+		}
+	}
+	xstatus := ""
+	if m.Answered {
+		xstatus += "A"
+	}
+	if m.Flagged {
+		xstatus += "F"
+	}
+	if m.Draft {
+		xstatus += "T"
+	}
+	if m.Deleted {
+		xstatus += "D"
+	}
+	if xstatus != "" {
+		if _, err := fmt.Fprintf(e.mboxwriter, "X-Status: %s\n", xstatus); err != nil {
+			return fmt.Errorf("writing x-status header: %v", err)
+		}
+	}
+	var xkeywords []string
+	if m.Forwarded {
+		xkeywords = append(xkeywords, "$Forwarded")
+	}
+	if m.Junk && !m.Notjunk {
+		xkeywords = append(xkeywords, "$Junk")
+	}
+	if m.Notjunk && !m.Junk {
+		xkeywords = append(xkeywords, "$NotJunk")
+	}
+	if m.Phishing {
+		xkeywords = append(xkeywords, "$Phishing")
+	}
+	if m.MDNSent {
+		xkeywords = append(xkeywords, "$MDNSent")
+	}
+	if len(xkeywords) > 0 {
+		if _, err := fmt.Fprintf(e.mboxwriter, "X-Keywords: %s\n", strings.Join(xkeywords, ",")); err != nil {
+			return fmt.Errorf("writing x-keywords header: %v", err)
+		}
+	}
+
+	// ../rfc/4155:365 todo: rewrite messages to be 7-bit. still useful nowadays?
+
+	header := true
+	r := bufio.NewReader(mr)
+	for {
+		line, rerr := r.ReadBytes('\n')
+		if rerr != io.EOF && rerr != nil {
+			return fmt.Errorf("reading message: %v", rerr)
+		}
+		if len(line) > 0 {
+			// ../rfc/4155:354
+			if bytes.HasSuffix(line, []byte("\r\n")) {
+				line = line[:len(line)-1]
+				line[len(line)-1] = '\n'
+			}
+			if header && len(line) == 1 {
+				header = false
+			}
+			if header {
+				// Skip any previously stored flag-holding or now incorrect content-length headers.
+				// This assumes these headers are just a single line.
+				switch strings.ToLower(string(bytes.SplitN(line, []byte(":"), 2)[0])) {
+				case "status", "x-status", "x-keywords", "content-length":
+					continue
+				}
+			}
+			// ../rfc/4155:119
+			if bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) {
+				if _, err := fmt.Fprint(e.mboxwriter, ">"); err != nil {
+					return fmt.Errorf("writing escaping >: %v", err)
+				}
+			}
+			if _, err := e.mboxwriter.Write(line); err != nil {
+				return fmt.Errorf("writing line: %v", err)
+			}
+		}
+		if rerr == io.EOF {
+			break
+		}
+	}
+	// ../rfc/4155:75
+	if _, err := fmt.Fprint(e.mboxwriter, "\n"); err != nil {
+		return fmt.Errorf("writing end of message newline: %v", err)
+	}
+	return nil
+}
+
+func (e *mailboxExport) Finish() error {
+	if e.maildir {
+		if e.maildirFlags.Empty() {
+			return nil
+		}
+
+		var b bytes.Buffer
+		for i, flag := range e.maildirFlags.List {
+			if _, err := fmt.Fprintf(&b, "%d %s\n", i, flag); err != nil {
+				return err
+			}
+		}
+		w, err := e.archiver.Create(e.mailboxName+"/dovecot-keywords", int64(b.Len()), e.start)
+		if err != nil {
+			return fmt.Errorf("adding dovecot-keywords: %v", err)
+		}
+		if _, err := w.Write(b.Bytes()); err != nil {
+			xerr := w.Close()
+			e.log.Check(xerr, "closing dovecot-keywords file after closing")
+			return fmt.Errorf("writing dovecot-keywords: %v", err)
+		}
+		return w.Close()
+	}
+
+	if err := e.mboxwriter.Flush(); err != nil {
+		return fmt.Errorf("flush mbox writer: %v", err)
+	}
+	fi, err := e.mboxtmp.Stat()
+	if err != nil {
+		return fmt.Errorf("stat temporary mbox file: %v", err)
+	}
+	if _, err := e.mboxtmp.Seek(0, 0); err != nil {
+		return fmt.Errorf("seek to start of temporary mbox file")
+	}
+	w, err := e.archiver.Create(e.mailboxName+".mbox", fi.Size(), fi.ModTime())
+	if err != nil {
+		return fmt.Errorf("add mbox to archive: %v", err)
+	}
+	if _, err := io.Copy(w, e.mboxtmp); err != nil {
+		xerr := w.Close()
+		e.log.Check(xerr, "closing mbox message file after error")
+		return fmt.Errorf("copying temp mbox file to archive: %v", err)
+	}
+	if err := w.Close(); err != nil {
+		return fmt.Errorf("closing message file: %v", err)
+	}
+	name := e.mboxtmp.Name()
+	err = e.mboxtmp.Close()
+	e.log.Check(err, "closing temporary mbox file")
+	err = os.Remove(name)
+	e.log.Check(err, "removing temporary mbox file", slog.String("path", name))
+	e.mboxwriter = nil
+	e.mboxtmp = nil
+	return nil
 }
diff --git a/store/export_test.go b/store/export_test.go
index 951092b..25c2a92 100644
--- a/store/export_test.go
+++ b/store/export_test.go
@@ -41,8 +41,9 @@ func TestExport(t *testing.T) {
 	_, err = msgFile.Write([]byte(msg))
 	tcheck(t, err, "write message")
 
+	var m Message
 	acc.WithWLock(func() {
-		m := Message{Received: time.Now(), Size: int64(len(msg))}
+		m = Message{Received: time.Now(), Size: int64(len(msg))}
 		err = acc.DeliverMailbox(pkglog, "Inbox", &m, msgFile)
 		tcheck(t, err, "deliver")
 
@@ -53,9 +54,9 @@ func TestExport(t *testing.T) {
 
 	var maildirZip, maildirTar, mboxZip, mboxTar bytes.Buffer
 
-	archive := func(archiver Archiver, maildir bool) {
+	archive := func(archiver Archiver, mailbox string, messageIDs []int64, maildir bool) {
 		t.Helper()
-		err = ExportMessages(ctxbg, log, acc.DB, acc.Dir, archiver, maildir, "", true)
+		err = ExportMessages(ctxbg, log, acc.DB, acc.Dir, archiver, maildir, mailbox, messageIDs, true)
 		tcheck(t, err, "export messages")
 		err = archiver.Close()
 		tcheck(t, err, "archiver close")
@@ -64,12 +65,14 @@ func TestExport(t *testing.T) {
 	os.RemoveAll("../testdata/exportmaildir")
 	os.RemoveAll("../testdata/exportmbox")
 
-	archive(ZipArchiver{zip.NewWriter(&maildirZip)}, true)
-	archive(ZipArchiver{zip.NewWriter(&mboxZip)}, false)
-	archive(TarArchiver{tar.NewWriter(&maildirTar)}, true)
-	archive(TarArchiver{tar.NewWriter(&mboxTar)}, false)
-	archive(DirArchiver{filepath.FromSlash("../testdata/exportmaildir")}, true)
-	archive(DirArchiver{filepath.FromSlash("../testdata/exportmbox")}, false)
+	archive(ZipArchiver{zip.NewWriter(&maildirZip)}, "", nil, true)
+	archive(ZipArchiver{zip.NewWriter(&mboxZip)}, "", nil, false)
+	archive(TarArchiver{tar.NewWriter(&maildirTar)}, "", nil, true)
+	archive(TarArchiver{tar.NewWriter(&mboxTar)}, "", nil, false)
+	archive(TarArchiver{tar.NewWriter(&mboxTar)}, "Inbox", nil, false)
+	archive(TarArchiver{tar.NewWriter(&mboxTar)}, "", []int64{m.ID}, false)
+	archive(DirArchiver{filepath.FromSlash("../testdata/exportmaildir")}, "", nil, true)
+	archive(DirArchiver{filepath.FromSlash("../testdata/exportmbox")}, "", nil, false)
 
 	const defaultMailboxes = 6 // Inbox, Drafts, etc
 	if r, err := zip.NewReader(bytes.NewReader(maildirZip.Bytes()), int64(maildirZip.Len())); err != nil {
diff --git a/webmail/webmail.go b/webmail/webmail.go
index 43cf6a3..44433a2 100644
--- a/webmail/webmail.go
+++ b/webmail/webmail.go
@@ -618,25 +618,42 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt
 		err = zw.Close()
 		log.Check(err, "final write to zip file")
 
-	// Raw display of a message, as text/plain.
-	case len(t) == 2 && t[1] == "raw":
-		_, _, _, msgr, p, cleanup, ok := xprepare()
+	// Raw display or download of a message, as text/plain.
+	case len(t) == 2 && (t[1] == "raw" || t[1] == "rawdl"):
+		_, _, m, msgr, p, cleanup, ok := xprepare()
 		if !ok {
 			return
 		}
 		defer cleanup()
 
+		headers(false, false, false, false)
+
 		// We intentially use text/plain. We certainly don't want to return a format that
 		// browsers or users would think of executing. We do set the charset if available
 		// on the outer part. If present, we assume it may be relevant for other parts. If
 		// not, there is not much we could do better...
-		headers(false, false, false, false)
 		ct := "text/plain"
 		params := map[string]string{}
-		if charset := p.ContentTypeParams["charset"]; charset != "" {
+
+		if t[1] == "rawdl" {
+			ct = "message/rfc822"
+			if smtputf8, err := p.NeedsSMTPUTF8(); err != nil {
+				log.Errorx("checking for smtputf8 for content-type", err, slog.Int64("msgid", m.ID))
+				http.Error(w, "500 - server error - checking message for content-type: "+err.Error(), http.StatusInternalServerError)
+				return
+			} else if smtputf8 {
+				ct = "message/global"
+				params["charset"] = "utf-8"
+			}
+		} else if charset := p.ContentTypeParams["charset"]; charset != "" {
 			params["charset"] = charset
 		}
 		h.Set("Content-Type", mime.FormatMediaType(ct, params))
+		if t[1] == "rawdl" {
+			filename := fmt.Sprintf("email-%d-%s.eml", m.ID, m.Received.Format("20060102-150405"))
+			cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
+			h.Set("Content-Disposition", cd)
+		}
 		h.Set("Cache-Control", "no-store, max-age=0")
 
 		_, err := io.Copy(w, &moxio.AtReader{R: msgr})
diff --git a/webmail/webmail.js b/webmail/webmail.js
index 70a770e..1b70055 100644
--- a/webmail/webmail.js
+++ b/webmail/webmail.js
@@ -3806,6 +3806,7 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
 		}
 		window.open('msg/' + m.ID + '/viewtext/' + [0, ...path].join('.'), '_blank');
 	};
+	const cmdDownloadRaw = async () => { window.open('msg/' + m.ID + '/rawdl', '_blank'); };
 	const cmdViewAttachments = async () => {
 		if (attachments.length > 0) {
 			view(attachments[0]);
@@ -3956,6 +3957,10 @@ const newMsgView = (miv, msglistView, listMailboxes, possibleLabels, messageLoad
 				dom.clickbutton('Mute thread', clickCmd(msglistView.cmdMute, shortcuts)),
 				dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)),
 				dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, shortcuts)),
+				dom.clickbutton('Download raw original message', clickCmd(cmdDownloadRaw, shortcuts)),
+				dom.clickbutton('Export as ...', function click(e) {
+					popoverExport(e.target, '', [m.ID]);
+				}),
 				dom.clickbutton('Show raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)),
 				dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)),
 				dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, shortcuts)),
@@ -4538,7 +4543,9 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash,
 				movePopover(e, listMailboxes(), effselected.map(miv => miv.messageitem.Message).filter(m => effselected.length === 1 || !sentMailboxID || m.MailboxID !== sentMailboxID || !otherMailbox(sentMailboxID)));
 			}), ' ', dom.clickbutton('Labels...', attr.title('Add/remove labels ...'), function click(e) {
 				labelsPopover(e, effselected.map(miv => miv.messageitem.Message), possibleLabels);
-			}), ' ', dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdMarkNotJunk, shortcuts)), ' ', dom.clickbutton('Mark Read', clickCmd(cmdMarkRead, shortcuts)), ' ', dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ', dom.clickbutton('Mute thread', clickCmd(cmdMute, shortcuts)), ' ', dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts))))));
+			}), ' ', dom.clickbutton('Mark Not Junk', attr.title('Mark as not junk, causing this message to be used in spam classification of new incoming messages.'), clickCmd(cmdMarkNotJunk, shortcuts)), ' ', dom.clickbutton('Mark Read', clickCmd(cmdMarkRead, shortcuts)), ' ', dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ', dom.clickbutton('Mute thread', clickCmd(cmdMute, shortcuts)), ' ', dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts)), ' ', dom.clickbutton('Export as...', function click(e) {
+				popoverExport(e.target, '', effselected.map(miv => miv.messageitem.Message.ID));
+			})))));
 		}
 		setLocationHash();
 	};
@@ -5471,11 +5478,37 @@ const newMsglistView = (msgElem, activeMailbox, listMailboxes, setLocationHash,
 	};
 	return mlv;
 };
-const popoverExport = (reference, mailboxName) => {
-	const removeExport = popover(reference, {}, dom.h1('Export ', mailboxName || 'all mailboxes'), dom.form(function submit() {
+// Export messages to maildir/mbox in tar/tgz/zip/no container. Either all
+// messages, messages in from 1 mailbox, or explicit message ids.
+const popoverExport = (reference, mailboxName, messageIDs) => {
+	let format;
+	let archive;
+	let mboxbtn;
+	const removeExport = popover(reference, {}, dom.h1('Export'), dom.form(function submit() {
 		// If we would remove the popup immediately, the form would be deleted too and never submitted.
 		window.setTimeout(() => removeExport(), 100);
-	}, attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), dom.div(css('exportFields', { display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox')), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None')), dom.div(dom.label(dom.input(attr.type('checkbox'), attr.checked(''), attr.name('recursive'), attr.value('on')), ' Recursive')), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Export')))));
+	}, attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)), dom.input(attr.type('hidden'), attr.name('messageids'), attr.value((messageIDs || []).join(','))), format = dom.input(attr.type('hidden'), attr.name('format')), archive = dom.input(attr.type('hidden'), attr.name('archive')), dom.div(css('exportFields', { display: 'flex', flexDirection: 'column', gap: '.5ex' }), mailboxName ? dom.div(dom.label(dom.input(attr.type('checkbox'), attr.name('recursive'), attr.value('on'), function change(e) { mboxbtn.disabled = e.target.checked; }), ' Recursive')) : [], dom.div(!mailboxName && !messageIDs ? 'Mbox ' : mboxbtn = dom.submitbutton('Mbox', attr.title('Export as mbox file, not wrapped in an archive.'), function click() {
+		format.value = 'mbox';
+		archive.value = 'none';
+	}), ' ', dom.submitbutton('zip', function click() {
+		format.value = 'mbox';
+		archive.value = 'zip';
+	}), ' ', dom.submitbutton('tgz', function click() {
+		format.value = 'mbox';
+		archive.value = 'tgz';
+	}), ' ', dom.submitbutton('tar', function click() {
+		format.value = 'mbox';
+		archive.value = 'tar';
+	})), dom.div('Maildir ', dom.submitbutton('zip', function click() {
+		format.value = 'maildir';
+		archive.value = 'zip';
+	}), ' ', dom.submitbutton('tgz', function click() {
+		format.value = 'maildir';
+		archive.value = 'tgz';
+	}), ' ', dom.submitbutton('tar', function click() {
+		format.value = 'maildir';
+		archive.value = 'tar';
+	})))));
 };
 const newMailboxView = (xmb, mailboxlistView, otherMailbox) => {
 	const plusbox = '⊞';
@@ -5559,8 +5592,8 @@ const newMailboxView = (xmb, mailboxlistView, otherMailbox) => {
 				await withStatus('Marking mailbox as special use', client.MailboxSetSpecialUse(mb));
 			};
 			popover(actionBtn, { transparent: true }, dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.clickbutton('Archive', async function click() { await setUse((mb) => { mb.Archive = true; }); })), dom.div(dom.clickbutton('Draft', async function click() { await setUse((mb) => { mb.Draft = true; }); })), dom.div(dom.clickbutton('Junk', async function click() { await setUse((mb) => { mb.Junk = true; }); })), dom.div(dom.clickbutton('Sent', async function click() { await setUse((mb) => { mb.Sent = true; }); })), dom.div(dom.clickbutton('Trash', async function click() { await setUse((mb) => { mb.Trash = true; }); }))));
-		})), dom.div(dom.clickbutton('Export', function click() {
-			popoverExport(actionBtn, mbv.mailbox.Name);
+		})), dom.div(dom.clickbutton('Export as...', function click() {
+			popoverExport(actionBtn, mbv.mailbox.Name, null);
 			remove();
 		}))));
 	};
@@ -5806,9 +5839,9 @@ const newMailboxlistView = (msglistView, requestNewView, updatePageTitle, setLoc
 			}, fieldset = dom.fieldset(dom.label('Name ', name = dom.input(attr.required('yes'), focusPlaceholder('Lists/Go/Nuts'))), ' ', dom.submitbutton('Create'))));
 			remove();
 			name.focus();
-		})), dom.div(dom.clickbutton('Export', function click(e) {
+		})), dom.div(dom.clickbutton('Export as...', function click(e) {
 			const ref = e.target;
-			popoverExport(ref, '');
+			popoverExport(ref, '', null);
 			remove();
 		}))));
 	})), mailboxesElem));
diff --git a/webmail/webmail.ts b/webmail/webmail.ts
index 0118356..63057c0 100644
--- a/webmail/webmail.ts
+++ b/webmail/webmail.ts
@@ -3059,6 +3059,7 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
 		}
 		window.open('msg/'+m.ID+'/viewtext/'+[0, ...path].join('.'), '_blank')
 	}
+	const cmdDownloadRaw = async () => { window.open('msg/'+m.ID+'/rawdl', '_blank') }
 	const cmdViewAttachments = async () => {
 		if (attachments.length > 0) {
 			view(attachments[0])
@@ -3271,6 +3272,10 @@ const newMsgView = (miv: MsgitemView, msglistView: MsglistView, listMailboxes: l
 								dom.clickbutton('Mute thread', clickCmd(msglistView.cmdMute, shortcuts)),
 								dom.clickbutton('Unmute thread', clickCmd(msglistView.cmdUnmute, shortcuts)),
 								dom.clickbutton('Open in new tab', clickCmd(cmdOpenNewTab, shortcuts)),
+								dom.clickbutton('Download raw original message', clickCmd(cmdDownloadRaw, shortcuts)),
+								dom.clickbutton('Export as ...', function click(e: {target: HTMLElement}) {
+									popoverExport(e.target, '', [m.ID])
+								}),
 								dom.clickbutton('Show raw original message in new tab', clickCmd(cmdOpenRaw, shortcuts)),
 								dom.clickbutton('Show currently displayed part as decoded text', clickCmd(cmdOpenRawPart, shortcuts)),
 								dom.clickbutton('Show internals in popup', clickCmd(cmdShowInternals, shortcuts)),
@@ -4111,7 +4116,11 @@ const newMsglistView = (msgElem: HTMLElement, activeMailbox: () => api.Mailbox |
 							dom.clickbutton('Mark Read', clickCmd(cmdMarkRead, shortcuts)), ' ',
 							dom.clickbutton('Mark Unread', clickCmd(cmdMarkUnread, shortcuts)), ' ',
 							dom.clickbutton('Mute thread', clickCmd(cmdMute, shortcuts)), ' ',
-							dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts)),
+							dom.clickbutton('Unmute thread', clickCmd(cmdUnmute, shortcuts)), ' ',
+							dom.clickbutton('Export as...', function click(e: {target: HTMLElement}) {
+								popoverExport(e.target, '', effselected.map(miv => miv.messageitem.Message.ID))
+							}),
+
 						),
 					),
 				),
@@ -5096,9 +5105,14 @@ interface MailboxView {
 	setKeywords: (keywords: string[]) => void
 }
 
-const popoverExport = (reference: HTMLElement, mailboxName: string) => {
-	const removeExport=popover(reference, {},
-		dom.h1('Export ', mailboxName || 'all mailboxes'),
+// Export messages to maildir/mbox in tar/tgz/zip/no container. Either all
+// messages, messages in from 1 mailbox, or explicit message ids.
+const popoverExport = (reference: HTMLElement, mailboxName: string, messageIDs: number[] | null) => {
+	let format: HTMLInputElement
+	let archive: HTMLInputElement
+	let mboxbtn: HTMLButtonElement
+	const removeExport = popover(reference, {},
+		dom.h1('Export'),
 		dom.form(
 			function submit() {
 				// If we would remove the popup immediately, the form would be deleted too and never submitted.
@@ -5107,20 +5121,45 @@ const popoverExport = (reference: HTMLElement, mailboxName: string) => {
 			attr.target('_blank'), attr.method('POST'), attr.action('export'),
 			dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webmailcsrftoken') || '')),
 			dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value(mailboxName)),
+			dom.input(attr.type('hidden'), attr.name('messageids'), attr.value((messageIDs || []).join(','))),
+			format=dom.input(attr.type('hidden'), attr.name('format')),
+			archive=dom.input(attr.type('hidden'), attr.name('archive')),
 
 			dom.div(css('exportFields', {display: 'flex', flexDirection: 'column', gap: '.5ex'}),
+				mailboxName ? dom.div(dom.label(dom.input(attr.type('checkbox'), attr.name('recursive'), attr.value('on'), function change(e: {target: HTMLInputElement}) { mboxbtn.disabled = e.target.checked }), ' Recursive')) : [],
 				dom.div(
-					dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ',
-					dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox'),
+					!mailboxName && !messageIDs ? 'Mbox ' : mboxbtn=dom.submitbutton('Mbox', attr.title('Export as mbox file, not wrapped in an archive.'), function click() {
+						format.value = 'mbox'
+						archive.value = 'none'
+					}), ' ',
+					dom.submitbutton('zip', function click() {
+						format.value = 'mbox'
+						archive.value = 'zip'
+					}), ' ',
+					dom.submitbutton('tgz', function click() {
+						format.value = 'mbox'
+						archive.value = 'tgz'
+					}), ' ',
+					dom.submitbutton('tar', function click() {
+						format.value = 'mbox'
+						archive.value = 'tar'
+					}),
 				),
 				dom.div(
-					dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ',
-					dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ',
-					dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' ',
-					dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('none')), ' None'),
+					'Maildir ',
+					dom.submitbutton('zip', function click() {
+						format.value = 'maildir'
+						archive.value = 'zip'
+					}), ' ',
+					dom.submitbutton('tgz', function click() {
+						format.value = 'maildir'
+						archive.value = 'tgz'
+					}), ' ',
+					dom.submitbutton('tar', function click() {
+						format.value = 'maildir'
+						archive.value = 'tar'
+					}),
 				),
-				dom.div(dom.label(dom.input(attr.type('checkbox'), attr.checked(''), attr.name('recursive'), attr.value('on')), ' Recursive')),
-				dom.div(style({marginTop: '1ex'}), dom.submitbutton('Export')),
 			),
 		),
 	)
@@ -5270,8 +5309,8 @@ const newMailboxView = (xmb: api.Mailbox, mailboxlistView: MailboxlistView, othe
 					}),
 				),
 				dom.div(
-					dom.clickbutton('Export', function click() {
-						popoverExport(actionBtn, mbv.mailbox.Name)
+					dom.clickbutton('Export as...', function click() {
+						popoverExport(actionBtn, mbv.mailbox.Name, null)
 						remove()
 					}),
 				),
@@ -5609,9 +5648,9 @@ const newMailboxlistView = (msglistView: MsglistView, requestNewView: requestNew
 									}),
 								),
 								dom.div(
-									dom.clickbutton('Export', function click(e: MouseEvent) {
+									dom.clickbutton('Export as...', function click(e: MouseEvent) {
 										const ref = e.target! as HTMLElement
-										popoverExport(ref, '')
+										popoverExport(ref, '', null)
 										remove()
 									}),
 								),
diff --git a/webmail/webmail_test.go b/webmail/webmail_test.go
index 56485b9..fbab9e3 100644
--- a/webmail/webmail_test.go
+++ b/webmail/webmail_test.go
@@ -185,7 +185,7 @@ var (
 	msgText = Message{
 		From:    "mjl <mjl@mox.example>",
 		To:      "mox <mox@other.example>",
-		Subject: "text message",
+		Subject: "text message ☺",
 		Part:    Part{Type: "text/plain; charset=utf-8", Content: "the body"},
 	}
 	msgHTML = Message{
@@ -383,6 +383,8 @@ func TestWebmail(t *testing.T) {
 	ctTextNoCharset := [2]string{"Content-Type", "text/plain"}
 	ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"}
 	ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
+	ctMessageRFC822 := [2]string{"Content-Type", "message/rfc822"}
+	ctMessageGlobal := [2]string{"Content-Type", "message/global; charset=utf-8"}
 
 	cookieOK := &http.Cookie{Name: "webmailsession", Value: sessionCookie.Value}
 	cookieBad := &http.Cookie{Name: "webmailsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
@@ -602,6 +604,10 @@ func TestWebmail(t *testing.T) {
 	testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
 	testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
 	testHTTPAuthREST("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, nil)
+	testHTTP("GET", pathInboxAltRel+"/rawdl", httpHeaders{}, http.StatusForbidden, nil, nil)
+	testHTTP("GET", pathInboxAltRel+"/rawdl", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
+	testHTTPAuthREST("GET", pathInboxAltRel+"/rawdl", http.StatusOK, httpHeaders{ctMessageRFC822}, nil)
+	testHTTPAuthREST("GET", pathInboxText+"/rawdl", http.StatusOK, httpHeaders{ctMessageGlobal}, nil)
 
 	// HTTP message: parsedmessage.js
 	testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil)
diff --git a/webops/export.go b/webops/export.go
index c69f659..45adde8 100644
--- a/webops/export.go
+++ b/webops/export.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"mime"
 	"net/http"
+	"strconv"
 	"strings"
 	"time"
 
@@ -23,7 +24,25 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
 		return
 	}
 
+	// We
 	mailbox := r.FormValue("mailbox") // Empty means all.
+	messageIDstr := r.FormValue("messageids")
+	var messageIDs []int64
+	if messageIDstr != "" {
+		for _, s := range strings.Split(messageIDstr, ",") {
+			id, err := strconv.ParseInt(s, 10, 64)
+			if err != nil {
+				http.Error(w, fmt.Sprintf("400 - bad request - bad message id %q: %v", s, err), http.StatusBadRequest)
+				return
+			}
+			messageIDs = append(messageIDs, id)
+		}
+	}
+	if mailbox != "" && len(messageIDs) > 0 {
+		http.Error(w, "400 - bad request - cannot specify both mailbox and message ids", http.StatusBadRequest)
+		return
+	}
+
 	format := r.FormValue("format")
 	archive := r.FormValue("archive")
 	recursive := r.FormValue("recursive") != ""
@@ -43,6 +62,10 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
 		http.Error(w, "400 - bad request - archive none can only be used with non-recursive mbox", http.StatusBadRequest)
 		return
 	}
+	if len(messageIDs) > 0 && recursive {
+		http.Error(w, "400 - bad request - cannot export message ids recursively", http.StatusBadRequest)
+		return
+	}
 
 	acc, err := store.OpenAccount(log, accName, false)
 	if err != nil {
@@ -55,11 +78,15 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
 		log.Check(err, "closing account")
 	}()
 
-	name := strings.ReplaceAll(mailbox, "/", "-")
-	if name == "" {
-		name = "all"
+	var name string
+	if mailbox != "" {
+		name = "-" + strings.ReplaceAll(mailbox, "/", "-")
+	} else if len(messageIDs) > 1 {
+		name = "-selection"
+	} else if len(messageIDs) == 0 {
+		name = "-all"
 	}
-	filename := fmt.Sprintf("mailexport-%s-%s", name, time.Now().Format("20060102-150405"))
+	filename := fmt.Sprintf("mailexport%s-%s", name, time.Now().Format("20060102-150405"))
 	filename += "." + format
 	var archiver store.Archiver
 	if archive == "none" {
@@ -90,7 +117,7 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request
 		log.Check(err, "exporting mail close")
 	}()
 	w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
-	if err := store.ExportMessages(r.Context(), log, acc.DB, acc.Dir, archiver, format == "maildir", mailbox, recursive); err != nil {
+	if err := store.ExportMessages(r.Context(), log, acc.DB, acc.Dir, archiver, format == "maildir", mailbox, messageIDs, recursive); err != nil {
 		log.Errorx("exporting mail", err)
 	}
 }