alps/plugins/base/imap.go
Drew DeVault cbeacf9d06 Copy unsent messages to Outbox
This patch:

1. Copies unsent messages to the outbox before attempting to deliver
   them with SMTP
2. Deletes those messages once they're sent, or leaves them if an error
   occured
3. Updates the message list to make it obvious when there are unsent
   messages in the outbox
2020-10-30 11:47:23 -04:00

619 lines
13 KiB
Go

package alpsbase
import (
"bufio"
"bytes"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/emersion/go-imap"
"github.com/emersion/go-message"
"github.com/emersion/go-message/textproto"
imapclient "github.com/emersion/go-imap/client"
imapspecialuse "github.com/emersion/go-imap-specialuse"
)
type MailboxInfo struct {
*imap.MailboxInfo
Active bool
Total int
Unseen int
}
func (mbox *MailboxInfo) URL() *url.URL {
return &url.URL{
Path: fmt.Sprintf("/mailbox/%v", url.PathEscape(mbox.Name)),
}
}
func (mbox *MailboxInfo) HasAttr(flag string) bool {
for _, attr := range mbox.Attributes {
if attr == flag {
return true
}
}
return false
}
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 {
mailboxes = append(mailboxes, MailboxInfo{mbox, false, -1, -1})
}
if err := <-done; 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" {
return true
}
if mailboxes[j].Name == "INBOX" {
return false
}
return mailboxes[i].Name < mailboxes[j].Name
})
return mailboxes, nil
}
type MailboxStatus struct {
*imap.MailboxStatus
}
func (mbox *MailboxStatus) URL() *url.URL {
return &url.URL{
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)
if err != nil {
return nil, fmt.Errorf("failed to get mailbox status: %v", err)
}
return &MailboxStatus{status}, nil
}
type mailboxType int
const (
mailboxSent mailboxType = iota
mailboxOutbox mailboxType = iota
mailboxDrafts
)
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 fallbackNames []string
switch mboxType {
case mailboxSent:
attr = imapspecialuse.Sent
fallbackNames = []string{"Sent"}
case mailboxDrafts:
attr = imapspecialuse.Drafts
fallbackNames = []string{"Draft", "Drafts"}
case mailboxOutbox:
attr = ""
fallbackNames = []string{"Outbox"}
}
var attrMatched bool
var best *imap.MailboxInfo
for mbox := range ch {
for _, a := range mbox.Attributes {
if attr == a {
best = mbox
attrMatched = true
break
}
}
if attrMatched {
break
}
for _, fallback := range fallbackNames {
if strings.EqualFold(fallback, mbox.Name) {
best = mbox
break
}
}
}
if err := <-done; err != nil {
return nil, fmt.Errorf("failed to get mailbox with attribute %q: %v", attr, err)
}
if best == nil {
return nil, nil
}
return &MailboxInfo{best, false, -1, -1}, nil
}
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 {
return fmt.Errorf("failed to select mailbox: %v", err)
}
}
return nil
}
type IMAPMessage struct {
*imap.Message
Mailbox string
}
func (msg *IMAPMessage) URL() *url.URL {
return &url.URL{
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{
Path: path,
MIMEType: strings.ToLower(part.MIMEType + "/" + part.MIMESubType),
Filename: filename,
Message: msg,
Size: part.Size,
}
}
func (msg *IMAPMessage) TextPart() *IMAPPartNode {
if msg.BodyStructure == nil {
return nil
}
var best *IMAPPartNode
isTextPlain := false
msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
if !strings.EqualFold(part.MIMEType, "text") {
return true
}
if part.Disposition != "" && !strings.EqualFold(part.Disposition, "inline") {
return true
}
switch strings.ToLower(part.MIMESubType) {
case "plain":
isTextPlain = true
best = newIMAPPartNode(msg, path, part)
case "html":
if !isTextPlain {
best = newIMAPPartNode(msg, path, part)
}
}
return true
})
return best
}
func (msg *IMAPMessage) HTMLPart() *IMAPPartNode {
if msg.BodyStructure == nil {
return nil
}
var best *IMAPPartNode
msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
if !strings.EqualFold(part.MIMEType, "text") {
return true
}
if part.Disposition != "" && !strings.EqualFold(part.Disposition, "inline") {
return true
}
if part.MIMESubType == "html" {
best = newIMAPPartNode(msg, path, part)
}
return true
})
return best
}
func (msg *IMAPMessage) Attachments() []IMAPPartNode {
if msg.BodyStructure == nil {
return nil
}
var attachments []IMAPPartNode
msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
if !strings.EqualFold(part.Disposition, "attachment") {
return true
}
attachments = append(attachments, *newIMAPPartNode(msg, path, part))
return true
})
return attachments
}
func pathsEqual(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func (msg *IMAPMessage) PartByPath(path []int) *IMAPPartNode {
if msg.BodyStructure == nil {
return nil
}
if len(path) == 0 {
return newIMAPPartNode(msg, nil, msg.BodyStructure)
}
var result *IMAPPartNode
msg.BodyStructure.Walk(func(p []int, part *imap.BodyStructure) bool {
if result == nil && pathsEqual(path, p) {
result = newIMAPPartNode(msg, p, part)
}
return result == nil
})
return result
}
func (msg *IMAPMessage) PartByID(id string) *IMAPPartNode {
if msg.BodyStructure == nil || id == "" {
return nil
}
var result *IMAPPartNode
msg.BodyStructure.Walk(func(path []int, part *imap.BodyStructure) bool {
if result == nil && part.Id == "<"+id+">" {
result = newIMAPPartNode(msg, path, part)
}
return result == nil
})
return result
}
type IMAPPartNode struct {
Path []int
MIMEType string
Filename string
Children []IMAPPartNode
Message *IMAPMessage
Size uint32
}
func (node IMAPPartNode) PathString() string {
l := make([]string, len(node.Path))
for i, partNum := range node.Path {
l[i] = strconv.Itoa(partNum)
}
return strings.Join(l, ".")
}
func (node IMAPPartNode) SizeString() string {
return humanize.IBytes(uint64(node.Size))
}
func (node IMAPPartNode) URL(raw bool) *url.URL {
u := node.Message.URL()
if raw {
u.Path += "/raw"
}
q := u.Query()
q.Set("part", node.PathString())
u.RawQuery = q.Encode()
return u
}
func (node IMAPPartNode) IsText() bool {
return strings.HasPrefix(strings.ToLower(node.MIMEType), "text/")
}
func (node IMAPPartNode) String() string {
if node.Filename != "" {
return fmt.Sprintf("%s (%s)", node.Filename, node.MIMEType)
} else {
return node.MIMEType
}
}
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()
node := &IMAPPartNode{
Path: path,
MIMEType: strings.ToLower(bs.MIMEType + "/" + bs.MIMESubType),
Filename: filename,
Children: make([]IMAPPartNode, len(bs.Parts)),
Message: msg,
Size: bs.Size,
}
for i, part := range bs.Parts {
num := i + 1
partPath := append([]int(nil), path...)
partPath = append(partPath, num)
node.Children[i] = *imapPartTree(msg, part, partPath)
}
return node
}
func (msg *IMAPMessage) PartTree() *IMAPPartNode {
if msg.BodyStructure == nil {
return nil
}
return imapPartTree(msg, msg.BodyStructure, nil)
}
func (msg *IMAPMessage) HasFlag(flag string) bool {
for _, f := range msg.Flags {
if imap.CanonicalFlag(f) == flag {
return true
}
}
return false
}
func listMessages(conn *imapclient.Client, mbox *MailboxStatus, page, messagesPerPage int) ([]IMAPMessage, error) {
if err := ensureMailboxSelected(conn, mbox.Name); err != nil {
return nil, err
}
to := int(mbox.Messages) - page*messagesPerPage
from := to - messagesPerPage + 1
if from <= 0 {
from = 1
}
if to <= 0 {
return nil, nil
}
var seqSet imap.SeqSet
seqSet.AddRange(uint32(from), uint32(to))
fetch := []imap.FetchItem{
imap.FetchFlags,
imap.FetchEnvelope,
imap.FetchUid,
imap.FetchBodyStructure,
}
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})
}
if err := <-done; err != nil {
return nil, fmt.Errorf("failed to fetch message list: %v", err)
}
// Reverse list of messages
for i := len(msgs)/2 - 1; i >= 0; i-- {
opp := len(msgs) - 1 - i
msgs[i], msgs[opp] = msgs[opp], msgs[i]
}
return msgs, nil
}
func searchMessages(conn *imapclient.Client, mboxName, query string, page, messagesPerPage int) (msgs []IMAPMessage, total int, err error) {
if err := ensureMailboxSelected(conn, mboxName); err != nil {
return nil, 0, err
}
criteria := PrepareSearch(query)
nums, err := conn.Search(criteria)
if err != nil {
return nil, 0, fmt.Errorf("UID SEARCH failed: %v", err)
}
total = len(nums)
from := page * messagesPerPage
to := from + messagesPerPage
if from >= len(nums) {
return nil, total, nil
}
if to > len(nums) {
to = len(nums)
}
nums = nums[from:to]
indexes := make(map[uint32]int)
for i, num := range nums {
indexes[num] = i
}
var seqSet imap.SeqSet
seqSet.AddNum(nums...)
fetch := []imap.FetchItem{
imap.FetchEnvelope,
imap.FetchFlags,
imap.FetchUid,
imap.FetchBodyStructure,
}
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 {
i, ok := indexes[msg.SeqNum]
if !ok {
continue
}
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
}
func getMessagePart(conn *imapclient.Client, mboxName string, uid uint32, partPath []int) (*IMAPMessage, *message.Entity, error) {
if err := ensureMailboxSelected(conn, mboxName); err != nil {
return nil, nil, err
}
seqSet := new(imap.SeqSet)
seqSet.AddNum(uid)
var partHeaderSection imap.BodySectionName
partHeaderSection.Peek = true
if len(partPath) > 0 {
partHeaderSection.Specifier = imap.MIMESpecifier
} else {
partHeaderSection.Specifier = imap.HeaderSpecifier
}
partHeaderSection.Path = partPath
var partBodySection imap.BodySectionName
if len(partPath) > 0 {
partBodySection.Specifier = imap.EntireSpecifier
} else {
partBodySection.Specifier = imap.TextSpecifier
}
partBodySection.Path = partPath
fetch := []imap.FetchItem{
imap.FetchEnvelope,
imap.FetchUid,
imap.FetchBodyStructure,
imap.FetchFlags,
imap.FetchRFC822Size,
partHeaderSection.FetchItem(),
partBodySection.FetchItem(),
}
ch := make(chan *imap.Message, 1)
if err := conn.UidFetch(seqSet, fetch, ch); err != nil {
return nil, nil, fmt.Errorf("failed to fetch message: %v", err)
}
msg := <-ch
if msg == nil {
return nil, nil, fmt.Errorf("server didn't return message")
}
body := msg.GetBody(&partHeaderSection)
if body == nil {
return nil, nil, fmt.Errorf("server didn't return message")
}
headerReader := bufio.NewReader(body)
h, err := textproto.ReadHeader(headerReader)
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))
if err != nil {
return nil, nil, fmt.Errorf("failed to create message reader: %v", err)
}
return &IMAPMessage{msg, mboxName}, part, nil
}
func markMessageAnswered(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.AnsweredFlag}
return conn.UidStore(seqSet, item, flags, nil)
}
func appendMessage(c *imapclient.Client, msg *OutgoingMessage, mboxType mailboxType) (saved bool, err error) {
mbox, err := getMailboxByType(c, mboxType)
if err != nil {
return false, err
}
if mbox == nil {
return false, nil
}
// IMAP needs to know in advance the final size of the message, so
// there's no way around storing it in a buffer here.
var buf bytes.Buffer
if err := msg.WriteTo(&buf); err != nil {
return false, err
}
flags := []string{imap.SeenFlag}
if mboxType == mailboxDrafts {
flags = append(flags, imap.DraftFlag)
}
if err := c.Append(mbox.Name, flags, time.Now(), &buf); err != nil {
return false, err
}
return true, nil
}
func deleteMessage(c *imapclient.Client, mboxName string, uid uint32) error {
if err := ensureMailboxSelected(c, 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 {
return err
}
return c.Expunge(nil)
}