diff --git a/.travis.yml b/.travis.yml index 5d6369b..f3d10f9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,3 +24,5 @@ env: - secure: DMlGSBmgzXV3aPrV79Lk6V+dMeH2O863vgq6F9rdV1zmFUc7yP1j1bgYiWXR11GJr0N7jaZRs/2qxJSOTQu8HHQwP+miUONAMIhnaiDps9aBWpCNEmUP/SinmGhCfkWSRWq4gXdz+OWJsABa22OFDOFsyR0bHJo4tDO5O5bcvKB5ZE3jfKL2pKjVeXBaOT+z8WKnW3U6ObHxgpfX7obLzH7p1zbdj0OA2ggdzM4cNuvl6MwFZI2brpQ9xynvvxgGJMq8XlpsI5WTxvxh8nv1zh/JD8MNU7twz1RhdP6i4a+GbVgdJ+fy7AFBiY5jz/LxnrBYMAEatmM4SbZtONUOYQE++6Hc7NyhcfWnW4KN4s/wxGprw6hiB7LAd/rAIRV6rJRdwEROz7rfpzWykcsppbLRXUIJiyvL9TNqVU2A1ZsA9Y56tlDOqYrfCo40oEB8bjetJBOQ85NKiLCOZ05lqiEfWpTmLTiLWTNyv/dhes9IWap2BNr1xiwdxhmfvXfCe9z79fn440CtCOLzt7sCnt++kZaij3gega1v7OfDjm5COB2hx96HFGeoeund8++pD9cT3iR+55Mphm6yR4QT0jQ/p4mSvdZbXnuHAhxgKBiwyiRkCKFARjjKfMxz4hzgpUe6hDkuqMQ9BEZQOMwp07gocTI12K6KQln+Zv789Zc= - secure: NFs7slWWJLQRmCDPbWfBUjTo9TOTc3wem+IFN97ZUoV8Zjwsc+XhLSyFJzph1u9HBt3wAljcGFKkerLv7EEeqhgBbFz7CV8/8nk8aujTLLONHOVdG956x+THOsNw/DEXufzppwRMGQUcICJzhgK5lLPzE1bNNbv3h2lcEjoH29QTON7XVjMrwvue6IZB61asgBlVFy2LHteC/QmQlPp5vsaCOSBdQDwX/htfSUXX+ipncAB0/iwxjWs4F1KLn+608At7w+9kUrXmcyhRIByC9DaFBp5IGl0yMJaIM9hUdxm/Qbb8sE3wMiWT+1G6Sjh2h17zdUWIVL1oJ46AJn0SVAwbPXsWKF9frfaCzZhcy3+j+savmVKgWNutd5l0NiATseILE65y+vFVSPnVuAhunVz0M3/7juBtGT5TwE79anCd1FUVc3GYC+8PEMvzC1yTfigG3LEHL25k8CxrNOdU5ovNlz1GaAQDe9u7Bqgf49+fhLT3JbvRgaIJlZ13QYzMIZG10GB/4F2cYqUChsoSHJrMKDOZh265Xgq3ViiwxcJHygzpMwxYI/p1dqOqdofQOI0Ya8f5v2h2mEFHOSAkRcU1WPde7DYlLvbCyXKzujdMzK0/eZlxeuUiL/pS7purBsS2TURyl/urOgqa1snjuPkiyp3R+Y7szyCQyIbTGII= - secure: IBxHwtWMR05+LkAnSInvQ/AysXYCMeUlFBfpI9j2Y4gSU6IBeKoiIlUGGyFS+Yesqzg79mIvewyoCitNvcn43oc8XplC1q4QWmiHGZREoFdcvyD1ozTVYTOCk1eTRh60iHKxHdtO1vBUq3VQFI8ko1sctFJ0K3+zn+qfDviPw6rR+4T20m9l4PrSTD213faLda/Vnx5EQEbFvwWMUTodEtKK4xOdVPstNEK7Km948eUurcJS6HECvGm1cB/e4GRpJ3mLSV27OL0YwJNxCsIRpE6h4l3ehqeWxAnuMcEFVMFdT5vhoRqGS7jBGZIwZMPsHp8jFRKs3/K/FgqqvKCPpAjib3j3W97F5E30Sw1+9nEa0foilRYuvFW2YbmhlEyn5mwMUA9oh6v9b9hEJH00dqebGqoT3CsGnutuO4x7e8b0QgOXlJy8OmtvCW0loMe9EE8THp6VYhSvbaiCyjAEWnabkhkSjIt/jhdnJhkB3ISU9JBiiRL3zi+LkKxlRz4LQXtqKSZ46XjwcvYg29I3XbTiTIdM/oUdIj94HKqSAT2Z36/IxIaNaiR2CQrkxpu0toSpaDBUmeXVUdBhhNBX9dRuGzogIfsaDbRyDKMjCnn2HzBhSzBtxmcxjjoeFSj7Egb/c8+2lW9AomnHQktMae0zWT5qsoanA0hPRp4eDUI= + - secure: 4cyxu37VZhnQwsGz7sdF1OG554EfYtAz5VyN4k4g4M/gXfWiFPUobfTGdmUXLmnFDRUh62RGhvSqLM8bEinv1ot5daMD5mtZdhx6jF7cmyXetkHFJGS1naf7QH7097ZIQNqUD+dnbmi4jodYKwik3hIGHKx/8HqBeeFlAMA1Ue1BPjsr6Yxbctj9V0NM0GyuR5Ug0Z/rwgWxV897CCyOekXH0dLMJqG/oZyPiE1c9xEnCKimo7HloXPviy7KitJQechrAzey+7Qz6EGAcEJW+Cnv1lvY8MJx1hKYXBDYGb7EoYQSLmAmEOyhfhNxdibEAUuOaLShb7/WwnMcLUIN/5moaqdjwck8kvFdTMNcduOTxvDiP1M8K3lMlmn4RWSn/QwBd6ozKSUQef1TIkHcnDyEYhGcIcokzSpci5ejh+lfyQBz9qi+lS6sLd35dgiZitG5iyhfxAOOO/gyDH6lRmBxSeugY+LJnRzN7tDidyu+depqaDv9bhtRFKX2ob23lr5+neMXtn4FX/Xp5uX1vrvPKeX93rq0k4kDiwNHewTaV+ihBNQ9cNGpkvHUrJ9xuc9CR4nd47XxGf1QsJPMXPXX1bP+qyUyIE3UsehKd+3tgpusaKj2KeI4MQSU41BzYKWyxE5WcM2k6CVBYNyuOLXCYaY26hjxJvbOGH2uobE= + - secure: W4pNNS4UjVWRW+5LysjsY3UEbCQ6Xp6O5ZmsxzehgNStqECvRUXhltG1EcZLRlNJPR3P1yTvk3o1tox052flhgBLt86XYGQ0+7am74zJ4+tOvZBD+JkXxwCaF37O71PjfT8IVgCanWm/fu5FP+hbZt64I/99W3MJJd1JQA5Qrx5Agi8MlJMgcLz9Dqaej/mU6IuRlXoPCq1wLyz9ggyreA/dYmP8p1Vt/XwqcBrp5PWdNDR80jcnuwS1Sozn73zDMITgTcbywOHx9K48lyhYYODZ4/Tjhjs2V/DCp+TNGcgezv5RcQKtYZ6lTd2s36bhSGfmTzKI0BK8e3bZlSll4spGAcoIfSmNRNb/44a7ZrmF3yDiBM/TePNBDJ+p4eWp+xKfF6EddJE2/sHjTbxov+1AjUKDhkD8rLg9GK0Bf+5X4QiB4l394fkCQyyW3l85c/Y+4nkMk0kQVpHB4d4O/2wLDCKUJMbBgq8tnv9PuY0tlqjlLeKW4xHaJ4GCRgb/aJkZgwSekWGOydUDDnhgOE2H0P0V+tNGb4C/qn8SUQifKX7XenRU1nzBAJ+Dh/U7csMbq2eEKE8zjJwbV+97hS6FN0VlvOwaPedGPyHfgTal+wstKaXAYDMx72BohFm+O7zb6+ny3BDAh9ukETfKuMklTYa5dQys9+DjYMZrPO0= diff --git a/src/main/avro/com.snowplowanalytics.sauna.responders/SendgridConfig/avro/1-0-1.avsc b/src/main/avro/com.snowplowanalytics.sauna.responders/SendgridConfig/avro/1-0-1.avsc new file mode 100644 index 0000000..9e333eb --- /dev/null +++ b/src/main/avro/com.snowplowanalytics.sauna.responders/SendgridConfig/avro/1-0-1.avsc @@ -0,0 +1,37 @@ +{ + "namespace": "com.snowplowanalytics.sauna.responders", + "name": "SendgridConfig_1_0_1", + "type": "record", + "fields": [ + { + "name": "enabled", + "type": "boolean" + }, + { + "name": "id", + "type": "string" + }, + { + "name": "parameters", + "type": { + "name": "SendgridConfigParameters_1_0_1", + "type": "record", + "fields": [ + { + "name": "recipientsEnabled", + "type": "boolean" + }, + { + "name": "emailsEnabled", + "type": "boolean", + "default": false + }, + { + "name": "apiKeyId", + "type": "string" + } + ] + } + } + ] +} diff --git a/src/main/scala/com.snowplowanalytics.sauna/SaunaOptions.scala b/src/main/scala/com.snowplowanalytics.sauna/SaunaOptions.scala index 7b51b99..c1ff3ce 100644 --- a/src/main/scala/com.snowplowanalytics.sauna/SaunaOptions.scala +++ b/src/main/scala/com.snowplowanalytics.sauna/SaunaOptions.scala @@ -52,6 +52,7 @@ case class SaunaOptions(configurationLocation: File) { getConfig[loggers.HipchatConfig_1_0_0], getConfig[responders.OptimizelyConfig_1_0_0], getConfig[responders.SendgridConfig_1_0_0], + getConfig[responders.SendgridConfig_1_0_1], getConfig[responders.HipchatConfig_1_0_0], getConfig[responders.SlackConfig_1_0_0], getConfig[responders.PagerDutyConfig_1_0_0], diff --git a/src/main/scala/com.snowplowanalytics.sauna/SaunaSettings.scala b/src/main/scala/com.snowplowanalytics.sauna/SaunaSettings.scala index 2da0cf9..25aa1d2 100644 --- a/src/main/scala/com.snowplowanalytics.sauna/SaunaSettings.scala +++ b/src/main/scala/com.snowplowanalytics.sauna/SaunaSettings.scala @@ -19,7 +19,8 @@ package com.snowplowanalytics.sauna * @param amazonDynamodbConfig optional DynamoDB logger configuration * @param hipchatLoggerConfig optional Hipchat logger configuration * @param optimizelyConfig optional Optimizely responders configuration - * @param sendgridConfig optional Sendgrid resonder configuration + * @param sendgridConfig_1_0_0 optional Sendgrid responder configuration (schema ver. 1-0-0) + * @param sendgridConfig_1_0_1 optional Sendgrid responder configuration (schema ver. 1-0-1) * @param hipchatResponderConfig optional Hipchat responder configuration * @param slackConfig optional Slack responder configuration * @param pagerDutyConfig optional PagerDuty responder configuration @@ -34,7 +35,8 @@ case class SaunaSettings( // Responders optimizelyConfig: Option[responders.OptimizelyConfig_1_0_0], - sendgridConfig: Option[responders.SendgridConfig_1_0_0], + sendgridConfig_1_0_0: Option[responders.SendgridConfig_1_0_0], + sendgridConfig_1_0_1: Option[responders.SendgridConfig_1_0_1], hipchatResponderConfig: Option[responders.HipchatConfig_1_0_0], slackConfig: Option[responders.SlackConfig_1_0_0], pagerDutyConfig: Option[responders.PagerDutyConfig_1_0_0], @@ -45,5 +47,5 @@ case class SaunaSettings( amazonKinesisConfigs: List[observers.AmazonKinesisConfig_1_0_0]) object SaunaSettings { - def apply(): SaunaSettings = SaunaSettings(None, None, None, None, None, None, None, Nil, Nil, Nil) + def apply(): SaunaSettings = SaunaSettings(None, None, None, None, None, None, None, None, Nil, Nil, Nil) } diff --git a/src/main/scala/com.snowplowanalytics.sauna/actors/Mediator.scala b/src/main/scala/com.snowplowanalytics.sauna/actors/Mediator.scala index 9196f60..4d23fa9 100644 --- a/src/main/scala/com.snowplowanalytics.sauna/actors/Mediator.scala +++ b/src/main/scala/com.snowplowanalytics.sauna/actors/Mediator.scala @@ -382,21 +382,41 @@ object Mediator { /** * Function producing `Props` (still requiring logger) for Sendgrid responders - * (only `RecipientsResponder` so far) + * ([[RecipientsResponder]] and [[SendEmailResponder]]) * * @param saunaSettings global settings object * @return list of functions that accept logger and produce sendgrid responders */ def sendgridCreator(saunaSettings: SaunaSettings): List[ActorConstructor] = { - saunaSettings.sendgridConfig match { - case Some(SendgridConfig_1_0_0(true, id, params)) => + val sendgrid_1_0_0_constructor: List[ActorConstructor] = saunaSettings.sendgridConfig_1_0_0.collect { + case SendgridConfig_1_0_0(true, id, params) => + val apiWrapper: Sendgrid = new Sendgrid(params.apiKeyId) + if (params.recipientsEnabled) { ((logger: SaunaLogger) => (id, RecipientsResponder.props(logger, apiWrapper))) :: Nil } else Nil - case _ => Nil - } + }.getOrElse(Nil) + + val sendgrid_1_0_1_constructor: List[ActorConstructor] = saunaSettings.sendgridConfig_1_0_1.collect { + case SendgridConfig_1_0_1(true, id, params) => + + val apiWrapper: Sendgrid = new Sendgrid(params.apiKeyId) + + val recipientsProps = if (params.recipientsEnabled) { + ((logger: SaunaLogger) => (id, RecipientsResponder.props(logger, apiWrapper))) :: Nil + } else Nil + + val emailProps = if (params.emailsEnabled) { + ((logger: SaunaLogger) => (id, SendEmailResponder.props(apiWrapper, logger))) :: Nil + } else Nil + + recipientsProps ++ emailProps + + }.getOrElse(Nil) + + sendgrid_1_0_0_constructor ++ sendgrid_1_0_1_constructor } /** diff --git a/src/main/scala/com.snowplowanalytics.sauna/apis/Sendgrid.scala b/src/main/scala/com.snowplowanalytics.sauna/apis/Sendgrid.scala index 91634f1..9bd38c8 100644 --- a/src/main/scala/com.snowplowanalytics.sauna/apis/Sendgrid.scala +++ b/src/main/scala/com.snowplowanalytics.sauna/apis/Sendgrid.scala @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016 Snowplow Analytics Ltd. All rights reserved. + * Copyright (c) 2016-2017 Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. @@ -21,8 +21,8 @@ import scala.util.Try import com.github.nscala_time.time.StaticDateTimeFormat // play -import play.api.libs.json._ import play.api.libs.functional.syntax._ +import play.api.libs.json._ import play.api.libs.ws.WSResponse // sauna @@ -34,6 +34,7 @@ import utils._ * @param apiKeyId Sendgrid token */ class Sendgrid(apiKeyId: String) { + import Sendgrid._ /** @@ -44,16 +45,16 @@ class Sendgrid(apiKeyId: String) { */ def getRecipient(id: String): Future[WSResponse] = wsClient.url(urlPrefix + s"contactdb/recipients/$id") - .withHeaders("Authorization" -> s"Bearer $apiKeyId") - .get + .withHeaders("Authorization" -> s"Bearer $apiKeyId") + .get /** * Tries to get types of custom fields in Sendgrid contacts DB */ def getTypeInfo: Future[WSResponse] = wsClient.url(urlPrefix + "contactdb/custom_fields") - .withHeaders("Authorization" -> s"Bearer $apiKeyId") - .get + .withHeaders("Authorization" -> s"Bearer $apiKeyId") + .get /** * Tries to upload several recipients. Note that this function is not limited by @@ -76,8 +77,19 @@ class Sendgrid(apiKeyId: String) { */ def deleteRecipient(id: String): Future[WSResponse] = wsClient.url(urlPrefix + s"contactdb/recipients/$id") - .withHeaders("Authorization" -> s"Bearer $apiKeyId") - .delete() + .withHeaders("Authorization" -> s"Bearer $apiKeyId") + .delete() + + /** + * Sends an email using the Sendgrid API. + * + * @param email An email object. + * @return Future WSResponse. + */ + def sendEmail(email: SendgridEmail): Future[WSResponse] = + wsClient.url(urlPrefix + "mail/send") + .withHeaders("Authorization" -> s"Bearer $apiKeyId", "Content-Type" -> "application/json") + .post(Json.toJson(email)) } @@ -117,18 +129,21 @@ object Sendgrid { sealed trait SendgridType extends Serializable { def correct: Correct } + case object SendgridText extends SendgridType { def correct: Correct = { case "" => JsNull case any => JsString(any) } } + case object SendgridDate extends SendgridType { def correct: Correct = { case "" => JsNull case date: String => correctTimestamps(date) } } + case object SendgridNumber extends SendgridType { def correct: Correct = { case "" => JsNull @@ -163,7 +178,7 @@ object Sendgrid { (JsPath \ "id").read[Long] and (JsPath \ "name").read[String] and (JsPath \ "type").read[SendgridType] - )(CustomType.apply _) + ) (CustomType.apply _) implicit val customTypesReads: Reads[CustomTypes] = (JsPath \ "custom_fields").read[List[CustomType]].map(types => CustomTypes(types, ordered = false)) @@ -177,4 +192,382 @@ object Sendgrid { case Left(errors) => Left("Cannot extract custom fields information: " + errors.toString) } } + + /** + * Represents an email recipient - may contain the recipient's name, + * must contain their email. + */ + case class SendgridEmailObject( + email: String, + name: Option[String] + ) + + implicit val sendgridEmailObjectReads: Reads[SendgridEmailObject] = ( + (JsPath \ "email").read[String] and + (JsPath \ "name").readNullable[String] + ) (SendgridEmailObject.apply _) + implicit val sendgridEmailObjectWrites: Writes[SendgridEmailObject] = Json.writes[SendgridEmailObject] + + /** + * Represents a message's "envelope" - metadata containing information + * about who should receive an individual message and how it should be handled. + */ + case class SendgridPersonalization( + to: Vector[SendgridEmailObject], + cc: Option[Vector[SendgridEmailObject]], + bcc: Option[Vector[SendgridEmailObject]], + subject: Option[String], + headers: Option[JsValue], + substitutions: Option[JsValue], + customArgs: Option[JsValue], + sendAt: Option[Int] + ) + + implicit val sendgridPersonalizationReads: Reads[SendgridPersonalization] = ( + (JsPath \ "to").read[Vector[SendgridEmailObject]] and + (JsPath \ "cc").readNullable[Vector[SendgridEmailObject]] and + (JsPath \ "bcc").readNullable[Vector[SendgridEmailObject]] and + (JsPath \ "subject").readNullable[String] and + (JsPath \ "headers").readNullable[JsValue] and + (JsPath \ "substitutions").readNullable[JsValue] and + (JsPath \ "custom_args").readNullable[JsValue] and + (JsPath \ "send_at").readNullable[Int] + ) (SendgridPersonalization.apply _) + implicit val sendgridPersonalizationWrites: Writes[SendgridPersonalization] = ( + (JsPath \ "to").write[Vector[SendgridEmailObject]] and + (JsPath \ "cc").writeNullable[Vector[SendgridEmailObject]] and + (JsPath \ "bcc").writeNullable[Vector[SendgridEmailObject]] and + (JsPath \ "subject").writeNullable[String] and + (JsPath \ "headers").writeNullable[JsValue] and + (JsPath \ "substitutions").writeNullable[JsValue] and + (JsPath \ "custom_args").writeNullable[JsValue] and + (JsPath \ "send_at").writeNullable[Int] + ) (unlift(SendgridPersonalization.unapply)) + + /** + * The content of an email. + */ + case class SendgridContent( + `type`: String, + value: String + ) + + implicit val sendgridContentReads: Reads[SendgridContent] = ( + (JsPath \ "type").read[String] and + (JsPath \ "value").read[String] + ) (SendgridContent.apply _) + implicit val sendgridContentWrites: Writes[SendgridContent] = Json.writes[SendgridContent] + + /** + * The attachment of an email. + */ + case class SendgridAttachment( + content: String, + `type`: Option[String], + filename: String, + disposition: Option[String], + contentId: Option[String] + ) + + implicit val sendgridAttachmentReads: Reads[SendgridAttachment] = ( + (JsPath \ "content").read[String] and + (JsPath \ "type").readNullable[String] and + (JsPath \ "filename").read[String] and + (JsPath \ "disposition").readNullable[String] and + (JsPath \ "content_id").readNullable[String] + ) (SendgridAttachment.apply _) + implicit val sendgridAttachmentWrites: Writes[SendgridAttachment] = ( + (JsPath \ "content").write[String] and + (JsPath \ "type").writeNullable[String] and + (JsPath \ "filename").write[String] and + (JsPath \ "disposition").writeNullable[String] and + (JsPath \ "content_id").writeNullable[String] + ) (unlift(SendgridAttachment.unapply)) + + /** + * An object specifying how to handle unsubscribes. + */ + case class SendgridAsm( + groupId: Int, + groupsToDisplay: Option[Vector[Int]] + ) + + implicit val sendgridAsmReads: Reads[SendgridAsm] = ( + (JsPath \ "group_id").read[Int] and + (JsPath \ "groups_to_display").readNullable[Vector[Int]] + ) (SendgridAsm.apply _) + implicit val sendgridAsmWrites: Writes[SendgridAsm] = ( + (JsPath \ "group_id").write[Int] and + (JsPath \ "groups_to_display").writeNullable[Vector[Int]] + ) (unlift(SendgridAsm.unapply)) + + /** + * Whether blind carbon copies should be sent to a specific email. + */ + case class SendgridBccSettings( + enable: Option[Boolean], + email: Option[String] + ) + + implicit val sendgridBccReads: Reads[SendgridBccSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "email").readNullable[String] + ) (SendgridBccSettings.apply _) + implicit val sendgridBccSettingsWrites: Writes[SendgridBccSettings] = Json.writes[SendgridBccSettings] + + /** + * Whether all unsubscribe groups and settings should be bypassed. + */ + case class SendgridBypassListManagementSettings( + enable: Option[Boolean] + ) + + implicit val sendgridBypassListManagementSettingsReads: Reads[SendgridBypassListManagementSettings] = + (JsPath \ "enable").readNullable[Boolean].map(SendgridBypassListManagementSettings.apply) + implicit val sendgridBypassListManagementSettingsWrites: Writes[SendgridBypassListManagementSettings] = + Json.writes[SendgridBypassListManagementSettings] + + /** + * The default footer appended to the bottom of every email. + */ + case class SendgridFooterSettings( + enable: Option[Boolean], + text: Option[String], + html: Option[String] + ) + + implicit val sendgridFooterSettingsReads: Reads[SendgridFooterSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "text").readNullable[String] and + (JsPath \ "html").readNullable[String] + ) (SendgridFooterSettings.apply _) + implicit val sendgridFooterSettingsWrites: Writes[SendgridFooterSettings] = Json.writes[SendgridFooterSettings] + + /** + * Enables sandbox mode, allowing for test emails. + */ + case class SendgridSandboxModeSettings( + enable: Option[Boolean] + ) + + implicit val sendgridSandboxModeReads: Reads[SendgridSandboxModeSettings] = + (JsPath \ "enable").readNullable[Boolean].map(SendgridSandboxModeSettings.apply) + implicit val sendgridSandboxModeWrites: Writes[SendgridSandboxModeSettings] = + Json.writes[SendgridSandboxModeSettings] + + /** + * Enables testing for spam contents. + */ + case class SendgridSpamCheckSettings( + enable: Option[Boolean], + threshold: Option[Int], + postToUrl: Option[String] + ) + + implicit val sendgridSpamCheckSettingsReads: Reads[SendgridSpamCheckSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "threshold").readNullable[Int] and + (JsPath \ "post_to_url").readNullable[String] + ) (SendgridSpamCheckSettings.apply _) + implicit val sendgridSpamCheckSettingsWrites: Writes[SendgridSpamCheckSettings] = ( + (JsPath \ "enable").writeNullable[Boolean] and + (JsPath \ "threshold").writeNullable[Int] and + (JsPath \ "post_to_url").writeNullable[String] + ) (unlift(SendgridSpamCheckSettings.unapply)) + + /** + * Various settings specifying how emails should be handled. + */ + case class SendgridMailSettings( + bcc: Option[SendgridBccSettings], + bypassListManagement: Option[SendgridBypassListManagementSettings], + footer: Option[SendgridFooterSettings], + sandboxMode: Option[SendgridSandboxModeSettings], + spamCheck: Option[SendgridSpamCheckSettings] + ) + + implicit val sendgridMailSettingsReads: Reads[SendgridMailSettings] = ( + (JsPath \ "bcc").readNullable[SendgridBccSettings] and + (JsPath \ "bypass_list_management").readNullable[SendgridBypassListManagementSettings] and + (JsPath \ "footer").readNullable[SendgridFooterSettings] and + (JsPath \ "sandbox_mode").readNullable[SendgridSandboxModeSettings] and + (JsPath \ "spam_check").readNullable[SendgridSpamCheckSettings] + ) (SendgridMailSettings.apply _) + implicit val sendgridMailSettingsWrites: Writes[SendgridMailSettings] = ( + (JsPath \ "bcc").writeNullable[SendgridBccSettings] and + (JsPath \ "bypass_list_management").writeNullable[SendgridBypassListManagementSettings] and + (JsPath \ "footer").writeNullable[SendgridFooterSettings] and + (JsPath \ "sandbox_mode").writeNullable[SendgridSandboxModeSettings] and + (JsPath \ "spam_check").writeNullable[SendgridSpamCheckSettings] + ) (unlift(SendgridMailSettings.unapply)) + + /** + * Track whether a recipient has clicked a link in your email. + */ + case class SendgridClickTrackingSettings( + enable: Option[Boolean], + enableText: Option[Boolean] + ) + + implicit val sendgridClickTrackingSettingsReads: Reads[SendgridClickTrackingSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "enable_text").readNullable[Boolean] + ) (SendgridClickTrackingSettings.apply _) + implicit val sendgridClickTrackingSettingsWrites: Writes[SendgridClickTrackingSettings] = ( + (JsPath \ "enable").writeNullable[Boolean] and + (JsPath \ "enableText").writeNullable[Boolean] + ) (unlift(SendgridClickTrackingSettings.unapply)) + + /** + * Tracks whether an email has been opened. + */ + case class SendgridOpenTrackingSettings( + enable: Option[Boolean], + substitutionTag: Option[String] + ) + + implicit val sendgridOpenTrackingSettingsReads: Reads[SendgridOpenTrackingSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "substitution_tag").readNullable[String] + ) (SendgridOpenTrackingSettings.apply _) + implicit val sendgridOpenTrackingSettingsWrites: Writes[SendgridOpenTrackingSettings] = ( + (JsPath \ "enable").writeNullable[Boolean] and + (JsPath \ "substitution_tag").writeNullable[String] + ) (unlift(SendgridOpenTrackingSettings.unapply)) + + /** + * Adds a subscription management link to the email. + */ + case class SendgridSubscriptionTrackingSettings( + enable: Option[Boolean], + text: Option[String], + html: Option[String], + substitutionTag: Option[String] + ) + + implicit val sendgridSubscriptionTrackingSettingsReads: Reads[SendgridSubscriptionTrackingSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "text").readNullable[String] and + (JsPath \ "html").readNullable[String] and + (JsPath \ "substitution_tag").readNullable[String] + ) (SendgridSubscriptionTrackingSettings.apply _) + implicit val sendgridSubscriptionTrackingSettingsWrites: Writes[SendgridSubscriptionTrackingSettings] = ( + (JsPath \ "enable").writeNullable[Boolean] and + (JsPath \ "text").writeNullable[String] and + (JsPath \ "html").writeNullable[String] and + (JsPath \ "substitution_tag").writeNullable[String] + ) (unlift(SendgridSubscriptionTrackingSettings.unapply)) + + /** + * Enables email analytics. + */ + case class SendgridGAnalyticsSettings( + enable: Option[Boolean], + utmSource: Option[String], + utmMedium: Option[String], + utmTerm: Option[String], + utmContent: Option[String], + utmCampaign: Option[String] + ) + + implicit val sendgridGAnalyticsSettingsReads: Reads[SendgridGAnalyticsSettings] = ( + (JsPath \ "enable").readNullable[Boolean] and + (JsPath \ "utm_source").readNullable[String] and + (JsPath \ "utm_medium").readNullable[String] and + (JsPath \ "utm_term").readNullable[String] and + (JsPath \ "utm_content").readNullable[String] and + (JsPath \ "utm_campaign").readNullable[String] + ) (SendgridGAnalyticsSettings.apply _) + implicit val sendgridGAnalyticsSettingsWrites: Writes[SendgridGAnalyticsSettings] = ( + (JsPath \ "enable").writeNullable[Boolean] and + (JsPath \ "utm_source").writeNullable[String] and + (JsPath \ "utm_medium").writeNullable[String] and + (JsPath \ "utm_term").writeNullable[String] and + (JsPath \ "utm_content").writeNullable[String] and + (JsPath \ "utm_campaign").writeNullable[String] + ) (unlift(SendgridGAnalyticsSettings.unapply)) + + /** + * Settings to determine tracking email metrics. + */ + case class SendgridTrackingSettings( + clickTracking: Option[SendgridClickTrackingSettings], + openTracking: Option[SendgridOpenTrackingSettings], + subscriptionTracking: Option[SendgridSubscriptionTrackingSettings], + ganalytics: Option[SendgridGAnalyticsSettings] + ) + + implicit val sendgridTrackingSettingsReads: Reads[SendgridTrackingSettings] = ( + (JsPath \ "click_tracking").readNullable[SendgridClickTrackingSettings] and + (JsPath \ "open_tracking").readNullable[SendgridOpenTrackingSettings] and + (JsPath \ "subscription_tracking").readNullable[SendgridSubscriptionTrackingSettings] and + (JsPath \ "ganalytics").readNullable[SendgridGAnalyticsSettings] + ) (SendgridTrackingSettings.apply _) + implicit val sendgridTrackingSettingsWrites: Writes[SendgridTrackingSettings] = ( + (JsPath \ "click_tracking").writeNullable[SendgridClickTrackingSettings] and + (JsPath \ "open_tracking").writeNullable[SendgridOpenTrackingSettings] and + (JsPath \ "subscription_tracking").writeNullable[SendgridSubscriptionTrackingSettings] and + (JsPath \ "ganalytics").writeNullable[SendgridGAnalyticsSettings] + ) (unlift(SendgridTrackingSettings.unapply)) + + /** + * The body of a Sendgrid v3 email message. + * + * @see https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html#-Request-Body-Parameters + */ + case class SendgridEmail( + personalizations: Vector[SendgridPersonalization], + from: SendgridEmailObject, + replyTo: Option[SendgridEmailObject], + subject: Option[String], + content: Option[Vector[SendgridContent]], + attachment: Option[Vector[SendgridAttachment]], + templateId: Option[String], + sections: Option[JsValue], + headers: Option[JsValue], + categories: Option[Vector[String]], + sendAt: Option[Int], + batchId: Option[String], + asm: Option[SendgridAsm], + ipPoolName: Option[String], + mailSettings: Option[SendgridMailSettings], + trackingSettings: Option[SendgridTrackingSettings] + ) + + implicit val sendgridEmailReads: Reads[SendgridEmail] = ( + (JsPath \ "personalizations").read[Vector[SendgridPersonalization]] and + (JsPath \ "from").read[SendgridEmailObject] and + (JsPath \ "reply_to").readNullable[SendgridEmailObject] and + (JsPath \ "subject").readNullable[String] and + (JsPath \ "content").readNullable[Vector[SendgridContent]] and + (JsPath \ "attachment").readNullable[Vector[SendgridAttachment]] and + (JsPath \ "template_id").readNullable[String] and + (JsPath \ "sections").readNullable[JsValue] and + (JsPath \ "headers").readNullable[JsValue] and + (JsPath \ "categories").readNullable[Vector[String]] and + (JsPath \ "send_at").readNullable[Int] and + (JsPath \ "batch_id").readNullable[String] and + (JsPath \ "asm").readNullable[SendgridAsm] and + (JsPath \ "ip_pool_name").readNullable[String] and + (JsPath \ "mail_settings").readNullable[SendgridMailSettings] and + (JsPath \ "tracking_settings").readNullable[SendgridTrackingSettings] + ) (SendgridEmail.apply _) + implicit val sendgridEmailWrites: Writes[SendgridEmail] = ( + (JsPath \ "personalizations").write[Vector[SendgridPersonalization]] and + (JsPath \ "from").write[SendgridEmailObject] and + (JsPath \ "reply_to").writeNullable[SendgridEmailObject] and + (JsPath \ "subject").writeNullable[String] and + (JsPath \ "content").writeNullable[Vector[SendgridContent]] and + (JsPath \ "attachment").writeNullable[Vector[SendgridAttachment]] and + (JsPath \ "template_id").writeNullable[String] and + (JsPath \ "sections").writeNullable[JsValue] and + (JsPath \ "headers").writeNullable[JsValue] and + (JsPath \ "categories").writeNullable[Vector[String]] and + (JsPath \ "send_at").writeNullable[Int] and + (JsPath \ "batch_id").writeNullable[String] and + (JsPath \ "asm").writeNullable[SendgridAsm] and + (JsPath \ "ip_pool_name").writeNullable[String] and + (JsPath \ "mail_settings").writeNullable[SendgridMailSettings] and + (JsPath \ "tracking_settings").writeNullable[SendgridTrackingSettings] + ) (unlift(SendgridEmail.unapply)) } \ No newline at end of file diff --git a/src/main/scala/com.snowplowanalytics.sauna/responders/sendgrid/SendEmailResponder.scala b/src/main/scala/com.snowplowanalytics.sauna/responders/sendgrid/SendEmailResponder.scala new file mode 100644 index 0000000..02ab042 --- /dev/null +++ b/src/main/scala/com.snowplowanalytics.sauna/responders/sendgrid/SendEmailResponder.scala @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2016-2017 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.sauna +package responders +package sendgrid + +// scala +import scala.concurrent.ExecutionContext.Implicits.global +import scala.io.Source +import scala.util.{Failure, Success} + +// akka +import akka.actor.{ActorRef, Props} + +// play +import play.api.libs.json.Json + +// sauna +import apis.Sendgrid +import apis.Sendgrid._ +import loggers.Logger.Notification +import observers.Observer.{ObserverCommandEvent, ObserverEvent} +import responders.Responder.{ResponderEvent, ResponderResult} +import responders.sendgrid.SendEmailResponder._ +import utils.Command + +class SendEmailResponder(sendgrid: Sendgrid, val logger: ActorRef) extends Responder[ObserverCommandEvent, SendgridEmailReceived] { + def extractEvent(observerEvent: ObserverEvent): Option[SendgridEmailReceived] = { + observerEvent match { + case e: ObserverCommandEvent => + val commandJson = Json.parse(Source.fromInputStream(e.streamContent).mkString) + Command.extractCommand[SendgridEmail](commandJson) match { + case Right((envelope, data)) => + Command.validateEnvelope(envelope) match { + case None => + Some(SendgridEmailReceived(data, e)) + case Some(error) => + logger ! Notification(error) + None + } + case Left(error) => + logger ! Notification(error) + None + } + case _ => None + } + } + + def process(event: SendgridEmailReceived): Unit = + sendgrid.sendEmail(event.data).onComplete { + case Success(message) => + if (message.status >= 200 && message.status <= 299) + context.parent ! SendgridEmailSent(event, s"Successfully sent Sendgrid email!") + else + logger ! Notification(s"Unexpected response from Sendgrid: ${message.body}") + case Failure(error) => logger ! Notification(s"Error while sending Sendgrid message: $error") + } +} + +object SendEmailResponder { + + case class SendgridEmailReceived( + data: SendgridEmail, + source: ObserverCommandEvent + ) extends ResponderEvent + + case class SendgridEmailSent( + source: SendgridEmailReceived, + message: String) extends ResponderResult + + /** + * Constructs a [[Props]] for a [[SendEmailResponder]] actor. + * + * @param sendgrid The SendGrid API wrapper. + * @param logger A logger actor. + * @return [[Props]] for the new actor. + */ + def props(sendgrid: Sendgrid, logger: ActorRef): Props = + Props(new SendEmailResponder(sendgrid, logger)) +} diff --git a/src/test/resources/commands/sendgrid.json b/src/test/resources/commands/sendgrid.json new file mode 100644 index 0000000..5903221 --- /dev/null +++ b/src/test/resources/commands/sendgrid.json @@ -0,0 +1,41 @@ +{ + "schema": "iglu:com.snowplowanalytics.sauna.commands/command/jsonschema/1-0-0", + "data": { + "envelope": { + "schema": "iglu:com.snowplowanalytics.sauna.commands/envelope/jsonschema/1-0-0", + "data": { + "commandId": "9dadfc92-9311-43c7-9cee-61ab590a6e81", + "whenCreated": "2017-01-02T19:14:42Z", + "execution": { + "semantics": "AT_LEAST_ONCE", + "timeToLive": null + }, + "tags": {} + } + }, + "command": { + "schema": "iglu:com.sendgrid.sauna.commands/send_email/jsonschema/1-0-0", + "data": { + "personalizations": [ + { + "to": [ + { + "email": "POSTMARK_INBOUND_EMAIL" + } + ], + "subject": "RANDOM_UUID" + } + ], + "from": { + "email": "staging@saunatests.com" + }, + "content": [ + { + "type": "text/plain", + "value": "Sauna integration test completed successfully!" + } + ] + } + } + } +} diff --git a/src/test/scala/com.snowplowanalytics.sauna/IntegrationTests.scala b/src/test/scala/com.snowplowanalytics.sauna/IntegrationTests.scala index 455f774..dcfea9a 100644 --- a/src/test/scala/com.snowplowanalytics.sauna/IntegrationTests.scala +++ b/src/test/scala/com.snowplowanalytics.sauna/IntegrationTests.scala @@ -20,9 +20,11 @@ import java.nio.file.{Files, Paths} import java.util.UUID // scala +import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration._ import scala.concurrent.{Await, Future} import scala.io.Source.fromInputStream +import scala.util.{Failure, Success} // scalatest import org.scalatest._ @@ -189,6 +191,9 @@ class IntegrationTests extends FunSuite with BeforeAndAfter { val slackWebhookUrl: Option[String] = sys.env.get("SLACK_WEBHOOK_URL") val pagerDutyServiceKey: Option[String] = sys.env.get("PAGERDUTY_SERVICE_KEY") + val postmarkApiToken: Option[String] = sys.env.get("POSTMARK_API_TOKEN") + val postmarkInboundEmail: Option[String] = sys.env.get("POSTMARK_INBOUND_EMAIL") + val saunaRoot = System.getProperty("java.io.tmpdir", "/tmp") + "/saunaRoot" before { @@ -902,4 +907,85 @@ class IntegrationTests extends FunSuite with BeforeAndAfter { // Assert that nothing went wrong. assert(error == null) } + + test("SendGrid command responder") { + assume(sendgridToken.isDefined) + assume(postmarkApiToken.isDefined) + assume(postmarkInboundEmail.isDefined) + + // Get some data from a resource file. + var command: String = fromInputStream(getClass.getResourceAsStream("/commands/sendgrid.json")).getLines().mkString + command = command.replaceAll("POSTMARK_INBOUND_EMAIL", postmarkInboundEmail.get) + val subject: String = UUID.randomUUID().toString + command = command.replaceAll("RANDOM_UUID", subject) + + // Define a mock logger. + var error: String = "Did not send SendGrid email" + val dummyLogger = system.actorOf(Props(new Actor { + def step1: Receive = { + case message: Notification => + val expectedText = "Successfully sent Sendgrid email" + if (!message.text.startsWith(expectedText)) { + error = s"in step1, [${message.text}] does not contain [$expectedText]]" + } else { + context.become(step2) + } + + case message => + error = s"in step1, got unexpected message [$message]" + } + + def step2: Receive = { + case message: Notification => + val expectedText = s"All actors finished processing message" + if (!message.text.startsWith(expectedText)) { + error = s"in step2, [${message.text}] is not equal to [$expectedText]]" + } else { + error = null + } + + case message => + error = s"in step2, got unexpected message [$message]" + } + + override def receive = step1 + })) + + // Define other actors. + val apiWrapper = new Sendgrid(sendgridToken.get) + val dummyObserver = Props(new MockRealTimeObserver()) + val responder = SendEmailResponder.props(apiWrapper, dummyLogger) + val root = system.actorOf(Props(new IntegrationTests.RootActor(List(responder), dummyObserver, dummyLogger))) + val postmarkWrapper = new Postmark(postmarkApiToken.get) + + Thread.sleep(3000) + + // Manually trigger the observer. + root ! ObserverTrigger(command) + + // Time for Sendgrid to send the email, and for Postmark to process it & expose to inbound API. + Thread.sleep(60000) + + // Assert that nothing went wrong with the command. + assert(error == null) + + // Verify that the email was successfully sent. + postmarkWrapper.getInboundMessage(subject).onComplete { + case Success(message) => + if (message.status == 200) { + val json: JsValue = Json.parse(message.body) + assert((json \ "TotalCount").as[Int] == 1) + val inbound: JsLookupResult = (json \ "InboundMessages") (0) + assert((inbound \ "From").as[String].equals("staging@saunatests.com")) + assert((inbound \ "To").as[String].equals(postmarkInboundEmail.get)) + assert((inbound \ "Subject").as[String].equals(subject)) + } + else + fail(message.body) + case Failure(error) => + fail(error) + } + + Thread.sleep(5000) + } } diff --git a/src/test/scala/com.snowplowanalytics.sauna/apis/Postmark.scala b/src/test/scala/com.snowplowanalytics.sauna/apis/Postmark.scala new file mode 100644 index 0000000..efd01ee --- /dev/null +++ b/src/test/scala/com.snowplowanalytics.sauna/apis/Postmark.scala @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2016-2017 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.sauna +package apis + +// scala +import scala.concurrent.Future + +// play +import play.api.libs.ws.WSResponse + +// sauna +import utils.wsClient + +/** + * Encapsulates all communication with Postmark - an outbound/inbound email service. + * + * @param apiToken The API token. + */ +class Postmark(apiToken: String) { + + import Postmark._ + + /** + * Attempt to retrieve message(s) with a specific subject sent to the inbound server. + * + * @param subject The subject of the messages. + * @return Future WSResponse. + */ + def getInboundMessage(subject: String): Future[WSResponse] = { + wsClient.url(urlPrefix + s"messages/inbound?count=1&offset=0&subject=$subject") + .withHeaders("Accept" -> "application/json", "X-Postmark-Server-Token" -> apiToken) + .get() + } +} + +object Postmark { + val urlPrefix = "https://api.postmarkapp.com/" +} \ No newline at end of file