diff --git a/go.mod b/go.mod index 8ab5f44..a251670 100644 --- a/go.mod +++ b/go.mod @@ -12,14 +12,19 @@ require ( github.com/go-chi/chi v1.5.5 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/jmoiron/sqlx v1.2.0 // indirect github.com/knadh/koanf v1.5.0 // indirect + github.com/lib/pq v1.3.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/yuin/goldmark v1.3.5 // indirect golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 // indirect golang.org/x/sys v0.4.0 // indirect google.golang.org/appengine v1.6.6 // indirect google.golang.org/protobuf v1.26.0 // indirect + gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b // indirect ) diff --git a/go.sum b/go.sum index 0276d25..6e0ba01 100644 --- a/go.sum +++ b/go.sum @@ -238,6 +238,7 @@ github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -271,6 +272,7 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -390,6 +392,7 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -408,6 +411,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.4/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= @@ -750,6 +754,7 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0= gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/handlers.go b/handlers.go index c9ecbf5..a6dc67e 100644 --- a/handlers.go +++ b/handlers.go @@ -1,2 +1,170 @@ package main +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/textproto" + + "github.com/HiWay-Media/listmonk-onesignal/lib" + "github.com/go-chi/chi" + "github.com/knadh/listmonk/models" +) + +type postback struct { + Subject string `json:"subject"` + FromEmail string `json:"from_email"` + ContentType string `json:"content_type"` + Body string `json:"body"` + Recipients []recipient `json:"recipients"` + Campaign *campaign `json:"campaign"` + Attachments []attachment `json:"attachments"` +} + +type campaign struct { + FromEmail string `json:"from_email"` + UUID string `json:"uuid"` + Name string `json:"name"` + Tags []string `json:"tags"` +} + +type recipient struct { + UUID string `json:"uuid"` + Email string `json:"email"` + Name string `json:"name"` + Attribs models.SubscriberAttribs `json:"attribs"` + Status string `json:"status"` +} + +type attachment struct { + Name string `json:"name"` + Header textproto.MIMEHeader `json:"header"` + Content []byte `json:"content"` +} + +type httpResp struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// handlePostback picks the messager based on url params and pushes message using it. +func handlePostback(w http.ResponseWriter, r *http.Request) { + var ( + app = r.Context().Value("app").(*App) + provider = chi.URLParam(r, "provider") + ) + + // Decode body + body, err := ioutil.ReadAll(r.Body) + if err != nil { + app.logger.ErrorWith("error reading request body").Err("err", err).Write() + sendErrorResponse(w, "invalid body", http.StatusBadRequest, nil) + return + } + defer r.Body.Close() + + data := &postback{} + if err := json.Unmarshal(body, &data); err != nil { + app.logger.ErrorWith("error unmarshalling request body").Err("err", err).Write() + sendErrorResponse(w, "invalid body", http.StatusBadRequest, nil) + return + } + + // Get the provider. + p, ok := app.messengers[provider] + if !ok { + sendErrorResponse(w, "unknown provider", http.StatusBadRequest, nil) + return + } + + if len(data.Recipients) > 1 { + sendErrorResponse(w, "invalid recipients", http.StatusBadRequest, nil) + return + } + + rec := data.Recipients[0] + message := lib.Message{ + From: data.FromEmail, + Subject: data.Subject, + ContentType: data.ContentType, + Body: []byte(data.Body), + Subscriber: models.Subscriber{ + UUID: rec.UUID, + Email: rec.Email, + Name: rec.Name, + Status: rec.Status, + Attribs: rec.Attribs, + }, + } + + if data.Campaign != nil { + message.Campaign = &models.Campaign{ + FromEmail: data.Campaign.FromEmail, + UUID: data.Campaign.UUID, + Name: data.Campaign.Name, + Tags: data.Campaign.Tags, + } + } + + if len(data.Attachments) > 0 { + files := make([]lib.Attachment, 0, len(data.Attachments)) + for _, f := range data.Attachments { + a := lib.Attachment{ + Name: f.Name, + Header: f.Header, + Content: make([]byte, len(f.Content)), + } + copy(a.Content, f.Content) + files = append(files, a) + } + + message.Attachments = files + } + + app.logger.DebugWith("sending message").String("provider", provider).String("message", fmt.Sprintf("%#+v", message)).Write() + + // Send message. + if err := p.Push(message); err != nil { + app.logger.ErrorWith("error sending message").Err("err", err).Write() + sendErrorResponse(w, "error sending message", http.StatusInternalServerError, nil) + return + } + + sendResponse(w, "OK") + return +} + +// wrap is a middleware that wraps HTTP handlers and injects the "app" context. +func wrap(app *App, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), "app", app) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sendResponse sends a JSON envelope to the HTTP response. +func sendResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + out, err := json.Marshal(httpResp{Status: "success", Data: data}) + if err != nil { + sendErrorResponse(w, "Internal Server Error", http.StatusInternalServerError, nil) + return + } + + w.Write(out) +} + +// sendErrorResponse sends a JSON error envelope to the HTTP response. +func sendErrorResponse(w http.ResponseWriter, message string, code int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + + resp := httpResp{Status: "error", + Message: message, + Data: data} + out, _ := json.Marshal(resp) + w.Write(out) +} diff --git a/lib/models.go b/lib/models.go new file mode 100644 index 0000000..c4dc205 --- /dev/null +++ b/lib/models.go @@ -0,0 +1,39 @@ +package lib + +import ( + "net/textproto" + + "github.com/knadh/listmonk/models" +) + +type Messenger interface { + Name() string + Push(Message) error + Flush() error + Close() error +} + +// Message is the message pushed to a Messenger. +type Message struct { + From string + To []string + Subject string + ContentType string + Body []byte + AltBody []byte + Headers textproto.MIMEHeader + Attachments []Attachment + + Subscriber models.Subscriber + + // Campaign is generally the same instance for a large number of subscribers. + Campaign *models.Campaign +} + +// Attachment represents a file or blob attachment that can be +// sent along with a message by a Messenger. +type Attachment struct { + Name string + Header textproto.MIMEHeader + Content []byte +} diff --git a/main.go b/main.go index 413132a..19f8bf2 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/HiWay-Media/listmonk-onesignal/lib" "github.com/francoispqt/onelog" "github.com/go-chi/chi" "github.com/knadh/koanf" @@ -24,11 +25,15 @@ var ( buildString = "unknown" ) - type Cfg struct { Config string `koanf:"config"` } +type App struct { + logger *onelog.Logger + + messengers map[string]lib.Messenger +} func init() { f := flag.NewFlagSet("config", flag.ContinueOnError) @@ -81,8 +86,8 @@ func main() { // load messengers app := &App{logger: l} - r := chi.NewRouter() - //r.Post("/webhook/{provider}", wrap(app, handlePostback)) + r := chi.NewRouter() + r.Post("/webhook/{provider}", wrap(app, handlePostback)) // HTTP Server. srv := &http.Server{ @@ -96,4 +101,4 @@ func main() { if err := srv.ListenAndServe(); err != nil { logger.Fatalf("couldn't start server: %v", err) } -} \ No newline at end of file +}