1package main
2
3import (
4 "bufio"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "os/exec"
10 "regexp"
11 "strings"
12 "time"
13)
14
15const (
16 Flagged MailFlag = iota
17 Unflagged
18 Seen
19 Unseen
20 Trashed
21 Untrashed
22)
23
24const (
25 // Output format used by mscan(1) (passed via the -f flag).
26 mscanFmt = "%0R %19D <%0f> %0S"
27
28 // Maximum amount of characters to output for the from header.
29 maxFrom = 17
30)
31
32var (
33 // POSIX extended regular expression for parsing 'mscanFmt'.
34 mscanRegex = regexp.MustCompilePOSIX("^([^ ]+) ([0-9]+-[0-9]+-[0-9]+ [0-9][0-9]:[0-9][0-9]:[0-9][0-9]| *\\(unknown\\)) <([^>]+)> (.+)$")
35
36 // Workaround for https://github.com/leahneukirchen/mblaze/issues/264
37 noMail = errors.New("mail no longer exists")
38)
39
40type Mail struct {
41 Path string
42 Date time.Time
43 From string
44 Subject string
45}
46
47// This is a workaround for a bug in mblaze. Presently, mblaze utilities do not check
48// if the mail still exists if it is passed as an absolute file path, hence we do it here.
49//
50// See: https://github.com/leahneukirchen/mblaze/issues/264
51func (m Mail) Exists() bool {
52 // XXX: Could use syscall.access with F_OK here instead.
53 _, err := os.Stat(m.Path)
54 return !errors.Is(err, fs.ErrNotExist)
55}
56
57func (m Mail) Show() error {
58 // Use custom command-line options for less to ensure
59 // the pager doesn't exit if the output fits on the screen.
60 //
61 // See also: https://github.com/leahneukirchen/mblaze/blob/v1.2/mshow.c#L818-L822
62 pager := os.Getenv("PAGER")
63 if pager == "" || strings.HasPrefix(pager, "less") {
64 pager = "less --RAW-CONTROL-CHARS"
65 }
66
67 if !m.Exists() {
68 return noMail
69 }
70 cmd := exec.Command("mshow", m.Path)
71 cmd.Env = append(os.Environ(), "MBLAZE_PAGER="+pager)
72
73 // Make sure that we use {stdout,stdin,stderr} of the parent
74 // process. Need to this explicitly when using os/exec.
75 cmd.Stdout = os.Stdout
76 cmd.Stderr = os.Stderr
77 cmd.Stdin = os.Stdin
78
79 return cmd.Run()
80}
81
82func (m Mail) Reply() error {
83 if !m.Exists() {
84 return noMail
85 }
86 cmd := exec.Command("mrep", m.Path)
87
88 // Make sure that we use {stdout,stdin,stderr} of the parent
89 // process. Need to this explicitly when using os/exec.
90 cmd.Stdout = os.Stdout
91 cmd.Stderr = os.Stderr
92 cmd.Stdin = os.Stdin
93
94 return cmd.Run()
95}
96
97func (m Mail) Flag(flag MailFlag) error {
98 if !m.Exists() {
99 return noMail
100 }
101 cmd := exec.Command("mflag", flag.CmdOpt(), m.Path)
102 return cmd.Run()
103}
104
105func (m Mail) String() string {
106 from := m.From[0:min(len(m.From), maxFrom)]
107
108 var date string
109 if m.Date.IsZero() {
110 date = "(unknown)"
111 } else {
112 date = adaptiveTime(m.Date)
113 }
114
115 out := fmt.Sprintf("%10s %17s %s", date, from, m.Subject)
116 return out
117}
118
119type MailFlag int
120
121func (f MailFlag) CmdOpt() string {
122 switch f {
123 case Unflagged:
124 return "-f"
125 case Flagged:
126 return "-F"
127 case Unseen:
128 return "-s"
129 case Seen:
130 return "-S"
131 case Untrashed:
132 return "t"
133 case Trashed:
134 return "T"
135 }
136
137 panic("unreachable")
138}
139
140func mscan() ([]Mail, error) {
141 var mails []Mail
142
143 cmd := exec.Command("mscan", "-f", mscanFmt, "1:-1")
144 cmd.Env = append(os.Environ(), "MBLAZE_PAGER=")
145 // TODO: Somehow configure mblaze to not strip at all.
146 cmd.Env = append(cmd.Env, "COLUMNS=99999")
147
148 reader, err := cmd.StdoutPipe()
149 if err != nil {
150 return mails, err
151 }
152 defer reader.Close()
153
154 err = cmd.Start()
155 if err != nil {
156 return mails, err
157 }
158
159 scanner := bufio.NewScanner(reader)
160 for scanner.Scan() {
161 subs := mscanRegex.FindStringSubmatch(scanner.Text())
162 if subs == nil {
163 // Message might have been moved since last mseq(1).
164 // For example, if it was marked as seen via mflag(1).
165 continue
166 }
167
168 fp := subs[1]
169 var date time.Time
170 if strings.TrimSpace(subs[2]) == "(unknown)" {
171 date = time.Time{} // TODO: Go doesn't have a Maybe monad :(
172 } else {
173 var err error
174 date, err = time.Parse(time.DateTime, subs[2])
175 if err != nil {
176 return mails, err
177 }
178 }
179 from := strings.TrimSpace(subs[3])
180 subject := subs[4]
181
182 mails = append(mails, Mail{
183 Path: fp,
184 Date: date,
185 From: from,
186 Subject: subject,
187 })
188 }
189
190 err = cmd.Wait()
191 if err != nil {
192 return mails, err
193 }
194
195 if len(mails) == 0 {
196 return mails, fmt.Errorf("current sequence is empty")
197 }
198
199 return mails, nil
200}