diff options
Diffstat (limited to 'plugins/base')
-rw-r--r-- | plugins/base/handlers.go | 285 | ||||
-rw-r--r-- | plugins/base/imap.go | 277 | ||||
-rw-r--r-- | plugins/base/plugin.go | 48 | ||||
-rw-r--r-- | plugins/base/public/assets/style.css | 1 | ||||
-rw-r--r-- | plugins/base/public/compose.html | 26 | ||||
-rw-r--r-- | plugins/base/public/foot.html | 2 | ||||
-rw-r--r-- | plugins/base/public/head.html | 8 | ||||
-rw-r--r-- | plugins/base/public/login.html | 14 | ||||
-rw-r--r-- | plugins/base/public/mailbox.html | 45 | ||||
-rw-r--r-- | plugins/base/public/message.html | 58 | ||||
-rw-r--r-- | plugins/base/smtp.go | 114 | ||||
-rw-r--r-- | plugins/base/strconv.go | 57 |
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 +} |