common/app/controllers/EmailSignupController.scala (540 lines of code) (raw):
package controllers
import com.typesafe.scalalogging.LazyLogging
import common.EmailSubsciptionMetrics._
import common.{GuLogging, ImplicitControllerExecutionContext, LinkTo}
import conf.Configuration
import conf.switches.Switches.{
EmailSignupRecaptcha,
NewslettersRemoveConfirmationStep,
ValidateEmailSignupRecaptchaTokens,
}
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
import model._
import play.api.data.Forms._
import play.api.data._
import play.api.data.format.Formats._
import play.api.data.validation.Constraints.{emailAddress, nonEmpty}
import play.api.libs.json._
import play.api.libs.ws.{WSClient, WSResponse}
import play.api.mvc._
import play.filters.csrf.CSRFAddToken
import services.newsletters.{GoogleRecaptchaValidationService, GoogleResponse, NewsletterSignupAgent}
import utils.RemoteAddress
import scala.concurrent.Future
import scala.concurrent.duration._
object emailLandingPage extends StandalonePage {
private val id = "email-landing-page"
override val metadata = MetaData.make(id = id, section = None, webTitle = "Email Landing Page")
}
case class EmailForm(
email: String,
listName: Option[String],
marketing: Option[String],
referrer: Option[String],
ref: Option[String],
refViewId: Option[String],
campaignCode: Option[String],
googleRecaptchaResponse: Option[String],
name: Option[String],
) {}
case class EmailFormManyNewsletters(
email: String,
listNames: Seq[String],
marketing: Option[String],
referrer: Option[String],
ref: Option[String],
refViewId: Option[String],
campaignCode: Option[String],
googleRecaptchaResponse: Option[String],
name: Option[String],
) {}
class EmailFormService(wsClient: WSClient, emailEmbedAgent: NewsletterSignupAgent)
extends LazyLogging
with RemoteAddress {
def submit(form: EmailForm)(implicit request: Request[AnyContent]): Future[WSResponse] = {
val consentMailerUrl = serviceUrl(form, emailEmbedAgent)
val consentMailerPayload = JsObject(
Json
.obj(
"email" -> form.email,
"set-lists" -> List(form.listName),
"set-consents" -> form.marketing.map(_ => List("similar_guardian_products")),
)
.fields,
)
val queryStringParameters = form.ref.map("ref" -> _).toList ++
form.refViewId.map("refViewId" -> _).toList ++
form.listName.map("listName" -> _).toList
// FIXME: this should go via the identity api client / app
wsClient
.url(consentMailerUrl)
.withQueryStringParameters(queryStringParameters: _*)
.addHttpHeaders(getHeaders(request): _*)
.post(consentMailerPayload)
}
def submitWithMany(form: EmailFormManyNewsletters)(implicit request: Request[AnyContent]): Future[WSResponse] = {
val consentMailerPayload = JsObject(
Json
.obj(
"email" -> form.email,
"set-lists" -> form.listNames,
"refViewId" -> form.refViewId,
"ref" -> form.ref,
"set-consents" -> form.marketing.map(_ => List("similar_guardian_products")),
)
.fields,
)
val queryStringParameters = form.ref.map("ref" -> _).toList ++
form.refViewId.map("refViewId" -> _).toList ++
form.listNames.map("listName" -> _).toList
wsClient
.url(s"${Configuration.id.apiRoot}/consent-email")
.withQueryStringParameters(queryStringParameters: _*)
.addHttpHeaders(getHeaders(request): _*)
.post(consentMailerPayload)
}
private def serviceUrl(form: EmailForm, emailEmbedAgent: NewsletterSignupAgent): String = {
val identityNewsletter = emailEmbedAgent.getV2NewsletterByName(form.listName.get)
val newsletterRequireConfirmation = identityNewsletter.map(_.get.emailConfirmation).getOrElse(true)
if (NewslettersRemoveConfirmationStep.isSwitchedOn && !newsletterRequireConfirmation) {
s"${Configuration.id.apiRoot}/consent-signup"
} else {
s"${Configuration.id.apiRoot}/consent-email"
}
}
private def getHeaders(request: Request[AnyContent]): List[(String, String)] = {
val idAccessClientToken = Configuration.id.apiClientToken
clientIp(request)
.map(ip => List("X-Forwarded-For" -> ip))
.getOrElse(List.empty) :+ "X-GU-ID-Client-Access-Token" -> s"Bearer $idAccessClientToken"
}
}
class EmailSignupController(
wsClient: WSClient,
val controllerComponents: ControllerComponents,
csrfAddToken: CSRFAddToken,
emailEmbedAgent: NewsletterSignupAgent,
)(implicit context: ApplicationContext)
extends BaseController
with ImplicitControllerExecutionContext
with GuLogging {
val emailFormService = new EmailFormService(wsClient, emailEmbedAgent)
val googleRecaptchaTokenValidationService = new GoogleRecaptchaValidationService(wsClient)
val emailForm: Form[EmailForm] = Form(
mapping(
"email" -> nonEmptyText.verifying(emailAddress),
"listName" -> optional[String](of[String]),
"marketing" -> optional[String](of[String]),
"referrer" -> optional[String](of[String]),
"ref" -> optional[String](of[String]),
"refViewId" -> optional[String](of[String]),
"campaignCode" -> optional[String](of[String]),
"g-recaptcha-response" -> optional[String](of[String]),
"name" -> optional[String](of[String]),
)(EmailForm.apply)(EmailForm.unapply),
)
val emailFormManyNewsletters: Form[EmailFormManyNewsletters] = Form(
mapping(
"email" -> nonEmptyText.verifying(emailAddress),
"listNames" -> seq(of[String]),
"marketing" -> optional[String](of[String]),
"referrer" -> optional[String](of[String]),
"ref" -> optional[String](of[String]),
"refViewId" -> optional[String](of[String]),
"campaignCode" -> optional[String](of[String]),
"g-recaptcha-response" -> optional[String](of[String]),
"name" -> optional[String](of[String]),
)(EmailFormManyNewsletters.apply)(EmailFormManyNewsletters.unapply),
)
def renderFooterForm(listName: String): Action[AnyContent] =
csrfAddToken {
Action { implicit request =>
val identityNewsletter = emailEmbedAgent.getV2NewsletterByName(listName)
identityNewsletter match {
case Right(Some(newsletter)) =>
if (EmailSignupRecaptcha.isSwitchedOn && newsletter.signupPage.isDefined) {
Cached(1.day)(
RevalidatableResult
.Ok(views.html.linkToEmailSignupPage(emailLandingPage, newsletter.signupPage.get, newsletter.name)),
)
} else {
Cached(1.day)(RevalidatableResult.Ok(views.html.emailFragmentFooter(emailLandingPage, listName)))
}
case Right(None) =>
logNewsletterNotFoundError(listName)
Cached(15.minute)(WithoutRevalidationResult(NoContent))
case Left(e) =>
logApiError(e)
Cached(15.minute)(WithoutRevalidationResult(InternalServerError))
}
}
}
def renderThrasherForm(listId: Int): Action[AnyContent] =
csrfAddToken {
Action { implicit request =>
val identityNewsletter = emailEmbedAgent.getV2NewsletterById(listId)
identityNewsletter match {
case Right(Some(newsletter)) =>
Cached(1.hour)(
RevalidatableResult.Ok(
views.html.emailFragmentThrasher(
emailLandingPage,
newsletter,
),
),
)
case Right(None) =>
logNewsletterNotFoundError(listId.toString)
Cached(15.minute)(WithoutRevalidationResult(NoContent))
case Left(e) =>
logApiError(e)
Cached(15.minute)(WithoutRevalidationResult(InternalServerError))
}
}
}
def renderThrasherFormFromName(listName: String): Action[AnyContent] =
csrfAddToken {
Action { implicit request =>
val identityNewsletter = emailEmbedAgent.getV2NewsletterByName(listName)
identityNewsletter match {
case Right(Some(newsletter)) =>
Cached(1.hour)(
RevalidatableResult.Ok(
views.html.emailFragmentThrasher(
emailLandingPage,
newsletter,
),
),
)
case Right(None) =>
logNewsletterNotFoundError(listName)
Cached(15.minute)(WithoutRevalidationResult(NoContent))
case Left(e) =>
logApiError(e)
Cached(15.minute)(WithoutRevalidationResult(InternalServerError))
}
}
}
def renderFormWithParentComponent(emailType: String, listId: Int, parentComponent: String): Action[AnyContent] =
renderForm(emailType, listId, Option(parentComponent))
def renderForm(emailType: String, listId: Int, iframeParentComponent: Option[String] = None): Action[AnyContent] =
csrfAddToken {
Action { implicit request =>
val identityNewsletter = emailEmbedAgent.getV2NewsletterById(listId)
identityNewsletter match {
case Right(Some(newsletter)) =>
Cached(1.hour)(
RevalidatableResult.Ok(
views.html.emailFragment(
emailLandingPage,
emailType,
newsletter,
iframeParentComponent,
),
),
)
case Right(None) =>
logNewsletterNotFoundError(listId.toString)
Cached(15.minute)(WithoutRevalidationResult(NoContent))
case Left(e) =>
logApiError(e)
Cached(15.minute)(WithoutRevalidationResult(InternalServerError))
}
}
}
def logApiError(error: String)(implicit request: RequestHeader): Unit = {
logErrorWithRequestId(s"API call to get newsletters failed: $error")
}
def logNewsletterNotFoundError(newsletterName: String)(implicit request: RequestHeader): Unit = {
logErrorWithRequestId(s"Newsletter not found: Couldn't find $newsletterName")
}
def renderFormFromNameWithParentComponent(
emailType: String,
listName: String,
parentComponent: String,
): Action[AnyContent] =
renderFormFromName(emailType, listName, Option(parentComponent))
def renderFormFromName(
emailType: String,
listName: String,
iframeParentComponent: Option[String] = None,
): Action[AnyContent] =
csrfAddToken {
Action { implicit request =>
val identityNewsletter = emailEmbedAgent.getV2NewsletterByName(listName)
identityNewsletter match {
case Right(Some(newsletter)) =>
Cached(1.hour)(
RevalidatableResult.Ok(
views.html.emailFragment(
emailLandingPage,
emailType,
newsletter,
iframeParentComponent,
),
),
)
case Right(None) =>
logNewsletterNotFoundError(listName)
Cached(15.minute)(WithoutRevalidationResult(NoContent))
case Left(e) =>
logApiError(e)
Cached(15.minute)(WithoutRevalidationResult(InternalServerError))
}
}
}
def subscriptionResultFooter(result: String): Action[AnyContent] =
Action { implicit request =>
Cached(1.hour)(result match {
case "success" =>
RevalidatableResult.Ok(views.html.emailSubscriptionResultFooter(emailLandingPage, Subscribed))
case "invalid" =>
RevalidatableResult.Ok(views.html.emailSubscriptionResultFooter(emailLandingPage, InvalidEmail))
case "error" =>
RevalidatableResult.Ok(views.html.emailSubscriptionResultFooter(emailLandingPage, OtherError))
case _ => WithoutRevalidationResult(NoContent)
})
}
def subscriptionSuccessResult(listName: String): Action[AnyContent] =
Action { implicit request =>
val identityNewsletter = emailEmbedAgent.getV2NewsletterByName(listName)
identityNewsletter match {
case Right(Some(newsletter)) =>
Cached(1.hour)(
RevalidatableResult.Ok(
views.html.emailSubscriptionSuccessResult(emailLandingPage, newsletter, listName),
),
)
case Right(None) =>
logNewsletterNotFoundError(listName)
Cached(15.minute)(WithoutRevalidationResult(NoContent))
case Left(e) =>
logApiError(e)
Cached(15.minute)(WithoutRevalidationResult(InternalServerError))
}
}
def subscriptionNonsuccessResult(result: String): Action[AnyContent] =
Action { implicit request =>
Cached(1.hour)(result match {
case "invalid" =>
RevalidatableResult.Ok(
views.html.emailSubscriptionNonsuccessResult(emailLandingPage, InvalidEmail),
)
case "error" =>
RevalidatableResult.Ok(
views.html.emailSubscriptionNonsuccessResult(emailLandingPage, OtherError),
)
case _ => WithoutRevalidationResult(NoContent)
})
}
def submitFooter(): Action[AnyContent] =
Action.async { implicit request =>
AllEmailSubmission.increment()
emailForm
.bindFromRequest()
.fold(
formWithErrors => {
logInfoWithRequestId(s"Form has been submitted with errors: ${formWithErrors.errors}")
EmailFormError.increment()
Future.successful(respondFooter(InvalidEmail))
},
form => {
logInfoWithRequestId(
s"Post request received to /email/ - " +
s"ref: ${form.ref}, " +
s"refViewId: ${form.refViewId}, " +
s"referer: ${request.headers.get("referer").getOrElse("unknown")}, " +
s"user-agent: ${request.headers.get("user-agent").getOrElse("unknown")}, " +
s"x-requested-with: ${request.headers.get("x-requested-with").getOrElse("unknown")}",
)
(for {
_ <- validateCaptcha(form.googleRecaptchaResponse, ValidateEmailSignupRecaptchaTokens.isSwitchedOn)
result <- submitFormFooter(form)
} yield {
result
}) recover { case _ =>
respondFooter(OtherError)
}
},
)
}
private def respondFooter(result: SubscriptionResult)(implicit
request: Request[AnyContent],
): Result = {
render {
case Accepts.Html() =>
result match {
case Subscribed => SeeOther(LinkTo(s"/email/success/footer"))
case InvalidEmail => SeeOther(LinkTo(s"/email/invalid/footer"))
case OtherError => SeeOther(LinkTo(s"/email/error/footer"))
}
case Accepts.Json() =>
Cors(NoCache(result match {
case Subscribed => Created("Subscribed")
case InvalidEmail => BadRequest("Invalid email")
case OtherError => InternalServerError("Internal error")
}))
case _ =>
NotAccepted.increment()
NotAcceptable
}
}
private def submitFormFooter(form: EmailForm)(implicit request: Request[AnyContent]) = {
emailFormService
.submit(form)
.map(_.status match {
case 200 | 201 =>
EmailSubmission.increment()
respondFooter(Subscribed)
case status =>
logErrorWithRequestId(s"Error posting to Identity API: HTTP $status")
APIHTTPError.increment()
respondFooter(OtherError)
}) recover {
case _: IllegalAccessException =>
respondFooter(Subscribed)
case e: Exception =>
logErrorWithRequestId(s"Error posting to Identity API: ${e.getMessage}")
APINetworkError.increment()
respondFooter(OtherError)
}
}
private def validateCaptcha(googleRecaptchaResponse: Option[String], shouldValidateCaptcha: Boolean)(implicit
request: Request[AnyContent],
) = {
if (shouldValidateCaptcha) {
for {
token <- googleRecaptchaResponse match {
case Some(token) => Future.successful(token)
case None =>
RecaptchaMissingTokenError.increment()
Future.failed(new IllegalAccessException("reCAPTCHA client token not provided"))
}
wsResponse <- googleRecaptchaTokenValidationService.submit(token) recoverWith { case e =>
RecaptchaAPIUnavailableError.increment()
Future.failed(e)
}
googleResponse = wsResponse.json.as[GoogleResponse]
_ <- {
if (googleResponse.success) {
RecaptchaValidationSuccess.increment()
Future.successful(())
} else {
RecaptchaValidationError.increment()
val errorMessage = s"Google token validation failed with error: ${googleResponse.`error-codes`}"
Future.failed(new IllegalAccessException(errorMessage))
}
}
} yield ()
} else {
Future.successful(())
}
}
def submit(): Action[AnyContent] =
Action.async { implicit request =>
AllEmailSubmission.increment()
emailForm
.bindFromRequest()
.fold(
formWithErrors => {
logInfoWithRequestId(s"Form has been submitted with errors: ${formWithErrors.errors}")
EmailFormError.increment()
Future.successful(respond(InvalidEmail))
},
form => {
logInfoWithRequestId(
s"Post request received to /email/ - " +
s"ref: ${form.ref}, " +
s"refViewId: ${form.refViewId}, " +
s"referer: ${request.headers.get("referer").getOrElse("unknown")}, " +
s"user-agent: ${request.headers.get("user-agent").getOrElse("unknown")}, " +
s"x-requested-with: ${request.headers.get("x-requested-with").getOrElse("unknown")}",
)
(for {
_ <- validateCaptcha(form.googleRecaptchaResponse, ValidateEmailSignupRecaptchaTokens.isSwitchedOn)
result <- buildSubmissionResult(emailFormService.submit(form), form.listName)
} yield {
result
}) recover { case _ =>
respond(OtherError)
}
},
)
}
def submitMany(): Action[AnyContent] =
Action.async { implicit request =>
AllEmailSubmission.increment()
emailFormManyNewsletters
.bindFromRequest()
.fold(
formWithErrors => {
logInfoWithRequestId(s"Form has been submitted with errors: ${formWithErrors.errors}")
EmailFormError.increment()
Future.successful(respond(InvalidEmail))
},
form => {
logInfoWithRequestId(
s"Post request received to /email/many/ - " +
s"listNames.size: ${form.listNames.size.toString()}, " +
s"ref: ${form.ref}, " +
s"refViewId: ${form.refViewId}, " +
s"referer: ${request.headers.get("referer").getOrElse("unknown")}, " +
s"user-agent: ${request.headers.get("user-agent").getOrElse("unknown")}, " +
s"x-requested-with: ${request.headers.get("x-requested-with").getOrElse("unknown")}",
)
(for {
_ <- validateCaptcha(form.googleRecaptchaResponse, ValidateEmailSignupRecaptchaTokens.isSwitchedOn)
result <- buildSubmissionResult(emailFormService.submitWithMany(form), Option.empty[String])
} yield {
result
}) recover { case _ =>
respond(OtherError)
}
},
)
}
private def buildSubmissionResult(wsResponse: Future[WSResponse], listName: Option[String])(implicit
request: Request[AnyContent],
) = {
wsResponse.map(_.status match {
case 200 | 201 =>
EmailSubmission.increment()
respond(Subscribed, listName)
case status =>
logErrorWithRequestId(s"Error posting to Identity API: HTTP $status")
APIHTTPError.increment()
respond(OtherError)
}) recover {
case _: IllegalAccessException =>
respond(Subscribed)
case e: Exception =>
logErrorWithRequestId(s"Error posting to Identity API: ${e.getMessage}")
APINetworkError.increment()
respond(OtherError)
}
}
private def respond(result: SubscriptionResult, listName: Option[String] = None)(implicit
request: Request[AnyContent],
): Result = {
render {
case Accepts.Html() =>
result match {
case Subscribed => SeeOther(LinkTo(s"/email/success/${listName.get}"))
case InvalidEmail => SeeOther(LinkTo(s"/email/invalid"))
case OtherError => SeeOther(LinkTo(s"/email/error"))
}
case Accepts.Json() =>
Cors(NoCache(result match {
case Subscribed => Created("Subscribed")
case InvalidEmail => BadRequest("Invalid email")
case OtherError => InternalServerError("Internal error")
}))
case _ =>
NotAccepted.increment()
NotAcceptable
}
}
def options(): Action[AnyContent] =
Action { implicit request =>
TinyResponse.noContent(Some("GET, POST, OPTIONS"))
}
}