diff --git a/cmd/courier/main.go b/cmd/courier/main.go index 21c67b649..10a2c9863 100644 --- a/cmd/courier/main.go +++ b/cmd/courier/main.go @@ -45,6 +45,7 @@ import ( _ "github.com/nyaruka/courier/handlers/line" _ "github.com/nyaruka/courier/handlers/m3tech" _ "github.com/nyaruka/courier/handlers/macrokiosk" + _ "github.com/nyaruka/courier/handlers/mailgun" _ "github.com/nyaruka/courier/handlers/mblox" _ "github.com/nyaruka/courier/handlers/messagebird" _ "github.com/nyaruka/courier/handlers/messangi" diff --git a/handlers/mailgun/handler.go b/handlers/mailgun/handler.go new file mode 100644 index 000000000..6035761b7 --- /dev/null +++ b/handlers/mailgun/handler.go @@ -0,0 +1,130 @@ +package mailgun + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "mime/multipart" + "net/http" + + "github.com/buger/jsonparser" + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" +) + +const ( + configSubject = "subject" + configWebhookSigningKey = "webhook_signing_key" +) + +var ( + defaultAPIURL = "https://api.mailgun.net/v3" +) + +func init() { + courier.RegisterHandler(newHandler()) +} + +type handler struct { + handlers.BaseHandler +} + +func newHandler() courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(courier.ChannelType("MLG"), "Mailgun")} +} + +func (h *handler) Initialize(s courier.Server) error { + h.SetServer(s) + s.AddHandlerRoute(h, http.MethodPost, "receive", courier.ChannelLogTypeMsgReceive, h.receive) + return nil +} + +type receiveRequest struct { + Recipient string `form:"recipient" validate:"required,email"` + Sender string `form:"sender" validate:"required,email"` + From string `form:"From"` + ReplyTo string `form:"Reply-To"` + MessageID string `form:"Message-Id" validate:"required"` + Subject string `form:"subject" validate:"required"` + PlainBody string `form:"body-plain"` + StrippedText string `form:"stripped-text" validate:"required"` + HTMLBody string `form:"body-html"` + Timestamp string `form:"timestamp" validate:"required"` + Token string `form:"token" validate:"required"` + Signature string `form:"signature" validate:"required"` + AttachmentCount int `form:"attachment-count"` +} + +// see https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks +func (r *receiveRequest) verify(signingKey string) bool { + v := r.Timestamp + r.Token + + mac := hmac.New(sha256.New, []byte(signingKey)) + mac.Write([]byte(v)) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(r.Signature), []byte(expectedMAC)) +} + +func (h *handler) receive(ctx context.Context, c courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { + signingKey := c.StringConfigForKey(configWebhookSigningKey, "") + if signingKey == "" { + return nil, fmt.Errorf("missing signing key for %s channel", h.ChannelName()) + } + + request := &receiveRequest{} + if err := handlers.DecodeAndValidateForm(request, r); err != nil { + return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, err) + } + + if !request.verify(signingKey) { + return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, errors.New("invalid signing key")) // TODO should return 406 + } + + // TODO + + return nil, nil +} + +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + domain := msg.Channel().Address() + sendURL := fmt.Sprintf("%s/%s/messages", defaultAPIURL, domain) + + sendingKey := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") + if sendingKey == "" { + return nil, fmt.Errorf("missing sending key for %s channel", h.ChannelName()) + } + + subject := msg.Channel().StringConfigForKey(configSubject, "Chat with TextIt") + + b := &bytes.Buffer{} + w := multipart.NewWriter(b) + w.WriteField("from", fmt.Sprintf("no-reply@%s", domain)) + w.WriteField("to", msg.URN().Path()) + w.WriteField("subject", subject) + w.WriteField("text", msg.Text()) + + // TODO add attachments + + w.Close() + + req, _ := http.NewRequest("POST", sendURL, b) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.SetBasicAuth("api", sendingKey) + + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusWired, clog) + + resp, respBody, err := h.RequestHTTP(req, clog) + if err != nil || resp.StatusCode/100 != 2 { + status.SetStatus(courier.MsgStatusErrored) + } else { + id, _ := jsonparser.GetString(respBody, "id") + status.SetExternalID(id) + } + + return status, nil +}