Start coding postfix config reader

This commit is contained in:
Melon 2023-08-21 00:26:22 +01:00
parent e84e0e3d4b
commit 7698bc9d50
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
38 changed files with 1207 additions and 42 deletions

View File

@ -11,5 +11,5 @@ jobs:
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3
- run: go build ./cmd/primrose/
- run: go build ./cmd/lotus/
- run: go test ./...

View File

@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/primrose.iml" filepath="$PROJECT_DIR$/.idea/primrose.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/lotus.iml" filepath="$PROJECT_DIR$/.idea/lotus.iml" />
</modules>
</component>
</project>

7
.idea/sqldialects.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/postfix-config/map-provider/mysql-prepared-query_test.go" dialect="GenericSQL" />
<file url="PROJECT" dialect="MySQL" />
</component>
</project>

View File

@ -2,22 +2,23 @@ package api
import (
"encoding/json"
"github.com/1f349/primrose/imap"
"github.com/1f349/primrose/smtp"
"github.com/1f349/lotus/imap"
"github.com/1f349/lotus/smtp"
"github.com/julienschmidt/httprouter"
"net/http"
"time"
)
type Conf struct {
Listen string `yaml:"listen"`
}
func SetupApiServer(conf Conf, send *smtp.Smtp, recv *imap.Imap) *http.Server {
func SetupApiServer(listen string, auth func(callback AuthCallback) httprouter.Handle, send *smtp.Smtp, recv *imap.Imap) *http.Server {
r := httprouter.New()
// smtp
r.POST("/message", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
// === ACCOUNT ===
r.GET("/account", auth(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
// TODO(melon): find users aliases and other account data
}))
// === SMTP ===
r.POST("/message", auth(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
// check body exists
if req.Body == nil {
rw.WriteHeader(http.StatusBadRequest)
@ -32,6 +33,11 @@ func SetupApiServer(conf Conf, send *smtp.Smtp, recv *imap.Imap) *http.Server {
return
}
// TODO(melon): add alias support
if j.From == b.Subject {
}
mail, err := j.PrepareMail()
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
@ -44,10 +50,63 @@ func SetupApiServer(conf Conf, send *smtp.Smtp, recv *imap.Imap) *http.Server {
}
rw.WriteHeader(http.StatusAccepted)
})
}))
// === IMAP ===
type statusJson struct {
Folder string `json:"folder"`
}
r.GET("/status", auth(imapClient(recv, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t statusJson) {
status, err := cli.Status(t.Folder)
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(rw).Encode(status)
})))
r.GET("/list-messages", auth(imapClient(recv, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t statusJson) {
messages, err := cli.Fetch(t.Folder, 1, 100, 100)
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(rw).Encode(messages)
})))
r.GET("/search-messages", auth(imapClient(recv, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t statusJson) {
status, err := cli.Status(t.Folder)
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(rw).Encode(status)
})))
r.POST("/create-message", auth(imapClient(recv, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t statusJson) {
status, err := cli.Status(t.Folder)
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(rw).Encode(status)
})))
r.POST("/update-messages-flags", auth(imapClient(recv, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t statusJson) {
status, err := cli.Status(t.Folder)
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(rw).Encode(status)
})))
r.POST("/copy-messages", auth(imapClient(recv, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t statusJson) {
status, err := cli.Status(t.Folder)
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
_ = json.NewEncoder(rw).Encode(status)
})))
return &http.Server{
Addr: conf.Listen,
Addr: listen,
Handler: r,
ReadTimeout: time.Minute,
ReadHeaderTimeout: time.Minute,
@ -56,3 +115,33 @@ func SetupApiServer(conf Conf, send *smtp.Smtp, recv *imap.Imap) *http.Server {
MaxHeaderBytes: 2500,
}
}
// apiError outputs a generic JSON error message
func apiError(rw http.ResponseWriter, code int, m string) {
rw.WriteHeader(code)
_ = json.NewEncoder(rw).Encode(map[string]string{
"error": m,
})
}
type IcCallback[T any] func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, cli *imap.Client, t T)
func imapClient[T any](recv *imap.Imap, cb IcCallback[T]) AuthCallback {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
if req.Body == nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
var t T
if json.NewDecoder(req.Body).Decode(&t) != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
cli, err := recv.MakeClient(b.Subject)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
cb(rw, req, params, cli, t)
}
}

61
api/auth.go Normal file
View File

@ -0,0 +1,61 @@
package api
import (
"crypto/subtle"
"github.com/1f349/violet/utils"
"github.com/MrMelon54/mjwt"
"github.com/MrMelon54/mjwt/auth"
"github.com/julienschmidt/httprouter"
"net/http"
)
type AuthClaims mjwt.BaseTypeClaims[auth.AccessTokenClaims]
type AuthCallback func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims)
type authChecker struct {
verify mjwt.Verifier
aud string
cb AuthCallback
}
func (a *authChecker) Handle(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
// Get bearer token
bearer := utils.GetBearer(req)
if bearer == "" {
apiError(rw, http.StatusForbidden, "Missing bearer token")
return
}
// Read claims from mjwt
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.verify, bearer)
if err != nil {
apiError(rw, http.StatusForbidden, "Invalid token")
return
}
var validAud bool
for _, i := range b.Audience {
if subtle.ConstantTimeCompare([]byte(i), []byte(a.aud)) == 1 {
validAud = true
}
}
if !validAud {
apiError(rw, http.StatusForbidden, "Invalid audience claim")
return
}
a.cb(rw, req, params, AuthClaims(b))
}
// CheckAuth validates the bearer token against a mjwt.Verifier and returns an
// error message or continues to the next handler
func CheckAuth(verify mjwt.Verifier, aud string) func(cb AuthCallback) httprouter.Handle {
return func(cb AuthCallback) httprouter.Handle {
return (&authChecker{
verify: verify,
aud: aud,
cb: cb,
}).Handle
}
}

13
cmd/lotus/conf.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
"github.com/1f349/lotus/imap"
"github.com/1f349/lotus/smtp"
)
type Conf struct {
Listen string `yaml:"listen"`
Audience string `yaml:"audience"`
Smtp *smtp.Smtp `yaml:"smtp"`
Imap *imap.Imap `yaml:"imap"`
}

59
cmd/lotus/main.go Normal file
View File

@ -0,0 +1,59 @@
package main
import (
"flag"
"github.com/1f349/lotus/api"
"github.com/1f349/violet/utils"
exitReload "github.com/MrMelon54/exit-reload"
"github.com/MrMelon54/mjwt"
"gopkg.in/yaml.v3"
"log"
"os"
"path/filepath"
)
var configPath string
func main() {
flag.StringVar(&configPath, "conf", "", "/path/to/config.yml : path to the config file")
flag.Parse()
if configPath == "" {
log.Println("[Lotus] Error: config flag is missing")
return
}
openConf, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
log.Println("[Lotus] Error: missing config file")
} else {
log.Println("[Lotus] Error: open config file: ", err)
}
return
}
var conf Conf
err = yaml.NewDecoder(openConf).Decode(&conf)
if err != nil {
log.Println("[Lotus] Error: invalid config file: ", err)
return
}
wd := filepath.Dir(configPath)
verify, err := mjwt.NewMJwtVerifierFromFile(filepath.Join(wd, "signer.public.pem"))
if err != nil {
log.Fatalf("[Lotus] Failed to load MJWT verifier public key from file '%s': %s", filepath.Join(wd, "signer.public.pem"), err)
}
userAuth := api.CheckAuth(verify, conf.Audience)
srv := api.SetupApiServer(conf.Listen, userAuth, conf.Smtp, conf.Imap)
log.Printf("[Lotus] Starting API server on: '%s'\n", srv.Addr)
go utils.RunBackgroundHttp("Lotus", srv)
exitReload.ExitReload("Lotus", func() {}, func() {
// stop server
srv.Close()
})
}

View File

@ -1,13 +0,0 @@
package main
import (
"github.com/1f349/primrose/api"
"github.com/1f349/primrose/imap"
"github.com/1f349/primrose/smtp"
)
type Conf struct {
Smtp smtp.Smtp `yaml:"smtp"`
Imap imap.Imap `yaml:"imap"`
Api api.Conf `yaml:"api"`
}

View File

@ -1,5 +0,0 @@
package main
func main() {
// TODO(Melon): write start up code
}

12
go.mod
View File

@ -1,20 +1,28 @@
module github.com/1f349/primrose
module github.com/1f349/lotus
go 1.21.0
require (
github.com/1f349/violet v0.0.7
github.com/MrMelon54/exit-reload v0.0.1
github.com/MrMelon54/mjwt v0.1.1
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-message v0.16.0
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
github.com/emersion/go-smtp v0.17.0
github.com/go-sql-driver/mysql v1.7.1
github.com/hydrogen18/memlistener v1.0.0
github.com/julienschmidt/httprouter v1.3.0
github.com/stretchr/testify v1.8.4
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/text v0.12.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

22
go.sum
View File

@ -1,3 +1,10 @@
github.com/1f349/violet v0.0.7 h1:FxCAIVjzUzkgGfhGMX7FcvGj+kaJky45PnLfqKNgA8M=
github.com/1f349/violet v0.0.7/go.mod h1:YfKZX9p55Uot8iSDnbqQbAgU717H0rFNo8ieu2wbxI4=
github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc=
github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug=
github.com/MrMelon54/mjwt v0.1.1 h1:m+aTpxbhQCrOPKHN170DQMFR5r938LkviU38unob5Jw=
github.com/MrMelon54/mjwt v0.1.1/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
@ -12,8 +19,20 @@ github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBME
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/hydrogen18/memlistener v1.0.0 h1:JR7eDj8HD6eXrc5fWLbSUnfcQFL06PYvCc0DKQnWfaU=
github.com/hydrogen18/memlistener v1.0.0/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
@ -23,7 +42,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -3,6 +3,7 @@ package imap
import (
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"time"
)
var imapStatusFlags = []imap.StatusItem{
@ -17,9 +18,20 @@ type Client struct {
ic *client.Client
}
func (c *Client) Status(folder string) (*imap.MailboxStatus, error) {
mbox, err := c.ic.Status(folder, imapStatusFlags)
return mbox, err
func (c *Client) Append(name string, flags []string, date time.Time, msg imap.Literal) error {
return c.ic.Append(name, flags, date, msg)
}
func (c *Client) Copy(seqset *imap.SeqSet, dest string) error {
return c.ic.Copy(seqset, dest)
}
func (c *Client) Create(name string) error {
return c.ic.Create(name)
}
func (c *Client) Delete(name string) error {
return c.ic.Delete(name)
}
func (c *Client) Fetch(folder string, start, end, limit uint32) ([]*imap.Message, error) {
@ -45,12 +57,50 @@ func (c *Client) Fetch(folder string, start, end, limit uint32) ([]*imap.Message
done <- c.ic.Fetch(seqSet, []imap.FetchItem{imap.FetchEnvelope}, messages)
}()
outMsg := make([]*imap.Message, 0, limit)
out := make([]*imap.Message, 0, limit)
for msg := range messages {
outMsg = append(outMsg, msg)
out = append(out, msg)
}
if err := <-done; err != nil {
return nil, err
}
return outMsg, nil
return out, nil
}
func (c *Client) List(ref, name string) ([]*imap.MailboxInfo, error) {
infos := make(chan *imap.MailboxInfo, 1)
done := make(chan error, 1)
go func() {
done <- c.ic.List(ref, name, infos)
}()
out := make([]*imap.MailboxInfo, 0)
for info := range infos {
out = append(out, info)
}
if err := <-done; err != nil {
return nil, err
}
return out, nil
}
func (c *Client) Move(seqset *imap.SeqSet, dest string) error {
return c.ic.Move(seqset, dest)
}
func (c *Client) Noop() error {
return c.ic.Noop()
}
func (c *Client) Rename(existingName, newName string) error {
return c.ic.Rename(existingName, newName)
}
func (c *Client) Search(criteria *imap.SearchCriteria) ([]uint32, error) {
return c.ic.Search(criteria)
}
func (c *Client) Status(name string) (*imap.MailboxStatus, error) {
mbox, err := c.ic.Status(name, imapStatusFlags)
return mbox, err
}

20
imap/fake/backend.go Normal file
View File

@ -0,0 +1,20 @@
package fake
import (
"fmt"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend"
)
type Backend struct {
Debug chan []byte
Username string
Password string
}
func (i *Backend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
if username != i.Username || password != i.Password {
return nil, fmt.Errorf("invalid user")
}
return &User{i.Debug, username}, nil
}

63
imap/fake/mailbox.go Normal file
View File

@ -0,0 +1,63 @@
package fake
import (
"fmt"
"github.com/emersion/go-imap"
"time"
)
type Mailbox struct {
Debug chan []byte
ImapName string
}
func (m *Mailbox) Name() string {
return m.ImapName
}
func (m *Mailbox) Info() (*imap.MailboxInfo, error) {
return &imap.MailboxInfo{
Attributes: []string{imap.UnmarkedAttr, imap.HasNoChildrenAttr},
Delimiter: "/",
Name: m.ImapName,
}, nil
}
func (m *Mailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
return &imap.MailboxStatus{
Name: m.ImapName,
Messages: 1,
}, nil
}
func (m *Mailbox) SetSubscribed(subscribed bool) error {
return fmt.Errorf("failed to subscribe")
}
func (m *Mailbox) Check() error {
return nil
}
func (m *Mailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
return fmt.Errorf("failed to list messages")
}
func (m *Mailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
return nil, fmt.Errorf("failed to search messages")
}
func (m *Mailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
return fmt.Errorf("failed to create message")
}
func (m *Mailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, operation imap.FlagsOp, flags []string) error {
return fmt.Errorf("failed to update message flags")
}
func (m *Mailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
return fmt.Errorf("failed to copy messages")
}
func (m *Mailbox) Expunge() error {
return fmt.Errorf("failed to expunge")
}

39
imap/fake/user.go Normal file
View File

@ -0,0 +1,39 @@
package fake
import (
"fmt"
"github.com/emersion/go-imap/backend"
)
type User struct {
Debug chan []byte
ImapUser string
}
func (i *User) Username() string {
return i.ImapUser
}
func (i *User) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) {
return []backend.Mailbox{}, nil
}
func (i *User) GetMailbox(name string) (backend.Mailbox, error) {
return &Mailbox{i.Debug, name}, nil
}
func (i *User) CreateMailbox(name string) error {
return fmt.Errorf("failed to create mailbox")
}
func (i *User) DeleteMailbox(name string) error {
return fmt.Errorf("failed to delete mailbox")
}
func (i *User) RenameMailbox(existingName, newName string) error {
return fmt.Errorf("failed to rename mailbox")
}
func (i *User) Logout() error {
return nil
}

View File

@ -13,9 +13,11 @@ type Imap struct {
Separator string `yaml:"separator"`
}
var defaultDialer = client.Dial
func (i *Imap) MakeClient(user string) (*Client, error) {
// dial imap server
imapClient, err := client.Dial(i.Server)
imapClient, err := defaultDialer(i.Server)
if err != nil {
return nil, err
}

36
imap/imap_test.go Normal file
View File

@ -0,0 +1,36 @@
package imap
import (
"github.com/1f349/lotus/imap/fake"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-imap/server"
"github.com/hydrogen18/memlistener"
"github.com/stretchr/testify/assert"
"testing"
)
func TestImap_MakeClient(t *testing.T) {
listener := memlistener.NewMemoryListener()
serverData := make(chan []byte, 4)
srv := server.New(&fake.Backend{Debug: serverData, Username: "a@localhost*master@localhost", Password: "1234"})
srv.AllowInsecureAuth = true
go func() {
_ = srv.Serve(listener)
}()
defaultDialer = func(addr string) (*client.Client, error) {
dial, err := listener.Dial("", "")
if err != nil {
return nil, err
}
return client.New(dial)
}
i := &Imap{Server: "localhost", Username: "master@localhost", Password: "1234", Separator: "*"}
cli, err := i.MakeClient("a@localhost")
assert.NoError(t, err)
status, err := cli.Status("INBOX")
assert.NoError(t, err)
assert.Equal(t, "INBOX", status.Name)
assert.Equal(t, uint32(1), status.Messages)
}

View File

@ -0,0 +1,44 @@
package comma_list_scanner
import (
"bufio"
"bytes"
"io"
)
type CommaListScanner struct {
r *bufio.Scanner
text string
err error
}
func NewCommaListScanner(r io.Reader) *CommaListScanner {
s := bufio.NewScanner(r)
s.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, ','); i >= 0 {
return i + 1, bytes.TrimSpace(data[0:i]), nil
}
// If we're at EOF, we have a final non-terminated line. Return it.
if atEOF {
return len(data), bytes.TrimSpace(data), nil
}
// Request more data.
return 0, nil, nil
})
return &CommaListScanner{r: s}
}
func (c *CommaListScanner) Scan() bool {
if c.r.Scan() {
c.text = c.r.Text()
}
c.err = c.r.Err()
return false
}
func (c *CommaListScanner) Text() string {
return c.text
}

View File

@ -0,0 +1,27 @@
package comma_list_scanner
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
var testCommaList = []struct {
text string
out []string
}{
{"hello, wow this is cool, amazing", []string{"hello", "wow this is cool", "amazing"}},
{"hello, wow this is cool, amazing", []string{"hello", "wow this is cool", "amazing"}},
}
func TestNewCommaListScanner(t *testing.T) {
for _, i := range testCommaList {
t.Run(i.text, func(t *testing.T) {
s := NewCommaListScanner(strings.NewReader(i.text))
n := 0
for s.Scan() {
assert.Equal(t, i.out[n], s.Text())
}
})
}
}

View File

@ -0,0 +1,49 @@
package config_parser
import (
"bufio"
"errors"
"io"
"strings"
)
var ErrInvalidConfigLine = errors.New("invalid config line")
type ConfigParser struct {
s *bufio.Scanner
pair [2]string
err error
}
func NewConfigParser(r io.Reader) *ConfigParser {
return &ConfigParser{s: bufio.NewScanner(r)}
}
func (c *ConfigParser) Scan() bool {
scanAgain:
if !c.s.Scan() {
return false
}
text := strings.TrimSpace(c.s.Text())
if text == "" || strings.HasPrefix(text, "#") {
goto scanAgain
}
n := strings.IndexByte(text, '=')
if n < 2 || n+2 >= len(text) || text[n-1] != ' ' || text[n+1] != ' ' {
c.err = ErrInvalidConfigLine
return false
}
c.pair = [2]string{text[:n-1], text[n+2:]}
return true
}
func (c *ConfigParser) Pair() (string, string) {
return c.pair[0], c.pair[1]
}
func (c *ConfigParser) Err() error {
if c.err != nil {
return c.err
}
return c.s.Err()
}

View File

@ -0,0 +1,39 @@
package config_parser
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
var configParserData = []struct {
Input string
Values [][2]string
}{
{
"a = a",
[][2]string{{"a", "a"}},
},
{
" a = a ",
[][2]string{{"a", "a"}},
},
{
" # this is a comment\n a = a, b\nb = c, d",
[][2]string{{"a", "a, b"}, {"b", "c, d"}},
},
}
func TestConfigParser(t *testing.T) {
for _, i := range configParserData {
t.Run(i.Input, func(t *testing.T) {
a := NewConfigParser(strings.NewReader(i.Input))
n := 0
for a.Scan() {
assert.False(t, n >= len(i.Values))
assert.Equal(t, i.Values[n], a.pair)
n++
}
})
}
}

38
postfix-config/config.go Normal file
View File

@ -0,0 +1,38 @@
package postfix_config
import mapProvider "github.com/1f349/lotus/postfix-config/map-provider"
type Config struct {
// same
VirtualMailboxDomains mapProvider.MapProvider
VirtualAliasMaps mapProvider.MapProvider
VirtualMailboxMaps mapProvider.MapProvider
AliasMaps mapProvider.MapProvider
LocalRecipientMaps mapProvider.MapProvider
SmtpdSenderLoginMaps string // TODO(melon): union map?
}
func (c *Config) SetKey(k string, m mapProvider.MapProvider) {
switch k {
case "virtual_mailbox_domains":
c.VirtualMailboxDomains = m
case "virtual_alias_maps":
c.VirtualAliasMaps = m
case "virtual_mailbox_maps":
c.VirtualMailboxMaps = m
case "alias_maps":
c.AliasMaps = m
case "local_recipient_maps":
c.LocalRecipientMaps = m
case "smtpd_sender_login_maps":
c.SmtpdSenderLoginMaps = "<ERROR>"
}
}
func (c *Config) NeedsMapProvider(k string) bool {
switch k {
case "virtual_mailbox_domains", "virtual_alias_maps", "virtual_mailbox_maps", "alias_maps", "local_recipient_maps", "smtpd_sender_login_maps":
return true
}
return false
}

49
postfix-config/decoder.go Normal file
View File

@ -0,0 +1,49 @@
package postfix_config
import (
"bufio"
"fmt"
configParser "github.com/1f349/lotus/postfix-config/config-parser"
mapProvider "github.com/1f349/lotus/postfix-config/map-provider"
"io"
"strings"
)
type Decoder struct {
r *configParser.ConfigParser
v *Config
t map[string]string
}
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{r: configParser.NewConfigParser(r)}
}
func (d *Decoder) Load() error {
d.v = &Config{}
for d.r.Scan() {
k, v := d.r.Pair()
if d.v.NeedsMapProvider(k) {
m := mapProvider.SequenceMapProvider{}
s := bufio.NewScanner(strings.NewReader(v))
s.Split(bufio.ScanWords)
for s.Scan() {
a := s.Text()
println("a", a)
if strings.HasPrefix(a, "$") {
// is variable
}
n := strings.IndexByte(a, ':')
if n == -1 {
return fmt.Errorf("missing prefix")
}
}
if err := s.Err(); err != nil {
return err
}
d.v.SetKey(k, m)
}
}
return d.r.Err()
}

View File

@ -0,0 +1,18 @@
package postfix_config
import (
"bytes"
_ "embed"
configParser "github.com/1f349/lotus/postfix-config/config-parser"
"github.com/stretchr/testify/assert"
"testing"
)
//go:embed example.cf
var exampleConfig []byte
func TestDecoder_Load(t *testing.T) {
b := bytes.NewReader(exampleConfig)
d := &Decoder{r: configParser.NewConfigParser(b)}
assert.NoError(t, d.Load())
}

10
postfix-config/example.cf Normal file
View File

@ -0,0 +1,10 @@
# this only contains the relevant config properties
recipient_delimiter = +
virtual_mailbox_domains = mysql:/etc/postfix/sql/mysql_virtual_domains_maps.cf
virtual_alias_maps = mysql:/etc/postfix/sql/mysql_virtual_alias_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_wildcard_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_domain_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_user_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_userdomain_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_domain_catchall_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_user_catchall_maps.cf
virtual_mailbox_maps = mysql:/etc/postfix/sql/mysql_virtual_mailbox_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_domain_mailbox_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_user_mailbox_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_userdomain_mailbox_maps.cf
alias_maps = hash:/etc/aliases $virtual_alias_maps
local_recipient_maps = $virtual_mailbox_maps $alias_maps
smtpd_sender_login_maps = unionmap:{ hash:/etc/aliases, mysql:/etc/postfix/sql/mysql_sender_alias_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_domain_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_user_maps.cf, mysql:/etc/postfix/sql/mysql_virtual_alias_userdomain_maps.cf }

View File

@ -0,0 +1,48 @@
package map_provider
import (
"bufio"
"io"
"os"
"strings"
)
type Hash struct {
r io.Reader
v map[string]string
}
var _ MapProvider = &Hash{}
func NewHashMapProvider(filename string) (*Hash, error) {
open, err := os.Open(filename)
if err != nil {
return nil, err
}
return &Hash{open, make(map[string]string)}, nil
}
func (h *Hash) Load() error {
scanner := bufio.NewScanner(h.r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(text, "#") {
continue
}
n := strings.IndexByte(text, ':')
key := strings.TrimSpace(text[:n])
values := strings.Split(text[n+1:], ",")
for _, i := range values {
k := strings.TrimSpace(i)
h.v[k] = key
}
}
return scanner.Err()
}
func (h *Hash) Find(name string) (string, bool) {
v, ok := h.v[name]
return v, ok
}

View File

@ -0,0 +1,3 @@
# See man 5 aliases for format
postmaster: root
test: this, is, an, example

View File

@ -0,0 +1,23 @@
package map_provider
import (
"bytes"
_ "embed"
"github.com/stretchr/testify/assert"
"testing"
)
//go:embed hash_example.txt
var hashExample []byte
func TestHash_Load(t *testing.T) {
h := &Hash{r: bytes.NewReader(hashExample), v: make(map[string]string)}
assert.NoError(t, h.Load())
assert.Equal(t, map[string]string{
"root": "postmaster",
"this": "test",
"is": "test",
"an": "test",
"example": "test",
}, h.v)
}

View File

@ -0,0 +1,23 @@
package map_provider
// MapProvider is an interface to allow looking up mapped values from variables,
// hash files or mysql queries.
type MapProvider interface {
Find(name string) (string, bool)
}
// SequenceMapProvider calls Find against each provider in a slice and outputs
// the true mapped value of the input. first mapped value found. If the input was
// not found then "", false is returned.
type SequenceMapProvider []MapProvider
func (s SequenceMapProvider) Find(name string) (string, bool) {
for _, i := range s {
if find, ok := i.Find(name); ok {
return find, true
}
}
return "", false
}
var _ MapProvider = SequenceMapProvider{}

View File

@ -0,0 +1,76 @@
package map_provider
import (
"errors"
"sort"
"strings"
"unicode"
)
var (
ErrMissingArgument = errors.New("missing argument")
ErrInvalidRawQuery = errors.New("invalid raw query")
)
type PreparedQuery struct {
raw string
params map[int]byte
}
func NewPreparedQuery(raw string) (*PreparedQuery, error) {
var s strings.Builder
origin := 0
params := make(map[int]byte)
for {
n := strings.IndexByte(raw[origin:], '%')
if n == -1 {
break
}
n += origin
if n+1 == len(raw) {
return nil, ErrInvalidRawQuery
}
s.WriteString(raw[origin:n])
if raw[n+1] == '%' {
s.WriteByte('%')
origin = n + 1
continue
}
params[s.Len()] = toLower(raw[n+1])
origin = n + 2
}
s.WriteString(raw[origin:])
return &PreparedQuery{
raw: s.String(),
params: params,
}, nil
}
func (p *PreparedQuery) Format(args map[byte]string) (string, error) {
var s strings.Builder
keys := make([]int, 0, len(p.params))
for k := range p.params {
keys = append(keys, k)
}
sort.Ints(keys)
origin := 0
for _, k := range keys {
r, ok := args[p.params[k]]
if !ok {
return "", ErrMissingArgument
}
// write up to and including the next parameter
s.WriteString(p.raw[origin:k])
s.WriteString(strings.ReplaceAll(r, "'", ""))
origin = k
}
// write the rest of the query
s.WriteString(p.raw[origin:])
return s.String(), nil
}
func toLower(a byte) byte {
return byte(unicode.ToLower(rune(a)))
}

View File

@ -0,0 +1,40 @@
package map_provider
import (
"github.com/stretchr/testify/assert"
"testing"
)
const (
testQuery = "SELECT aliasMap.goto FROM aliasMap,aliasdomainMap WHERE aliasdomainMap.domain='%d' AND aliasMap.address = CONCAT('%u', '@', aliasdomainMap.goto) AND aliasMap.active > 0 AND aliasdomainMap.active > 0"
testQueryRaw = "SELECT aliasMap.goto FROM aliasMap,aliasdomainMap WHERE aliasdomainMap.domain='' AND aliasMap.address = CONCAT('', '@', aliasdomainMap.goto) AND aliasMap.active > 0 AND aliasdomainMap.active > 0"
testQueryFormat = "SELECT aliasMap.goto FROM aliasMap,aliasdomainMap WHERE aliasdomainMap.domain='example.com' AND aliasMap.address = CONCAT('test', '@', aliasdomainMap.goto) AND aliasMap.active > 0 AND aliasdomainMap.active > 0"
)
func TestNewPreparedQuery(t *testing.T) {
query, err := NewPreparedQuery(testQuery)
assert.NoError(t, err)
assert.Equal(t, PreparedQuery{
raw: testQueryRaw,
params: map[int]byte{
79: 'd',
112: 'u',
},
}, *query)
}
func TestPreparedQuery_Format(t *testing.T) {
query := &PreparedQuery{
raw: testQueryRaw,
params: map[int]byte{
79: 'd',
112: 'u',
},
}
format, err := query.Format(map[byte]string{
'd': "example.com",
'u': "test",
})
assert.NoError(t, err)
assert.Equal(t, testQueryFormat, format)
}

View File

@ -0,0 +1,112 @@
package map_provider
import (
"database/sql"
configParser "github.com/1f349/lotus/postfix-config/config-parser"
"github.com/go-sql-driver/mysql"
_ "github.com/go-sql-driver/mysql"
"io"
"os"
"regexp"
"strings"
)
var checkUatD = regexp.MustCompile("^[^@]+@[^@]+$")
type MySql struct {
r io.Reader
db *sql.DB
query *PreparedQuery
}
var _ MapProvider = &MySql{}
func NewMySqlMapProvider(filename string) (*MySql, error) {
open, err := os.Open(filename)
if err != nil {
return nil, err
}
return &MySql{r: open}, nil
}
func (m *MySql) Load() error {
p := configParser.NewConfigParser(m.r)
c := mysql.NewConfig()
var q string
for p.Scan() {
k, v := p.Pair()
switch k {
case "user":
c.User = v
case "password":
c.Passwd = v
case "hosts":
c.Net = "tcp"
c.Addr = v
case "dbname":
c.DBName = v
case "query":
q = v
}
}
if err := p.Err(); err != nil {
return err
}
q2, err := NewPreparedQuery(q)
if err != nil {
return err
}
m.query = q2
// try opening connection
db, err := sql.Open("mysql", c.FormatDSN())
if err != nil {
return err
}
m.db = db
return db.Ping()
}
func (m *MySql) Find(name string) (string, bool) {
format, err := m.query.Format(genQueryArgs(name))
return format, err == nil
}
// genQueryArgs converts an input key into the % encoded parameters
//
// %s - full input key
// %u - user part of user@domain or full input key
// %d - domain part of user@domain or missing parameter
// %[1-9] - replaced with the most significant component of the input key's domain
// for `user@mail.example.com` %1 = com, %2 = example, %3 = mail
// otherwise they are missing parameters
func genQueryArgs(name string) map[byte]string {
args := make(map[byte]string)
args['s'] = name
args['u'] = name
if checkUatD.MatchString(name) {
n := strings.IndexByte(name, '@')
args['u'] = name[:n]
args['d'] = name[n+1:]
genDomainArgs(args, name[n+1:])
}
return args
}
// genDomainArgs replaces with the most significant component of the input key's
// domain for `user@mail.example.com` %1 = com, %2 = example, %3 = mail,
// otherwise they are missing parameters
func genDomainArgs(args map[byte]string, s string) {
i, l := byte(1), len(s)
for {
n := strings.LastIndexByte(s, '.')
if n == -1 {
break
}
args[(i + '0')] = s[n+1 : l]
l = n
}
}

View File

@ -0,0 +1,5 @@
user = example
password = 1234
hosts = 127.0.0.1
dbname = mail
query = SELECT aliasMap.goto FROM aliasMap,aliasdomainMap WHERE aliasdomainMap.domain='%d' AND aliasMap.address = CONCAT('%u', '@', aliasdomainMap.goto) AND aliasMap.active > 0 AND aliasdomainMap.active > 0

View File

@ -0,0 +1,12 @@
package map_provider
type Variable struct {
Name string
Value MapProvider
}
func (v *Variable) Find(name string) (string, bool) {
return v.Value.Find(name)
}
var _ MapProvider = &Variable{}

45
smtp/fake/fake-smtp.go Normal file
View File

@ -0,0 +1,45 @@
package fake
import (
"github.com/emersion/go-smtp"
"io"
"log"
)
type SmtpBackend struct {
Debug chan []byte
}
func (f *SmtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
return &SmtpSession{f.Debug}, nil
}
type SmtpSession struct {
Debug chan []byte
}
func (f *SmtpSession) Reset() {}
func (f *SmtpSession) Logout() error { return nil }
func (f *SmtpSession) AuthPlain(username, password string) error { return nil }
func (f *SmtpSession) Mail(from string, opts *smtp.MailOptions) error {
log.Println("MAIL " + from)
f.Debug <- []byte("MAIL " + from + "\n")
return nil
}
func (f *SmtpSession) Rcpt(to string) error {
f.Debug <- []byte("RCPT " + to + "\n")
return nil
}
func (f *SmtpSession) Data(r io.Reader) error {
all, err := io.ReadAll(r)
if err != nil {
return err
}
f.Debug <- all
return nil
}

View File

@ -16,9 +16,11 @@ type Mail struct {
body []byte
}
var defaultDialer = smtp.Dial
func (s *Smtp) Send(mail *Mail) error {
// dial smtp server
smtpClient, err := smtp.Dial(s.Server)
smtpClient, err := defaultDialer(s.Server)
if err != nil {
return err
}

View File

@ -1,11 +1,64 @@
package smtp
import (
"bytes"
"github.com/1f349/lotus/smtp/fake"
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-smtp"
"github.com/hydrogen18/memlistener"
"github.com/stretchr/testify/assert"
"log"
"strings"
"testing"
"time"
)
var sendTestMessage []byte
func init() {
var h mail.Header
h.SetDate(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.Local))
h.SetSubject("Happy Millennium")
h.SetAddressList("From", []*mail.Address{{Name: "Test", Address: "test@localhost"}})
h.SetAddressList("To", []*mail.Address{{Name: "A", Address: "a@localhost"}})
h.Set("Content-Type", "text/plain; charset=utf-8")
entity, err := message.New(h.Header, strings.NewReader("Thanks"))
if err != nil {
log.Fatal(err)
}
out := new(bytes.Buffer)
if entity.WriteTo(out) != nil {
log.Fatal(err)
}
sendTestMessage = out.Bytes()
}
func TestSmtp_Send(t *testing.T) {
listener := memlistener.NewMemoryListener()
serverData := make(chan []byte, 4)
server := smtp.NewServer(&fake.SmtpBackend{Debug: serverData})
go func() {
_ = server.Serve(listener)
}()
defaultDialer = func(addr string) (*smtp.Client, error) {
dial, err := listener.Dial("", "")
if err != nil {
return nil, err
}
return smtp.NewClient(dial, "localhost")
}
s := &Smtp{Server: "localhost:25"}
err := s.Send(&Mail{from: "test@localhost", deliver: []string{"a@localhost", "b@localhost"}, body: sendTestMessage})
assert.NoError(t, err)
assert.Equal(t, []byte("MAIL test@localhost\n"), <-serverData)
assert.Equal(t, []byte("RCPT a@localhost\n"), <-serverData)
assert.Equal(t, []byte("RCPT b@localhost\n"), <-serverData)
assert.Equal(t, append(sendTestMessage, '\r', '\n'), <-serverData)
}
func TestCreateSenderSlice(t *testing.T) {
a := []*mail.Address{{Address: "a@example.com"}, {Address: "b@example.com"}}
b := []*mail.Address{{Address: "a@example.com"}, {Address: "c@example.com"}}