diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
index b7d9ab9..56782ca 100644
--- a/.idea/sqldialects.xml
+++ b/.idea/sqldialects.xml
@@ -1,7 +1,6 @@
-
\ No newline at end of file
diff --git a/api/api.go b/api/api.go
index bdbadba..06ba934 100644
--- a/api/api.go
+++ b/api/api.go
@@ -3,13 +3,12 @@ package api
import (
"encoding/json"
"github.com/1f349/lotus/imap"
- "github.com/1f349/lotus/smtp"
"github.com/julienschmidt/httprouter"
"net/http"
"time"
)
-func SetupApiServer(listen string, auth func(callback AuthCallback) httprouter.Handle, send *smtp.Smtp, recv *imap.Imap) *http.Server {
+func SetupApiServer(listen string, auth func(callback AuthCallback) httprouter.Handle, send Smtp, recv Imap) *http.Server {
r := httprouter.New()
// === ACCOUNT ===
@@ -18,39 +17,7 @@ func SetupApiServer(listen string, auth func(callback AuthCallback) httprouter.H
}))
// === 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)
- return
- }
-
- // parse json body
- var j smtp.Json
- err := json.NewDecoder(req.Body).Decode(&j)
- if err != nil {
- rw.WriteHeader(http.StatusBadRequest)
- return
- }
-
- // TODO(melon): add alias support
- if j.From == b.Subject {
-
- }
-
- mail, err := j.PrepareMail()
- if err != nil {
- rw.WriteHeader(http.StatusBadRequest)
- return
- }
-
- if send.Send(mail) != nil {
- rw.WriteHeader(http.StatusInternalServerError)
- return
- }
-
- rw.WriteHeader(http.StatusAccepted)
- }))
+ r.POST("/message", auth(MessageSender(send)))
// === IMAP ===
type statusJson struct {
@@ -126,7 +93,7 @@ func apiError(rw http.ResponseWriter, code int, m string) {
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 {
+func imapClient[T any](recv 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)
diff --git a/api/interfaces.go b/api/interfaces.go
new file mode 100644
index 0000000..2ec9239
--- /dev/null
+++ b/api/interfaces.go
@@ -0,0 +1,14 @@
+package api
+
+import (
+ "github.com/1f349/lotus/imap"
+ "github.com/1f349/lotus/smtp"
+)
+
+type Smtp interface {
+ Send(mail *smtp.Mail) error
+}
+
+type Imap interface {
+ MakeClient(user string) (*imap.Client, error)
+}
diff --git a/api/send-message.go b/api/send-message.go
new file mode 100644
index 0000000..bd35c22
--- /dev/null
+++ b/api/send-message.go
@@ -0,0 +1,71 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ postfixLookup "github.com/1f349/lotus/postfix-lookup"
+ "github.com/1f349/lotus/smtp"
+ "github.com/julienschmidt/httprouter"
+ "net/http"
+ "time"
+)
+
+var defaultPostfixLookup = postfixLookup.NewPostfixLookup().Lookup
+var timeNow = time.Now
+
+// MessageSender is the internal handler for `POST /message` requests
+// the access token is already validated at this point
+func MessageSender(send Smtp) func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
+ return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
+ // check body exists
+ if req.Body == nil {
+ apiError(rw, http.StatusBadRequest, "Missing request body")
+ return
+ }
+
+ // parse json body
+ var j smtp.Json
+ err := json.NewDecoder(req.Body).Decode(&j)
+ if err != nil {
+ apiError(rw, http.StatusBadRequest, "Invalid JSON body")
+ return
+ }
+
+ // prepare the mail for sending
+ mail, err := j.PrepareMail(timeNow())
+ if err != nil {
+ apiError(rw, http.StatusBadRequest, "Invalid mail message")
+ return
+ }
+
+ // this looks up the underlying account for the sender alias
+ println(mail.From)
+ lookup, err := defaultPostfixLookup(mail.From)
+
+ // the alias does not exist
+ if errors.Is(err, postfixLookup.ErrInvalidAlias) {
+ apiError(rw, http.StatusBadRequest, "Invalid sender alias")
+ return
+ }
+
+ // the alias lookup failed to run
+ if err != nil {
+ apiError(rw, http.StatusInternalServerError, "Sender alias lookup failed")
+ return
+ }
+
+ // the alias does not match the logged-in user
+ if lookup != b.Subject {
+ apiError(rw, http.StatusBadRequest, "User does not own sender alias")
+ return
+ }
+
+ // try sending the mail
+ if send.Send(mail) != nil {
+ apiError(rw, http.StatusInternalServerError, "Failed to send mail")
+ return
+ }
+
+ rw.WriteHeader(http.StatusAccepted)
+ }
+}
diff --git a/api/send-message_test.go b/api/send-message_test.go
new file mode 100644
index 0000000..6829251
--- /dev/null
+++ b/api/send-message_test.go
@@ -0,0 +1,240 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ postfixLookup "github.com/1f349/lotus/postfix-lookup"
+ "github.com/1f349/lotus/smtp"
+ "github.com/MrMelon54/mjwt/auth"
+ "github.com/MrMelon54/mjwt/claims"
+ "github.com/golang-jwt/jwt/v4"
+ "github.com/julienschmidt/httprouter"
+ "github.com/stretchr/testify/assert"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+func init() {
+ defaultPostfixLookup = func(key string) (string, error) {
+ switch key {
+ case "noreply@example.com", "admin@example.com":
+ return "admin@example.com", nil
+ case "user@example.com":
+ return "user@example.com", nil
+ }
+ return "", postfixLookup.ErrInvalidAlias
+ }
+ timeNow = func() time.Time {
+ return time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
+ }
+}
+
+type fakeSmtp struct {
+ from string
+ deliver []string
+ body []byte
+}
+
+func (f *fakeSmtp) Send(mail *smtp.Mail) error {
+ if mail.From != f.from {
+ return fmt.Errorf("test fail: invalid from address")
+ }
+ if !slices.Equal(mail.Deliver, f.deliver) {
+ return fmt.Errorf("test fail: invalid deliver slice")
+ }
+ if !slices.Equal(mail.Body, f.body) {
+ return fmt.Errorf("test fail: invalid message body")
+ }
+ return nil
+}
+
+type fakeFailedSmtp struct{}
+
+func (f *fakeFailedSmtp) Send(mail *smtp.Mail) error {
+ return errors.New("sending failed")
+}
+
+var messageSenderTestData = []struct {
+ req func() (*http.Request, error)
+ smtp Smtp
+ claims AuthClaims
+ status int
+ output string
+}{
+ {
+ req: func() (*http.Request, error) {
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", nil)
+ },
+ smtp: &fakeSmtp{},
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusBadRequest,
+ output: "Missing request body",
+ },
+ {
+ req: func() (*http.Request, error) {
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", strings.NewReader(`{`))
+ },
+ smtp: &fakeSmtp{},
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusBadRequest,
+ output: "Invalid JSON body",
+ },
+ {
+ req: func() (*http.Request, error) {
+ j, err := json.Marshal(smtp.Json{
+ From: "noreply2@example.com",
+ ReplyTo: "admin@example.com",
+ To: "user@example.com",
+ Subject: "Test Subject",
+ BodyType: "plain",
+ Body: "Plain text",
+ })
+ if err != nil {
+ return nil, err
+ }
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", bytes.NewReader(j))
+ },
+ smtp: &fakeSmtp{},
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusBadRequest,
+ output: "Invalid sender alias",
+ },
+ {
+ req: func() (*http.Request, error) {
+ j, err := json.Marshal(smtp.Json{
+ From: "user@example.com",
+ ReplyTo: "admin@example.com",
+ To: "user@example.com",
+ Subject: "Test Subject",
+ BodyType: "plain",
+ Body: "Plain text",
+ })
+ if err != nil {
+ return nil, err
+ }
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", bytes.NewReader(j))
+ },
+ smtp: &fakeSmtp{},
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusBadRequest,
+ output: "User does not own sender alias",
+ },
+ {
+ req: func() (*http.Request, error) {
+ j, err := json.Marshal(smtp.Json{
+ From: "noreply@example.com, user2@example.com",
+ ReplyTo: "admin@example.com",
+ To: "user@example.com",
+ Subject: "Test Subject",
+ BodyType: "plain",
+ Body: "Plain text",
+ })
+ if err != nil {
+ return nil, err
+ }
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", bytes.NewReader(j))
+ },
+ smtp: &fakeSmtp{},
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusBadRequest,
+ output: "Invalid mail message",
+ },
+ {
+ req: func() (*http.Request, error) {
+ j, err := json.Marshal(smtp.Json{
+ From: "noreply@example.com",
+ ReplyTo: "admin@example.com",
+ To: "user@example.com",
+ Subject: "Test Subject",
+ BodyType: "plain",
+ Body: "Plain text",
+ })
+ if err != nil {
+ return nil, err
+ }
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", bytes.NewReader(j))
+ },
+ smtp: &fakeFailedSmtp{},
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusInternalServerError,
+ output: "Failed to send mail",
+ },
+ {
+ req: func() (*http.Request, error) {
+ j, err := json.Marshal(smtp.Json{
+ From: "noreply@example.com",
+ ReplyTo: "admin@example.com",
+ To: "user@example.com",
+ Cc: "user2@example.com",
+ Bcc: "user3@example.com",
+ Subject: "Test Subject",
+ BodyType: "plain",
+ Body: "Some plain text",
+ })
+ if err != nil {
+ return nil, err
+ }
+ return http.NewRequest(http.MethodPost, "https://api.example.com/v1/mail/message", bytes.NewReader(j))
+ },
+ smtp: &fakeSmtp{
+ from: "noreply@example.com",
+ deliver: []string{"user@example.com", "user2@example.com", "user3@example.com"},
+ body: []byte("Mime-Version: 1.0\r\n" +
+ "Content-Type: text/plain; charset=utf-8\r\n" +
+ "Cc: \r\n" +
+ "To: \r\n" +
+ "Reply-To: \r\n" +
+ "From: \r\n" +
+ "Subject: Test Subject\r\n" +
+ "Date: Sat, 01 Jan 2000 00:00:00 +0000\r\n" +
+ "\r\n" +
+ "Some plain text"),
+ },
+ claims: makeFakeAuthClaims("admin@example.com"),
+ status: http.StatusAccepted,
+ output: "",
+ },
+}
+
+func makeFakeAuthClaims(subject string) AuthClaims {
+ return struct {
+ jwt.RegisteredClaims
+ ClaimType string
+ Claims auth.AccessTokenClaims
+ }{
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: "Test",
+ Subject: subject,
+ Audience: jwt.ClaimStrings{"mail.example.com"},
+ },
+ ClaimType: "access-token",
+ Claims: auth.AccessTokenClaims{Perms: claims.NewPermStorage()},
+ }
+}
+
+func TestMessageSender(t *testing.T) {
+ for _, i := range messageSenderTestData {
+ rec := httptest.NewRecorder()
+ req, err := i.req()
+ assert.NoError(t, err)
+ MessageSender(i.smtp)(rec, req, httprouter.Params{}, i.claims)
+
+ res := rec.Result()
+ assert.Equal(t, i.status, res.StatusCode)
+ assert.NotNil(t, res.Body)
+ all, err := io.ReadAll(res.Body)
+ assert.NoError(t, err)
+ if i.output == "" {
+ assert.Equal(t, "", string(all))
+ } else {
+ assert.Equal(t, "{\"error\":\""+i.output+"\"}\n", string(all))
+ }
+ }
+}
diff --git a/postfix-config/comma-list-scanner/comma-list-scanner.go b/postfix-config/comma-list-scanner/comma-list-scanner.go
deleted file mode 100644
index 6a3e6a1..0000000
--- a/postfix-config/comma-list-scanner/comma-list-scanner.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package comma_list_scanner
-
-import (
- "bufio"
- "bytes"
- "fmt"
- "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
- }
- println("data", fmt.Sprintf("%s", data))
- println("index", bytes.IndexAny(data, " ,"))
- if i := bytes.IndexAny(data, " ,"); i >= 0 {
- // consume all spaces after the comma
- j := i + 1
- for j < len(data) && data[j] == ' ' {
- j++
- }
- return j, 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()
- return true
- }
- c.err = c.r.Err()
- return false
-}
-
-func (c *CommaListScanner) Text() string {
- return c.text
-}
-
-func (c *CommaListScanner) Err() error {
- return c.err
-}
diff --git a/postfix-config/comma-list-scanner/comma-list-scanner_test.go b/postfix-config/comma-list-scanner/comma-list-scanner_test.go
deleted file mode 100644
index a4b779d..0000000
--- a/postfix-config/comma-list-scanner/comma-list-scanner_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package comma_list_scanner
-
-import (
- "github.com/stretchr/testify/assert"
- "strings"
- "testing"
-)
-
-var testCommaList = []string{
- "hello, wow-this-is-cool, amazing",
- "hello, wow-this-is-cool",
- "hello, wow-this-is-cool, ",
- "hello, wow-this-is-cool,",
- ",hello, wow-this-is-cool",
- ",hello, wow-this-is-cool,",
- "hello, wow-this-is-cool,,,",
-}
-
-func TestNewCommaListScanner(t *testing.T) {
- for _, i := range testCommaList {
- t.Run(i, func(t *testing.T) {
- // use comma list scanner
- s := NewCommaListScanner(strings.NewReader(i))
- n := strings.Count(i, ",")
- a := make([]string, 0, n+1)
- for s.Scan() {
- a = append(a, s.Text())
- }
- assert.NoError(t, s.Err())
-
- // test against splitting and trimming strings
- b := strings.Split(i, ",")
- for i := 0; i < len(b); i++ {
- c := strings.TrimSpace(b[i])
- if c == "" {
- b = append(b[0:i], b[i+1:]...)
- i--
- } else {
- b[i] = c
- }
- }
- assert.Equal(t, b, a)
- })
- }
-}
diff --git a/postfix-config/config-parser/config-parser.go b/postfix-config/config-parser/config-parser.go
deleted file mode 100644
index 7738bd3..0000000
--- a/postfix-config/config-parser/config-parser.go
+++ /dev/null
@@ -1,49 +0,0 @@
-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 strings.TrimSpace(c.pair[0]), strings.TrimSpace(c.pair[1])
-}
-
-func (c *ConfigParser) Err() error {
- if c.err != nil {
- return c.err
- }
- return c.s.Err()
-}
diff --git a/postfix-config/config-parser/config-parser_test.go b/postfix-config/config-parser/config-parser_test.go
deleted file mode 100644
index b1762e1..0000000
--- a/postfix-config/config-parser/config-parser_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-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++
- }
- })
- }
-}
diff --git a/postfix-config/config.go b/postfix-config/config.go
deleted file mode 100644
index 4057930..0000000
--- a/postfix-config/config.go
+++ /dev/null
@@ -1,43 +0,0 @@
-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 mapProvider.MapProvider
-}
-
-var parseProviderData = map[string]string{
- "virtual_mailbox_domains": "comma",
- "virtual_alias_maps": "comma",
- "virtual_mailbox_maps": "comma",
- "alias_maps": "comma",
- "local_recipient_maps": "comma",
- "smtpd_sender_login_maps": "union",
-}
-
-func (c *Config) ParseProvider(k string) string {
- return parseProviderData[k]
-}
-
-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 = m
- }
-}
diff --git a/postfix-config/decoder.go b/postfix-config/decoder.go
deleted file mode 100644
index 0d96848..0000000
--- a/postfix-config/decoder.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package postfix_config
-
-import (
- "errors"
- "fmt"
- commaListScanner "github.com/1f349/lotus/postfix-config/comma-list-scanner"
- configParser "github.com/1f349/lotus/postfix-config/config-parser"
- mapProvider "github.com/1f349/lotus/postfix-config/map-provider"
- "io"
- "path/filepath"
- "strings"
-)
-
-type Decoder struct {
- r *configParser.ConfigParser
- temp map[string]string
- basePath string
-}
-
-func NewDecoder(r io.Reader) *Decoder {
- return &Decoder{r: configParser.NewConfigParser(r)}
-}
-
-func (d *Decoder) Load() error {
- for d.r.Scan() {
- k, v := d.r.Pair()
- d.temp[k] = v
- }
- if err := d.r.Err(); err != nil {
- return err
- }
-
- switch d.value.ParseProvider(k) {
- case "comma":
- m := mapProvider.SequenceMapProvider{}
-
- s := commaListScanner.NewCommaListScanner(strings.NewReader(v))
- for s.Scan() {
- a := s.Text()
- println("a", a)
- if strings.HasPrefix(a, "$") {
- m = append(m, &mapProvider.Variable{Name: a[1:]})
- continue
- }
-
- v2, err := d.createValue(a)
- if err != nil {
- return err
- }
- m = append(m, v2)
- }
- if err := s.Err(); err != nil {
- return err
- }
- d.value.SetKey(k, m)
- case "union":
- if !strings.HasPrefix(v, "unionmap:{") || !strings.HasSuffix(v, "}") {
- return errors.New("key requires a union map")
- }
- v = v[len("unionmap:{") : len(v)-1]
-
- m := mapProvider.SequenceMapProvider{}
- s := commaListScanner.NewCommaListScanner(strings.NewReader(v))
- for s.Scan() {
- a := s.Text()
- v2, err := d.createValue(a)
- if err != nil {
- return err
- }
- m = append(m, v2)
- }
- default:
- return fmt.Errorf("key '%s' has no defined parse provider", k)
- }
- }
- return d.r.Err()
-}
-
-func (d *Decoder) createValue(a string) (mapProvider.MapProvider, error) {
- n := strings.IndexByte(a, ':')
- if n == -1 {
- return nil, fmt.Errorf("missing prefix")
- }
-
- namespace := a[:n]
- value := a[n+1:]
- switch namespace {
- case "mysql":
- if !filepath.IsAbs(value) {
- value = filepath.Join(d.basePath, value)
- }
- provider, err := mapProvider.NewMySqlMapProvider(value)
- if err != nil {
- return nil, err
- }
- return provider, nil
- case "hash":
- if !filepath.IsAbs(value) {
- value = filepath.Join(d.basePath, value)
- }
- provider, err := mapProvider.NewHashMapProvider(value)
- if err != nil {
- return nil, err
- }
- return provider, nil
- }
- return nil, errors.New("invalid provider namespace")
-}
diff --git a/postfix-config/decoder_test.go b/postfix-config/decoder_test.go
deleted file mode 100644
index 7bc4e2e..0000000
--- a/postfix-config/decoder_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package postfix_config
-
-import (
- "bytes"
- _ "embed"
- configParser "github.com/1f349/lotus/postfix-config/config-parser"
- "github.com/stretchr/testify/assert"
- "os"
- "path/filepath"
- "testing"
-)
-
-//go:embed example.cf
-var exampleConfig []byte
-
-func TestDecoder_Load(t *testing.T) {
- // get working directory
- wd, err := os.Getwd()
- assert.NoError(t, err)
-
- // read example config
- b := bytes.NewReader(exampleConfig)
- d := &Decoder{
- r: configParser.NewConfigParser(b),
- basePath: filepath.Join(wd, "test-data"),
- }
- assert.NoError(t, d.Load())
-}
diff --git a/postfix-config/example.cf b/postfix-config/example.cf
deleted file mode 100644
index 359b837..0000000
--- a/postfix-config/example.cf
+++ /dev/null
@@ -1,10 +0,0 @@
-# this only contains the relevant config properties
-
-#recipient_delimiter = +
-
-virtual_mailbox_domains = mysql:mysql_virtual_domains_maps.cf
-virtual_alias_maps = mysql:mysql_virtual_alias_maps.cf, mysql:mysql_virtual_alias_wildcard_maps.cf, mysql:mysql_virtual_alias_domain_maps.cf, mysql:mysql_virtual_alias_user_maps.cf, mysql:mysql_virtual_alias_userdomain_maps.cf, mysql:mysql_virtual_alias_domain_catchall_maps.cf, mysql:mysql_virtual_alias_user_catchall_maps.cf
-virtual_mailbox_maps = mysql:mysql_virtual_mailbox_maps.cf, mysql:mysql_virtual_alias_domain_mailbox_maps.cf, mysql:mysql_virtual_alias_user_mailbox_maps.cf, mysql:mysql_virtual_alias_userdomain_mailbox_maps.cf
-alias_maps = hash:aliases.txt $virtual_alias_maps
-local_recipient_maps = $virtual_mailbox_maps $alias_maps
-smtpd_sender_login_maps = unionmap:{ hash:aliases.txt, mysql:mysql_sender_alias_maps.cf, mysql:mysql_virtual_alias_maps.cf, mysql:mysql_virtual_alias_domain_maps.cf, mysql:mysql_virtual_alias_user_maps.cf, mysql:mysql_virtual_alias_userdomain_maps.cf }
diff --git a/postfix-config/map-provider/hash.go b/postfix-config/map-provider/hash.go
deleted file mode 100644
index 025e82a..0000000
--- a/postfix-config/map-provider/hash.go
+++ /dev/null
@@ -1,48 +0,0 @@
-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
-}
diff --git a/postfix-config/map-provider/hash_example.txt b/postfix-config/map-provider/hash_example.txt
deleted file mode 100644
index b8c3f94..0000000
--- a/postfix-config/map-provider/hash_example.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-# See man 5 aliases for format
-postmaster: root
-test: this, is, an, example
diff --git a/postfix-config/map-provider/hash_test.go b/postfix-config/map-provider/hash_test.go
deleted file mode 100644
index 846b1b1..0000000
--- a/postfix-config/map-provider/hash_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-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)
-}
diff --git a/postfix-config/map-provider/map-provider.go b/postfix-config/map-provider/map-provider.go
deleted file mode 100644
index be220db..0000000
--- a/postfix-config/map-provider/map-provider.go
+++ /dev/null
@@ -1,23 +0,0 @@
-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{}
diff --git a/postfix-config/map-provider/mysql-prepared-query.go b/postfix-config/map-provider/mysql-prepared-query.go
deleted file mode 100644
index 757fd48..0000000
--- a/postfix-config/map-provider/mysql-prepared-query.go
+++ /dev/null
@@ -1,76 +0,0 @@
-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)))
-}
diff --git a/postfix-config/map-provider/mysql-prepared-query_test.go b/postfix-config/map-provider/mysql-prepared-query_test.go
deleted file mode 100644
index e94fc5f..0000000
--- a/postfix-config/map-provider/mysql-prepared-query_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-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)
-}
diff --git a/postfix-config/map-provider/mysql.go b/postfix-config/map-provider/mysql.go
deleted file mode 100644
index 937fcef..0000000
--- a/postfix-config/map-provider/mysql.go
+++ /dev/null
@@ -1,112 +0,0 @@
-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
- }
-}
diff --git a/postfix-config/map-provider/mysql_example.cf b/postfix-config/map-provider/mysql_example.cf
deleted file mode 100644
index e22ea38..0000000
--- a/postfix-config/map-provider/mysql_example.cf
+++ /dev/null
@@ -1,5 +0,0 @@
-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
diff --git a/postfix-config/map-provider/variable.go b/postfix-config/map-provider/variable.go
deleted file mode 100644
index a0dc17f..0000000
--- a/postfix-config/map-provider/variable.go
+++ /dev/null
@@ -1,12 +0,0 @@
-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{}
diff --git a/postfix-config/test-data/aliases.txt b/postfix-config/test-data/aliases.txt
deleted file mode 100644
index 01c5c6a..0000000
--- a/postfix-config/test-data/aliases.txt
+++ /dev/null
@@ -1 +0,0 @@
-a: a b
\ No newline at end of file
diff --git a/postfix-config/test-data/mysql_sender_alias_maps.cf b/postfix-config/test-data/mysql_sender_alias_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_domain_catchall_maps.cf b/postfix-config/test-data/mysql_virtual_alias_domain_catchall_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_domain_mailbox_maps.cf b/postfix-config/test-data/mysql_virtual_alias_domain_mailbox_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_domain_maps.cf b/postfix-config/test-data/mysql_virtual_alias_domain_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_maps.cf b/postfix-config/test-data/mysql_virtual_alias_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_user_catchall_maps.cf b/postfix-config/test-data/mysql_virtual_alias_user_catchall_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_user_mailbox_maps.cf b/postfix-config/test-data/mysql_virtual_alias_user_mailbox_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_user_maps.cf b/postfix-config/test-data/mysql_virtual_alias_user_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_userdomain_mailbox_maps.cf b/postfix-config/test-data/mysql_virtual_alias_userdomain_mailbox_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_userdomain_maps.cf b/postfix-config/test-data/mysql_virtual_alias_userdomain_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_alias_wildcard_maps.cf b/postfix-config/test-data/mysql_virtual_alias_wildcard_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_domains_maps.cf b/postfix-config/test-data/mysql_virtual_domains_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-config/test-data/mysql_virtual_mailbox_maps.cf b/postfix-config/test-data/mysql_virtual_mailbox_maps.cf
deleted file mode 100644
index e69de29..0000000
diff --git a/postfix-lookup/lookup.sh b/postfix-lookup/lookup.sh
new file mode 100644
index 0000000..001ce84
--- /dev/null
+++ b/postfix-lookup/lookup.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+virtual_alias_maps=$(postconf -h virtual_alias_maps | tr ',' '\n')
+alias_to_lookup="$1"
+result=$(echo "$virtual_alias_maps" | xargs -I {} postmap -q "$alias_to_lookup" {})
+echo "result=$result"
diff --git a/postfix-lookup/postfix-lookup.go b/postfix-lookup/postfix-lookup.go
new file mode 100644
index 0000000..9e454f2
--- /dev/null
+++ b/postfix-lookup/postfix-lookup.go
@@ -0,0 +1,47 @@
+package postfix_lookup
+
+import (
+ "bufio"
+ "bytes"
+ _ "embed"
+ "errors"
+ "os/exec"
+ "strings"
+)
+
+var ErrInvalidAlias = errors.New("invalid alias")
+
+//go:embed lookup.sh
+var lookupScript string
+
+type PostfixLookup struct {
+ execCmd func(key string) ([]byte, error)
+}
+
+func NewPostfixLookup() *PostfixLookup {
+ return &PostfixLookup{
+ execCmd: func(key string) ([]byte, error) {
+ return exec.Command("bash", "-c", lookupScript, "--", key).Output()
+ },
+ }
+}
+
+func (d *PostfixLookup) Lookup(key string) (string, error) {
+ output, err := d.execCmd(key)
+ if err != nil {
+ return "", err
+ }
+
+ s := bufio.NewScanner(bytes.NewReader(output))
+ for s.Scan() {
+ a := s.Text()
+ n := strings.IndexByte(a, '=')
+ if n != -1 && a[:n] == "result" {
+ return a[n+1:], nil
+ }
+ }
+ if err := s.Err(); err != nil {
+ return "", err
+ }
+ return "", ErrInvalidAlias
+}
diff --git a/postfix-lookup/postfix-lookup_test.go b/postfix-lookup/postfix-lookup_test.go
new file mode 100644
index 0000000..198c206
--- /dev/null
+++ b/postfix-lookup/postfix-lookup_test.go
@@ -0,0 +1,45 @@
+package postfix_lookup
+
+import (
+ _ "embed"
+ "github.com/stretchr/testify/assert"
+ "strings"
+ "testing"
+)
+
+var postfixLookupData = []struct {
+ Input string
+ Output string
+}{
+ {"hi@example.com", "admin@example.com"},
+ {"test@example.com", "admin@example.com"},
+ {"user@example.org", "admin@example.org"},
+ {"user@example.net", ""},
+}
+
+func TestDecoder_Load(t *testing.T) {
+ p := &PostfixLookup{execCmd: func(key string) ([]byte, error) {
+ n := strings.IndexByte(key, '@')
+ if n == -1 {
+ return []byte{}, nil
+ }
+ addr := key[n+1:]
+ switch addr {
+ case "example.com", "example.org":
+ return []byte("result=admin@" + addr + "\nadmin@" + addr + "\n"), nil
+ }
+ return []byte{}, nil
+ }}
+ for _, i := range postfixLookupData {
+ t.Run(i.Input, func(t *testing.T) {
+ lookup, err := p.Lookup(i.Input)
+ if i.Output == "" && err == nil {
+ t.Fatal("expected error for empty output test case")
+ }
+ if i.Output != "" && err != nil {
+ t.Fatal("expected no error for non-empty output test case")
+ }
+ assert.Equal(t, i.Output, lookup)
+ })
+ }
+}
diff --git a/smtp/json.go b/smtp/json.go
index eee11b7..856a066 100644
--- a/smtp/json.go
+++ b/smtp/json.go
@@ -22,7 +22,7 @@ type Json struct {
Bcc string `json:"bcc"`
Subject string `json:"subject"`
BodyType string `json:"body_type"`
- Body string `json:"body"`
+ Body string `json:"Body"`
}
func (s Json) parseAddresses() (addrFrom, addrReplyTo, addrTo, addrCc, addrBcc []*mail.Address, err error) {
@@ -31,23 +31,31 @@ func (s Json) parseAddresses() (addrFrom, addrReplyTo, addrTo, addrCc, addrBcc [
if err != nil {
return
}
- addrReplyTo, err = mail.ParseAddressList(s.ReplyTo)
- if err != nil {
- return
+ if s.ReplyTo != "" {
+ addrReplyTo, err = mail.ParseAddressList(s.ReplyTo)
+ if err != nil {
+ return
+ }
}
- addrTo, err = mail.ParseAddressList(s.To)
- if err != nil {
- return
+ if s.To != "" {
+ addrTo, err = mail.ParseAddressList(s.To)
+ if err != nil {
+ return
+ }
}
- addrCc, err = mail.ParseAddressList(s.Cc)
- if err != nil {
- return
+ if s.Cc != "" {
+ addrCc, err = mail.ParseAddressList(s.Cc)
+ if err != nil {
+ return
+ }
+ }
+ if s.Bcc != "" {
+ addrBcc, err = mail.ParseAddressList(s.Bcc)
}
- addrBcc, err = mail.ParseAddressList(s.Bcc)
return
}
-func (s Json) PrepareMail() (*Mail, error) {
+func (s Json) PrepareMail(now time.Time) (*Mail, error) {
// parse addresses from json data
addrFrom, addrReplyTo, addrTo, addrCc, addrBcc, err := s.parseAddresses()
if err != nil {
@@ -64,7 +72,7 @@ func (s Json) PrepareMail() (*Mail, error) {
// set base headers
var h mail.Header
- h.SetDate(time.Now())
+ h.SetDate(now)
h.SetSubject(s.Subject)
h.SetAddressList("From", addrFrom)
h.SetAddressList("Reply-To", addrReplyTo)
@@ -87,8 +95,8 @@ func (s Json) PrepareMail() (*Mail, error) {
}
m := &Mail{
- from: from,
- deliver: CreateSenderSlice(addrTo, addrCc, addrBcc),
+ From: from,
+ Deliver: CreateSenderSlice(addrTo, addrCc, addrBcc),
}
out := new(bytes.Buffer)
@@ -96,6 +104,6 @@ func (s Json) PrepareMail() (*Mail, error) {
return nil, err
}
- m.body = out.Bytes()
+ m.Body = out.Bytes()
return m, nil
}
diff --git a/smtp/smtp.go b/smtp/smtp.go
index 20e3bc7..8944338 100644
--- a/smtp/smtp.go
+++ b/smtp/smtp.go
@@ -11,9 +11,9 @@ type Smtp struct {
}
type Mail struct {
- from string
- deliver []string
- body []byte
+ From string
+ Deliver []string
+ Body []byte
}
var defaultDialer = smtp.Dial
@@ -26,10 +26,10 @@ func (s *Smtp) Send(mail *Mail) error {
}
// use a reader to send bytes
- r := bytes.NewReader(mail.body)
+ r := bytes.NewReader(mail.Body)
// send mail
- return smtpClient.SendMail(mail.from, mail.deliver, r)
+ return smtpClient.SendMail(mail.From, mail.Deliver, r)
}
func CreateSenderSlice(to, cc, bcc []*mail.Address) []string {
diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go
index 3e1fdb5..ce74fb3 100644
--- a/smtp/smtp_test.go
+++ b/smtp/smtp_test.go
@@ -51,7 +51,7 @@ func TestSmtp_Send(t *testing.T) {
}
s := &Smtp{Server: "localhost:25"}
- err := s.Send(&Mail{from: "test@localhost", deliver: []string{"a@localhost", "b@localhost"}, body: sendTestMessage})
+ 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)