PageRenderTime 88ms CodeModel.GetById 28ms app.highlight 54ms RepoModel.GetById 0ms app.codeStats 1ms

/widgets/msgviewer.go

https://git.sr.ht/~sircmpwn/aerc/
Go | 712 lines | 634 code | 67 blank | 11 comment | 148 complexity | 2b33e6c66ba5d5f6945af5a7c303583b MD5 | raw file
  1package widgets
  2
  3import (
  4	"bufio"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"os/exec"
  9	"regexp"
 10	"strings"
 11
 12	"github.com/danwakefield/fnmatch"
 13	"github.com/gdamore/tcell"
 14	"github.com/google/shlex"
 15	"github.com/mattn/go-runewidth"
 16
 17	"git.sr.ht/~sircmpwn/aerc/config"
 18	"git.sr.ht/~sircmpwn/aerc/lib"
 19	"git.sr.ht/~sircmpwn/aerc/lib/ui"
 20	"git.sr.ht/~sircmpwn/aerc/models"
 21)
 22
 23var ansi = regexp.MustCompile("\x1B\\[[0-?]*[ -/]*[@-~]")
 24
 25var _ ProvidesMessages = (*MessageViewer)(nil)
 26
 27type MessageViewer struct {
 28	ui.Invalidatable
 29	acct     *AccountView
 30	conf     *config.AercConfig
 31	err      error
 32	grid     *ui.Grid
 33	switcher *PartSwitcher
 34	msg      lib.MessageView
 35}
 36
 37type PartSwitcher struct {
 38	ui.Invalidatable
 39	parts          []*PartViewer
 40	selected       int
 41	showHeaders    bool
 42	alwaysShowMime bool
 43
 44	height int
 45	mv     *MessageViewer
 46}
 47
 48func NewMessageViewer(acct *AccountView,
 49	conf *config.AercConfig, msg lib.MessageView) *MessageViewer {
 50
 51	hf := HeaderLayoutFilter{
 52		layout: HeaderLayout(conf.Viewer.HeaderLayout),
 53		keep: func(msg *models.MessageInfo, header string) bool {
 54			if fmtHeader(msg, header, "2") != "" {
 55				return true
 56			}
 57			return false
 58		},
 59	}
 60	layout := hf.forMessage(msg.MessageInfo())
 61	header, headerHeight := layout.grid(
 62		func(header string) ui.Drawable {
 63			return &HeaderView{
 64				Name: header,
 65				Value: fmtHeader(msg.MessageInfo(), header,
 66					acct.UiConfig().TimestampFormat),
 67			}
 68		},
 69	)
 70
 71	rows := []ui.GridSpec{
 72		{ui.SIZE_EXACT, headerHeight},
 73	}
 74
 75	if msg.PGPDetails() != nil {
 76		height := 1
 77		if msg.PGPDetails().IsSigned && msg.PGPDetails().IsEncrypted {
 78			height = 2
 79		}
 80		rows = append(rows, ui.GridSpec{ui.SIZE_EXACT, height})
 81	}
 82
 83	rows = append(rows, []ui.GridSpec{
 84		{ui.SIZE_EXACT, 1},
 85		{ui.SIZE_WEIGHT, 1},
 86	}...)
 87
 88	grid := ui.NewGrid().Rows(rows).Columns([]ui.GridSpec{
 89		{ui.SIZE_WEIGHT, 1},
 90	})
 91
 92	switcher := &PartSwitcher{}
 93	err := createSwitcher(acct, switcher, conf, msg)
 94	if err != nil {
 95		return &MessageViewer{
 96			err:  err,
 97			grid: grid,
 98			msg:  msg,
 99		}
100	}
101
102	grid.AddChild(header).At(0, 0)
103	if msg.PGPDetails() != nil {
104		grid.AddChild(NewPGPInfo(msg.PGPDetails())).At(1, 0)
105		grid.AddChild(ui.NewFill(' ')).At(2, 0)
106		grid.AddChild(switcher).At(3, 0)
107	} else {
108		grid.AddChild(ui.NewFill(' ')).At(1, 0)
109		grid.AddChild(switcher).At(2, 0)
110	}
111
112	mv := &MessageViewer{
113		acct:     acct,
114		conf:     conf,
115		grid:     grid,
116		msg:      msg,
117		switcher: switcher,
118	}
119	switcher.mv = mv
120
121	return mv
122}
123
124func fmtHeader(msg *models.MessageInfo, header string, timefmt string) string {
125	switch header {
126	case "From":
127		return models.FormatAddresses(msg.Envelope.From)
128	case "To":
129		return models.FormatAddresses(msg.Envelope.To)
130	case "Cc":
131		return models.FormatAddresses(msg.Envelope.Cc)
132	case "Bcc":
133		return models.FormatAddresses(msg.Envelope.Bcc)
134	case "Date":
135		return msg.Envelope.Date.Local().Format(timefmt)
136	case "Subject":
137		return msg.Envelope.Subject
138	case "Labels":
139		return strings.Join(msg.Labels, ", ")
140	default:
141		return msg.RFC822Headers.Get(header)
142	}
143}
144
145func enumerateParts(acct *AccountView, conf *config.AercConfig,
146	msg lib.MessageView, body *models.BodyStructure,
147	index []int) ([]*PartViewer, error) {
148
149	var parts []*PartViewer
150	for i, part := range body.Parts {
151		curindex := append(index, i+1)
152		if part.MIMEType == "multipart" {
153			// Multipart meta-parts are faked
154			pv := &PartViewer{part: part}
155			parts = append(parts, pv)
156			subParts, err := enumerateParts(
157				acct, conf, msg, part, curindex)
158			if err != nil {
159				return nil, err
160			}
161			parts = append(parts, subParts...)
162			continue
163		}
164		pv, err := NewPartViewer(acct, conf, msg, part, curindex)
165		if err != nil {
166			return nil, err
167		}
168		parts = append(parts, pv)
169	}
170	return parts, nil
171}
172
173func createSwitcher(acct *AccountView, switcher *PartSwitcher,
174	conf *config.AercConfig, msg lib.MessageView) error {
175
176	var err error
177	switcher.selected = -1
178	switcher.showHeaders = conf.Viewer.ShowHeaders
179	switcher.alwaysShowMime = conf.Viewer.AlwaysShowMime
180
181	if len(msg.BodyStructure().Parts) == 0 {
182		switcher.selected = 0
183		pv, err := NewPartViewer(acct, conf, msg, msg.BodyStructure(), []int{1})
184		if err != nil {
185			return err
186		}
187		switcher.parts = []*PartViewer{pv}
188		pv.OnInvalidate(func(_ ui.Drawable) {
189			switcher.Invalidate()
190		})
191	} else {
192		switcher.parts, err = enumerateParts(acct, conf, msg,
193			msg.BodyStructure(), []int{})
194		if err != nil {
195			return err
196		}
197		selectedPriority := -1
198		fmt.Printf("Selecting best message from %v\n", conf.Viewer.Alternatives)
199		for i, pv := range switcher.parts {
200			pv.OnInvalidate(func(_ ui.Drawable) {
201				switcher.Invalidate()
202			})
203			// Switch to user's preferred mimetype
204			if switcher.selected == -1 && pv.part.MIMEType != "multipart" {
205				switcher.selected = i
206			}
207			mime := strings.ToLower(pv.part.MIMEType) +
208				"/" + strings.ToLower(pv.part.MIMESubType)
209			for idx, m := range conf.Viewer.Alternatives {
210				if m != mime {
211					continue
212				}
213				priority := len(conf.Viewer.Alternatives) - idx
214				if priority > selectedPriority {
215					selectedPriority = priority
216					switcher.selected = i
217				}
218			}
219		}
220	}
221	return nil
222}
223
224func (mv *MessageViewer) Draw(ctx *ui.Context) {
225	if mv.err != nil {
226		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
227		ctx.Printf(0, 0, tcell.StyleDefault, "%s", mv.err.Error())
228		return
229	}
230	mv.grid.Draw(ctx)
231}
232
233func (mv *MessageViewer) MouseEvent(localX int, localY int, event tcell.Event) {
234	if mv.err != nil {
235		return
236	}
237	mv.grid.MouseEvent(localX, localY, event)
238}
239
240func (mv *MessageViewer) Invalidate() {
241	mv.grid.Invalidate()
242}
243
244func (mv *MessageViewer) OnInvalidate(fn func(d ui.Drawable)) {
245	mv.grid.OnInvalidate(func(_ ui.Drawable) {
246		fn(mv)
247	})
248}
249
250func (mv *MessageViewer) Store() *lib.MessageStore {
251	return mv.msg.Store()
252}
253
254func (mv *MessageViewer) SelectedAccount() *AccountView {
255	return mv.acct
256}
257
258func (mv *MessageViewer) SelectedMessage() (*models.MessageInfo, error) {
259	if mv.msg == nil {
260		return nil, errors.New("no message selected")
261	}
262	return mv.msg.MessageInfo(), nil
263}
264
265func (mv *MessageViewer) MarkedMessages() ([]*models.MessageInfo, error) {
266	store := mv.Store()
267	return msgInfoFromUids(store, store.Marked())
268}
269
270func (mv *MessageViewer) ToggleHeaders() {
271	switcher := mv.switcher
272	mv.conf.Viewer.ShowHeaders = !mv.conf.Viewer.ShowHeaders
273	err := createSwitcher(mv.acct, switcher, mv.conf, mv.msg)
274	if err != nil {
275		mv.acct.Logger().Printf(
276			"warning: error during create switcher - %v", err)
277	}
278	switcher.Invalidate()
279}
280
281func (mv *MessageViewer) SelectedMessagePart() *PartInfo {
282	switcher := mv.switcher
283	part := switcher.parts[switcher.selected]
284
285	return &PartInfo{
286		Index: part.index,
287		Msg:   part.msg.MessageInfo(),
288		Part:  part.part,
289	}
290}
291
292func (mv *MessageViewer) PreviousPart() {
293	switcher := mv.switcher
294	for {
295		switcher.selected--
296		if switcher.selected < 0 {
297			switcher.selected = len(switcher.parts) - 1
298		}
299		if switcher.parts[switcher.selected].part.MIMEType != "multipart" {
300			break
301		}
302	}
303	mv.Invalidate()
304}
305
306func (mv *MessageViewer) NextPart() {
307	switcher := mv.switcher
308	for {
309		switcher.selected++
310		if switcher.selected >= len(switcher.parts) {
311			switcher.selected = 0
312		}
313		if switcher.parts[switcher.selected].part.MIMEType != "multipart" {
314			break
315		}
316	}
317	mv.Invalidate()
318}
319
320func (mv *MessageViewer) Close() error {
321	mv.switcher.Cleanup()
322	return nil
323}
324
325func (ps *PartSwitcher) Invalidate() {
326	ps.DoInvalidate(ps)
327}
328
329func (ps *PartSwitcher) Focus(focus bool) {
330	if ps.parts[ps.selected].term != nil {
331		ps.parts[ps.selected].term.Focus(focus)
332	}
333}
334
335func (ps *PartSwitcher) Event(event tcell.Event) bool {
336	return ps.parts[ps.selected].Event(event)
337}
338
339func (ps *PartSwitcher) Draw(ctx *ui.Context) {
340	height := len(ps.parts)
341	if height == 1 && !ps.alwaysShowMime {
342		ps.parts[ps.selected].Draw(ctx)
343		return
344	}
345	// TODO: cap height and add scrolling for messages with many parts
346	ps.height = ctx.Height()
347	y := ctx.Height() - height
348	for i, part := range ps.parts {
349		style := tcell.StyleDefault.Reverse(ps.selected == i)
350		ctx.Fill(0, y+i, ctx.Width(), 1, ' ', style)
351		name := fmt.Sprintf("%s/%s",
352			strings.ToLower(part.part.MIMEType),
353			strings.ToLower(part.part.MIMESubType))
354		if filename, ok := part.part.DispositionParams["filename"]; ok {
355			name += fmt.Sprintf(" (%s)", filename)
356		} else if filename, ok := part.part.Params["name"]; ok {
357			// workaround golang not supporting RFC2231 besides ASCII and UTF8
358			name += fmt.Sprintf(" (%s)", filename)
359		}
360		ctx.Printf(len(part.index)*2, y+i, style, "%s", name)
361	}
362	ps.parts[ps.selected].Draw(ctx.Subcontext(
363		0, 0, ctx.Width(), ctx.Height()-height))
364}
365
366func (ps *PartSwitcher) MouseEvent(localX int, localY int, event tcell.Event) {
367	switch event := event.(type) {
368	case *tcell.EventMouse:
369		switch event.Buttons() {
370		case tcell.Button1:
371			height := len(ps.parts)
372			y := ps.height - height
373			if localY < y && ps.parts[ps.selected].term != nil {
374				ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
375			}
376			for i, _ := range ps.parts {
377				if localY != y+i {
378					continue
379				}
380				if ps.parts[i].part.MIMEType == "multipart" {
381					continue
382				}
383				if ps.parts[ps.selected].term != nil {
384					ps.parts[ps.selected].term.Focus(false)
385				}
386				ps.selected = i
387				ps.Invalidate()
388				if ps.parts[ps.selected].term != nil {
389					ps.parts[ps.selected].term.Focus(true)
390				}
391			}
392		case tcell.WheelDown:
393			height := len(ps.parts)
394			y := ps.height - height
395			if localY < y && ps.parts[ps.selected].term != nil {
396				ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
397			}
398			if ps.parts[ps.selected].term != nil {
399				ps.parts[ps.selected].term.Focus(false)
400			}
401			ps.mv.NextPart()
402			if ps.parts[ps.selected].term != nil {
403				ps.parts[ps.selected].term.Focus(true)
404			}
405		case tcell.WheelUp:
406			height := len(ps.parts)
407			y := ps.height - height
408			if localY < y && ps.parts[ps.selected].term != nil {
409				ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
410			}
411			if ps.parts[ps.selected].term != nil {
412				ps.parts[ps.selected].term.Focus(false)
413			}
414			ps.mv.PreviousPart()
415			if ps.parts[ps.selected].term != nil {
416				ps.parts[ps.selected].term.Focus(true)
417			}
418		}
419	}
420}
421
422func (ps *PartSwitcher) Cleanup() {
423	for _, partViewer := range ps.parts {
424		partViewer.Cleanup()
425	}
426}
427
428func (mv *MessageViewer) Event(event tcell.Event) bool {
429	return mv.switcher.Event(event)
430}
431
432func (mv *MessageViewer) Focus(focus bool) {
433	mv.switcher.Focus(focus)
434}
435
436type PartViewer struct {
437	ui.Invalidatable
438	err         error
439	fetched     bool
440	filter      *exec.Cmd
441	index       []int
442	msg         lib.MessageView
443	pager       *exec.Cmd
444	pagerin     io.WriteCloser
445	part        *models.BodyStructure
446	showHeaders bool
447	sink        io.WriteCloser
448	source      io.Reader
449	term        *Terminal
450	selecter    *Selecter
451	grid        *ui.Grid
452}
453
454func NewPartViewer(acct *AccountView, conf *config.AercConfig,
455	msg lib.MessageView, part *models.BodyStructure,
456	index []int) (*PartViewer, error) {
457
458	var (
459		filter  *exec.Cmd
460		pager   *exec.Cmd
461		pipe    io.WriteCloser
462		pagerin io.WriteCloser
463		term    *Terminal
464	)
465	cmd, err := shlex.Split(conf.Viewer.Pager)
466	if err != nil {
467		return nil, err
468	}
469
470	pager = exec.Command(cmd[0], cmd[1:]...)
471
472	info := msg.MessageInfo()
473	for _, f := range conf.Filters {
474		mime := strings.ToLower(part.MIMEType) +
475			"/" + strings.ToLower(part.MIMESubType)
476		switch f.FilterType {
477		case config.FILTER_MIMETYPE:
478			if fnmatch.Match(f.Filter, mime, 0) {
479				filter = exec.Command("sh", "-c", f.Command)
480			}
481		case config.FILTER_HEADER:
482			var header string
483			switch f.Header {
484			case "subject":
485				header = info.Envelope.Subject
486			case "from":
487				header = models.FormatAddresses(info.Envelope.From)
488			case "to":
489				header = models.FormatAddresses(info.Envelope.To)
490			case "cc":
491				header = models.FormatAddresses(info.Envelope.Cc)
492			}
493			if f.Regex.Match([]byte(header)) {
494				filter = exec.Command("sh", "-c", f.Command)
495			}
496		}
497		if filter != nil {
498			break
499		}
500	}
501	if filter != nil {
502		if pipe, err = filter.StdinPipe(); err != nil {
503			return nil, err
504		}
505		if pagerin, _ = pager.StdinPipe(); err != nil {
506			return nil, err
507		}
508		if term, err = NewTerminal(pager); err != nil {
509			return nil, err
510		}
511	}
512
513	grid := ui.NewGrid().Rows([]ui.GridSpec{
514		{ui.SIZE_EXACT, 3}, // Message
515		{ui.SIZE_EXACT, 1}, // Selector
516		{ui.SIZE_WEIGHT, 1},
517	}).Columns([]ui.GridSpec{
518		{ui.SIZE_WEIGHT, 1},
519	})
520
521	selecter := NewSelecter([]string{"Save message", "Pipe to command"}, 0).
522		OnChoose(func(option string) {
523			switch option {
524			case "Save message":
525				acct.aerc.BeginExCommand("save ")
526			case "Pipe to command":
527				acct.aerc.BeginExCommand("pipe ")
528			}
529		})
530
531	grid.AddChild(selecter).At(2, 0)
532
533	pv := &PartViewer{
534		filter:      filter,
535		index:       index,
536		msg:         msg,
537		pager:       pager,
538		pagerin:     pagerin,
539		part:        part,
540		showHeaders: conf.Viewer.ShowHeaders,
541		sink:        pipe,
542		term:        term,
543		selecter:    selecter,
544		grid:        grid,
545	}
546
547	if term != nil {
548		term.OnStart = func() {
549			pv.attemptCopy()
550		}
551		term.OnInvalidate(func(_ ui.Drawable) {
552			pv.Invalidate()
553		})
554	}
555
556	return pv, nil
557}
558
559func (pv *PartViewer) SetSource(reader io.Reader) {
560	pv.source = reader
561	pv.attemptCopy()
562}
563
564func (pv *PartViewer) attemptCopy() {
565	if pv.source != nil && pv.pager != nil && pv.pager.Process != nil {
566		if pv.filter != nil {
567			stdout, _ := pv.filter.StdoutPipe()
568			stderr, _ := pv.filter.StderrPipe()
569			pv.filter.Start()
570			ch := make(chan interface{})
571			go func() {
572				_, err := io.Copy(pv.pagerin, stdout)
573				if err != nil {
574					pv.err = err
575					pv.Invalidate()
576				}
577				stdout.Close()
578				ch <- nil
579			}()
580			go func() {
581				_, err := io.Copy(pv.pagerin, stderr)
582				if err != nil {
583					pv.err = err
584					pv.Invalidate()
585				}
586				stderr.Close()
587				ch <- nil
588			}()
589			go func() {
590				<-ch
591				<-ch
592				pv.filter.Wait()
593				pv.pagerin.Close()
594			}()
595		}
596		go func() {
597			info := pv.msg.MessageInfo()
598			if pv.showHeaders && info.RFC822Headers != nil {
599				// header need to bypass the filter, else we run into issues
600				// with the filter messing with newlines etc.
601				// hence all writes in this block go directly to the pager
602				fields := info.RFC822Headers.Fields()
603				for fields.Next() {
604					var value string
605					var err error
606					if value, err = fields.Text(); err != nil {
607						// better than nothing, use the non decoded version
608						value = fields.Value()
609					}
610					field := fmt.Sprintf(
611						"%s: %s\n", fields.Key(), value)
612					pv.pagerin.Write([]byte(field))
613				}
614				// virtual header
615				if len(info.Labels) != 0 {
616					labels := fmtHeader(info, "Labels", "")
617					pv.pagerin.Write([]byte(fmt.Sprintf("Labels: %s\n", labels)))
618				}
619				pv.pagerin.Write([]byte{'\n'})
620			}
621
622			if pv.part.MIMEType == "text" {
623				scanner := bufio.NewScanner(pv.source)
624				for scanner.Scan() {
625					text := scanner.Text()
626					text = ansi.ReplaceAllString(text, "")
627					io.WriteString(pv.sink, text+"\n")
628				}
629			} else {
630				io.Copy(pv.sink, pv.source)
631			}
632			pv.sink.Close()
633		}()
634	}
635}
636
637func (pv *PartViewer) Invalidate() {
638	pv.DoInvalidate(pv)
639}
640
641func (pv *PartViewer) Draw(ctx *ui.Context) {
642	if pv.filter == nil {
643		// TODO: Let them download it directly or something
644		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
645		ctx.Printf(0, 0, tcell.StyleDefault.Foreground(tcell.ColorRed),
646			"No filter configured for this mimetype ('%s/%s')",
647			pv.part.MIMEType, pv.part.MIMESubType,
648		)
649		ctx.Printf(0, 2, tcell.StyleDefault,
650			"You can still :save the message or :pipe it to an external command")
651		pv.selecter.Focus(true)
652		pv.grid.Draw(ctx)
653		return
654	}
655	if !pv.fetched {
656		pv.msg.FetchBodyPart(pv.msg.BodyStructure(),
657			pv.index, pv.SetSource)
658		pv.fetched = true
659	}
660	if pv.err != nil {
661		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
662		ctx.Printf(0, 0, tcell.StyleDefault, "%s", pv.err.Error())
663		return
664	}
665	pv.term.Draw(ctx)
666}
667
668func (pv *PartViewer) Cleanup() {
669	if pv.pager != nil && pv.pager.Process != nil {
670		pv.pager.Process.Kill()
671		pv.pager = nil
672	}
673}
674
675func (pv *PartViewer) Event(event tcell.Event) bool {
676	if pv.term != nil {
677		return pv.term.Event(event)
678	}
679	return pv.selecter.Event(event)
680}
681
682type HeaderView struct {
683	ui.Invalidatable
684	Name  string
685	Value string
686}
687
688func (hv *HeaderView) Draw(ctx *ui.Context) {
689	name := hv.Name
690	size := runewidth.StringWidth(name)
691	lim := ctx.Width() - size - 1
692	value := runewidth.Truncate(" "+hv.Value, lim, "…")
693	var (
694		hstyle tcell.Style
695		vstyle tcell.Style
696	)
697	// TODO: Make this more robust and less dumb
698	if hv.Name == "PGP" {
699		vstyle = tcell.StyleDefault.Foreground(tcell.ColorGreen)
700		hstyle = tcell.StyleDefault.Bold(true)
701	} else {
702		vstyle = tcell.StyleDefault
703		hstyle = tcell.StyleDefault.Bold(true)
704	}
705	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
706	ctx.Printf(0, 0, hstyle, "%s", name)
707	ctx.Printf(size, 0, vstyle, "%s", value)
708}
709
710func (hv *HeaderView) Invalidate() {
711	hv.DoInvalidate(hv)
712}