~migadu/alps

f1d00df4dda7a35651359b76bad34a9d1262994d — Simon Ser 6 months ago eeedc9a
Upgrade to go-imap v2
M docs/example-go-plugin/plugin.go => docs/example-go-plugin/plugin.go +1 -1
@@ 17,7 17,7 @@ func init() {
	// Setup a function called when the mailbox view is rendered
	p.Inject("mailbox.html", func(ctx *alps.Context, kdata alps.RenderData) error {
		data := kdata.(*alpsbase.MailboxRenderData)
		fmt.Println("The mailbox view for " + data.Mailbox.Name + " is being rendered")
		fmt.Println("The mailbox view for " + data.Mailbox.Name() + " is being rendered")
		// Set extra data that can be accessed from the mailbox.html template
		data.Extra["Example"] = "Hi from Go"
		return nil

M go.mod => go.mod +1 -4
@@ 7,10 7,7 @@ require (
	github.com/chris-ramon/douceur v0.2.0
	github.com/dustin/go-humanize v1.0.1
	github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
	github.com/emersion/go-imap v1.2.1
	github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915
	github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872
	github.com/emersion/go-imap-specialuse v0.0.0-20201101201809-1ab93d3d150e
	github.com/emersion/go-imap/v2 v2.0.0-alpha.4
	github.com/emersion/go-message v0.16.0
	github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
	github.com/emersion/go-smtp v0.16.0

M go.sum => go.sum +2 -17
@@ 12,26 12,15 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/emersion/go-imap v1.0.3/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915 h1:8xzODjLqrfAJo+CNhX0Fp47vdVN0ZvmGV3CPt/Ex1nU=
github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915/go.mod h1:6mXMzbK9Ts0mrrBibqy56SqZpuFMry5AedTgu6qY5zM=
github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872 h1:HGBfonz0q/zq7y3ew+4oy4emHSvk6bkmV0mdDG3E77M=
github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w=
github.com/emersion/go-imap-specialuse v0.0.0-20201101201809-1ab93d3d150e h1:AwVkRMFFUMNu+tx0jchwyoXhS2VClQSzTtByVuzxbsE=
github.com/emersion/go-imap-specialuse v0.0.0-20201101201809-1ab93d3d150e/go.mod h1:/nybxhI8kXom8Tw6BrHMl42usALvka6meORflnnYwe4=
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-imap/v2 v2.0.0-alpha.4 h1:zaSLXl0TmWMg/ddcn9nwGD0scb8j+Rk7Yul/oVFrYVw=
github.com/emersion/go-imap/v2 v2.0.0-alpha.4/go.mod h1:NQQIs7aGbZC7CuvEp9yfidW2TCstC3rUIo4k8LbqxzA=
github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4=
github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ=
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=


@@ 56,7 45,6 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=


@@ 81,7 69,6 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=


@@ 136,9 123,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=

M imap.go => imap.go +21 -8
@@ 2,29 2,42 @@ package alps

import (
	"fmt"
	"io"
	"mime"
	"net"
	"os"

	"github.com/emersion/go-imap"
	imapclient "github.com/emersion/go-imap/client"
	"github.com/emersion/go-imap/v2/imapclient"
	"github.com/emersion/go-message/charset"
)

func init() {
	imap.CharsetReader = charset.Reader
}

func (s *Server) dialIMAP() (*imapclient.Client, error) {
	// TODO: don't print passwords to debug logs
	var debugWriter io.Writer
	if s.Options.Debug {
		debugWriter = os.Stderr
	}

	options := &imapclient.Options{
		DebugWriter: debugWriter,
		WordDecoder: &mime.WordDecoder{
			CharsetReader: charset.Reader,
		},
	}

	var c *imapclient.Client
	var err error
	if s.imap.tls {
		c, err = imapclient.DialTLS(s.imap.host, nil)
		c, err = imapclient.DialTLS(s.imap.host, options)
		if err != nil {
			return nil, fmt.Errorf("failed to connect to IMAPS server: %v", err)
		}
	} else {
		c, err = imapclient.Dial(s.imap.host)
		conn, err := net.Dial("tcp", s.imap.host)
		if err != nil {
			return nil, fmt.Errorf("failed to connect to IMAP server: %v", err)
		}
		c = imapclient.New(conn, options)
		if !s.imap.insecure {
			if err := c.StartTLS(nil); err != nil {
				c.Close()

M plugins/base/imap.go => plugins/base/imap.go +204 -179
@@ 4,37 4,41 @@ import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"
	//"time"

	"github.com/dustin/go-humanize"
	"github.com/emersion/go-imap"
	imapspecialuse "github.com/emersion/go-imap-specialuse"
	imapclient "github.com/emersion/go-imap/client"
	"github.com/emersion/go-imap/v2"
	"github.com/emersion/go-imap/v2/imapclient"
	"github.com/emersion/go-message"
	"github.com/emersion/go-message/textproto"
)

type MailboxInfo struct {
	*imap.MailboxInfo
	*imap.ListData

	Active bool
	Total  int
	Unseen int
}

func (mbox *MailboxInfo) Name() string {
	return mbox.Mailbox
}

func (mbox *MailboxInfo) URL() *url.URL {
	return &url.URL{
		Path: fmt.Sprintf("/mailbox/%v", url.PathEscape(mbox.Name)),
		Path: fmt.Sprintf("/mailbox/%v", url.PathEscape(mbox.Name())),
	}
}

func (mbox *MailboxInfo) HasAttr(flag string) bool {
	for _, attr := range mbox.Attributes {
		if attr == flag {
	for _, attr := range mbox.Attrs {
		if string(attr) == flag {
			return true
		}
	}


@@ 42,50 46,51 @@ func (mbox *MailboxInfo) HasAttr(flag string) bool {
}

func listMailboxes(conn *imapclient.Client) ([]MailboxInfo, error) {
	ch := make(chan *imap.MailboxInfo, 10)
	done := make(chan error, 1)
	go func() {
		done <- conn.List("", "*", ch)
	}()

	var mailboxes []MailboxInfo
	for mbox := range ch {
	list := conn.List("", "*", nil)
	for {
		mbox := list.Next()
		if mbox == nil {
			break
		}
		mailboxes = append(mailboxes, MailboxInfo{mbox, false, -1, -1})
	}

	if err := <-done; err != nil {
	if err := list.Close(); err != nil {
		return nil, fmt.Errorf("failed to list mailboxes: %v", err)
	}

	sort.Slice(mailboxes, func(i, j int) bool {
		if mailboxes[i].Name == "INBOX" {
		if mailboxes[i].Mailbox == "INBOX" {
			return true
		}
		if mailboxes[j].Name == "INBOX" {
		if mailboxes[j].Mailbox == "INBOX" {
			return false
		}
		return mailboxes[i].Name < mailboxes[j].Name
		return mailboxes[i].Mailbox < mailboxes[j].Mailbox
	})
	return mailboxes, nil
}

type MailboxStatus struct {
	*imap.MailboxStatus
	*imap.StatusData
}

func (mbox *MailboxStatus) Name() string {
	return mbox.Mailbox
}

func (mbox *MailboxStatus) URL() *url.URL {
	return &url.URL{
		Path: fmt.Sprintf("/mailbox/%v", url.PathEscape(mbox.Name)),
		Path: fmt.Sprintf("/mailbox/%v", url.PathEscape(mbox.Name())),
	}
}

func getMailboxStatus(conn *imapclient.Client, name string) (*MailboxStatus, error) {
	items := []imap.StatusItem{
		imap.StatusMessages,
		imap.StatusUidValidity,
		imap.StatusUnseen,
	}
	status, err := conn.Status(name, items)
	status, err := conn.Status(name, &imap.StatusOptions{
		NumMessages: true,
		UIDValidity: true,
		NumUnseen:   true,
	}).Wait()
	if err != nil {
		return nil, fmt.Errorf("failed to get mailbox status: %v", err)
	}


@@ 100,28 105,29 @@ const (
)

func getMailboxByType(conn *imapclient.Client, mboxType mailboxType) (*MailboxInfo, error) {
	ch := make(chan *imap.MailboxInfo, 10)
	done := make(chan error, 1)
	go func() {
		done <- conn.List("", "%", ch)
	}()

	// TODO: configurable fallback names?
	var attr string
	var attr imap.MailboxAttr
	var fallbackNames []string
	switch mboxType {
	case mailboxSent:
		attr = imapspecialuse.Sent
		attr = imap.MailboxAttrSent
		fallbackNames = []string{"Sent"}
	case mailboxDrafts:
		attr = imapspecialuse.Drafts
		attr = imap.MailboxAttrDrafts
		fallbackNames = []string{"Draft", "Drafts"}
	}

	list := conn.List("", "%", nil)

	var attrMatched bool
	var best *imap.MailboxInfo
	for mbox := range ch {
		for _, a := range mbox.Attributes {
	var best *imap.ListData
	for {
		mbox := list.Next()
		if mbox == nil {
			break
		}

		for _, a := range mbox.Attrs {
			if attr == a {
				best = mbox
				attrMatched = true


@@ 133,14 139,13 @@ func getMailboxByType(conn *imapclient.Client, mboxType mailboxType) (*MailboxIn
		}

		for _, fallback := range fallbackNames {
			if strings.EqualFold(fallback, mbox.Name) {
			if strings.EqualFold(fallback, mbox.Mailbox) {
				best = mbox
				break
			}
		}
	}

	if err := <-done; err != nil {
	if err := list.Close(); err != nil {
		return nil, fmt.Errorf("failed to get mailbox with attribute %q: %v", attr, err)
	}



@@ 151,9 156,8 @@ func getMailboxByType(conn *imapclient.Client, mboxType mailboxType) (*MailboxIn
}

func ensureMailboxSelected(conn *imapclient.Client, mboxName string) error {
	mbox := conn.Mailbox()
	if mbox == nil || mbox.Name != mboxName {
		if _, err := conn.Select(mboxName, false); err != nil {
	if mbox := conn.Mailbox(); mbox == nil || mbox.Name != mboxName {
		if _, err := conn.Select(mboxName, nil).Wait(); err != nil {
			return fmt.Errorf("failed to select mailbox: %v", err)
		}
	}


@@ 161,26 165,28 @@ func ensureMailboxSelected(conn *imapclient.Client, mboxName string) error {
}

type IMAPMessage struct {
	*imap.Message
	*imapclient.FetchMessageBuffer

	Mailbox string
}

func (msg *IMAPMessage) URL() *url.URL {
	return &url.URL{
		Path: fmt.Sprintf("/message/%v/%v", url.PathEscape(msg.Mailbox), msg.Uid),
		Path: fmt.Sprintf("/message/%v/%v", url.PathEscape(msg.Mailbox), msg.UID),
	}
}

func newIMAPPartNode(msg *IMAPMessage, path []int, part *imap.BodyStructure) *IMAPPartNode {
	filename, _ := part.Filename()
	return &IMAPPartNode{
func newIMAPPartNode(msg *IMAPMessage, path []int, part imap.BodyStructure) *IMAPPartNode {
	node := &IMAPPartNode{
		Path:     path,
		MIMEType: strings.ToLower(part.MIMEType + "/" + part.MIMESubType),
		Filename: filename,
		MIMEType: part.MediaType(),
		Message:  msg,
		Size:     part.Size,
	}
	if singlePart, ok := part.(*imap.BodyStructureSinglePart); ok {
		node.Filename = singlePart.Filename()
		node.Size = singlePart.Size
	}
	return node
}

func (msg *IMAPMessage) TextPart() *IMAPPartNode {


@@ 190,21 196,26 @@ func (msg *IMAPMessage) TextPart() *IMAPPartNode {

	var best *IMAPPartNode
	isTextPlain := false
	msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
		if !strings.EqualFold(part.MIMEType, "text") {
	msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
		singlePart, ok := part.(*imap.BodyStructureSinglePart)
		if !ok {
			return true
		}

		if !strings.EqualFold(singlePart.Type, "text") {
			return true
		}
		if part.Disposition != "" && !strings.EqualFold(part.Disposition, "inline") {
		if disp := singlePart.Disposition(); disp != nil && !strings.EqualFold(disp.Value, "inline") {
			return true
		}

		switch strings.ToLower(part.MIMESubType) {
		switch strings.ToLower(singlePart.Subtype) {
		case "plain":
			isTextPlain = true
			best = newIMAPPartNode(msg, path, part)
			best = newIMAPPartNode(msg, path, singlePart)
		case "html":
			if !isTextPlain {
				best = newIMAPPartNode(msg, path, part)
				best = newIMAPPartNode(msg, path, singlePart)
			}
		}
		return true


@@ 219,16 230,21 @@ func (msg *IMAPMessage) HTMLPart() *IMAPPartNode {
	}

	var best *IMAPPartNode
	msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
		if !strings.EqualFold(part.MIMEType, "text") {
	msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
		singlePart, ok := part.(*imap.BodyStructureSinglePart)
		if !ok {
			return true
		}

		if !strings.EqualFold(singlePart.Type, "text") {
			return true
		}
		if part.Disposition != "" && !strings.EqualFold(part.Disposition, "inline") {
		if disp := singlePart.Disposition(); disp != nil && !strings.EqualFold(disp.Value, "inline") {
			return true
		}

		if part.MIMESubType == "html" {
			best = newIMAPPartNode(msg, path, part)
		if singlePart.Subtype == "html" {
			best = newIMAPPartNode(msg, path, singlePart)
		}
		return true
	})


@@ 242,12 258,17 @@ func (msg *IMAPMessage) Attachments() []IMAPPartNode {
	}

	var attachments []IMAPPartNode
	msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
		if !strings.EqualFold(part.Disposition, "attachment") {
	msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
		singlePart, ok := part.(*imap.BodyStructureSinglePart)
		if !ok {
			return true
		}

		if disp := singlePart.Disposition(); disp == nil || !strings.EqualFold(disp.Value, "attachment") {
			return true
		}

		attachments = append(attachments, *newIMAPPartNode(msg, path, part))
		attachments = append(attachments, *newIMAPPartNode(msg, path, singlePart))
		return true
	})
	return attachments


@@ 274,7 295,7 @@ func (msg *IMAPMessage) PartByPath(path []int) *IMAPPartNode {
	}

	var result *IMAPPartNode
	msg.BodyStructure.Walk(func(p []int, part *imap.BodyStructure) bool {
	msg.BodyStructure.Walk(func(p []int, part imap.BodyStructure) bool {
		if result == nil && pathsEqual(path, p) {
			result = newIMAPPartNode(msg, p, part)
		}


@@ 289,9 310,13 @@ func (msg *IMAPMessage) PartByID(id string) *IMAPPartNode {
	}

	var result *IMAPPartNode
	msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
		if result == nil && part.Id == "<"+id+">" {
			result = newIMAPPartNode(msg, path, part)
	msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
		singlePart, ok := part.(*imap.BodyStructureSinglePart)
		if !ok {
			return result == nil
		}
		if result == nil && singlePart.ID == "<"+id+">" {
			result = newIMAPPartNode(msg, path, singlePart)
		}
		return result == nil
	})


@@ 342,29 367,29 @@ func (node IMAPPartNode) String() string {
	}
}

func imapPartTree(msg *IMAPMessage, bs *imap.BodyStructure, path []int) *IMAPPartNode {
	if !strings.EqualFold(bs.MIMEType, "multipart") && len(path) == 0 {
		path = []int{1}
	}

	filename, _ := bs.Filename()

func imapPartTree(msg *IMAPMessage, bs imap.BodyStructure, path []int) *IMAPPartNode {
	node := &IMAPPartNode{
		Path:     path,
		MIMEType: strings.ToLower(bs.MIMEType + "/" + bs.MIMESubType),
		Filename: filename,
		Children: make([]IMAPPartNode, len(bs.Parts)),
		MIMEType: bs.MediaType(),
		Message:  msg,
		Size:     bs.Size,
	}

	for i, part := range bs.Parts {
		num := i + 1
	switch bs := bs.(type) {
	case *imap.BodyStructureMultiPart:
		for i, part := range bs.Children {
			num := i + 1

		partPath := append([]int(nil), path...)
		partPath = append(partPath, num)
			partPath := append([]int(nil), path...)
			partPath = append(partPath, num)

		node.Children[i] = *imapPartTree(msg, part, partPath)
			node.Children = append(node.Children, *imapPartTree(msg, part, partPath))
		}
	case *imap.BodyStructureSinglePart:
		if len(path) == 0 {
			node.Path = []int{1}
		}
		node.Filename = bs.Filename()
		node.Size = bs.Size
	}

	return node


@@ 378,9 403,9 @@ func (msg *IMAPMessage) PartTree() *IMAPPartNode {
	return imapPartTree(msg, msg.BodyStructure, nil)
}

func (msg *IMAPMessage) HasFlag(flag string) bool {
func (msg *IMAPMessage) HasFlag(flag imap.Flag) bool {
	for _, f := range msg.Flags {
		if imap.CanonicalFlag(f) == flag {
		if f == flag {
			return true
		}
	}


@@ 388,11 413,11 @@ func (msg *IMAPMessage) HasFlag(flag string) bool {
}

func listMessages(conn *imapclient.Client, mbox *MailboxStatus, page, messagesPerPage int) ([]IMAPMessage, error) {
	if err := ensureMailboxSelected(conn, mbox.Name); err != nil {
	if err := ensureMailboxSelected(conn, mbox.Name()); err != nil {
		return nil, err
	}

	to := int(mbox.Messages) - page*messagesPerPage
	to := int(*mbox.NumMessages) - page*messagesPerPage
	from := to - messagesPerPage + 1
	if from <= 0 {
		from = 1


@@ 401,29 426,21 @@ func listMessages(conn *imapclient.Client, mbox *MailboxStatus, page, messagesPe
		return nil, nil
	}

	var seqSet imap.SeqSet
	seqSet.AddRange(uint32(from), uint32(to))

	fetch := []imap.FetchItem{
		imap.FetchFlags,
		imap.FetchEnvelope,
		imap.FetchUid,
		imap.FetchBodyStructure,
	seqSet := imap.SeqSetRange(uint32(from), uint32(to))
	items := []imap.FetchItem{
		imap.FetchItemFlags,
		imap.FetchItemEnvelope,
		imap.FetchItemUID,
		imap.FetchItemBodyStructure,
	}

	ch := make(chan *imap.Message, 10)
	done := make(chan error, 1)
	go func() {
		done <- conn.Fetch(&seqSet, fetch, ch)
	}()

	msgs := make([]IMAPMessage, 0, to-from)
	for msg := range ch {
		msgs = append(msgs, IMAPMessage{msg, mbox.Name})
	imapMsgs, err := conn.Fetch(seqSet, items, nil).Collect()
	if err != nil {
		return nil, fmt.Errorf("failed to fetch message list: %v", err)
	}

	if err := <-done; err != nil {
		return nil, fmt.Errorf("failed to fetch message list: %v", err)
	var msgs []IMAPMessage
	for _, msg := range imapMsgs {
		msgs = append(msgs, IMAPMessage{msg, mbox.Name()})
	}

	// Reverse list of messages


@@ 441,10 458,11 @@ func searchMessages(conn *imapclient.Client, mboxName, query string, page, messa
	}

	criteria := PrepareSearch(query)
	nums, err := conn.Search(criteria)
	data, err := conn.Search(criteria, nil).Wait()
	if err != nil {
		return nil, 0, fmt.Errorf("UID SEARCH failed: %v", err)
	}
	nums := data.AllNums()
	total = len(nums)

	from := page * messagesPerPage


@@ 462,24 480,20 @@ func searchMessages(conn *imapclient.Client, mboxName, query string, page, messa
		indexes[num] = i
	}

	var seqSet imap.SeqSet
	seqSet.AddNum(nums...)

	fetch := []imap.FetchItem{
		imap.FetchEnvelope,
		imap.FetchFlags,
		imap.FetchUid,
		imap.FetchBodyStructure,
	seqSet := imap.SeqSetNum(nums...)
	items := []imap.FetchItem{
		imap.FetchItemEnvelope,
		imap.FetchItemFlags,
		imap.FetchItemUID,
		imap.FetchItemBodyStructure,
	}
	results, err := conn.Fetch(seqSet, items, nil).Collect()
	if err != nil {
		return nil, 0, fmt.Errorf("failed to fetch message list: %v", err)
	}

	ch := make(chan *imap.Message, 10)
	done := make(chan error, 1)
	go func() {
		done <- conn.Fetch(&seqSet, fetch, ch)
	}()

	msgs = make([]IMAPMessage, len(nums))
	for msg := range ch {
	for _, msg := range results {
		i, ok := indexes[msg.SeqNum]
		if !ok {
			continue


@@ 487,10 501,6 @@ func searchMessages(conn *imapclient.Client, mboxName, query string, page, messa
		msgs[i] = IMAPMessage{msg, mboxName}
	}

	if err := <-done; err != nil {
		return nil, 0, fmt.Errorf("failed to fetch message list: %v", err)
	}

	return msgs, total, nil
}



@@ 499,58 509,64 @@ func getMessagePart(conn *imapclient.Client, mboxName string, uid uint32, partPa
		return nil, nil, err
	}

	seqSet := new(imap.SeqSet)
	seqSet.AddNum(uid)
	seqSet := imap.SeqSetNum(uid)

	var partHeaderSection imap.BodySectionName
	partHeaderSection.Peek = true
	headerItem := &imap.FetchItemBodySection{
		Peek: true,
		Part: partPath,
	}
	if len(partPath) > 0 {
		partHeaderSection.Specifier = imap.MIMESpecifier
		headerItem.Specifier = imap.PartSpecifierMIME
	} else {
		partHeaderSection.Specifier = imap.HeaderSpecifier
		headerItem.Specifier = imap.PartSpecifierHeader
	}
	partHeaderSection.Path = partPath

	var partBodySection imap.BodySectionName
	bodyItem := &imap.FetchItemBodySection{
		Part: partPath,
	}
	if len(partPath) > 0 {
		partBodySection.Specifier = imap.EntireSpecifier
		bodyItem.Specifier = imap.PartSpecifierNone
	} else {
		partBodySection.Specifier = imap.TextSpecifier
		bodyItem.Specifier = imap.PartSpecifierText
	}
	partBodySection.Path = partPath

	fetch := []imap.FetchItem{
		imap.FetchEnvelope,
		imap.FetchUid,
		imap.FetchBodyStructure,
		imap.FetchFlags,
		imap.FetchRFC822Size,
		partHeaderSection.FetchItem(),
		partBodySection.FetchItem(),
	items := []imap.FetchItem{
		imap.FetchItemEnvelope,
		imap.FetchItemUID,
		imap.FetchItemBodyStructure,
		imap.FetchItemFlags,
		imap.FetchItemRFC822Size,
		headerItem,
		bodyItem,
	}

	ch := make(chan *imap.Message, 1)
	if err := conn.UidFetch(seqSet, fetch, ch); err != nil {
	// TODO: stream attachments
	msgs, err := conn.UIDFetch(seqSet, items, nil).Collect()
	if err != nil {
		return nil, nil, fmt.Errorf("failed to fetch message: %v", err)
	}

	msg := <-ch
	if msg == nil {
	} else if len(msgs) == 0 {
		return nil, nil, fmt.Errorf("server didn't return message")
	}
	msg := msgs[0]

	body := msg.GetBody(&partHeaderSection)
	if body == nil {
		return nil, nil, fmt.Errorf("server didn't return message")
	var headerBuf, bodyBuf []byte
	for item, b := range msg.BodySection {
		if item.Specifier == headerItem.Specifier {
			headerBuf = b
		} else if item.Specifier == bodyItem.Specifier {
			bodyBuf = b
		}
	}
	if headerBuf == nil || bodyBuf == nil {
		return nil, nil, fmt.Errorf("server didn't return header and body")
	}

	headerReader := bufio.NewReader(body)
	h, err := textproto.ReadHeader(headerReader)
	h, err := textproto.ReadHeader(bufio.NewReader(bytes.NewReader(headerBuf)))
	if err != nil {
		return nil, nil, fmt.Errorf("failed to read part header: %v", err)
	}

	part, err := message.New(message.Header{h}, msg.GetBody(&partBodySection))
	part, err := message.New(message.Header{h}, bytes.NewReader(bodyBuf))
	if err != nil {
		return nil, nil, fmt.Errorf("failed to create message reader: %v", err)
	}


@@ 563,11 579,12 @@ func markMessageAnswered(conn *imapclient.Client, mboxName string, uid uint32) e
		return err
	}

	seqSet := new(imap.SeqSet)
	seqSet.AddNum(uid)
	item := imap.FormatFlagsOp(imap.AddFlags, true)
	flags := []interface{}{imap.AnsweredFlag}
	return conn.UidStore(seqSet, item, flags, nil)
	seqSet := imap.SeqSetNum(uid)
	return conn.UIDStore(seqSet, &imap.StoreFlags{
		Op:     imap.StoreFlagsAdd,
		Silent: true,
		Flags:  []imap.Flag{imap.FlagAnswered},
	}, nil).Close()
}

func appendMessage(c *imapclient.Client, msg *OutgoingMessage, mboxType mailboxType) (*MailboxInfo, error) {


@@ 586,28 603,36 @@ func appendMessage(c *imapclient.Client, msg *OutgoingMessage, mboxType mailboxT
		return nil, err
	}

	flags := []string{imap.SeenFlag}
	flags := []imap.Flag{imap.FlagSeen}
	if mboxType == mailboxDrafts {
		flags = append(flags, imap.DraftFlag)
		flags = append(flags, imap.FlagDraft)
	}
	if err := c.Append(mbox.Name, flags, time.Now(), &buf); err != nil {
	options := imap.AppendOptions{Flags: flags}
	appendCmd := c.Append(mbox.Name(), int64(buf.Len()), &options)
	defer appendCmd.Close()
	if _, err := io.Copy(appendCmd, &buf); err != nil {
		return nil, err
	}
	if err := appendCmd.Close(); err != nil {
		return nil, err
	}
	return mbox, nil
}

func deleteMessage(c *imapclient.Client, mboxName string, uid uint32) error {
	if err := ensureMailboxSelected(c, mboxName); err != nil {
func deleteMessage(conn *imapclient.Client, mboxName string, uid uint32) error {
	if err := ensureMailboxSelected(conn, mboxName); err != nil {
		return err
	}

	seqSet := new(imap.SeqSet)
	seqSet.AddNum(uid)
	item := imap.FormatFlagsOp(imap.AddFlags, true)
	flags := []interface{}{imap.DeletedFlag}
	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
	seqSet := imap.SeqSetNum(uid)
	err := conn.UIDStore(seqSet, &imap.StoreFlags{
		Op:     imap.StoreFlagsAdd,
		Silent: true,
		Flags:  []imap.Flag{imap.FlagDeleted},
	}, nil).Close()
	if err != nil {
		return err
	}

	return c.Expunge(nil)
	return conn.Expunge().Close()
}

M plugins/base/routes.go => plugins/base/routes.go +65 -71
@@ 7,15 7,13 @@ import (
	"io/ioutil"
	"mime"
	"net/http"
	"net/textproto"
	"net/url"
	"strconv"
	"strings"

	"git.sr.ht/~migadu/alps"
	"github.com/emersion/go-imap"
	imapmove "github.com/emersion/go-imap-move"
	imapclient "github.com/emersion/go-imap/client"
	"github.com/emersion/go-imap/v2"
	"github.com/emersion/go-imap/v2/imapclient"
	"github.com/emersion/go-message"
	"github.com/emersion/go-message/mail"
	"github.com/emersion/go-smtp"


@@ 113,7 111,7 @@ func (cc *CategorizedMailboxes) Append(mi MailboxInfo, status *MailboxStatus) {
		Info:   &mi,
		Status: status,
	}
	if name := mi.Name; name == "INBOX" {
	if name := mi.Mailbox; name == "INBOX" {
		cc.Common.Inbox = details
	} else if name == "Drafts" {
		cc.Common.Drafts = details


@@ 187,13 185,13 @@ func newIMAPBaseRenderData(ctx *alps.Context,
	var categorized CategorizedMailboxes
	for i := range mailboxes {
		// Populate unseen & active states
		if active != nil && mailboxes[i].Name == active.Name {
		if active != nil && mailboxes[i].Name() == active.Mailbox {
			mailboxes[i].Active = true
		}
		status := statuses[mailboxes[i].Name]
		status := statuses[mailboxes[i].Name()]
		if status != nil {
			mailboxes[i].Unseen = int(status.Unseen)
			mailboxes[i].Total = int(status.Messages)
			mailboxes[i].Unseen = int(*status.NumUnseen)
			mailboxes[i].Total = int(*status.NumMessages)
		}

		categorized.Append(mailboxes[i], status)


@@ 216,12 214,12 @@ func handleGetMailbox(ctx *alps.Context) error {
	}

	mbox := ibase.Mailbox
	title := mbox.Name
	title := mbox.Name()
	if title == "INBOX" {
		title = "Inbox"
	}
	if mbox.Unseen > 0 {
		title = fmt.Sprintf("(%d) %s", mbox.Unseen, title)
	if *mbox.NumUnseen > 0 {
		title = fmt.Sprintf("(%d) %s", *mbox.NumUnseen, title)
	}
	ibase.BaseRenderData.WithTitle(title)



@@ 248,7 246,7 @@ func handleGetMailbox(ctx *alps.Context) error {
	err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
		var err error
		if query != "" {
			msgs, total, err = searchMessages(c, mbox.Name, query, page, messagesPerPage)
			msgs, total, err = searchMessages(c, mbox.Name(), query, page, messagesPerPage)
		} else {
			msgs, err = listMessages(c, mbox, page, messagesPerPage)
		}


@@ 273,7 271,7 @@ func handleGetMailbox(ctx *alps.Context) error {
		if page > 0 {
			prevPage = page - 1
		}
		if (page+1)*messagesPerPage < int(mbox.Messages) {
		if (page+1)*messagesPerPage < int(*mbox.NumMessages) {
			nextPage = page + 1
		}
	}


@@ 309,7 307,7 @@ func handleNewMailbox(ctx *alps.Context) error {
		}

		err := ctx.Session.DoIMAP(func(c *imapclient.Client) error {
			return c.Create(name)
			return c.Create(name, nil).Wait()
		})

		if err != nil {


@@ 335,11 333,11 @@ func handleDeleteMailbox(ctx *alps.Context) error {
	}

	mbox := ibase.Mailbox
	ibase.BaseRenderData.WithTitle("Delete folder '" + mbox.Name + "'")
	ibase.BaseRenderData.WithTitle("Delete folder '" + mbox.Name() + "'")

	if ctx.Request().Method == http.MethodPost {
		ctx.Session.DoIMAP(func(c *imapclient.Client) error {
			return c.Delete(mbox.Name)
			return c.Delete(mbox.Name()).Wait()
		})
		ctx.Session.PutNotice("Mailbox deleted.")
		return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")


@@ 429,11 427,13 @@ func handleGetPart(ctx *alps.Context, raw bool) error {

	var msg *IMAPMessage
	var part *message.Entity
	var selected *imapclient.SelectedMailbox
	err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
		var err error
		if msg, part, err = getMessagePart(c, mbox.Name, uid, partPath); err != nil {
		if msg, part, err = getMessagePart(c, mbox.Name(), uid, partPath); err != nil {
			return err
		}
		selected = c.Mailbox()
		return nil
	})
	if err != nil {


@@ 485,12 485,11 @@ func handleGetPart(ctx *alps.Context, raw bool) error {
	}

	flags := make(map[string]bool)
	for _, f := range mbox.PermanentFlags {
		f = imap.CanonicalFlag(f)
		if f == imap.TryCreateFlag {
	for _, f := range selected.PermanentFlags {
		if f == imap.FlagWildcard {
			continue
		}
		flags[f] = msg.HasFlag(f)
		flags[string(f)] = msg.HasFlag(f)
	}

	ibase.BaseRenderData.WithTitle(msg.Envelope.Subject)


@@ 500,7 499,7 @@ func handleGetPart(ctx *alps.Context, raw bool) error {
		Message:            msg,
		Part:               msg.PartByPath(partPath),
		View:               view,
		MailboxPage:        int(mbox.Messages-msg.SeqNum) / messagesPerPage,
		MailboxPage:        int(*mbox.NumMessages-msg.SeqNum) / messagesPerPage,
		Flags:              flags,
	})
}


@@ 686,20 685,21 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti
					}
				}

				if err := ensureMailboxSelected(c, drafts.Name); err != nil {
				if err := ensureMailboxSelected(c, drafts.Name()); err != nil {
					return err
				}

				criteria := &imap.SearchCriteria{
					Header: make(textproto.MIMEHeader),
				// TODO: use APPENDUID instead when available
				criteria := imap.SearchCriteria{
					Header: []imap.SearchCriteriaHeaderField{
						{Key: "Message-Id", Value: msg.MessageID},
					},
				}
				criteria.Header.Add("Message-Id", msg.MessageID)
				if uids, err := c.UidSearch(criteria); err != nil {
				if data, err := c.UIDSearch(&criteria, nil).Wait(); err != nil {
					return err
				} else if uids := data.AllNums(); len(uids) != 1 {
					panic(fmt.Errorf("Duplicate message ID"))
				} else {
					if len(uids) != 1 {
						panic(fmt.Errorf("Duplicate message ID"))
					}
					uid = uids[0]
				}
				return nil


@@ 709,7 709,7 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti
			}
			ctx.Session.PutNotice("Message saved as draft.")
			return ctx.Redirect(http.StatusFound, fmt.Sprintf(
				"/message/%s/%d/edit?part=1", drafts.Name, uid))
				"/message/%s/%d/edit?part=1", drafts.Mailbox, uid))
		} else {
			return submitCompose(ctx, msg, options)
		}


@@ 789,10 789,10 @@ func handleCancelAttachment(ctx *alps.Context) error {
	return ctx.JSON(http.StatusOK, nil)
}

func unwrapIMAPAddressList(addrs []*imap.Address) []string {
func unwrapIMAPAddressList(addrs []imap.Address) []string {
	l := make([]string, len(addrs))
	for i, addr := range addrs {
		l[i] = addr.Address()
		l[i] = addr.Addr()
	}
	return l
}


@@ 852,7 852,7 @@ func handleReply(ctx *alps.Context) error {
		hdr.GenerateMessageID()
		mid, _ := hdr.MessageID()
		msg.MessageID = "<" + mid + ">"
		msg.InReplyTo = inReplyTo.Envelope.MessageId
		msg.InReplyTo = inReplyTo.Envelope.MessageID
		// TODO: populate From from known user addresses and inReplyTo.Envelope.To
		replyTo := inReplyTo.Envelope.ReplyTo
		if len(replyTo) == 0 {


@@ 987,12 987,12 @@ func handleEdit(ctx *alps.Context) error {
		msg.Text = string(b)

		if len(source.Envelope.From) > 0 {
			msg.From = source.Envelope.From[0].Address()
			msg.From = source.Envelope.From[0].Addr()
		}
		msg.To = unwrapIMAPAddressList(source.Envelope.To)
		msg.Subject = source.Envelope.Subject
		msg.InReplyTo = source.Envelope.InReplyTo
		msg.MessageID = source.Envelope.MessageId
		msg.MessageID = source.Envelope.MessageID

		attachments := source.Attachments()
		for i := range attachments {


@@ 1042,15 1042,12 @@ func handleMove(ctx *alps.Context) error {
	}

	err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
		mc := imapmove.NewClient(c)

		if err := ensureMailboxSelected(c, mboxName); err != nil {
			return err
		}

		var seqSet imap.SeqSet
		seqSet.AddNum(uids...)
		if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
		seqSet := imap.SeqSetNum(uids...)
		if _, err := c.UIDMove(seqSet, to).Wait(); err != nil {
			return fmt.Errorf("failed to move message: %v", err)
		}



@@ 1093,25 1090,20 @@ func handleDelete(ctx *alps.Context) error {
			return err
		}

		var seqSet imap.SeqSet
		seqSet.AddNum(uids...)

		item := imap.FormatFlagsOp(imap.AddFlags, true)
		flags := []interface{}{imap.DeletedFlag}
		if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
		seqSet := imap.SeqSetNum(uids...)
		err := c.UIDStore(seqSet, &imap.StoreFlags{
			Op:     imap.StoreFlagsAdd,
			Silent: true,
			Flags:  []imap.Flag{imap.FlagDeleted},
		}, nil).Close()
		if err != nil {
			return fmt.Errorf("failed to add deleted flag: %v", err)
		}

		if err := c.Expunge(nil); err != nil {
		if err := c.Expunge().Close(); err != nil {
			return fmt.Errorf("failed to expunge mailbox: %v", err)
		}

		// Deleting a message invalidates our cached message count
		// TODO: listen to async updates instead
		if _, err := c.Select(mboxName, false); err != nil {
			return fmt.Errorf("failed to select mailbox: %v", err)
		}

		return nil
	})
	if err != nil {


@@ 1155,34 1147,36 @@ func handleSetFlags(ctx *alps.Context) error {
		actionStr = ctx.QueryParam("action")
	}

	var op imap.FlagsOp
	var op imap.StoreFlagsOp
	switch actionStr {
	case "", "set":
		op = imap.SetFlags
		op = imap.StoreFlagsSet
	case "add":
		op = imap.AddFlags
		op = imap.StoreFlagsAdd
	case "remove":
		op = imap.RemoveFlags
		op = imap.StoreFlagsDel
	default:
		return echo.NewHTTPError(http.StatusBadRequest, "invalid 'action' value")
	}

	l := make([]imap.Flag, len(flags))
	for i, s := range flags {
		l[i] = imap.Flag(s)
	}

	err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
		if err := ensureMailboxSelected(c, mboxName); err != nil {
			return err
		}

		var seqSet imap.SeqSet
		seqSet.AddNum(uids...)

		storeItems := make([]interface{}, len(flags))
		for i, f := range flags {
			storeItems[i] = f
		}

		item := imap.FormatFlagsOp(op, true)
		if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
			return fmt.Errorf("failed to add deleted flag: %v", err)
		seqSet := imap.SeqSetNum(uids...)
		err := c.UIDStore(seqSet, &imap.StoreFlags{
			Op:     op,
			Silent: true,
			Flags:  l,
		}, nil).Close()
		if err != nil {
			return fmt.Errorf("failed to set flags: %v", err)
		}

		return nil


@@ 1194,7 1188,7 @@ func handleSetFlags(ctx *alps.Context) error {
	if path := formOrQueryParam(ctx, "next"); path != "" {
		return ctx.Redirect(http.StatusFound, path)
	}
	if len(uids) != 1 || (op == imap.RemoveFlags && len(flags) == 1 && flags[0] == imap.SeenFlag) {
	if len(uids) != 1 || (op == imap.StoreFlagsDel && len(l) == 1 && l[0] == imap.FlagSeen) {
		// Redirecting to the message view would mark the message as read again
		return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
	}

M plugins/base/search.go => plugins/base/search.go +5 -23
@@ 3,16 3,15 @@ package alpsbase
import (
	"bufio"
	"bytes"
	"net/textproto"
	"strings"

	"github.com/emersion/go-imap"
	"github.com/emersion/go-imap/v2"
)

func searchCriteriaHeader(k, v string) *imap.SearchCriteria {
	return &imap.SearchCriteria{
		Header: map[string][]string{
			k: []string{v},
		Header: []imap.SearchCriteriaHeaderField{
			{Key: k, Value: v},
		},
	}
}


@@ 24,7 23,7 @@ func searchCriteriaOr(criteria ...*imap.SearchCriteria) *imap.SearchCriteria {
	or := criteria[0]
	for _, c := range criteria[1:] {
		or = &imap.SearchCriteria{
			Or: [][2]*imap.SearchCriteria{{or, c}},
			Or: [][2]imap.SearchCriteria{{*or, *c}},
		}
	}
	return or


@@ 36,24 35,7 @@ func searchCriteriaAnd(criteria ...*imap.SearchCriteria) *imap.SearchCriteria {
	}
	and := criteria[0]
	for _, c := range criteria[1:] {
		// TODO: Maybe pitch the AND and OR functions to go-imap upstream
		if c.Header != nil {
			if and.Header == nil {
				and.Header = make(textproto.MIMEHeader)
			}

			for key, value := range c.Header {
				if _, ok := and.Header[key]; !ok {
					and.Header[key] = nil
				}
				and.Header[key] = append(and.Header[key], value...)
			}
		}
		and.Body = append(and.Body, c.Body...)
		and.Text = append(and.Text, c.Text...)
		and.WithFlags = append(and.WithFlags, c.WithFlags...)
		and.WithoutFlags = append(and.WithoutFlags, c.WithoutFlags...)
		// TODO: Merge more things
		and.And(c)
	}
	return and
}

M plugins/base/template.go => plugins/base/template.go +9 -9
@@ 7,7 7,7 @@ import (
	"time"

	"github.com/dustin/go-humanize"
	"github.com/emersion/go-imap"
	"github.com/emersion/go-imap/v2"
)

const (


@@ 23,23 23,23 @@ var templateFuncs = template.FuncMap{
	"formatdate": func(t time.Time) string {
		return t.Format("Mon Jan 02 15:04")
	},
	"formatflag": func(flag string) string {
	"formatflag": func(flag imap.Flag) string {
		switch flag {
		case imap.SeenFlag:
		case imap.FlagSeen:
			return "Seen"
		case imap.AnsweredFlag:
		case imap.FlagAnswered:
			return "Answered"
		case imap.FlaggedFlag:
		case imap.FlagFlagged:
			return "Starred"
		case imap.DraftFlag:
		case imap.FlagDraft:
			return "Draft"
		default:
			return flag
			return string(flag)
		}
	},
	"ismutableflag": func(flag string) bool {
	"ismutableflag": func(flag imap.Flag) bool {
		switch flag {
		case imap.AnsweredFlag, imap.DeletedFlag, imap.DraftFlag:
		case imap.FlagAnswered, imap.FlagDeleted, imap.FlagDraft:
			return false
		default:
			return true

M server.go => server.go +1 -1
@@ 65,7 65,7 @@ func newServer(e *echo.Echo, options *Options) (*Server, error) {
		return nil, err
	}

	s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP, e.Logger, options.Debug)
	s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP, e.Logger)
	return s, nil
}


M session.go => session.go +12 -22
@@ 7,11 7,11 @@ import (
	"fmt"
	"mime/multipart"
	"net/http"
	"os"
	"sync"
	"time"

	imapclient "github.com/emersion/go-imap/client"
	"github.com/emersion/go-imap/v2"
	"github.com/emersion/go-imap/v2/imapclient"
	"github.com/emersion/go-sasl"
	"github.com/emersion/go-smtp"
	"github.com/google/uuid"


@@ 84,6 84,11 @@ func (s *Session) DoIMAP(f func(*imapclient.Client) error) error {
	s.imapLocker.Lock()
	defer s.imapLocker.Unlock()

	if s.imapConn != nil && s.imapConn.State() == imap.ConnStateLogout {
		s.imapConn.Close()
		s.imapConn = nil
	}

	if s.imapConn == nil {
		var err error
		s.imapConn, err = s.manager.connectIMAP(s.username, s.password)


@@ 93,6 98,8 @@ func (s *Session) DoIMAP(f func(*imapclient.Client) error) error {
		}
	}

	// TODO: to avoid races wrt. disconnection, re-run f if it returns
	// io.UnexpectedEOF
	return f(s.imapConn)
}



@@ 210,19 217,17 @@ type SessionManager struct {
	dialIMAP DialIMAPFunc
	dialSMTP DialSMTPFunc
	logger   echo.Logger
	debug    bool

	locker   sync.Mutex
	sessions map[string]*Session // protected by locker
}

func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc, logger echo.Logger, debug bool) *SessionManager {
func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc, logger echo.Logger) *SessionManager {
	return &SessionManager{
		sessions: make(map[string]*Session),
		dialIMAP: dialIMAP,
		dialSMTP: dialSMTP,
		logger:   logger,
		debug:    debug,
	}
}



@@ 238,15 243,11 @@ func (sm *SessionManager) connectIMAP(username, password string) (*imapclient.Cl
		return nil, err
	}

	if err := c.Login(username, password); err != nil {
	if err := c.Login(username, password).Wait(); err != nil {
		c.Logout()
		return nil, AuthError{err}
	}

	if sm.debug {
		c.SetDebug(os.Stderr)
	}

	return c, nil
}



@@ 308,18 309,7 @@ func (sm *SessionManager) Put(username, password string) (*Session, error) {

		alive := true
		for alive {
			var loggedOut <-chan struct{}
			s.imapLocker.Lock()
			if s.imapConn != nil {
				loggedOut = s.imapConn.LoggedOut()
			}
			s.imapLocker.Unlock()

			select {
			case <-loggedOut:
				s.imapLocker.Lock()
				s.imapConn = nil
				s.imapLocker.Unlock()
			case <-s.pings:
				if !timer.Stop() {
					<-timer.C


@@ 336,7 326,7 @@ func (sm *SessionManager) Put(username, password string) (*Session, error) {

		s.imapLocker.Lock()
		if s.imapConn != nil {
			s.imapConn.Logout()
			s.imapConn.Close()
		}
		s.imapLocker.Unlock()


M store.go => store.go +14 -20
@@ 6,8 6,8 @@ import (
	"reflect"
	"sync"

	imapmetadata "github.com/emersion/go-imap-metadata"
	imapclient "github.com/emersion/go-imap/client"
	"github.com/emersion/go-imap/v2"
	"github.com/emersion/go-imap/v2/imapclient"
	"github.com/labstack/echo/v4"
)



@@ 76,12 76,7 @@ var errIMAPMetadataUnsupported = fmt.Errorf("alps: IMAP server doesn't support M

func newIMAPStore(session *Session) (*imapStore, error) {
	err := session.DoIMAP(func(c *imapclient.Client) error {
		mc := imapmetadata.NewClient(c)
		ok, err := mc.SupportMetadata()
		if err != nil {
			return fmt.Errorf("alps: failed to check for IMAP METADATA support: %v", err)
		}
		if !ok {
		if caps := c.Caps(); !caps.Has(imap.CapMetadata) && !caps.Has(imap.CapMetadataServer) {
			return errIMAPMetadataUnsupported
		}
		return nil


@@ 101,21 96,23 @@ func (s *imapStore) Get(key string, out interface{}) error {
		return err
	}

	var entries map[string]string
	var entries map[string]*[]byte
	err := s.session.DoIMAP(func(c *imapclient.Client) error {
		mc := imapmetadata.NewClient(c)
		var err error
		entries, err = mc.GetMetadata("", []string{s.key(key)}, nil)
		return err
		data, err := c.GetMetadata("", []string{s.key(key)}, nil).Wait()
		if err != nil {
			return err
		}
		entries = data.EntryValues
		return nil
	})
	if err != nil {
		return fmt.Errorf("alps: failed to fetch IMAP store entry %q: %v", key, err)
	}
	v, ok := entries[s.key(key)]
	if !ok {
	if !ok || v == nil {
		return ErrNoStoreEntry
	}
	if err := json.Unmarshal([]byte(v), out); err != nil {
	if err := json.Unmarshal(*v, out); err != nil {
		return fmt.Errorf("alps: failed to unmarshal IMAP store entry %q: %v", key, err)
	}
	return s.cache.Put(key, out)


@@ 126,12 123,9 @@ func (s *imapStore) Put(key string, v interface{}) error {
	if err != nil {
		return fmt.Errorf("alps: failed to marshal IMAP store entry %q: %v", key, err)
	}
	entries := map[string]string{
		s.key(key): string(b),
	}
	entries := map[string]*[]byte{s.key(key): &b}
	err = s.session.DoIMAP(func(c *imapclient.Client) error {
		mc := imapmetadata.NewClient(c)
		return mc.SetMetadata("", entries)
		return c.SetMetadata("", entries).Wait()
	})
	if err != nil {
		return fmt.Errorf("alps: failed to put IMAP store entry %q: %v", key, err)

M themes/alps/mailbox.html => themes/alps/mailbox.html +6 -6
@@ 23,15 23,15 @@

          {{ if and (not (.HasFlag "\\Deleted")) .Envelope }}
          <div class="message-list-checkbox {{$classes}}">
            <input type="checkbox" name="uids" value="{{.Uid}}" form="messages-form">
            <input type="checkbox" name="uids" value="{{.UID}}" form="messages-form">
          </div>
          <div class="message-list-sender {{$classes}}">
            {{ range .Envelope.From }}
            <a href='?query=from:"{{.MailboxName}}@{{.HostName}}"'>
            {{ if .PersonalName }}
              {{.PersonalName}}
            <a href='?query=from:"{{.Addr}}"'>
            {{ if .Name }}
              {{.Name}}
            {{ else }}
              {{.MailboxName}}@{{.HostName}}
              {{.Addr}}
            {{ end }}
            </a>
            {{ end }}


@@ 41,7 41,7 @@
            {{if .HasFlag "\\Answered"}}<span class="Replied">↩</span>{{end}}
            {{if .HasFlag "$Forwarded"}}<span class="Forwarded">↪</span>{{end}}
            <form method="POST" action="/message/{{.Mailbox}}/flag">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.UID}}">
              {{ if .HasFlag "\\Flagged" -}}
              <input type="hidden" name="action" value="remove">
              {{ else }}

M themes/alps/message.html => themes/alps/message.html +9 -9
@@ 15,7 15,7 @@

            {{ if and (ne .Mailbox.Name "Archive") (ne .Mailbox.Name "Drafts") (ne .Mailbox.Name "Sent") }}
            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/move">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <input type="hidden" name="to" value="Archive">
              <input type="hidden" name="next" value="{{$back}}">
              <button>Archive</button>


@@ 24,7 24,7 @@

            {{ if and (ne .Mailbox.Name "INBOX") (ne .Mailbox.Name "Sent") (ne .Mailbox.Name "Drafts") }}
            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/move">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <input type="hidden" name="to" value="INBOX">
              <button>
              {{ if (eq .Mailbox.Name "Junk") }}


@@ 38,7 38,7 @@

            {{ if or (eq .Mailbox.Name "INBOX") (eq .Mailbox.Name "Trash") }}
            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/move">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <input type="hidden" name="next" value="{{$back}}">
              <input type="hidden" name="to" value="Junk">
              <button>Report Spam</button>


@@ 47,13 47,13 @@

            {{ if or (eq .Mailbox.Name "Trash") (eq .Mailbox.Name "Junk") }}
            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/delete">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <input type="hidden" name="next" value="{{$back}}">
              <button>Delete Permanently</button>
            </form>
            {{ else }}
            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/move">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <input type="hidden" name="next" value="{{$back}}">
              <input type="hidden" name="to" value="Trash">
              <button>Delete</button>


@@ 61,7 61,7 @@
            {{ end }}

            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/flag">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <input type="hidden" name="action" value="remove">
              <input type="hidden" name="flags" value="\Seen">
              <input type="hidden" name="next" value="{{$back}}">


@@ 69,7 69,7 @@
            </form>

            <form class="action-group" method="post" action="/message/{{.Mailbox.Name | pathescape}}/move">
              <input type="hidden" name="uids" value="{{.Message.Uid}}">
              <input type="hidden" name="uids" value="{{.Message.UID}}">
              <select class="action-group" name="to">
                {{range .Mailboxes}}
                  <option value="{{.Name}}" {{if eq .Name $.Mailbox.Name}}selected>Move to...{{else}}>{{.Name}}{{ end }}</option>


@@ 176,8 176,8 @@
      {{define "addr-list"}}
        {{range $i, $addr := .}}
          {{if $i}},{{end}}
          <strong>{{.PersonalName}}</strong>
          &lt;<a href="/compose?to={{.Address}}">{{.Address}}</a>&gt;
          <strong>{{.Name}}</strong>
          &lt;<a href="/compose?to={{.Addr}}">{{.Addr}}</a>&gt;
        {{end}}
      {{end}}


M themes/alps/util.html => themes/alps/util.html +2 -4
@@ 9,10 9,8 @@
    {{- end -}}
    {{- if .Info.HasAttr "\\HasChildren" }}/{{ end }}
  </a>
  {{ if .Status }}
  {{ if .Status.Unseen }}
  <span class="unseen">({{.Status.Unseen}})</span>
  {{ end }}
  {{ if gt .Info.Unseen 0 }}
  <span class="unseen">({{.Info.Unseen}})</span>
  {{ end }}
</li>
{{ else }}