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)
26// MboxReader reads messages from an mbox file, implementing MsgSource.
27type MboxReader struct {
29 createTemp func(log mlog.Log, pattern string) (*os.File, error)
36 fromLine string // "From "-line for this message.
37 header bool // Now in header section.
40func NewMboxReader(log mlog.Log, createTemp func(log mlog.Log, pattern string) (*os.File, error), filename string, r io.Reader) *MboxReader {
43 createTemp: createTemp,
46 r: bufio.NewReader(r),
50// Position returns "<filename>:<lineno>" for the current position.
51func (mr *MboxReader) Position() string {
52 return fmt.Sprintf("%s:%d", mr.path, mr.line)
55// Next returns the next message read from the mbox file. The file is a temporary
56// file and must be removed/consumed. The third return value is the position in the
58func (mr *MboxReader) Next() (*Message, *os.File, string, error) {
60 return nil, nil, "", io.EOF
63 from := []byte("From ")
67 // First read, we're at the beginning of the file.
68 line, err := mr.r.ReadBytes('\n')
70 return nil, nil, "", io.EOF
74 if !bytes.HasPrefix(line, from) {
75 return nil, nil, mr.Position(), fmt.Errorf(`first line does not start with "From "`)
78 mr.fromLine = strings.TrimSpace(string(line))
81 f, err := mr.createTemp(mr.log, "mboxreader")
83 return nil, nil, mr.Position(), err
87 CloseRemoveTempFile(mr.log, f, "message after mbox read error")
91 fromLine := mr.fromLine
92 bf := bufio.NewWriter(f)
94 keywords := map[string]bool{}
97 line, err := mr.r.ReadBytes('\n')
98 if err != nil && err != io.EOF {
99 return nil, nil, mr.Position(), fmt.Errorf("reading from mbox: %v", err)
103 // We store data with crlf, adjust any imported messages with bare newlines.
../rfc/4155:354
104 if !bytes.HasSuffix(line, []byte("\r\n")) {
105 line = append(line[:len(line)-1], "\r\n"...)
109 // See https://doc.dovecot.org/admin_manual/mailbox_formats/mbox/
110 if bytes.HasPrefix(line, []byte("Status:")) {
111 s := strings.TrimSpace(strings.SplitN(string(line), ":", 2)[1])
112 for _, c := range s {
118 } else if bytes.HasPrefix(line, []byte("X-Status:")) {
119 s := strings.TrimSpace(strings.SplitN(string(line), ":", 2)[1])
120 for _, c := range s {
123 flags.Answered = true
132 } else if bytes.HasPrefix(line, []byte("X-Keywords:")) {
133 s := strings.TrimSpace(strings.SplitN(string(line), ":", 2)[1])
134 for _, t := range strings.Split(s, ",") {
135 word := strings.ToLower(strings.TrimSpace(t))
137 case "forwarded", "$forwarded":
138 flags.Forwarded = true
139 case "junk", "$junk":
141 case "notjunk", "$notjunk", "nonjunk", "$nonjunk":
143 case "phishing", "$phishing":
144 flags.Phishing = true
145 case "mdnsent", "$mdnsent":
148 if err := CheckKeyword(word); err == nil {
149 keywords[word] = true
155 if bytes.Equal(line, []byte("\r\n")) {
160 if mr.prevempty && bytes.HasPrefix(line, from) {
161 mr.fromLine = strings.TrimSpace(string(line))
166 if bytes.HasPrefix(line, []byte(">")) && bytes.HasPrefix(bytes.TrimLeft(line, ">"), []byte("From ")) {
169 n, err := bf.Write(line)
171 return nil, nil, mr.Position(), fmt.Errorf("writing message to file: %v", err)
174 mr.prevempty = bytes.Equal(line, []byte("\r\n"))
181 if err := bf.Flush(); err != nil {
182 return nil, nil, mr.Position(), fmt.Errorf("flush: %v", err)
185 m := &Message{Flags: flags, Keywords: slices.Sorted(maps.Keys(keywords)), Size: size}
187 if t := strings.SplitN(fromLine, " ", 3); len(t) == 3 {
188 layouts := []string{time.ANSIC, time.UnixDate, time.RubyDate}
189 for _, l := range layouts {
190 t, err := time.Parse(l, t[2])
198 // Prevent cleanup by defer.
202 return m, mf, mr.Position(), nil
205type MaildirReader struct {
207 createTemp func(log mlog.Log, pattern string) (*os.File, error)
209 f *os.File // File we are currently reading from. We first read newf, then curf.
210 dir string // Name of directory for f. Can be empty on first call.
211 entries []os.DirEntry
212 dovecotFlags []string // Lower-case flags/keywords.
215func NewMaildirReader(log mlog.Log, createTemp func(log mlog.Log, pattern string) (*os.File, error), newf, curf *os.File) *MaildirReader {
216 mr := &MaildirReader{
218 createTemp: createTemp,
224 // Best-effort parsing of dovecot keywords.
225 kf, err := os.Open(filepath.Join(filepath.Dir(newf.Name()), "dovecot-keywords"))
227 mr.dovecotFlags, err = ParseDovecotKeywordsFlags(kf, log)
228 log.Check(err, "parsing dovecot keywords file")
230 log.Check(err, "closing dovecot-keywords file")
236func (mr *MaildirReader) Next() (*Message, *os.File, string, error) {
241 if len(mr.entries) == 0 {
243 mr.entries, err = mr.f.ReadDir(100)
244 if err != nil && err != io.EOF {
245 return nil, nil, "", err
247 if len(mr.entries) == 0 {
249 return nil, nil, "", io.EOF
257 p := filepath.Join(mr.dir, mr.entries[0].Name())
258 mr.entries = mr.entries[1:]
259 sf, err := os.Open(p)
261 return nil, nil, p, fmt.Errorf("open message in maildir: %s", err)
265 mr.log.Check(err, "closing message file after error")
267 f, err := mr.createTemp(mr.log, "maildirreader")
269 return nil, nil, p, err
273 CloseRemoveTempFile(mr.log, f, "maildir temp message file")
277 // Copy data, changing bare \n into \r\n.
278 r := bufio.NewReader(sf)
279 w := bufio.NewWriter(f)
282 line, err := r.ReadBytes('\n')
283 if err != nil && err != io.EOF {
284 return nil, nil, p, fmt.Errorf("reading message: %v", err)
287 if !bytes.HasSuffix(line, []byte("\r\n")) {
288 line = append(line[:len(line)-1], "\r\n"...)
291 if n, err := w.Write(line); err != nil {
292 return nil, nil, p, fmt.Errorf("writing message: %v", err)
301 if err := w.Flush(); err != nil {
302 return nil, nil, p, fmt.Errorf("writing message: %v", err)
305 // Take received time from filename, falling back to mtime for maildirs
306 // reconstructed some other sources of message files.
307 var received time.Time
308 t := strings.SplitN(filepath.Base(sf.Name()), ".", 3)
309 if v, err := strconv.ParseInt(t[0], 10, 64); len(t) == 3 && err == nil {
310 received = time.Unix(v, 0)
311 } else if fi, err := sf.Stat(); err == nil {
312 received = fi.ModTime()
315 // Parse flags. See https://cr.yp.to/proto/maildir.html.
317 keywords := map[string]bool{}
318 t = strings.SplitN(filepath.Base(sf.Name()), ":2,", 2)
320 for _, c := range t[1] {
323 // Passed, doesn't map to a common IMAP flag.
325 flags.Answered = true
335 if c >= 'a' && c <= 'z' {
336 index := int(c - 'a')
337 if index >= len(mr.dovecotFlags) {
340 kw := mr.dovecotFlags[index]
342 case "$forwarded", "forwarded":
343 flags.Forwarded = true
344 case "$junk", "junk":
346 case "$notjunk", "notjunk", "nonjunk":
348 case "$mdnsent", "mdnsent":
350 case "$phishing", "phishing":
351 flags.Phishing = true
360 m := &Message{Received: received, Flags: flags, Keywords: slices.Sorted(maps.Keys(keywords)), Size: size}
362 // Prevent cleanup by defer.
369// ParseDovecotKeywordsFlags attempts to parse a dovecot-keywords file. It only
370// returns valid flags/keywords, as lower-case. If an error is encountered and
371// returned, any keywords that were found are still returned. The returned list has
372// both system/well-known flags and custom keywords.
373func ParseDovecotKeywordsFlags(r io.Reader, log mlog.Log) ([]string, error) {
375 If the dovecot-keywords file is present, we parse its additional flags, see
376 https://doc.dovecot.org/admin_manual/mailbox_formats/maildir/
384 keywords := make([]string, 26)
386 scanner := bufio.NewScanner(r)
390 t := strings.SplitN(s, " ", 2)
392 errs = append(errs, fmt.Sprintf("unexpected dovecot keyword line: %q", s))
395 v, err := strconv.ParseInt(t[0], 10, 32)
397 errs = append(errs, fmt.Sprintf("unexpected dovecot keyword index: %q", s))
400 if v < 0 || v >= int64(len(keywords)) {
401 errs = append(errs, fmt.Sprintf("dovecot keyword index too big: %q", s))
405 if keywords[index] != "" {
406 errs = append(errs, fmt.Sprintf("duplicate dovecot keyword: %q", s))
409 kw := strings.ToLower(t[1])
410 if !systemWellKnownFlags[kw] {
411 if err := CheckKeyword(kw); err != nil {
412 errs = append(errs, fmt.Sprintf("invalid keyword %q", kw))
421 if err := scanner.Err(); err != nil {
422 errs = append(errs, fmt.Sprintf("reading dovecot keywords file: %v", err))
426 err = errors.New(strings.Join(errs, "; "))
428 return keywords[:end], err