diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1366b26d2..2cd2c370f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -32,3 +32,10 @@ services: - "9324:9324" volumes: - ./elasticmq/custom.conf:/etc/elasticmq/elasticmq.conf + + mail: + image: flaviovs/mock-smtp + ports: + - "19025:25" + volumes: + - ../target:/var/lib/mock-smtp diff --git a/modules/api/src/it/resources/application.conf b/modules/api/src/it/resources/application.conf index 9065ba88a..b20d9dd5a 100644 --- a/modules/api/src/it/resources/application.conf +++ b/modules/api/src/it/resources/application.conf @@ -93,6 +93,10 @@ vinyldns { crypto { type = "vinyldns.core.crypto.NoOpCrypto" } + + email.settings.smtp { + port = 19025 + } } # Global settings diff --git a/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala new file mode 100644 index 000000000..430b6a1ba --- /dev/null +++ b/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala @@ -0,0 +1,109 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.notifier.email + +import com.typesafe.config.{Config, ConfigFactory} +import vinyldns.core.notifier._ +import vinyldns.api.MySqlApiIntegrationSpec +import vinyldns.mysql.MySqlIntegrationSpec +import org.scalatest.{Matchers, WordSpecLike} +import vinyldns.core.domain.batch._ +import vinyldns.core.domain.record.RecordType +import vinyldns.core.domain.record.AData +import org.joda.time.DateTime +import vinyldns.core.TestMembershipData._ +import java.nio.file.{Files, Path, Paths} +import cats.effect.{IO, Resource} +import scala.collection.JavaConverters._ +import org.scalatest.BeforeAndAfterEach +import cats.implicits._ + +class EmailNotifierIntegrationSpec + extends MySqlApiIntegrationSpec + with MySqlIntegrationSpec + with Matchers + with WordSpecLike + with BeforeAndAfterEach { + + import vinyldns.api.domain.DomainValidations._ + + val emailConfig: Config = ConfigFactory.load().getConfig("vinyldns.email.settings") + + val targetDirectory = Paths.get("../../target") + + override def beforeEach: Unit = + deleteEmailFiles(targetDirectory).unsafeRunSync() + + override def afterEach: Unit = + deleteEmailFiles(targetDirectory).unsafeRunSync() + + "Email Notifier" should { + + "send an email" in { + val batchChange = BatchChange( + okUser.id, + okUser.userName, + None, + DateTime.now, + List( + SingleAddChange( + "some-zone-id", + "zone-name", + "record-name", + "a" * HOST_MAX_LENGTH, + RecordType.A, + 300, + AData("1.1.1.1"), + SingleChangeStatus.Complete, + None, + None, + None)), + approvalStatus = BatchChangeApprovalStatus.AutoApproved + ) + + val program = for { + _ <- userRepository.save(okUser) + notifier <- new EmailNotifierProvider() + .load(NotifierConfig("", emailConfig), userRepository) + _ <- notifier.notify(Notification(batchChange)) + emailFiles <- retrieveEmailFiles(targetDirectory) + } yield emailFiles + + val files = program.unsafeRunSync() + + files.length should be(1) + + } + + } + + def deleteEmailFiles(path: Path): IO[Unit] = + for { + files <- retrieveEmailFiles(path) + _ <- files.traverse { file => + IO(Files.delete(file)) + } + } yield () + + def retrieveEmailFiles(path: Path): IO[List[Path]] = + Resource.fromAutoCloseable(IO(Files.newDirectoryStream(path, "*.eml"))).use { s => + IO { + s.iterator.asScala.toList + } + } + +} diff --git a/modules/api/src/main/resources/reference.conf b/modules/api/src/main/resources/reference.conf index 5e64be4b3..7f2684ce1 100644 --- a/modules/api/src/main/resources/reference.conf +++ b/modules/api/src/main/resources/reference.conf @@ -91,6 +91,13 @@ vinyldns { notifiers = [] + email = { + class-name = "vinyldns.api.notifier.email.EmailNotifierProvider" + settings = { + from = "VinylDNS " + } + } + defaultZoneConnection { name = "vinyldns." keyName = "vinyldns." diff --git a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala new file mode 100644 index 000000000..9417ca95e --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala @@ -0,0 +1,144 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.notifier.email + +import vinyldns.core.notifier.{Notification, Notifier} +import cats.effect.IO +import vinyldns.core.domain.batch.BatchChange +import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.domain.membership.User +import org.slf4j.LoggerFactory +import javax.mail.internet.{InternetAddress, MimeMessage} +import javax.mail.{Address, Message, Session} +import scala.util.Try +import vinyldns.core.domain.batch.SingleChange +import vinyldns.core.domain.batch.SingleAddChange +import vinyldns.core.domain.batch.SingleDeleteChange +import vinyldns.core.domain.record.AData +import vinyldns.core.domain.record.AAAAData +import vinyldns.core.domain.record.CNAMEData +import vinyldns.core.domain.record.MXData +import vinyldns.core.domain.record.TXTData +import vinyldns.core.domain.record.PTRData +import vinyldns.core.domain.record.RecordData +import org.joda.time.format.DateTimeFormat +import vinyldns.core.domain.batch.BatchChangeStatus._ +import vinyldns.core.domain.batch.BatchChangeApprovalStatus._ + +class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepository: UserRepository) + extends Notifier { + + private val logger = LoggerFactory.getLogger("EmailNotifier") + + def notify(notification: Notification[_]): IO[Unit] = + notification.change match { + case bc: BatchChange => sendBatchChangeNotification(bc) + case _ => IO.unit + } + + def send(addresses: Address*)(buildMessage: Message => Message): IO[Unit] = IO { + val message = new MimeMessage(session) + message.setRecipients(Message.RecipientType.TO, addresses.toArray) + message.setFrom(config.from) + buildMessage(message) + message.saveChanges() + val transport = session.getTransport("smtp") + transport.connect() + transport.sendMessage(message, message.getAllRecipients()) + transport.close() + } + + def sendBatchChangeNotification(bc: BatchChange): IO[Unit] = + userRepository.getUser(bc.userId).flatMap { + case Some(UserWithEmail(email)) => + send(email) { message => + message.setSubject(s"VinylDNS Batch change ${bc.id} results") + message.setContent(formatBatchChange(bc), "text/html") + message + } + case Some(user: User) if user.email.isDefined => + IO { + logger.warn( + s"Unable to properly parse email for ${user.id}: ${user.email.getOrElse("")}") + } + case None => IO { logger.warn(s"Unable to find user: ${bc.userId}") } + case _ => IO.unit + } + + def formatBatchChange(bc: BatchChange): String = + s"""

Batch Change Results

+ | Submitter: ${bc.userName}
+ | ${bc.comments.map(comments => s"Description: ${comments}
").getOrElse("")} + | Created: ${bc.createdTimestamp.toString(DateTimeFormat.fullDateTime)}
+ | Id: ${bc.id}
+ | Status: ${formatStatus(bc.approvalStatus, bc.status)}
+ | + | + | + | ${bc.changes.zipWithIndex.map((formatSingleChange _).tupled).mkString("\n")} + |
#Change TypeRecord TypeInput NameTTLRecord DataStatusMessage
+ """.stripMargin + + def formatStatus(approval: BatchChangeApprovalStatus, status: BatchChangeStatus): String = + (approval, status) match { + case (ManuallyRejected, _) => "Rejected" + case (PendingApproval, _) => "Pending Approval" + case (_, PartialFailure) => "Partially Failed" + case (_, status) => status.toString + } + + def formatSingleChange(sc: SingleChange, index: Int): String = sc match { + case SingleAddChange( + _, + _, + _, + inputName, + typ, + ttl, + recordData, + status, + systemMessage, + _, + _, + _) => + s"""${index + 1}Add$typ$inputName + | $ttl${formatRecordData(recordData)}$status + | ${systemMessage.getOrElse("")}""" + case SingleDeleteChange(_, _, _, inputName, typ, status, systemMessage, _, _, _) => + s"""${index + 1}Delete$typ$inputName + | $status${systemMessage.getOrElse("")}""" + } + + def formatRecordData(rd: RecordData): String = rd match { + case AData(address) => address + case AAAAData(address) => address + case CNAMEData(cname) => cname + case MXData(preference, exchange) => s"Preference: $preference Exchange: $exchange" + case PTRData(name) => name + case TXTData(text) => text + case _ => rd.toString + } + + object UserWithEmail { + def unapply(user: User): Option[Address] = + for { + email <- user.email + address <- Try(new InternetAddress(email)).toOption + } yield address + } + +} diff --git a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierConfig.scala b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierConfig.scala new file mode 100644 index 000000000..266b76830 --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierConfig.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.notifier.email + +import scala.collection.JavaConverters._ +import javax.mail.Address +import javax.mail.internet.InternetAddress +import pureconfig.ConfigReader +import scala.util.Try +import pureconfig.error.CannotConvert +import java.util.Properties +import com.typesafe.config.{ConfigObject, ConfigValue} +import com.typesafe.config.ConfigValueType + +object EmailNotifierConfig { + + implicit val smtpPropertiesReader: ConfigReader[Properties] = { + ConfigReader[ConfigObject].map { config => + val props = new Properties() + + def convertToProperties(baseKey: String, config: ConfigObject): Unit = + config.keySet().asScala.foreach { + case key => + config.get(key) match { + case value: ConfigObject => + convertToProperties(s"${baseKey}.${key}", value) + case value: ConfigValue if value.valueType != ConfigValueType.NULL => + props.put(s"${baseKey}.${key}", value.unwrapped()) + case _ => + } + } + + convertToProperties("mail.smtp", config) + props + } + } + + implicit val addressReader: ConfigReader[Address] = ConfigReader[String].emap { s => + Try(new InternetAddress(s)).toEither.left.map { exc => + CannotConvert(s, "InternetAddress", exc.getMessage) + } + } + +} + +case class EmailNotifierConfig(from: Address, smtp: Properties = new Properties()) diff --git a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala new file mode 100644 index 000000000..111eaeaab --- /dev/null +++ b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.notifier.email + +import vinyldns.core.notifier.{Notifier, NotifierConfig, NotifierProvider} +import vinyldns.core.domain.membership.UserRepository +import pureconfig.module.catseffect.loadConfigF +import cats.effect.IO +import javax.mail.Session + +class EmailNotifierProvider extends NotifierProvider { + + import EmailNotifierConfig._ + + def load(config: NotifierConfig, userRepository: UserRepository): IO[Notifier] = + for { + emailConfig <- loadConfigF[IO, EmailNotifierConfig](config.settings) + session <- createSession(emailConfig) + } yield new EmailNotifier(emailConfig, session, userRepository) + + def createSession(config: EmailNotifierConfig): IO[Session] = IO { + Session.getInstance(config.smtp) + } + +} diff --git a/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala b/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala new file mode 100644 index 000000000..1200937c1 --- /dev/null +++ b/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala @@ -0,0 +1,237 @@ +/* + * Copyright 2018 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package vinyldns.api.notifier.email +import org.scalatest.{BeforeAndAfterEach, Matchers, WordSpec} +import org.scalatest.mockito.MockitoSugar +import vinyldns.api.CatsHelpers +import javax.mail.{Provider, Session, Transport, URLName} +import java.util.Properties +import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.notifier.Notification +import javax.mail.internet.InternetAddress +import org.mockito.Matchers.{eq => eqArg, _} +import org.mockito.Mockito._ +import org.mockito.ArgumentCaptor +import cats.effect.IO +import javax.mail.{Address, Message} +import vinyldns.core.domain.membership.User +import vinyldns.core.domain.batch.BatchChange +import org.joda.time.DateTime +import vinyldns.core.domain.batch.BatchChangeApprovalStatus +import vinyldns.core.domain.batch.SingleChange +import vinyldns.core.domain.batch.SingleAddChange +import vinyldns.core.domain.batch.SingleDeleteChange +import vinyldns.core.domain.record.RecordType +import vinyldns.core.domain.record.AData +import _root_.vinyldns.core.domain.batch.SingleChangeStatus +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import scala.collection.JavaConverters._ +import vinyldns.core.notifier.NotifierConfig + +object MockTransport extends MockitoSugar { + val mockTransport = mock[Transport] +} + +class MockTransport(session: Session, urlname: URLName) extends Transport(session, urlname) { + import MockTransport._ + + override def connect(): Unit = mockTransport.connect() + + override def close(): Unit = mockTransport.close() + + def sendMessage(msg: Message, addresses: Array[Address]): Unit = + mockTransport.sendMessage(msg, addresses) +} + +class EmailNotifierSpec + extends WordSpec + with Matchers + with MockitoSugar + with BeforeAndAfterEach + with CatsHelpers { + + import MockTransport._ + + val mockUserRepository = mock[UserRepository] + val session = Session.getInstance(new Properties()) + session.setProvider( + new Provider( + Provider.Type.TRANSPORT, + "smtp", + "vinyldns.api.notifier.email.MockTransport", + "vinyl", + "1.0")) + + override protected def beforeEach(): Unit = + reset(mockUserRepository, mockTransport) + + def batchChange( + description: Option[String] = None, + changes: List[SingleChange] = List.empty): BatchChange = + BatchChange( + "test", + "testUser", + description, + DateTime.now(), + changes, + None, + BatchChangeApprovalStatus.AutoApproved, + None, + None, + None, + "testBatch") + + "Email Notifier" should { + "do nothing for unsupported Notifications" in { + val emailConfig: Config = ConfigFactory.parseMap( + Map[String, Any]( + "from" -> "Testing ", + "smtp.host" -> "wouldfail.mail.com", + "smtp.auth.mechanisms" -> "PLAIN" + ).asJava) + val notifier = new EmailNotifierProvider() + .load(NotifierConfig("", emailConfig), mockUserRepository) + .unsafeRunSync() + + notifier.notify(new Notification("this won't be supported ever")) should be(IO.unit) + } + + "do nothing for user without email" in { + val notifier = new EmailNotifier( + EmailNotifierConfig(new InternetAddress("test@test.com"), new Properties()), + session, + mockUserRepository + ) + + doReturn(IO.pure(Some(User("testUser", "access", "secret")))) + .when(mockUserRepository) + .getUser("test") + + notifier.notify(Notification(batchChange())).unsafeRunSync() + + verify(mockUserRepository).getUser("test") + } + + "do nothing when user not found" in { + val notifier = new EmailNotifier( + EmailNotifierConfig(new InternetAddress("test@test.com"), new Properties()), + session, + mockUserRepository + ) + + doReturn(IO.pure(None)) + .when(mockUserRepository) + .getUser("test") + + notifier.notify(Notification(batchChange())).unsafeRunSync() + + verify(mockUserRepository).getUser("test") + } + + "send an email to a user" in { + val fromAddress = new InternetAddress("test@test.com") + val notifier = new EmailNotifier( + EmailNotifierConfig(fromAddress, new Properties()), + session, + mockUserRepository + ) + + doReturn( + IO.pure(Some(User("testUser", "access", "secret", None, None, Some("testuser@test.com"))))) + .when(mockUserRepository) + .getUser("test") + + val expectedAddresses = Array[Address](new InternetAddress("testuser@test.com")) + val messageArgument = ArgumentCaptor.forClass(classOf[Message]) + + doNothing().when(mockTransport).connect() + doNothing() + .when(mockTransport) + .sendMessage(messageArgument.capture(), eqArg(expectedAddresses)) + doNothing().when(mockTransport).close() + + val description = "notes" + val singleChanges: List[SingleChange] = List( + SingleAddChange( + "", + "", + "", + "www.test.com", + RecordType.A, + 200, + AData("1.2.3.4"), + SingleChangeStatus.Complete, + None, + None, + None), + SingleDeleteChange( + "", + "", + "", + "deleteme.test.com", + RecordType.A, + SingleChangeStatus.Failed, + Some("message for you"), + None, + None) + ) + val change = batchChange(Some(description), singleChanges) + + notifier.notify(Notification(change)).unsafeRunSync() + + val message = messageArgument.getValue() + + message.getFrom() should be(Array(fromAddress)) + message.getContentType() should be("text/html; charset=us-ascii") + message.getAllRecipients() should be(expectedAddresses) + message.getSubject() should be("VinylDNS Batch change testBatch results") + val content = message.getContent().asInstanceOf[String] + + content.contains(change.id) should be(true) + content.contains(description) should be(true) + + val regex = raw"((.|\s)+?)<\/tr>".r + val rows = (for (m <- regex.findAllMatchIn(content)) yield m.group(0)).toList + + def assertSingleChangeCaptured(row: String, sc: SingleChange): Unit = { + row.contains(sc.inputName) should be(true) + row.contains(sc.typ.toString) should be(true) + row.contains(sc.status.toString) should be(true) + sc.systemMessage.foreach(row.contains(_) should be(true)) + sc match { + case ac: SingleAddChange => + row.contains("Add") should be(true) + ac.recordData match { + case AData(address) => row.contains(address) should be(true) + case _ => row.contains(ac.recordData) should be(true) + } + row.contains(ac.ttl.toString) should be(true) + case _: SingleDeleteChange => + row.contains("Delete") should be(true) + } + } + + rows.tail.zip(singleChanges).foreach((assertSingleChangeCaptured _).tupled) + + verify(mockUserRepository).getUser("test") + verify(mockTransport).sendMessage(any[Message], eqArg(expectedAddresses)) + + } + } + +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 08a95e7c8..a809951c6 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -49,7 +49,9 @@ object Dependencies { "com.47deg" %% "github4s" % "0.18.6", "com.comcast" %% "ip4s-core" % ip4sV, "com.comcast" %% "ip4s-cats" % ip4sV, - "com.iheart" %% "ficus" % "1.4.3" + "com.iheart" %% "ficus" % "1.4.3", + "com.sun.mail" % "javax.mail" % "1.6.2", + "javax.mail" % "javax.mail-api" % "1.6.2" ) lazy val coreDependencies = Seq(