Introduce base plugin
This plugin offers base IMAP/SMTP functionality. References: https://todo.sr.ht/~sircmpwn/koushin/39
This commit is contained in:
parent
e83844fbad
commit
d897eeee5c
22 changed files with 521 additions and 450 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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() {
|
||||
|
|
271
imap.go
271
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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
277
plugins/base/imap.go
Normal file
277
plugins/base/imap.go
Normal file
|
@ -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
|
||||
}
|
48
plugins/base/plugin.go
Normal file
48
plugins/base/plugin.go
Normal file
|
@ -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>
|
114
plugins/base/smtp.go
Normal file
114
plugins/base/smtp.go
Normal file
|
@ -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"
|
35
server.go
35
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 {
|
||||
|
|
41
session.go
41
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
|
||||
}
|
||||
|
|
109
smtp.go
109
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
|
||||
}
|
||||
|
|
15
template.go
15
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 {
|
||||
|
|
Loading…
Reference in a new issue