@@ -8,11 +8,13 @@ | |||
<h2>Compose new message</h2> | |||
<form method="post" action="/compose"> | |||
<form method="post" action=""> | |||
<input type="hidden" name="in_reply_to" value="{{.Message.InReplyTo}}"> | |||
<p>From:</p> | |||
<input type="email" name="from" value="{{.Message.From}}"> | |||
<p>To:</p> | |||
<input type="email" name="to" multiple> | |||
<input type="email" name="to" multiple value="{{.Message.ToString}}"> | |||
<p>Subject:</p> | |||
<input type="text" name="subject" value="{{.Message.Subject}}"> | |||
<p>Body:</p> | |||
@@ -40,6 +40,7 @@ | |||
<hr> | |||
{{if .Body}} | |||
<p><a href="{{.Message.Uid}}/reply?part={{.PartPath}}">Reply</a></p> | |||
<pre>{{.Body}}</pre> | |||
{{else}} | |||
<p>Can't preview this message part.</p> | |||
@@ -142,11 +142,7 @@ func handleLogin(ectx echo.Context) error { | |||
} | |||
func handleGetPart(ctx *context, raw bool) error { | |||
mboxName, err := url.PathUnescape(ctx.Param("mbox")) | |||
if err != nil { | |||
return echo.NewHTTPError(http.StatusBadRequest, err) | |||
} | |||
uid, err := parseUid(ctx.Param("uid")) | |||
mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid")) | |||
if err != nil { | |||
return echo.NewHTTPError(http.StatusBadRequest, err) | |||
} | |||
@@ -219,6 +215,61 @@ func handleCompose(ectx echo.Context) error { | |||
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.MailboxName + "@" + to.HostName | |||
} | |||
} | |||
msg.Subject = inReplyTo.Envelope.Subject | |||
if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") { | |||
msg.Subject = "Re: " + msg.Subject | |||
} | |||
} | |||
if ctx.Request().Method == http.MethodPost { | |||
// TODO: parse address lists | |||
from := ctx.FormValue("from") | |||
@@ -374,6 +425,9 @@ func New(imapURL, smtpURL string) *echo.Echo { | |||
e.GET("/compose", handleCompose) | |||
e.POST("/compose", handleCompose) | |||
e.GET("/message/:mbox/:uid/reply", handleCompose) | |||
e.POST("/message/:mbox/:uid/reply", handleCompose) | |||
e.Static("/assets", "public/assets") | |||
return e | |||
@@ -1,14 +1,30 @@ | |||
package koushin | |||
import ( | |||
"bufio" | |||
"fmt" | |||
"io" | |||
"time" | |||
"io" | |||
"strings" | |||
"github.com/emersion/go-message/mail" | |||
"github.com/emersion/go-smtp" | |||
) | |||
func quote(r io.Reader) (string, error) { | |||
scanner := bufio.NewScanner(r) | |||
var builder strings.Builder | |||
for scanner.Scan() { | |||
builder.WriteString("> ") | |||
builder.Write(scanner.Bytes()) | |||
builder.WriteString("\n") | |||
} | |||
if err := scanner.Err(); err != nil { | |||
return "", fmt.Errorf("quote: failed to read original message: %s", err) | |||
} | |||
return builder.String(), nil | |||
} | |||
func (s *Server) connectSMTP() (*smtp.Client, error) { | |||
var c *smtp.Client | |||
var err error | |||
@@ -34,10 +50,15 @@ func (s *Server) connectSMTP() (*smtp.Client, error) { | |||
} | |||
type OutgoingMessage struct { | |||
From string | |||
To []string | |||
From string | |||
To []string | |||
Subject string | |||
Text string | |||
InReplyTo string | |||
Text string | |||
} | |||
func (msg *OutgoingMessage) ToString() string { | |||
return strings.Join(msg.To, ", ") | |||
} | |||
func (msg *OutgoingMessage) WriteTo(w io.Writer) error { | |||
@@ -55,6 +76,9 @@ func (msg *OutgoingMessage) WriteTo(w io.Writer) error { | |||
if msg.Subject != "" { | |||
h.SetText("Subject", msg.Subject) | |||
} | |||
if msg.InReplyTo != "" { | |||
h.Set("In-Reply-To", msg.InReplyTo) | |||
} | |||
mw, err := mail.CreateWriter(w, h) | |||
if err != nil { | |||
@@ -4,12 +4,13 @@ import ( | |||
"fmt" | |||
"strconv" | |||
"strings" | |||
"net/url" | |||
) | |||
func parseUid(s string) (uint32, error) { | |||
uid, err := strconv.ParseUint(s, 10, 32) | |||
if err != nil { | |||
return 0, err | |||
return 0, fmt.Errorf("invalid UID: %v", err) | |||
} | |||
if uid == 0 { | |||
return 0, fmt.Errorf("UID must be non-zero") | |||
@@ -17,6 +18,15 @@ func parseUid(s string) (uint32, error) { | |||
return uint32(uid), nil | |||
} | |||
func parseMboxAndUid(mboxString, uidString string) (string, uint32, error) { | |||
mboxName, err := url.PathUnescape(mboxString) | |||
if err != nil { | |||
return "", 0, fmt.Errorf("invalid mailbox name: %v", err) | |||
} | |||
uid, err := parseUid(uidString) | |||
return mboxName, uid, err | |||
} | |||
func parsePartPath(s string) ([]int, error) { | |||
if s == "" { | |||
return nil, nil | |||