This plugin offers base IMAP/SMTP functionality. References: https://todo.sr.ht/~sircmpwn/koushin/39master
@@ -2,3 +2,4 @@ | |||
/public/themes/* | |||
!/public/themes/sourcehut | |||
/plugins/* | |||
!/plugins/base |
@@ -14,7 +14,7 @@ They should be put in `public/themes/<name>/`. | |||
Templates in `public/themes/<name>/*.html` override default templates in | |||
`public/*.html`. Assets in `public/themes/<name>/assets/*` are served by the | |||
HTTP server at `themes/<name>/assets/*`. | |||
HTTP server at `/themes/<name>/assets/*`. | |||
## Plugins | |||
@@ -29,6 +29,8 @@ API: | |||
called with the HTTP context | |||
Plugins can provide their own templates in `plugins/<name>/public/*.html`. | |||
Assets in `plugins/<name>/public/assets/*` are served by the HTTP server at | |||
`/plugins/<name>/assets/*`. | |||
## Contributing | |||
@@ -8,6 +8,8 @@ import ( | |||
"github.com/labstack/echo/v4" | |||
"github.com/labstack/echo/v4/middleware" | |||
"github.com/labstack/gommon/log" | |||
_ "git.sr.ht/~emersion/koushin/plugins/base" | |||
) | |||
func main() { | |||
@@ -1,24 +1,18 @@ | |||
package koushin | |||
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/charset" | |||
"github.com/emersion/go-message/textproto" | |||
) | |||
func init() { | |||
imap.CharsetReader = charset.Reader | |||
} | |||
func (s *Server) connectIMAP() (*imapclient.Client, error) { | |||
func (s *Server) dialIMAP() (*imapclient.Client, error) { | |||
var c *imapclient.Client | |||
var err error | |||
if s.imap.tls { | |||
@@ -41,266 +35,3 @@ func (s *Server) connectIMAP() (*imapclient.Client, error) { | |||
return c, err | |||
} | |||
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 | |||
} |
@@ -37,7 +37,7 @@ func (p *goPlugin) SetRoutes(group *echo.Group) { | |||
group.Add(r.Method, r.Path, r.Handler) | |||
} | |||
group.Static("/assets", pluginDir + "/" + p.p.Name + "/public/assets") | |||
group.Static("/plugins/" + p.p.Name + "/assets", pluginDir + "/" + p.p.Name + "/public/assets") | |||
} | |||
func (p *goPlugin) Inject(name string, data interface{}) error { | |||
@@ -117,6 +117,9 @@ func (p *luaPlugin) SetRoutes(group *echo.Group) { | |||
return nil | |||
}) | |||
} | |||
_, name := filepath.Split(filepath.Dir(p.filename)) | |||
group.Static("/plugins/" + name + "/assets", filepath.Dir(p.filename) + "/public/assets") | |||
} | |||
func (p *luaPlugin) Close() error { | |||
@@ -1,4 +1,4 @@ | |||
package koushin | |||
package koushinbase | |||
import ( | |||
"fmt" | |||
@@ -9,15 +9,15 @@ import ( | |||
"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/emersion/go-sasl" | |||
"github.com/labstack/echo/v4" | |||
) | |||
type MailboxRenderData struct { | |||
RenderData | |||
koushin.RenderData | |||
Mailbox *imap.MailboxStatus | |||
Mailboxes []*imap.MailboxInfo | |||
Messages []imapMessage | |||
@@ -25,7 +25,7 @@ type MailboxRenderData struct { | |||
} | |||
func handleGetMailbox(ectx echo.Context) error { | |||
ctx := ectx.(*Context) | |||
ctx := ectx.(*koushin.Context) | |||
mboxName, err := url.PathUnescape(ctx.Param("mbox")) | |||
if err != nil { | |||
@@ -67,7 +67,7 @@ func handleGetMailbox(ectx echo.Context) error { | |||
} | |||
return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{ | |||
RenderData: *NewRenderData(ctx), | |||
RenderData: *koushin.NewRenderData(ctx), | |||
Mailbox: mbox, | |||
Mailboxes: mailboxes, | |||
Messages: msgs, | |||
@@ -77,14 +77,14 @@ func handleGetMailbox(ectx echo.Context) error { | |||
} | |||
func handleLogin(ectx echo.Context) error { | |||
ctx := ectx.(*Context) | |||
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.(AuthError); ok { | |||
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) | |||
@@ -94,11 +94,11 @@ func handleLogin(ectx echo.Context) error { | |||
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") | |||
} | |||
return ctx.Render(http.StatusOK, "login.html", NewRenderData(ctx)) | |||
return ctx.Render(http.StatusOK, "login.html", koushin.NewRenderData(ctx)) | |||
} | |||
func handleLogout(ectx echo.Context) error { | |||
ctx := ectx.(*Context) | |||
ctx := ectx.(*koushin.Context) | |||
ctx.Session.Close() | |||
ctx.SetSession(nil) | |||
@@ -106,7 +106,7 @@ func handleLogout(ectx echo.Context) error { | |||
} | |||
type MessageRenderData struct { | |||
RenderData | |||
koushin.RenderData | |||
Mailbox *imap.MailboxStatus | |||
Message *imapMessage | |||
Body string | |||
@@ -114,7 +114,7 @@ type MessageRenderData struct { | |||
MailboxPage int | |||
} | |||
func handleGetPart(ctx *Context, raw bool) error { | |||
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) | |||
@@ -173,7 +173,7 @@ func handleGetPart(ctx *Context, raw bool) error { | |||
} | |||
return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{ | |||
RenderData: *NewRenderData(ctx), | |||
RenderData: *koushin.NewRenderData(ctx), | |||
Mailbox: mbox, | |||
Message: msg, | |||
Body: body, | |||
@@ -183,16 +183,16 @@ func handleGetPart(ctx *Context, raw bool) error { | |||
} | |||
type ComposeRenderData struct { | |||
RenderData | |||
koushin.RenderData | |||
Message *OutgoingMessage | |||
} | |||
func handleCompose(ectx echo.Context) error { | |||
ctx := ectx.(*Context) | |||
ctx := ectx.(*koushin.Context) | |||
var msg OutgoingMessage | |||
if strings.ContainsRune(ctx.Session.username, '@') { | |||
msg.From = ctx.Session.username | |||
if strings.ContainsRune(ctx.Session.Username(), '@') { | |||
msg.From = ctx.Session.Username() | |||
} | |||
if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" { | |||
@@ -257,16 +257,13 @@ func handleCompose(ectx echo.Context) error { | |||
msg.Text = ctx.FormValue("text") | |||
msg.InReplyTo = ctx.FormValue("in_reply_to") | |||
c, err := ctx.Server.connectSMTP() | |||
c, err := ctx.Session.ConnectSMTP() | |||
if err != nil { | |||
if _, ok := err.(koushin.AuthError); ok { | |||
return echo.NewHTTPError(http.StatusForbidden, err) | |||
} | |||
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 | |||
@@ -282,7 +279,7 @@ func handleCompose(ectx echo.Context) error { | |||
} | |||
return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{ | |||
RenderData: *NewRenderData(ctx), | |||
RenderData: *koushin.NewRenderData(ctx), | |||
Message: &msg, | |||
}) | |||
} |
@@ -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 | |||
} |
@@ -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()) | |||
} |
@@ -3,6 +3,6 @@ | |||
<head> | |||
<meta charset="utf-8"> | |||
<title>koushin</title> | |||
<link rel="stylesheet" href="/assets/style.css"> | |||
<link rel="stylesheet" href="/plugins/base/assets/style.css"> | |||
</head> | |||
<body> |
@@ -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 | |||
} |
@@ -1,4 +1,4 @@ | |||
package koushin | |||
package koushinbase | |||
import ( | |||
"fmt" |
@@ -12,8 +12,6 @@ import ( | |||
const cookieName = "koushin_session" | |||
const messagesPerPage = 50 | |||
// Server holds all the koushin server state. | |||
type Server struct { | |||
Sessions *SessionManager | |||
@@ -76,7 +74,6 @@ func (s *Server) parseSMTPURL(smtpURL string) error { | |||
func newServer(imapURL, smtpURL string) (*Server, error) { | |||
s := &Server{} | |||
s.Sessions = newSessionManager(s.connectIMAP) | |||
if err := s.parseIMAPURL(imapURL); err != nil { | |||
return nil, err | |||
@@ -88,6 +85,8 @@ func newServer(imapURL, smtpURL string) (*Server, error) { | |||
} | |||
} | |||
s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP) | |||
return s, nil | |||
} | |||
@@ -121,8 +120,11 @@ func (ctx *Context) SetSession(s *Session) { | |||
} | |||
func isPublic(path string) bool { | |||
return path == "/login" || strings.HasPrefix(path, "/assets/") || | |||
strings.HasPrefix(path, "/themes/") | |||
if strings.HasPrefix(path, "/plugins/") { | |||
parts := strings.Split(path, "/") | |||
return len(parts) >= 4 && parts[3] == "assets" | |||
} | |||
return path == "/login" || strings.HasPrefix(path, "/themes/") | |||
} | |||
type Options struct { | |||
@@ -194,29 +196,6 @@ func New(e *echo.Echo, options *Options) error { | |||
} | |||
}) | |||
e.GET("/mailbox/:mbox", handleGetMailbox) | |||
e.GET("/message/:mbox/:uid", func(ectx echo.Context) error { | |||
ctx := ectx.(*Context) | |||
return handleGetPart(ctx, false) | |||
}) | |||
e.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error { | |||
ctx := ectx.(*Context) | |||
return handleGetPart(ctx, true) | |||
}) | |||
e.GET("/login", handleLogin) | |||
e.POST("/login", handleLogin) | |||
e.GET("/logout", handleLogout) | |||
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") | |||
e.Static("/themes", "public/themes") | |||
for _, p := range s.Plugins { | |||
@@ -9,6 +9,8 @@ import ( | |||
"time" | |||
imapclient "github.com/emersion/go-imap/client" | |||
"github.com/emersion/go-smtp" | |||
"github.com/emersion/go-sasl" | |||
) | |||
// TODO: make this configurable | |||
@@ -51,6 +53,11 @@ func (s *Session) ping() { | |||
s.pings <- struct{}{} | |||
} | |||
// Username returns the session's username. | |||
func (s *Session) Username() string { | |||
return s.username | |||
} | |||
// Do executes an IMAP operation on this session. The IMAP client can only be | |||
// used from inside f. | |||
func (s *Session) Do(f func(*imapclient.Client) error) error { | |||
@@ -69,6 +76,23 @@ func (s *Session) Do(f func(*imapclient.Client) error) error { | |||
return f(s.imapConn) | |||
} | |||
// ConnectSMTP connects to the upstream SMTP server and authenticates this | |||
// session. | |||
func (s *Session) ConnectSMTP() (*smtp.Client, error) { | |||
c, err := s.manager.dialSMTP() | |||
if err != nil { | |||
return nil, err | |||
} | |||
auth := sasl.NewPlainClient("", s.username, s.password) | |||
if err := c.Auth(auth); err != nil { | |||
c.Close() | |||
return nil, AuthError{err} | |||
} | |||
return c, nil | |||
} | |||
// Close destroys the session. This can be used to log the user out. | |||
func (s *Session) Close() { | |||
select { | |||
@@ -79,24 +103,33 @@ func (s *Session) Close() { | |||
} | |||
} | |||
type ( | |||
// DialIMAPFunc connects to the upstream IMAP server. | |||
DialIMAPFunc func() (*imapclient.Client, error) | |||
// DialSMTPFunc connects to the upstream SMTP server. | |||
DialSMTPFunc func() (*smtp.Client, error) | |||
) | |||
// SessionManager keeps track of active sessions. It connects and re-connects | |||
// to the upstream IMAP server as necessary. It prunes expired sessions. | |||
type SessionManager struct { | |||
newIMAPClient func() (*imapclient.Client, error) | |||
dialIMAP DialIMAPFunc | |||
dialSMTP DialSMTPFunc | |||
locker sync.Mutex | |||
sessions map[string]*Session // protected by locker | |||
} | |||
func newSessionManager(newIMAPClient func() (*imapclient.Client, error)) *SessionManager { | |||
func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc) *SessionManager { | |||
return &SessionManager{ | |||
sessions: make(map[string]*Session), | |||
newIMAPClient: newIMAPClient, | |||
dialIMAP: dialIMAP, | |||
dialSMTP: dialSMTP, | |||
} | |||
} | |||
func (sm *SessionManager) connect(username, password string) (*imapclient.Client, error) { | |||
c, err := sm.newIMAPClient() | |||
c, err := sm.dialIMAP() | |||
if err != nil { | |||
return nil, err | |||
} | |||
@@ -1,31 +1,16 @@ | |||
package koushin | |||
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") | |||
func (s *Server) dialSMTP() (*smtp.Client, error) { | |||
if s.smtp.host == "" { | |||
return nil, fmt.Errorf("SMTP is disabled") | |||
} | |||
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 | |||
if s.smtp.tls { | |||
@@ -48,91 +33,3 @@ func (s *Server) connectSMTP() (*smtp.Client, error) { | |||
return c, err | |||
} | |||
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 | |||
} |
@@ -5,7 +5,6 @@ import ( | |||
"html/template" | |||
"io" | |||
"io/ioutil" | |||
"net/url" | |||
"os" | |||
"github.com/labstack/echo/v4" | |||
@@ -86,19 +85,7 @@ func loadTheme(name string, base *template.Template) (*template.Template, error) | |||
} | |||
func loadTemplates(logger echo.Logger, defaultTheme string, plugins []Plugin) (*renderer, error) { | |||
base := template.New("").Funcs(template.FuncMap{ | |||
"tuple": func(values ...interface{}) []interface{} { | |||
return values | |||
}, | |||
"pathescape": func(s string) string { | |||
return url.PathEscape(s) | |||
}, | |||
}) | |||
base, err := base.ParseGlob("public/*.html") | |||
if err != nil { | |||
return nil, err | |||
} | |||
base := template.New("") | |||
for _, p := range plugins { | |||
if err := p.LoadTemplate(base); err != nil { | |||