mblaze-ui

A minimal TUI for the mblaze email client

git clone https://git.8pit.net/mblaze-ui.git

  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}