ses.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package webhooks
  2. import (
  3. "bytes"
  4. "crypto/x509"
  5. "encoding/base64"
  6. "encoding/json"
  7. "encoding/pem"
  8. "errors"
  9. "fmt"
  10. "io/ioutil"
  11. "net/http"
  12. "net/url"
  13. "regexp"
  14. "strings"
  15. "time"
  16. "github.com/knadh/listmonk/models"
  17. )
  18. // AWS signature/validation logic borrowed from @cavnit's contrib:
  19. // https://gist.github.com/cavnit/f4d63ba52b3aa05406c07dcbca2ca6cf
  20. // https://sns.ap-southeast-1.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem
  21. var sesRegCertURL = regexp.MustCompile(`(?i)^https://sns\.[a-z0-9\-]+\.amazonaws\.com(\.cn)?/SimpleNotificationService\-[a-z0-9]+\.pem$`)
  22. // sesNotif is an individual notification wrapper posted by SNS.
  23. type sesNotif struct {
  24. // Message may be a plaintext message or a stringified JSON payload based on the message type.
  25. // Four SES messages, this is the actual payload.
  26. Message string `json:"Message"`
  27. MessageId string `json:"MessageId"`
  28. Signature string `json:"Signature"`
  29. SignatureVersion string `json:"SignatureVersion"`
  30. SigningCertURL string `json:"SigningCertURL"`
  31. Subject string `json:"Subject"`
  32. Timestamp string `json:"Timestamp"`
  33. Token string `json:"Token"`
  34. TopicArn string `json:"TopicArn"`
  35. Type string `json:"Type"`
  36. SubscribeURL string `json:"SubscribeURL"`
  37. UnsubscribeURL string `json:"UnsubscribeURL"`
  38. }
  39. type sesTimestamp time.Time
  40. type sesMail struct {
  41. NotifType string `json:"notificationType"`
  42. Bounce struct {
  43. BounceType string `json:"bounceType"`
  44. } `json:"bounce"`
  45. Mail struct {
  46. Timestamp sesTimestamp `json:"timestamp"`
  47. HeadersTruncated bool `json:"headersTruncated"`
  48. Destination []string `json:"destination"`
  49. Headers []map[string]string `json:"headers"`
  50. } `json:"mail"`
  51. }
  52. // SES handles SES/SNS webhook notifications including confirming SNS topic subscription
  53. // requests and bounce notifications.
  54. type SES struct {
  55. certs map[string]*x509.Certificate
  56. }
  57. // NewSES returns a new SES instance.
  58. func NewSES() *SES {
  59. return &SES{
  60. certs: make(map[string]*x509.Certificate),
  61. }
  62. }
  63. // ProcessSubscription processes an SNS topic subscribe / unsubscribe notification
  64. // by parsing and verifying the payload and calling the subscribe / unsubscribe URL.
  65. func (s *SES) ProcessSubscription(b []byte) error {
  66. var n sesNotif
  67. if err := json.Unmarshal(b, &n); err != nil {
  68. return fmt.Errorf("error unmarshalling SNS notification: %v", err)
  69. }
  70. if err := s.verifyNotif(n); err != nil {
  71. return err
  72. }
  73. // Make an HTTP request to the sub/unsub URL.
  74. u := n.SubscribeURL
  75. if n.Type == "UnsubscriptionConfirmation" {
  76. u = n.UnsubscribeURL
  77. }
  78. resp, err := http.Get(u)
  79. if err != nil {
  80. return fmt.Errorf("error requesting subscription URL: %v", err)
  81. }
  82. if resp.StatusCode != http.StatusOK {
  83. return fmt.Errorf("non 200 response on subscription URL: %v", resp.StatusCode)
  84. }
  85. return nil
  86. }
  87. // ProcessBounce processes an SES bounce notification and returns a Bounce object.
  88. func (s *SES) ProcessBounce(b []byte) (models.Bounce, error) {
  89. var (
  90. bounce models.Bounce
  91. n sesNotif
  92. )
  93. if err := json.Unmarshal(b, &n); err != nil {
  94. return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
  95. }
  96. if err := s.verifyNotif(n); err != nil {
  97. return bounce, err
  98. }
  99. var m sesMail
  100. if err := json.Unmarshal([]byte(n.Message), &m); err != nil {
  101. return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
  102. }
  103. if len(m.Mail.Destination) == 0 {
  104. return bounce, errors.New("no destination e-mails found in SES notification")
  105. }
  106. typ := "soft"
  107. if m.Bounce.BounceType == "Permanent" {
  108. typ = "hard"
  109. }
  110. // Look for the campaign ID in headers.
  111. campUUID := ""
  112. if !m.Mail.HeadersTruncated {
  113. for _, h := range m.Mail.Headers {
  114. key, ok := h["name"]
  115. if !ok || key != models.EmailHeaderCampaignUUID {
  116. continue
  117. }
  118. campUUID, ok = h["value"]
  119. if !ok {
  120. continue
  121. }
  122. break
  123. }
  124. }
  125. return models.Bounce{
  126. Email: strings.ToLower(m.Mail.Destination[0]),
  127. CampaignUUID: campUUID,
  128. Type: typ,
  129. Source: "ses",
  130. Meta: json.RawMessage(n.Message),
  131. CreatedAt: time.Time(m.Mail.Timestamp),
  132. }, nil
  133. }
  134. func (s *SES) buildSignature(n sesNotif) []byte {
  135. var b bytes.Buffer
  136. b.WriteString("Message" + "\n" + n.Message + "\n")
  137. b.WriteString("MessageId" + "\n" + n.MessageId + "\n")
  138. if n.Subject != "" {
  139. b.WriteString("Subject" + "\n" + n.Subject + "\n")
  140. }
  141. if n.SubscribeURL != "" {
  142. b.WriteString("SubscribeURL" + "\n" + n.SubscribeURL + "\n")
  143. }
  144. b.WriteString("Timestamp" + "\n" + n.Timestamp + "\n")
  145. if n.Token != "" {
  146. b.WriteString("Token" + "\n" + n.Token + "\n")
  147. }
  148. b.WriteString("TopicArn" + "\n" + n.TopicArn + "\n")
  149. b.WriteString("Type" + "\n" + n.Type + "\n")
  150. return b.Bytes()
  151. }
  152. // verifyNotif verifies the signature on a notification payload.
  153. func (s *SES) verifyNotif(n sesNotif) error {
  154. // Get the message signing certificate.
  155. cert, err := s.getCert(n.SigningCertURL)
  156. if err != nil {
  157. return fmt.Errorf("error getting SNS cert: %v", err)
  158. }
  159. sign, err := base64.StdEncoding.DecodeString(n.Signature)
  160. if err != nil {
  161. return err
  162. }
  163. return cert.CheckSignature(x509.SHA1WithRSA, s.buildSignature(n), sign)
  164. }
  165. // getCert takes the SNS certificate URL and fetches it and caches it for the first time,
  166. // and returns the cached cert for subsequent calls.
  167. func (s *SES) getCert(certURL string) (*x509.Certificate, error) {
  168. // Ensure that the cert URL is Amazon's.
  169. u, err := url.Parse(certURL)
  170. if err != nil {
  171. return nil, err
  172. }
  173. if !sesRegCertURL.MatchString(certURL) {
  174. return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
  175. }
  176. // Return if it's cached.
  177. if c, ok := s.certs[u.Path]; ok {
  178. return c, nil
  179. }
  180. // Fetch the certificate.
  181. resp, err := http.Get(certURL)
  182. if err != nil {
  183. return nil, err
  184. }
  185. defer resp.Body.Close()
  186. if resp.StatusCode != http.StatusOK {
  187. return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
  188. }
  189. body, err := ioutil.ReadAll(resp.Body)
  190. if err != nil {
  191. return nil, err
  192. }
  193. p, _ := pem.Decode(body)
  194. if p == nil {
  195. return nil, errors.New("invalid PEM")
  196. }
  197. cert, err := x509.ParseCertificate(p.Bytes)
  198. // Cache the cert in-memory.
  199. s.certs[u.Path] = cert
  200. return cert, err
  201. }
  202. func (st *sesTimestamp) UnmarshalJSON(b []byte) error {
  203. t, err := time.Parse("2006-01-02T15:04:05.999999999Z", strings.Trim(string(b), `"`))
  204. if err != nil {
  205. return err
  206. }
  207. *st = sesTimestamp(t)
  208. return nil
  209. }