aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md4
-rw-r--r--cmd/koushin/main.go2
-rw-r--r--imap.go271
-rw-r--r--plugin_go.go2
-rw-r--r--plugin_lua.go3
-rw-r--r--plugins/base/handlers.go (renamed from handlers.go)45
-rw-r--r--plugins/base/imap.go277
-rw-r--r--plugins/base/plugin.go48
-rw-r--r--plugins/base/public/assets/style.css (renamed from public/assets/style.css)0
-rw-r--r--plugins/base/public/compose.html (renamed from public/compose.html)0
-rw-r--r--plugins/base/public/foot.html (renamed from public/foot.html)0
-rw-r--r--plugins/base/public/head.html (renamed from public/head.html)2
-rw-r--r--plugins/base/public/login.html (renamed from public/login.html)0
-rw-r--r--plugins/base/public/mailbox.html (renamed from public/mailbox.html)0
-rw-r--r--plugins/base/public/message.html (renamed from public/message.html)0
-rw-r--r--plugins/base/smtp.go114
-rw-r--r--plugins/base/strconv.go (renamed from strconv.go)2
-rw-r--r--server.go35
-rw-r--r--session.go41
-rw-r--r--smtp.go109
-rw-r--r--template.go15
22 files changed, 521 insertions, 450 deletions
diff --git a/.gitignore b/.gitignore
index c7cfe55..b7d2b50 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
/public/themes/*
!/public/themes/sourcehut
/plugins/*
+!/plugins/base
diff --git a/README.md b/README.md
index 74706d3..1ca62d5 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cmd/koushin/main.go b/cmd/koushin/main.go
index e644884..c9df12b 100644
--- a/cmd/koushin/main.go
+++ b/cmd/koushin/main.go
@@ -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() {
diff --git a/imap.go b/imap.go
index 0e0edc1..2a43dd1 100644
--- a/imap.go
+++ b/imap.go
@@ -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
-}
diff --git a/plugin_go.go b/plugin_go.go
index 30858b5..1ae0562 100644
--- a/plugin_go.go
+++ b/plugin_go.go
@@ -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 {
diff --git a/plugin_lua.go b/plugin_lua.go
index 9354de7..55c1d10 100644
--- a/plugin_lua.go
+++ b/plugin_lua.go
@@ -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 {
diff --git a/handlers.go b/plugins/base/handlers.go
index f53085c..3160026 100644
--- a/handlers.go
+++ b/plugins/base/handlers.go
@@ -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,
})
}
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/public/assets/style.css b/plugins/base/public/assets/style.css
index 8f414f5..8f414f5 100644
--- a/public/assets/style.css
+++ b/plugins/base/public/assets/style.css
diff --git a/public/compose.html b/plugins/base/public/compose.html
index 2a52675..2a52675 100644
--- a/public/compose.html
+++ b/plugins/base/public/compose.html
diff --git a/public/foot.html b/plugins/base/public/foot.html
index b605728..b605728 100644
--- a/public/foot.html
+++ b/plugins/base/public/foot.html
diff --git a/public/head.html b/plugins/base/public/head.html
index 35dda42..bed1bb3 100644
--- a/public/head.html
+++ b/plugins/base/public/head.html
@@ -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>
diff --git a/public/login.html b/plugins/base/public/login.html
index 6ae1737..6ae1737 100644
--- a/public/login.html
+++ b/plugins/base/public/login.html
diff --git a/public/mailbox.html b/plugins/base/public/mailbox.html
index ddd1260..ddd1260 100644
--- a/public/mailbox.html
+++ b/plugins/base/public/mailbox.html
diff --git a/public/message.html b/plugins/base/public/message.html
index 729937d..729937d 100644
--- a/public/message.html
+++ b/plugins/base/public/message.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/strconv.go b/plugins/base/strconv.go
index 63cbffc..1a32e75 100644
--- a/strconv.go
+++ b/plugins/base/strconv.go
@@ -1,4 +1,4 @@
-package koushin
+package koushinbase
import (
"fmt"
diff --git a/server.go b/server.go
index 98b5fb2..f2d4c11 100644
--- a/server.go
+++ b/server.go
@@ -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 {
diff --git a/session.go b/session.go
index 8fafd57..8f3f748 100644
--- a/session.go
+++ b/session.go
@@ -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
}
diff --git a/smtp.go b/smtp.go
index b9d77b2..0eabf4d 100644
--- a/smtp.go
+++ b/smtp.go
@@ -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
-}
diff --git a/template.go b/template.go
index cdcbf66..e5078c5 100644
--- a/template.go
+++ b/template.go
@@ -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 {