17 "github.com/mjl-/mox/mlog"
20// MsgSource is implemented by readers for mailbox file formats.
21type MsgSource interface {
22 // Return next message, or io.EOF when there are no more.
23 Next() (*Message, *os.File, string, error)
27// MboxReader reads messages from an mbox file, implementing MsgSource.
28type MboxReader struct {
30 createTemp func(log mlog.Log, pattern string) (*os.File, error)
37 fromLine string // "From "-line for this message.
38 header bool // Now in header section.
41// NewMboxReader initializes a MsgSource from which messages can be read.
42func NewMboxReader(log mlog.Log, createTemp func(log mlog.Log, pattern string) (*os.File, error), filename string, r io.Reader) (*MboxReader, error) {
45 createTemp: createTemp,
48 r: bufio.NewReader(r),
52// Position returns "<filename>:<lineno>" for the current position.
53func (mr *MboxReader) Position() string {
54 return fmt.Sprintf("%s:%d", mr.path, mr.line)
57// Next returns the next message read from the mbox file. The file is a temporary
58// file and must be removed/consumed. The third return value is the position in the
60func (mr *MboxReader) Next() (*Message, *os.File, string, error) {
62 return nil, nil, "", io.EOF
65 from := []byte("From ")
69 // First read, we're at the beginning of the file.
70 line, err := mr.r.ReadBytes('\n')
72 return nil, nil, "", io.EOF
76 if !bytes.HasPrefix(line, from) {
77 return nil, nil, mr.Position(), fmt.Errorf(`first line does not start with "From "`)
80 mr.fromLine = strings.TrimSpace(string(line))
83 f, err := mr.createTemp(mr.log, "mboxreader")
85 return nil, nil, mr.Position(), err
89 CloseRemoveTempFile(mr.log, f, "message after mbox read error")
93 fromLine := mr.fromLine
94 bf := bufio.NewWriter(f)
96 keywords := map[string]bool{}
99 line, err := mr.r.ReadBytes('\n')
100 if err != nil && err != io.EOF {
101 return nil, nil, mr.Position(), fmt.Errorf("reading from mbox: %v", err)
105 // We store data with crlf, adjust any imported messages with bare newlines.
../rfc/4155:354
106 if !bytes.HasSuffix(line, []byte("\r\n")) {
107 line = append(line[:len(line)-1], "\r\n"...)
111 // See https://doc.dovecot.org/admin_manual/mailbox_formats/mbox/
112 if bytes.HasPrefix(line, []byte("Status:")) {
113 s := strings.TrimSpace(strings.SplitN(string(line), ":", 2)[1])
114 for _, c := range s {
120 } else if bytes.HasPrefix(line, []byte("X-Status:")) {
121 s := strings.TrimSpace(strings.SplitN(string(line), ":", 2)[1])
122 for _, c := range s {
125 flags.Answered = true
134 } else if bytes.HasPrefix(line, []byte("X-Keywords:")) {
135 s := strings.TrimSpace(strings.SplitN(string(line), ":", 2)[1])
136 for t := range strings.SplitSeq(s, ",") {
137 word := strings.ToLower(strings.TrimSpace(t))
139 case "forwarded", "$forwarded":
140 flags.Forwarded = true
141 case "junk", "$junk":
143 case "notjunk", "$notjunk", "nonjunk", "$nonjunk":
145 case "phishing", "$phishing":
146 flags.Phishing = true
147 case "mdnsent", "$mdnsent":
150 if err := CheckKeyword(word); err == nil {
151 keywords[word] = true
157 if bytes.Equal(line, []byte("\r\n")) {
162 if mr.prevempty && bytes.HasPrefix(line, from) {
163 mr.fromLine = strings.TrimSpace(string(line))
168 if bytes.HasPrefix(line, []byte(">")) && bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) {
171 n, err := bf.Write(line)
173 return nil, nil, mr.Position(), fmt.Errorf("writing message to file: %v", err)
176 mr.prevempty = bytes.Equal(line, []byte("\r\n"))
183 if err := bf.Flush(); err != nil {
184 return nil, nil, mr.Position(), fmt.Errorf("flush: %v", err)
187 m := &Message{Flags: flags, Keywords: slices.Sorted(maps.Keys(keywords)), Size: size}
189 if t := strings.SplitN(fromLine, " ", 3); len(t) == 3 {
190 layouts := []string{time.ANSIC, time.UnixDate, time.RubyDate}
191 for _, l := range layouts {
192 t, err := time.Parse(l, t[2])
200 // Prevent cleanup by defer.
204 return m, mf, mr.Position(), nil
207// Close is currently a no op, for interface MsgSource.
208func (mr *MboxReader) Close() error {
212// we make a slice of files, for cur & new, for sorting by time, so we import
213// messages in a natural order, with most recent messages latest.
214type maildirFile struct {
219type MaildirReader struct {
221 createTemp func(log mlog.Log, pattern string) (*os.File, error)
222 dirNameCur, dirNameNew string
223 rootCur, rootNew *os.Root // For opening files. Closed when Close is called.
224 filesCur, filesNew []maildirFile
225 dovecotFlags []string // Lower-case flags/keywords.
228// NewMaildirReader opens the "cur" and "new" files in dir, and returns a MsgSource
229// to read messages from.
230func NewMaildirReader(log mlog.Log, createTemp func(log mlog.Log, pattern string) (*os.File, error), dir string) (*MaildirReader, error) {
231 pathCur := filepath.Join(dir, "cur")
232 pathNew := filepath.Join(dir, "new")
234 var rootCur, rootNew *os.Root
238 err := rootCur.Close()
239 log.Check(err, "closing root for cur dir")
242 err := rootNew.Close()
243 log.Check(err, "closing root for new dir")
248 rootCur, err = os.OpenRoot(pathCur)
250 return nil, fmt.Errorf("open 'cur' path: %w", err)
252 rootNew, err = os.OpenRoot(pathNew)
254 return nil, fmt.Errorf("open 'new' path: %w", err)
257 filesCur, err := maildirRead(log, pathCur)
259 return nil, fmt.Errorf("reading 'cur' directory: %w", err)
261 filesNew, err := maildirRead(log, pathNew)
263 return nil, fmt.Errorf("reading 'new' directory: %w", err)
266 mr := &MaildirReader{
268 createTemp: createTemp,
277 // Best-effort parsing of dovecot keywords.
278 kf, err := os.Open(filepath.Join(dir, "dovecot-keywords"))
280 mr.dovecotFlags, err = ParseDovecotKeywordsFlags(kf, log)
281 log.Check(err, "parsing dovecot keywords file")
283 log.Check(err, "closing dovecot-keywords file")
286 // Prevent cleanup, no more chance of error.
293func maildirRead(log mlog.Log, p string) ([]maildirFile, error) {
294 dir, err := os.Open(p)
300 log.Check(err, "closing maildir dir")
303 var files []maildirFile
305 ents, err := dir.ReadDir(100)
306 for _, e := range ents {
309 Time: messageTime(e),
311 files = append(files, f)
315 } else if err != nil {
316 return nil, fmt.Errorf("read dir: %w", err)
320 slices.SortFunc(files, func(a, b maildirFile) int { return a.Time.Compare(b.Time) })
324// Take received time from filename, falling back to mtime for maildirs
325// reconstructed some other sources of message files.
326func messageTime(f os.DirEntry) time.Time {
328 parts := strings.SplitN(f.Name(), ".", 3)
329 if v, err := strconv.ParseInt(parts[0], 10, 64); len(parts) == 3 && err == nil {
331 } else if fi, err := f.Info(); err == nil {
337func (mr *MaildirReader) Next() (*Message, *os.File, string, error) {
341 if len(mr.filesCur) > 0 {
342 file = mr.filesCur[0]
343 mr.filesCur = mr.filesCur[1:]
345 dirName = mr.dirNameCur
346 } else if len(mr.filesNew) > 0 {
347 file = mr.filesNew[0]
348 mr.filesNew = mr.filesNew[1:]
350 dirName = mr.dirNameNew
352 return nil, nil, "", io.EOF
355 p := filepath.Join(dirName, file.Name)
356 sf, err := root.Open(file.Name)
358 return nil, nil, p, fmt.Errorf("open message in maildir: %s", err)
362 mr.log.Check(err, "closing message file after error")
364 f, err := mr.createTemp(mr.log, "maildirreader")
366 return nil, nil, p, err
370 CloseRemoveTempFile(mr.log, f, "maildir temp message file")
374 // Copy data, changing bare \n into \r\n.
375 r := bufio.NewReader(sf)
376 w := bufio.NewWriter(f)
379 line, err := r.ReadBytes('\n')
380 if err != nil && err != io.EOF {
381 return nil, nil, p, fmt.Errorf("reading message: %v", err)
384 if !bytes.HasSuffix(line, []byte("\r\n")) {
385 line = append(line[:len(line)-1], "\r\n"...)
388 if n, err := w.Write(line); err != nil {
389 return nil, nil, p, fmt.Errorf("writing message: %v", err)
398 if err := w.Flush(); err != nil {
399 return nil, nil, p, fmt.Errorf("writing message: %v", err)
402 // Parse flags. See https://cr.yp.to/proto/maildir.html.
404 keywords := map[string]bool{}
405 t := strings.SplitN(file.Name, ":2,", 2)
407 for _, c := range t[1] {
410 // Passed, doesn't map to a common IMAP flag.
412 flags.Answered = true
422 if c >= 'a' && c <= 'z' {
423 index := int(c - 'a')
424 if index >= len(mr.dovecotFlags) {
427 kw := mr.dovecotFlags[index]
429 case "$forwarded", "forwarded":
430 flags.Forwarded = true
431 case "$junk", "junk":
433 case "$notjunk", "notjunk", "nonjunk":
435 case "$mdnsent", "mdnsent":
437 case "$phishing", "phishing":
438 flags.Phishing = true
447 m := &Message{Received: file.Time, Flags: flags, Keywords: slices.Sorted(maps.Keys(keywords)), Size: size}
449 // Prevent cleanup by defer.
456// Close closes internal state. It does not close dirNew and dirCur passed to
458func (mr *MaildirReader) Close() error {
460 if mr.rootCur != nil {
461 err0 = mr.rootCur.Close()
463 if mr.rootNew != nil {
464 err1 = mr.rootNew.Close()
466 return errors.Join(err0, err1)
469// ParseDovecotKeywordsFlags attempts to parse a dovecot-keywords file. It only
470// returns valid flags/keywords, as lower-case. If an error is encountered and
471// returned, any keywords that were found are still returned. The returned list has
472// both system/well-known flags and custom keywords.
473func ParseDovecotKeywordsFlags(r io.Reader, log mlog.Log) ([]string, error) {
475 If the dovecot-keywords file is present, we parse its additional flags, see
476 https://doc.dovecot.org/admin_manual/mailbox_formats/maildir/
484 keywords := make([]string, 26)
486 scanner := bufio.NewScanner(r)
490 t := strings.SplitN(s, " ", 2)
492 errs = append(errs, fmt.Sprintf("unexpected dovecot keyword line: %q", s))
495 v, err := strconv.ParseInt(t[0], 10, 32)
497 errs = append(errs, fmt.Sprintf("unexpected dovecot keyword index: %q", s))
500 if v < 0 || v >= int64(len(keywords)) {
501 errs = append(errs, fmt.Sprintf("dovecot keyword index too big: %q", s))
505 if keywords[index] != "" {
506 errs = append(errs, fmt.Sprintf("duplicate dovecot keyword: %q", s))
509 kw := strings.ToLower(t[1])
510 if !systemWellKnownFlags[kw] {
511 if err := CheckKeyword(kw); err != nil {
512 errs = append(errs, fmt.Sprintf("invalid keyword %q", kw))
521 if err := scanner.Err(); err != nil {
522 errs = append(errs, fmt.Sprintf("reading dovecot keywords file: %v", err))
526 err = errors.New(strings.Join(errs, "; "))
528 return keywords[:end], err