cbeacf9d06
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
618 lines
13 KiB
Go
618 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)
|
|
}
|