alps/plugins/base/routes.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

1135 lines
29 KiB
Go

package alpsbase
import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"git.sr.ht/~emersion/alps"
"git.sr.ht/~sircmpwn/dowork"
"github.com/emersion/go-imap"
imapmove "github.com/emersion/go-imap-move"
imapclient "github.com/emersion/go-imap/client"
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-smtp"
"github.com/labstack/echo/v4"
)
func registerRoutes(p *alps.GoPlugin) {
p.GET("/", func(ctx *alps.Context) error {
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
})
p.GET("/mailbox/:mbox", handleGetMailbox)
p.POST("/mailbox/:mbox", handleGetMailbox)
p.GET("/message/:mbox/:uid", func(ctx *alps.Context) error {
return handleGetPart(ctx, false)
})
p.GET("/message/:mbox/:uid/raw", func(ctx *alps.Context) error {
return handleGetPart(ctx, true)
})
p.GET("/login", handleLogin)
p.POST("/login", handleLogin)
p.GET("/logout", handleLogout)
p.GET("/compose", handleComposeNew)
p.POST("/compose", handleComposeNew)
p.POST("/compose/attachment", handleComposeAttachment)
p.GET("/message/:mbox/:uid/reply", handleReply)
p.POST("/message/:mbox/:uid/reply", handleReply)
p.GET("/message/:mbox/:uid/forward", handleForward)
p.POST("/message/:mbox/:uid/forward", handleForward)
p.GET("/message/:mbox/:uid/edit", handleEdit)
p.POST("/message/:mbox/:uid/edit", handleEdit)
p.POST("/message/:mbox/move", handleMove)
p.POST("/message/:mbox/delete", handleDelete)
p.POST("/message/:mbox/flag", handleSetFlags)
p.GET("/settings", handleSettings)
p.POST("/settings", handleSettings)
}
type IMAPBaseRenderData struct {
alps.BaseRenderData
CategorizedMailboxes CategorizedMailboxes
Mailboxes []MailboxInfo
Mailbox *MailboxStatus
Inbox *MailboxStatus
Outbox *MailboxStatus
}
type MailboxRenderData struct {
IMAPBaseRenderData
Messages []IMAPMessage
PrevPage, NextPage int
Query string
}
// Organizes mailboxes into common/uncommon categories
type CategorizedMailboxes struct {
Common struct {
Inbox *MailboxInfo
Drafts *MailboxInfo
Outbox *MailboxInfo
Sent *MailboxInfo
Junk *MailboxInfo
Trash *MailboxInfo
Archive *MailboxInfo
}
Additional []*MailboxInfo
}
func newIMAPBaseRenderData(ctx *alps.Context,
base *alps.BaseRenderData) (*IMAPBaseRenderData, error) {
mboxName, err := url.PathUnescape(ctx.Param("mbox"))
if err != nil {
return nil, echo.NewHTTPError(http.StatusBadRequest, err)
}
var mailboxes []MailboxInfo
var active, inbox, outbox *MailboxStatus
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
if mailboxes, err = listMailboxes(c); err != nil {
return err
}
if mboxName != "" {
if active, err = getMailboxStatus(c, mboxName); err != nil {
return err
}
}
if mboxName == "INBOX" {
inbox = active
} else {
if inbox, err = getMailboxStatus(c, "INBOX"); err != nil {
return err
}
}
if mboxName == "Outbox" {
outbox = active
} else {
if outbox, err = getMailboxStatus(c, "Outbox"); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
var categorized CategorizedMailboxes
mmap := map[string]**MailboxInfo{
"INBOX": &categorized.Common.Inbox,
"Drafts": &categorized.Common.Drafts,
"Outbox": &categorized.Common.Outbox,
"Sent": &categorized.Common.Sent,
"Junk": &categorized.Common.Junk,
"Trash": &categorized.Common.Trash,
"Archive": &categorized.Common.Archive,
}
for i, _ := range mailboxes {
// Populate unseen & active states
if active != nil && mailboxes[i].Name == active.Name {
mailboxes[i].Unseen = int(active.Unseen)
mailboxes[i].Total = int(active.Messages)
mailboxes[i].Active = true
}
if mailboxes[i].Name == inbox.Name {
mailboxes[i].Unseen = int(inbox.Unseen)
mailboxes[i].Total = int(inbox.Messages)
}
if mailboxes[i].Name == outbox.Name {
mailboxes[i].Unseen = int(outbox.Unseen)
mailboxes[i].Total = int(outbox.Messages)
}
if ptr, ok := mmap[mailboxes[i].Name]; ok {
*ptr = &mailboxes[i]
} else {
categorized.Additional = append(
categorized.Additional, &mailboxes[i])
}
}
return &IMAPBaseRenderData{
BaseRenderData: *base,
CategorizedMailboxes: categorized,
Mailboxes: mailboxes,
Inbox: inbox,
Mailbox: active,
}, nil
}
func handleGetMailbox(ctx *alps.Context) error {
ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
if err != nil {
return err
}
mbox := ibase.Mailbox
title := mbox.Name
if title == "INBOX" {
title = "Inbox"
}
if mbox.Unseen > 0 {
title = fmt.Sprintf("(%d) %s", mbox.Unseen, title)
}
ibase.BaseRenderData.WithTitle(title)
page := 0
if pageStr := ctx.QueryParam("page"); pageStr != "" {
var err error
if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
}
}
settings, err := loadSettings(ctx.Session.Store())
if err != nil {
return err
}
messagesPerPage := settings.MessagesPerPage
query := ctx.QueryParam("query")
var (
msgs []IMAPMessage
total int
)
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
if query != "" {
msgs, total, err = searchMessages(c, mbox.Name, query, page, messagesPerPage)
} else {
msgs, err = listMessages(c, mbox, page, messagesPerPage)
}
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
prevPage, nextPage := -1, -1
if query != "" {
if page > 0 {
prevPage = page - 1
}
if (page+1)*messagesPerPage <= total {
nextPage = page + 1
}
} else {
if page > 0 {
prevPage = page - 1
}
if (page+1)*messagesPerPage < int(mbox.Messages) {
nextPage = page + 1
}
}
return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
IMAPBaseRenderData: *ibase,
Messages: msgs,
PrevPage: prevPage,
NextPage: nextPage,
Query: query,
})
}
func handleLogin(ctx *alps.Context) error {
username := ctx.FormValue("username")
password := ctx.FormValue("password")
remember := ctx.FormValue("remember-me")
renderData := struct {
alps.BaseRenderData
CanRememberMe bool
}{
BaseRenderData: *alps.NewBaseRenderData(ctx),
CanRememberMe: ctx.Server.Options.LoginKey != nil,
}
if username == "" && password == "" {
username, password = ctx.GetLoginToken()
}
if username != "" && password != "" {
s, err := ctx.Server.Sessions.Put(username, password)
if err != nil {
if _, ok := err.(alps.AuthError); ok {
return ctx.Render(http.StatusOK, "login.html", &renderData)
}
return fmt.Errorf("failed to put connection in pool: %v", err)
}
ctx.SetSession(s)
if remember == "on" {
ctx.SetLoginToken(username, password)
}
if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" {
return ctx.Redirect(http.StatusFound, path)
}
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
}
return ctx.Render(http.StatusOK, "login.html", &renderData)
}
func handleLogout(ctx *alps.Context) error {
ctx.Session.Close()
ctx.SetSession(nil)
ctx.SetLoginToken("", "")
return ctx.Redirect(http.StatusFound, "/login")
}
type MessageRenderData struct {
IMAPBaseRenderData
Message *IMAPMessage
Part *IMAPPartNode
View interface{}
MailboxPage int
Flags map[string]bool
}
func handleGetPart(ctx *alps.Context, raw bool) error {
_, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
if err != nil {
return err
}
mbox := ibase.Mailbox
partPath, err := parsePartPath(ctx.QueryParam("part"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
settings, err := loadSettings(ctx.Session.Store())
if err != nil {
return err
}
messagesPerPage := settings.MessagesPerPage
var msg *IMAPMessage
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
if msg, part, err = getMessagePart(c, mbox.Name, uid, partPath); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
if len(partPath) == 0 {
if ctx.QueryParam("plain") == "1" {
mimeType = "text/plain"
} else {
mimeType = "message/rfc822"
}
}
if raw {
ctx.Response().Header().Set("Content-Type", mimeType)
disp, dispParams, _ := part.Header.ContentDisposition()
filename := dispParams["filename"]
if len(partPath) == 0 {
filename = msg.Envelope.Subject + ".eml"
}
// TODO: set Content-Length if possible
// Be careful not to serve types like text/html as inline
if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
dispParams := make(map[string]string)
if filename != "" {
dispParams["filename"] = filename
}
disp := mime.FormatMediaType("attachment", dispParams)
ctx.Response().Header().Set("Content-Disposition", disp)
}
if len(partPath) == 0 {
return part.WriteTo(ctx.Response())
} else {
return ctx.Stream(http.StatusOK, mimeType, part.Body)
}
}
view, err := viewMessagePart(ctx, msg, part)
if err == ErrViewUnsupported {
view = nil
}
flags := make(map[string]bool)
for _, f := range mbox.PermanentFlags {
f = imap.CanonicalFlag(f)
if f == imap.TryCreateFlag {
continue
}
flags[f] = msg.HasFlag(f)
}
ibase.BaseRenderData.WithTitle(msg.Envelope.Subject)
return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
IMAPBaseRenderData: *ibase,
Message: msg,
Part: msg.PartByPath(partPath),
View: view,
MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
Flags: flags,
})
}
type ComposeRenderData struct {
IMAPBaseRenderData
Message *OutgoingMessage
}
type messagePath struct {
Mailbox string
Uid uint32
}
type composeOptions struct {
Draft *messagePath
Forward *messagePath
InReplyTo *messagePath
}
// Send message, append it to the Sent mailbox, mark the original message as
// answered
func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
msg.Ref(3)
err := ctx.Session.DoIMAP(func(c *imapclient.Client) error {
// (disregard error, we don't care if Outbox already existed)
c.Create("Outbox")
if _, err := appendMessage(c, msg, mailboxOutbox); err != nil {
return err
}
msg.Unref()
return nil
})
if err != nil {
return fmt.Errorf("failed to save message to outbox: %v", err)
}
task := work.NewTask(func(_ context.Context) error {
err := ctx.Session.DoSMTP(func (c *smtp.Client) error {
return sendMessage(c, msg)
})
if err != nil {
ctx.Logger().Printf("Error sending email: %v\n", err)
}
return err
}).Retries(5).After(func(_ context.Context, task *work.Task) {
ctx.Logger().Printf("email sent: %v", task.Result())
if task.Result() == nil {
// Remove from outbox
err := ctx.Session.DoIMAP(func(c *imapclient.Client) error {
ctx.Logger().Printf("DoIMAP")
if err := ensureMailboxSelected(c, "Outbox"); err != nil {
return err
}
uids, err := c.UidSearch(&imap.SearchCriteria{
Header: map[string][]string{
"Message-Id": []string{msg.MessageID},
},
})
if err != nil {
return fmt.Errorf("UID SEARCH failed: %v", err)
}
if len(uids) == 1 {
if err = deleteMessage(c, "Outbox", uids[0]); err != nil {
return err
}
} else {
ctx.Logger().Errorf(
"Unexpectedly found multiple results in outbox for message ID %s",
msg.MessageID)
}
return nil
})
if err != nil {
ctx.Logger().Errorf("Error removing message from outbox: %v", err)
}
} else {
ctx.Logger().Errorf("Message delivery failed with error %v", err)
}
msg.Unref()
})
err = ctx.Server.Queue.Enqueue(task)
if err != nil {
if _, ok := err.(alps.AuthError); ok {
return echo.NewHTTPError(http.StatusForbidden, err)
}
return fmt.Errorf("failed to send message: %v", err)
}
if inReplyTo := options.InReplyTo; inReplyTo != nil {
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid)
})
if err != nil {
return fmt.Errorf("failed to mark original message as answered: %v", err)
}
}
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
if _, err := appendMessage(c, msg, mailboxSent); err != nil {
return err
}
msg.Unref()
if draft := options.Draft; draft != nil {
if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("failed to save message to Sent mailbox: %v", err)
}
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
}
func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
if err != nil {
return err
}
if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') {
msg.From = ctx.Session.Username()
}
if ctx.Request().Method == http.MethodPost {
formParams, err := ctx.FormParams()
if err != nil {
return fmt.Errorf("failed to parse form: %v", err)
}
_, saveAsDraft := formParams["save_as_draft"]
msg.From = ctx.FormValue("from")
msg.To = parseAddressList(ctx.FormValue("to"))
msg.Subject = ctx.FormValue("subject")
msg.Text = ctx.FormValue("text")
msg.InReplyTo = ctx.FormValue("in_reply_to")
form, err := ctx.MultipartForm()
if err != nil {
return fmt.Errorf("failed to get multipart form: %v", err)
}
// Fetch previous attachments from original message
var original *messagePath
if options.Draft != nil {
original = options.Draft
} else if options.Forward != nil {
original = options.Forward
}
if original != nil {
for _, s := range form.Value["prev_attachments"] {
path, err := parsePartPath(s)
if err != nil {
return fmt.Errorf("failed to parse original attachment path: %v", err)
}
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
_, part, err = getMessagePart(c, original.Mailbox, original.Uid, path)
return err
})
if err != nil {
return fmt.Errorf("failed to fetch attachment from original message: %v", err)
}
var buf bytes.Buffer
if _, err := io.Copy(&buf, part.Body); err != nil {
return fmt.Errorf("failed to copy attachment from original message: %v", err)
}
h := mail.AttachmentHeader{part.Header}
mimeType, _, _ := h.ContentType()
filename, _ := h.Filename()
msg.Attachments = append(msg.Attachments, &imapAttachment{
Mailbox: original.Mailbox,
Uid: original.Uid,
Node: &IMAPPartNode{
Path: path,
MIMEType: mimeType,
Filename: filename,
},
Body: buf.Bytes(),
})
}
} else if len(form.Value["prev_attachments"]) > 0 {
return fmt.Errorf("previous attachments specified but no original message available")
}
for _, fh := range form.File["attachments"] {
msg.Attachments = append(msg.Attachments, &formAttachment{fh})
}
uuids := ctx.FormValue("attachment-uuids")
for _, uuid := range strings.Split(uuids, ",") {
if uuid == "" {
continue
}
attachment := ctx.Session.PopAttachment(uuid)
if attachment == nil {
return fmt.Errorf("Unable to retrieve message attachment %s from session", uuid)
}
msg.Attachments = append(msg.Attachments, &refcountedAttachment{
attachment.File,
attachment.Form,
0,
})
}
if saveAsDraft {
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
copied, err := appendMessage(c, msg, mailboxDrafts)
if err != nil {
return err
}
if !copied {
return fmt.Errorf("no Draft mailbox found")
}
if draft := options.Draft; draft != nil {
if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("failed to save message to Draft mailbox: %v", err)
}
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
} else {
return submitCompose(ctx, msg, options)
}
}
return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
IMAPBaseRenderData: *ibase,
Message: msg,
})
}
func handleComposeNew(ctx *alps.Context) error {
// These are common mailto URL query parameters
// TODO: cc, bcc
return handleCompose(ctx, &OutgoingMessage{
To: strings.Split(ctx.QueryParam("to"), ","),
Subject: ctx.QueryParam("subject"),
Text: ctx.QueryParam("body"),
MessageID: mail.GenerateMessageID(),
InReplyTo: ctx.QueryParam("in-reply-to"),
}, &composeOptions{})
}
func handleComposeAttachment(ctx *alps.Context) error {
reader, err := ctx.Request().MultipartReader()
if err != nil {
return fmt.Errorf("failed to get multipart form: %v", err)
}
form, err := reader.ReadForm(32 << 20) // 32 MB
if err != nil {
return fmt.Errorf("failed to decode multipart form: %v", err)
}
var uuids []string
for _, fh := range form.File["attachments"] {
uuid, err := ctx.Session.PutAttachment(fh, form)
if err != nil {
return err
}
uuids = append(uuids, uuid)
}
return ctx.JSON(http.StatusOK, &uuids)
}
func unwrapIMAPAddressList(addrs []*imap.Address) []string {
l := make([]string, len(addrs))
for i, addr := range addrs {
l[i] = addr.Address()
}
return l
}
func handleReply(ctx *alps.Context) error {
var inReplyToPath messagePath
var err error
inReplyToPath.Mailbox, inReplyToPath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var msg OutgoingMessage
if ctx.Request().Method == http.MethodGet {
// Populate fields from original message
partPath, err := parsePartPath(ctx.QueryParam("part"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var inReplyTo *IMAPMessage
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath)
return err
})
if err != nil {
return err
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
if !strings.EqualFold(mimeType, "text/plain") {
err := fmt.Errorf("cannot reply to %q part", mimeType)
return echo.NewHTTPError(http.StatusBadRequest, err)
}
// TODO: strip HTML tags if text/html
msg.Text, err = quote(part.Body)
if err != nil {
return err
}
msg.MessageID = mail.GenerateMessageID()
msg.InReplyTo = inReplyTo.Envelope.MessageId
// TODO: populate From from known user addresses and inReplyTo.Envelope.To
replyTo := inReplyTo.Envelope.ReplyTo
if len(replyTo) == 0 {
replyTo = inReplyTo.Envelope.From
}
msg.To = unwrapIMAPAddressList(replyTo)
msg.Subject = inReplyTo.Envelope.Subject
if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
msg.Subject = "Re: " + msg.Subject
}
}
return handleCompose(ctx, &msg, &composeOptions{InReplyTo: &inReplyToPath})
}
func handleForward(ctx *alps.Context) error {
var sourcePath messagePath
var err error
sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var msg OutgoingMessage
if ctx.Request().Method == http.MethodGet {
// Populate fields from original message
partPath, err := parsePartPath(ctx.QueryParam("part"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var source *IMAPMessage
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
return err
})
if err != nil {
return err
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
if !strings.EqualFold(mimeType, "text/plain") {
err := fmt.Errorf("cannot forward %q part", mimeType)
return echo.NewHTTPError(http.StatusBadRequest, err)
}
msg.Text, err = quote(part.Body)
if err != nil {
return err
}
msg.MessageID = mail.GenerateMessageID()
msg.Subject = source.Envelope.Subject
if !strings.HasPrefix(strings.ToLower(msg.Subject), "fwd:") &&
!strings.HasPrefix(strings.ToLower(msg.Subject), "fw:") {
msg.Subject = "Fwd: " + msg.Subject
}
msg.InReplyTo = source.Envelope.InReplyTo
attachments := source.Attachments()
for i := range attachments {
// No need to populate attachment body here, we just need the
// metadata
msg.Attachments = append(msg.Attachments, &imapAttachment{
Mailbox: sourcePath.Mailbox,
Uid: sourcePath.Uid,
Node: &attachments[i],
})
}
}
return handleCompose(ctx, &msg, &composeOptions{Forward: &sourcePath})
}
func handleEdit(ctx *alps.Context) error {
var sourcePath messagePath
var err error
sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
// TODO: somehow get the path to the In-Reply-To message (with a search?)
var msg OutgoingMessage
if ctx.Request().Method == http.MethodGet {
// Populate fields from source message
partPath, err := parsePartPath(ctx.QueryParam("part"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var source *IMAPMessage
var part *message.Entity
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
var err error
source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
return err
})
if err != nil {
return err
}
mimeType, _, err := part.Header.ContentType()
if err != nil {
return fmt.Errorf("failed to parse part Content-Type: %v", err)
}
if !strings.EqualFold(mimeType, "text/plain") {
err := fmt.Errorf("cannot edit %q part", mimeType)
return echo.NewHTTPError(http.StatusBadRequest, err)
}
b, err := ioutil.ReadAll(part.Body)
if err != nil {
return fmt.Errorf("failed to read part body: %v", err)
}
msg.Text = string(b)
if len(source.Envelope.From) > 0 {
msg.From = source.Envelope.From[0].Address()
}
msg.To = unwrapIMAPAddressList(source.Envelope.To)
msg.Subject = source.Envelope.Subject
msg.InReplyTo = source.Envelope.InReplyTo
msg.MessageID = source.Envelope.MessageId
attachments := source.Attachments()
for i := range attachments {
// No need to populate attachment body here, we just need the
// metadata
msg.Attachments = append(msg.Attachments, &imapAttachment{
Mailbox: sourcePath.Mailbox,
Uid: sourcePath.Uid,
Node: &attachments[i],
})
}
}
return handleCompose(ctx, &msg, &composeOptions{Draft: &sourcePath})
}
func formOrQueryParam(ctx *alps.Context, k string) string {
if v := ctx.FormValue(k); v != "" {
return v
}
return ctx.QueryParam(k)
}
func handleMove(ctx *alps.Context) error {
mboxName, err := url.PathUnescape(ctx.Param("mbox"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
formParams, err := ctx.FormParams()
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
uids, err := parseUidList(formParams["uids"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
to := formOrQueryParam(ctx, "to")
if to == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing 'to' form parameter")
}
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 {
return fmt.Errorf("failed to move message: %v", err)
}
// TODO: get the UID of the message in the destination mailbox with UIDPLUS
return nil
})
if err != nil {
return err
}
if path := formOrQueryParam(ctx, "next"); path != "" {
return ctx.Redirect(http.StatusFound, path)
}
return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
}
func handleDelete(ctx *alps.Context) error {
mboxName, err := url.PathUnescape(ctx.Param("mbox"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
formParams, err := ctx.FormParams()
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
uids, err := parseUidList(formParams["uids"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
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...)
item := imap.FormatFlagsOp(imap.AddFlags, true)
flags := []interface{}{imap.DeletedFlag}
if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
return fmt.Errorf("failed to add deleted flag: %v", err)
}
if err := c.Expunge(nil); 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 {
return err
}
if path := formOrQueryParam(ctx, "next"); path != "" {
return ctx.Redirect(http.StatusFound, path)
}
return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
}
func handleSetFlags(ctx *alps.Context) error {
mboxName, err := url.PathUnescape(ctx.Param("mbox"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
formParams, err := ctx.FormParams()
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
uids, err := parseUidList(formParams["uids"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
flags, ok := formParams["flags"]
if !ok {
flagsStr := ctx.QueryParam("to")
if flagsStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form parameter")
}
flags = strings.Fields(flagsStr)
}
actionStr := ctx.FormValue("action")
if actionStr == "" {
actionStr = ctx.QueryParam("action")
}
var op imap.FlagsOp
switch actionStr {
case "", "set":
op = imap.SetFlags
case "add":
op = imap.AddFlags
case "remove":
op = imap.RemoveFlags
default:
return echo.NewHTTPError(http.StatusBadRequest, "invalid 'action' value")
}
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)
}
return nil
})
if err != nil {
return err
}
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) {
// Redirecting to the message view would mark the message as read again
return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
}
return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uids[0]))
}
const settingsKey = "base.settings"
const maxMessagesPerPage = 100
type Settings struct {
MessagesPerPage int
}
func loadSettings(s alps.Store) (*Settings, error) {
settings := &Settings{
MessagesPerPage: 50,
}
if err := s.Get(settingsKey, settings); err != nil && err != alps.ErrNoStoreEntry {
return nil, err
}
if err := settings.check(); err != nil {
return nil, err
}
return settings, nil
}
func (s *Settings) check() error {
if s.MessagesPerPage <= 0 || s.MessagesPerPage > maxMessagesPerPage {
return fmt.Errorf("messages per page out of bounds: %v", s.MessagesPerPage)
}
return nil
}
type SettingsRenderData struct {
alps.BaseRenderData
Settings *Settings
}
func handleSettings(ctx *alps.Context) error {
settings, err := loadSettings(ctx.Session.Store())
if err != nil {
return fmt.Errorf("failed to load settings: %v", err)
}
if ctx.Request().Method == http.MethodPost {
settings.MessagesPerPage, err = strconv.Atoi(ctx.FormValue("messages_per_page"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid messages per page: %v", err)
}
if err := settings.check(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
if err := ctx.Session.Store().Put(settingsKey, settings); err != nil {
return fmt.Errorf("failed to save settings: %v", err)
}
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
}
return ctx.Render(http.StatusOK, "settings.html", &SettingsRenderData{
BaseRenderData: *alps.NewBaseRenderData(ctx),
Settings: settings,
})
}