mirror of
https://github.com/1f349/lotus.git
synced 2024-11-13 23:41:30 +00:00
Start coding postfix config reader
This commit is contained in:
parent
e84e0e3d4b
commit
7698bc9d50
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -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 ./...
|
||||
|
@ -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
7
.idea/sqldialects.xml
Normal 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>
|
111
api/api.go
111
api/api.go
@ -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
61
api/auth.go
Normal 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
13
cmd/lotus/conf.go
Normal 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
59
cmd/lotus/main.go
Normal 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()
|
||||
})
|
||||
}
|
@ -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"`
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
// TODO(Melon): write start up code
|
||||
}
|
12
go.mod
12
go.mod
@ -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
22
go.sum
@ -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=
|
||||
|
@ -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
20
imap/fake/backend.go
Normal 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
63
imap/fake/mailbox.go
Normal 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
39
imap/fake/user.go
Normal 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
|
||||
}
|
@ -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
36
imap/imap_test.go
Normal 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)
|
||||
}
|
44
postfix-config/comma-list-scanner/comma-list-scanner.go
Normal file
44
postfix-config/comma-list-scanner/comma-list-scanner.go
Normal 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
|
||||
}
|
27
postfix-config/comma-list-scanner/comma-list-scanner_test.go
Normal file
27
postfix-config/comma-list-scanner/comma-list-scanner_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
49
postfix-config/config-parser/config-parser.go
Normal file
49
postfix-config/config-parser/config-parser.go
Normal 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()
|
||||
}
|
39
postfix-config/config-parser/config-parser_test.go
Normal file
39
postfix-config/config-parser/config-parser_test.go
Normal 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
38
postfix-config/config.go
Normal 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
49
postfix-config/decoder.go
Normal 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()
|
||||
}
|
18
postfix-config/decoder_test.go
Normal file
18
postfix-config/decoder_test.go
Normal 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
10
postfix-config/example.cf
Normal 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 }
|
48
postfix-config/map-provider/hash.go
Normal file
48
postfix-config/map-provider/hash.go
Normal 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
|
||||
}
|
3
postfix-config/map-provider/hash_example.txt
Normal file
3
postfix-config/map-provider/hash_example.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# See man 5 aliases for format
|
||||
postmaster: root
|
||||
test: this, is, an, example
|
23
postfix-config/map-provider/hash_test.go
Normal file
23
postfix-config/map-provider/hash_test.go
Normal 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)
|
||||
}
|
23
postfix-config/map-provider/map-provider.go
Normal file
23
postfix-config/map-provider/map-provider.go
Normal 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{}
|
76
postfix-config/map-provider/mysql-prepared-query.go
Normal file
76
postfix-config/map-provider/mysql-prepared-query.go
Normal 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)))
|
||||
}
|
40
postfix-config/map-provider/mysql-prepared-query_test.go
Normal file
40
postfix-config/map-provider/mysql-prepared-query_test.go
Normal 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)
|
||||
}
|
112
postfix-config/map-provider/mysql.go
Normal file
112
postfix-config/map-provider/mysql.go
Normal 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
|
||||
}
|
||||
}
|
5
postfix-config/map-provider/mysql_example.cf
Normal file
5
postfix-config/map-provider/mysql_example.cf
Normal 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
|
12
postfix-config/map-provider/variable.go
Normal file
12
postfix-config/map-provider/variable.go
Normal 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
45
smtp/fake/fake-smtp.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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"}}
|
||||
|
Loading…
Reference in New Issue
Block a user