2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-22 02:02:14 +00:00

Add email notifier (#674)

* Add email notifier

Provide email on batch change to the requesting user

* Test email notifier

Add unit tests for email notifier

* Address EmailNotifier comments

Add integration test for Email Notifier
Log unparseable emails
Add detail to email
This commit is contained in:
Dave Cleaver 2019-06-18 10:53:50 -04:00 committed by Rebecca Star
parent c880b07145
commit 3074e503fa
9 changed files with 610 additions and 1 deletions

View File

@ -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

View File

@ -93,6 +93,10 @@ vinyldns {
crypto {
type = "vinyldns.core.crypto.NoOpCrypto"
}
email.settings.smtp {
port = 19025
}
}
# Global settings

View File

@ -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
}
}
}

View File

@ -91,6 +91,13 @@ vinyldns {
notifiers = []
email = {
class-name = "vinyldns.api.notifier.email.EmailNotifierProvider"
settings = {
from = "VinylDNS <do-not-reply@vinyldns.io>"
}
}
defaultZoneConnection {
name = "vinyldns."
keyName = "vinyldns."

View File

@ -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("<none>")}")
}
case None => IO { logger.warn(s"Unable to find user: ${bc.userId}") }
case _ => IO.unit
}
def formatBatchChange(bc: BatchChange): String =
s"""<h1>Batch Change Results</h1>
| <b>Submitter:</b> ${bc.userName} <br/>
| ${bc.comments.map(comments => s"<b>Description:</b> ${comments}</br>").getOrElse("")}
| <b>Created:</b> ${bc.createdTimestamp.toString(DateTimeFormat.fullDateTime)} <br/>
| <b>Id:</b> ${bc.id}<br/>
| <b>Status:</b> ${formatStatus(bc.approvalStatus, bc.status)}<br/>
| <table border = "1">
| <tr><th>#</th><th>Change Type</th><th>Record Type</th><th>Input Name</th>
| <th>TTL</th><th>Record Data</th><th>Status</th><th>Message</th></tr>
| ${bc.changes.zipWithIndex.map((formatSingleChange _).tupled).mkString("\n")}
| </table>
""".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"""<tr><td>${index + 1}</td><td>Add</td><td>$typ</td><td>$inputName</td>
| <td>$ttl</td><td>${formatRecordData(recordData)}</td><td>$status</td>
| <td>${systemMessage.getOrElse("")}</td></tr>"""
case SingleDeleteChange(_, _, _, inputName, typ, status, systemMessage, _, _, _) =>
s"""<tr><td>${index + 1}</td><td>Delete</td><td>$typ</td><td>$inputName</td>
| <td></td><td></td><td>$status</td><td>${systemMessage.getOrElse("")}</td></tr>"""
}
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
}
}

View File

@ -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())

View File

@ -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)
}
}

View File

@ -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 <test@test.com>",
"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"<tr>((.|\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))
}
}
}

View File

@ -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(