alps/handlers.go
Simon Ser c5afd1a61b
Reconnect to IMAP server when logged out
The session manager has been upgraded to deal with reconnections.

Each session has its own expiration timer. Each time a request is
received, the expiration timer is reset.

A session can be closed (this is used when the user wants to logout).

When the IMAP connection is closed by the server, it's set to nil in the
session. The next time an IMAP command needs to be issued, the
connection is re-established.

Closes: https://todo.sr.ht/~sircmpwn/koushin/30
2019-12-09 19:35:15 +01:00

288 lines
7.1 KiB
Go

package koushin
import (
"fmt"
"io/ioutil"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/emersion/go-imap"
imapclient "github.com/emersion/go-imap/client"
"github.com/emersion/go-message"
"github.com/emersion/go-sasl"
"github.com/labstack/echo/v4"
)
type MailboxRenderData struct {
Mailbox *imap.MailboxStatus
Mailboxes []*imap.MailboxInfo
Messages []imapMessage
PrevPage, NextPage int
Extra map[string]interface{}
}
func handleGetMailbox(ectx echo.Context) error {
ctx := ectx.(*context)
mboxName, err := url.PathUnescape(ctx.Param("mbox"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
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")
}
}
var mailboxes []*imap.MailboxInfo
var msgs []imapMessage
var mbox *imap.MailboxStatus
err = ctx.session.Do(func(c *imapclient.Client) error {
var err error
if mailboxes, err = listMailboxes(c); err != nil {
return err
}
if msgs, err = listMessages(c, mboxName, page); err != nil {
return err
}
mbox = c.Mailbox()
return nil
})
if err != nil {
return err
}
prevPage, nextPage := -1, -1
if page > 0 {
prevPage = page - 1
}
if (page+1)*messagesPerPage < int(mbox.Messages) {
nextPage = page + 1
}
return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
Mailbox: mbox,
Mailboxes: mailboxes,
Messages: msgs,
PrevPage: prevPage,
NextPage: nextPage,
Extra: make(map[string]interface{}),
})
}
func handleLogin(ectx echo.Context) error {
ctx := ectx.(*context)
username := ctx.FormValue("username")
password := ctx.FormValue("password")
if username != "" && password != "" {
s, err := ctx.server.sessions.Put(username, password)
if err != nil {
if _, ok := err.(AuthError); ok {
return ctx.Render(http.StatusOK, "login.html", nil)
}
return fmt.Errorf("failed to put connection in pool: %v", err)
}
ctx.setToken(s.Token)
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
}
return ctx.Render(http.StatusOK, "login.html", nil)
}
func handleLogout(ectx echo.Context) error {
ctx := ectx.(*context)
ctx.session.Close()
ctx.setToken("")
return ctx.Redirect(http.StatusFound, "/login")
}
type MessageRenderData struct {
Mailbox *imap.MailboxStatus
Message *imapMessage
Body string
PartPath string
MailboxPage int
Extra map[string]interface{}
}
func handleGetPart(ctx *context, raw bool) error {
mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
partPathString := ctx.QueryParam("part")
partPath, err := parsePartPath(partPathString)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
var msg *imapMessage
var part *message.Entity
var mbox *imap.MailboxStatus
err = ctx.session.Do(func(c *imapclient.Client) error {
var err error
msg, part, err = getMessagePart(c, mboxName, uid, partPath)
mbox = c.Mailbox()
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 len(partPath) == 0 {
mimeType = "message/rfc822"
}
if raw {
disp, dispParams, _ := part.Header.ContentDisposition()
filename := dispParams["filename"]
// TODO: set Content-Length if possible
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)
}
return ctx.Stream(http.StatusOK, mimeType, part.Body)
}
var body string
if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
b, err := ioutil.ReadAll(part.Body)
if err != nil {
return fmt.Errorf("failed to read part body: %v", err)
}
body = string(b)
}
return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
Mailbox: mbox,
Message: msg,
Body: body,
PartPath: partPathString,
MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
Extra: make(map[string]interface{}),
})
}
type ComposeRenderData struct {
Message *OutgoingMessage
Extra map[string]interface{}
}
func handleCompose(ectx echo.Context) error {
ctx := ectx.(*context)
var msg OutgoingMessage
if strings.ContainsRune(ctx.session.username, '@') {
msg.From = ctx.session.username
}
if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" {
// This is a reply
mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
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.Do(func(c *imapclient.Client) error {
var err error
inReplyTo, part, err = getMessagePart(c, mboxName, 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.HasPrefix(strings.ToLower(mimeType), "text/") {
err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
return echo.NewHTTPError(http.StatusBadRequest, err)
}
msg.Text, err = quote(part.Body)
if err != nil {
return err
}
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
}
if len(replyTo) > 0 {
msg.To = make([]string, len(replyTo))
for i, to := range replyTo {
msg.To[i] = to.Address()
}
}
msg.Subject = inReplyTo.Envelope.Subject
if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
msg.Subject = "Re: " + msg.Subject
}
}
if ctx.Request().Method == http.MethodPost {
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")
c, err := ctx.server.connectSMTP()
if err != nil {
return err
}
defer c.Close()
auth := sasl.NewPlainClient("", ctx.session.username, ctx.session.password)
if err := c.Auth(auth); err != nil {
return echo.NewHTTPError(http.StatusForbidden, err)
}
if err := sendMessage(c, &msg); err != nil {
return err
}
if err := c.Quit(); err != nil {
return fmt.Errorf("QUIT failed: %v", err)
}
// TODO: append to IMAP Sent mailbox
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
}
return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
Message: &msg,
Extra: make(map[string]interface{}),
})
}