aboutsummaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'plugins')
-rw-r--r--plugins/base/handlers.go285
-rw-r--r--plugins/base/imap.go277
-rw-r--r--plugins/base/plugin.go48
-rw-r--r--plugins/base/public/assets/style.css1
-rw-r--r--plugins/base/public/compose.html26
-rw-r--r--plugins/base/public/foot.html2
-rw-r--r--plugins/base/public/head.html8
-rw-r--r--plugins/base/public/login.html14
-rw-r--r--plugins/base/public/mailbox.html45
-rw-r--r--plugins/base/public/message.html58
-rw-r--r--plugins/base/smtp.go114
-rw-r--r--plugins/base/strconv.go57
12 files changed, 935 insertions, 0 deletions
diff --git a/plugins/base/handlers.go b/plugins/base/handlers.go
new file mode 100644
index 0000000..3160026
--- /dev/null
+++ b/plugins/base/handlers.go
@@ -0,0 +1,285 @@
+package koushinbase
+
+import (
+ "fmt"
+ "io/ioutil"
+ "mime"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "git.sr.ht/~emersion/koushin"
+ "github.com/emersion/go-imap"
+ imapclient "github.com/emersion/go-imap/client"
+ "github.com/emersion/go-message"
+ "github.com/labstack/echo/v4"
+)
+
+type MailboxRenderData struct {
+ koushin.RenderData
+ Mailbox *imap.MailboxStatus
+ Mailboxes []*imap.MailboxInfo
+ Messages []imapMessage
+ PrevPage, NextPage int
+}
+
+func handleGetMailbox(ectx echo.Context) error {
+ ctx := ectx.(*koushin.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{
+ RenderData: *koushin.NewRenderData(ctx),
+ Mailbox: mbox,
+ Mailboxes: mailboxes,
+ Messages: msgs,
+ PrevPage: prevPage,
+ NextPage: nextPage,
+ })
+}
+
+func handleLogin(ectx echo.Context) error {
+ ctx := ectx.(*koushin.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.(koushin.AuthError); ok {
+ return ctx.Render(http.StatusOK, "login.html", nil)
+ }
+ return fmt.Errorf("failed to put connection in pool: %v", err)
+ }
+ ctx.SetSession(s)
+
+ return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
+ }
+
+ return ctx.Render(http.StatusOK, "login.html", koushin.NewRenderData(ctx))
+}
+
+func handleLogout(ectx echo.Context) error {
+ ctx := ectx.(*koushin.Context)
+
+ ctx.Session.Close()
+ ctx.SetSession(nil)
+ return ctx.Redirect(http.StatusFound, "/login")
+}
+
+type MessageRenderData struct {
+ koushin.RenderData
+ Mailbox *imap.MailboxStatus
+ Message *imapMessage
+ Body string
+ PartPath string
+ MailboxPage int
+}
+
+func handleGetPart(ctx *koushin.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{
+ RenderData: *koushin.NewRenderData(ctx),
+ Mailbox: mbox,
+ Message: msg,
+ Body: body,
+ PartPath: partPathString,
+ MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
+ })
+}
+
+type ComposeRenderData struct {
+ koushin.RenderData
+ Message *OutgoingMessage
+}
+
+func handleCompose(ectx echo.Context) error {
+ ctx := ectx.(*koushin.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.Session.ConnectSMTP()
+ if err != nil {
+ if _, ok := err.(koushin.AuthError); ok {
+ return echo.NewHTTPError(http.StatusForbidden, err)
+ }
+ return 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{
+ RenderData: *koushin.NewRenderData(ctx),
+ Message: &msg,
+ })
+}
diff --git a/plugins/base/imap.go b/plugins/base/imap.go
new file mode 100644
index 0000000..93f3c4e
--- /dev/null
+++ b/plugins/base/imap.go
@@ -0,0 +1,277 @@
+package koushinbase
+
+import (
+ "bufio"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/emersion/go-imap"
+ imapclient "github.com/emersion/go-imap/client"
+ "github.com/emersion/go-message"
+ "github.com/emersion/go-message/textproto"
+)
+
+func listMailboxes(conn *imapclient.Client) ([]*imap.MailboxInfo, error) {
+ ch := make(chan *imap.MailboxInfo, 10)
+ done := make(chan error, 1)
+ go func() {
+ done <- conn.List("", "*", ch)
+ }()
+
+ var mailboxes []*imap.MailboxInfo
+ for mbox := range ch {
+ mailboxes = append(mailboxes, mbox)
+ }
+
+ if err := <-done; err != nil {
+ return nil, fmt.Errorf("failed to list mailboxes: %v", err)
+ }
+
+ sort.Slice(mailboxes, func(i, j int) bool {
+ return mailboxes[i].Name < mailboxes[j].Name
+ })
+ return mailboxes, 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
+}
+
+func textPartPath(bs *imap.BodyStructure) ([]int, bool) {
+ if bs.Disposition != "" && !strings.EqualFold(bs.Disposition, "inline") {
+ return nil, false
+ }
+
+ if strings.EqualFold(bs.MIMEType, "text") {
+ return []int{1}, true
+ }
+
+ if !strings.EqualFold(bs.MIMEType, "multipart") {
+ return nil, false
+ }
+
+ textPartNum := -1
+ for i, part := range bs.Parts {
+ num := i + 1
+
+ if strings.EqualFold(part.MIMEType, "multipart") {
+ if subpath, ok := textPartPath(part); ok {
+ return append([]int{num}, subpath...), true
+ }
+ }
+ if !strings.EqualFold(part.MIMEType, "text") {
+ continue
+ }
+
+ var pick bool
+ switch strings.ToLower(part.MIMESubType) {
+ case "plain":
+ pick = true
+ case "html":
+ pick = textPartNum < 0
+ }
+
+ if pick {
+ textPartNum = num
+ }
+ }
+
+ if textPartNum > 0 {
+ return []int{textPartNum}, true
+ }
+ return nil, false
+}
+
+func (msg *imapMessage) TextPartName() string {
+ if msg.BodyStructure == nil {
+ return ""
+ }
+
+ path, ok := textPartPath(msg.BodyStructure)
+ if !ok {
+ return ""
+ }
+
+ l := make([]string, len(path))
+ for i, partNum := range path {
+ l[i] = strconv.Itoa(partNum)
+ }
+
+ return strings.Join(l, ".")
+}
+
+type IMAPPartNode struct {
+ Path []int
+ MIMEType string
+ Filename string
+ Children []IMAPPartNode
+}
+
+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) 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(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)),
+ }
+
+ for i, part := range bs.Parts {
+ num := i + 1
+
+ partPath := append([]int(nil), path...)
+ partPath = append(partPath, num)
+
+ node.Children[i] = *imapPartTree(part, partPath)
+ }
+
+ return node
+}
+
+func (msg *imapMessage) PartTree() *IMAPPartNode {
+ if msg.BodyStructure == nil {
+ return nil
+ }
+
+ return imapPartTree(msg.BodyStructure, nil)
+}
+
+func listMessages(conn *imapclient.Client, mboxName string, page int) ([]imapMessage, error) {
+ if err := ensureMailboxSelected(conn, mboxName); err != nil {
+ return nil, err
+ }
+
+ mbox := conn.Mailbox()
+ to := int(mbox.Messages) - page*messagesPerPage
+ from := to - messagesPerPage + 1
+ if from <= 0 {
+ from = 1
+ }
+ if to <= 0 {
+ return nil, nil
+ }
+
+ seqSet := new(imap.SeqSet)
+ seqSet.AddRange(uint32(from), uint32(to))
+
+ fetch := []imap.FetchItem{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})
+ }
+
+ 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 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
+ partBodySection.Peek = true
+ 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,
+ 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")
+ }
+
+ headerReader := bufio.NewReader(msg.GetBody(&partHeaderSection))
+ 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}, part, nil
+}
diff --git a/plugins/base/plugin.go b/plugins/base/plugin.go
new file mode 100644
index 0000000..906730d
--- /dev/null
+++ b/plugins/base/plugin.go
@@ -0,0 +1,48 @@
+package koushinbase
+
+import (
+ "html/template"
+ "net/url"
+
+ "git.sr.ht/~emersion/koushin"
+ "github.com/labstack/echo/v4"
+)
+
+const messagesPerPage = 50
+
+func init() {
+ p := koushin.GoPlugin{Name: "base"}
+
+ p.TemplateFuncs(template.FuncMap{
+ "tuple": func(values ...interface{}) []interface{} {
+ return values
+ },
+ "pathescape": func(s string) string {
+ return url.PathEscape(s)
+ },
+ })
+
+ p.GET("/mailbox/:mbox", handleGetMailbox)
+
+ p.GET("/message/:mbox/:uid", func(ectx echo.Context) error {
+ ctx := ectx.(*koushin.Context)
+ return handleGetPart(ctx, false)
+ })
+ p.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error {
+ ctx := ectx.(*koushin.Context)
+ return handleGetPart(ctx, true)
+ })
+
+ p.GET("/login", handleLogin)
+ p.POST("/login", handleLogin)
+
+ p.GET("/logout", handleLogout)
+
+ p.GET("/compose", handleCompose)
+ p.POST("/compose", handleCompose)
+
+ p.GET("/message/:mbox/:uid/reply", handleCompose)
+ p.POST("/message/:mbox/:uid/reply", handleCompose)
+
+ koushin.RegisterPlugin(p.Plugin())
+}
diff --git a/plugins/base/public/assets/style.css b/plugins/base/public/assets/style.css
new file mode 100644
index 0000000..8f414f5
--- /dev/null
+++ b/plugins/base/public/assets/style.css
@@ -0,0 +1 @@
+/* TODO */
diff --git a/plugins/base/public/compose.html b/plugins/base/public/compose.html
new file mode 100644
index 0000000..2a52675
--- /dev/null
+++ b/plugins/base/public/compose.html
@@ -0,0 +1,26 @@
+{{template "head.html"}}
+
+<h1>koushin</h1>
+
+<p>
+ <a href="/mailbox/INBOX">Back</a>
+</p>
+
+<h2>Compose new message</h2>
+
+<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 value="{{.Message.ToString}}">
+ <p>Subject:</p>
+ <input type="text" name="subject" value="{{.Message.Subject}}">
+ <p>Body:</p>
+ <textarea name="text" cols="80" rows="20">{{.Message.Text}}</textarea>
+ <br><br>
+ <input type="submit" value="Send">
+</form>
+
+{{template "foot.html"}}
diff --git a/plugins/base/public/foot.html b/plugins/base/public/foot.html
new file mode 100644
index 0000000..b605728
--- /dev/null
+++ b/plugins/base/public/foot.html
@@ -0,0 +1,2 @@
+ </body>
+</html>
diff --git a/plugins/base/public/head.html b/plugins/base/public/head.html
new file mode 100644
index 0000000..bed1bb3
--- /dev/null
+++ b/plugins/base/public/head.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>koushin</title>
+ <link rel="stylesheet" href="/plugins/base/assets/style.css">
+ </head>
+ <body>
diff --git a/plugins/base/public/login.html b/plugins/base/public/login.html
new file mode 100644
index 0000000..6ae1737
--- /dev/null
+++ b/plugins/base/public/login.html
@@ -0,0 +1,14 @@
+{{template "head.html"}}
+
+<h1>koushin</h1>
+
+<form method="post" action="/login">
+ <label for="username">Username:</label>
+ <input type="text" name="username" id="username"/>
+ <label for="password">Password:</label>
+ <input type="password" name="password" id="password"/>
+ <br><br>
+ <input type="submit" value="Login">
+</form>
+
+{{template "foot.html"}}
diff --git a/plugins/base/public/mailbox.html b/plugins/base/public/mailbox.html
new file mode 100644
index 0000000..ddd1260
--- /dev/null
+++ b/plugins/base/public/mailbox.html
@@ -0,0 +1,45 @@
+{{template "head.html"}}
+
+<h1>koushin</h1>
+
+<p>
+ <a href="/logout">Logout</a> · <a href="/compose">Compose</a>
+</p>
+
+<h2>{{.Mailbox.Name}}</h2>
+
+<p>Mailboxes:</p>
+<ul>
+ {{range .Mailboxes}}
+ <li><a href="/mailbox/{{.Name | pathescape}}">{{.Name}}</a></li>
+ {{end}}
+</ul>
+
+{{if .Messages}}
+ <p>Messages:</p>
+ <ul>
+ {{range .Messages}}
+ <li><a href="/message/{{$.Mailbox.Name | pathescape}}/{{.Uid}}?part={{.TextPartName}}">
+ {{if .Envelope.Subject}}
+ {{.Envelope.Subject}}
+ {{else}}
+ (No subject)
+ {{end}}
+ </a></li>
+ {{end}}
+ </ul>
+
+ <p>
+ {{if ge .PrevPage 0}}
+ <a href="?page={{.PrevPage}}">Prev</a>
+ {{end}}
+ {{if and (ge .PrevPage 0) (ge .NextPage 0)}}·{{end}}
+ {{if ge .NextPage 0}}
+ <a href="?page={{.NextPage}}">Next</a>
+ {{end}}
+ </p>
+{{else}}
+ <p>Mailbox is empty.</p>
+{{end}}
+
+{{template "foot.html"}}
diff --git a/plugins/base/public/message.html b/plugins/base/public/message.html
new file mode 100644
index 0000000..729937d
--- /dev/null
+++ b/plugins/base/public/message.html
@@ -0,0 +1,58 @@
+{{template "head.html"}}
+
+<h1>koushin</h1>
+
+<p>
+ <a href="/mailbox/{{.Mailbox.Name | pathescape}}?page={{.MailboxPage}}">
+ Back
+ </a>
+</p>
+
+<h2>
+ {{if .Message.Envelope.Subject}}
+ {{.Message.Envelope.Subject}}
+ {{else}}
+ (No subject)
+ {{end}}
+</h2>
+
+{{define "message-part-tree"}}
+ {{/* nested templates can't access the parent's context */}}
+ {{$ = index . 0}}
+ {{with index . 1}}
+ <a
+ {{if .IsText}}
+ href="{{$.Message.Uid}}?part={{.PathString}}"
+ {{else}}
+ href="{{$.Message.Uid}}/raw?part={{.PathString}}"
+ {{end}}
+ >
+ {{if eq $.PartPath .PathString}}<strong>{{end}}
+ {{.String}}
+ {{if eq $.PartPath .PathString}}</strong>{{end}}
+ </a>
+ {{if gt (len .Children) 0}}
+ <ul>
+ {{range .Children}}
+ <li>{{template "message-part-tree" (tuple $ .)}}</li>
+ {{end}}
+ </ul>
+ {{end}}
+ {{end}}
+{{end}}
+
+<p>Parts:</p>
+
+{{template "message-part-tree" (tuple $ .Message.PartTree)}}
+
+<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>
+ <a href="{{.Message.Uid}}/raw?part={{.PartPath}}">Download</a>
+{{end}}
+
+{{template "foot.html"}}
diff --git a/plugins/base/smtp.go b/plugins/base/smtp.go
new file mode 100644
index 0000000..9ade78f
--- /dev/null
+++ b/plugins/base/smtp.go
@@ -0,0 +1,114 @@
+package koushinbase
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "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
+}
+
+type OutgoingMessage struct {
+ From string
+ To []string
+ Subject string
+ InReplyTo string
+ Text string
+}
+
+func (msg *OutgoingMessage) ToString() string {
+ return strings.Join(msg.To, ", ")
+}
+
+func (msg *OutgoingMessage) WriteTo(w io.Writer) error {
+ from := []*mail.Address{{"", msg.From}}
+
+ to := make([]*mail.Address, len(msg.To))
+ for i, addr := range msg.To {
+ to[i] = &mail.Address{"", addr}
+ }
+
+ var h mail.Header
+ h.SetDate(time.Now())
+ h.SetAddressList("From", from)
+ h.SetAddressList("To", to)
+ 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 {
+ return fmt.Errorf("failed to create mail writer: %v", err)
+ }
+
+ var th mail.InlineHeader
+ th.Set("Content-Type", "text/plain; charset=utf-8")
+
+ tw, err := mw.CreateSingleInline(th)
+ if err != nil {
+ return fmt.Errorf("failed to create text part: %v", err)
+ }
+ defer tw.Close()
+
+ if _, err := io.WriteString(tw, msg.Text); err != nil {
+ return fmt.Errorf("failed to write text part: %v", err)
+ }
+
+ if err := tw.Close(); err != nil {
+ return fmt.Errorf("failed to close text part: %v", err)
+ }
+
+ if err := mw.Close(); err != nil {
+ return fmt.Errorf("failed to close mail writer: %v", err)
+ }
+
+ return nil
+}
+
+func sendMessage(c *smtp.Client, msg *OutgoingMessage) error {
+ if err := c.Mail(msg.From, nil); err != nil {
+ return fmt.Errorf("MAIL FROM failed: %v", err)
+ }
+
+ for _, to := range msg.To {
+ if err := c.Rcpt(to); err != nil {
+ return fmt.Errorf("RCPT TO failed: %v", err)
+ }
+ }
+
+ w, err := c.Data()
+ if err != nil {
+ return fmt.Errorf("DATA failed: %v", err)
+ }
+ defer w.Close()
+
+ if err := msg.WriteTo(w); err != nil {
+ return fmt.Errorf("failed to write outgoing message: %v", err)
+ }
+
+ if err := w.Close(); err != nil {
+ return fmt.Errorf("failed to close SMTP data writer: %v", err)
+ }
+
+ return nil
+}
diff --git a/plugins/base/strconv.go b/plugins/base/strconv.go
new file mode 100644
index 0000000..1a32e75
--- /dev/null
+++ b/plugins/base/strconv.go
@@ -0,0 +1,57 @@
+package koushinbase
+
+import (
+ "fmt"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+func parseUid(s string) (uint32, error) {
+ uid, err := strconv.ParseUint(s, 10, 32)
+ if err != nil {
+ return 0, fmt.Errorf("invalid UID: %v", err)
+ }
+ if uid == 0 {
+ return 0, fmt.Errorf("UID must be non-zero")
+ }
+ 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
+ }
+
+ l := strings.Split(s, ".")
+ path := make([]int, len(l))
+ for i, s := range l {
+ var err error
+ path[i], err = strconv.Atoi(s)
+ if err != nil {
+ return nil, err
+ }
+
+ if path[i] <= 0 {
+ return nil, fmt.Errorf("part num must be strictly positive")
+ }
+ }
+ return path, nil
+}
+
+func parseAddressList(s string) []string {
+ l := strings.Split(s, ",")
+ for i, addr := range l {
+ l[i] = strings.TrimSpace(addr)
+ }
+ return l
+}