Repository: Texera/texera
Pull request #3393: Enable email notifications to users upon an inactive login attempt
Author: login: GspikeHalo
Created: 2025-04-26T02:43:00Z | Merged: 2025-04-30T06:25:27Z

### Purpose:
This PR enables automatic email notifications when an INACTIVE user attempts to log in under inviteOnly mode, including a confirmation email sent to the user acknowledging receipt of their account request.

### Changes:
1. Add `EmailTemplate.scala` to centralize and manage different types of email content.
2. Implement `createAdminNotification` method to generate notification emails for all administrators.
3. Implement `createUserNotification` method to generate confirmation emails for users.
4. Refactor `notifyUnauthorizedUser` to use the email templates from EmailTemplate instead of constructing email content inline; additionally, extend it to send a notification email to the user.

### Demos:
shenghaf@uci.edu is an INACTIVE user
shenghaofu0709@gmail.com and 2318002579@qq.com are administrators:
![image](https://github.com/user-attachments/assets/446b548e-c315-428b-a0e3-fb049b45a131)

An email will be sent to the user when shenghaf@uci.edu attempts to log in:
![image](https://github.com/user-attachments/assets/48200746-8460-454c-8a98-2bfaf36b7366)

Display of User's Email:
![image](https://github.com/user-attachments/assets/d53cf783-3897-4006-8ece-f92016622a40)

Display of Administrators' Emails:
shenghaofu0709@gmail.com:
![image](https://github.com/user-attachments/assets/4854ce04-c94a-49c2-894c-f30cea1ba00d)
2318002579@qq.com:
![image](https://github.com/user-attachments/assets/5395d9cf-ff03-40fa-a752-243a4be3cec0)



Diff:
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/EmailTemplate.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/EmailTemplate.scala
new file mode 100644
index 00000000000..1c04282e2a6
--- /dev/null
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/EmailTemplate.scala
@@ -0,0 +1,79 @@
+package edu.uci.ics.texera.web.resource
+
+import edu.uci.ics.texera.dao.jooq.generated.enums.UserRoleEnum
+
+/**
+  * EmailTemplate provides factory methods to generate email messages
+  * for different user notification scenarios.
+  */
+object EmailTemplate {
+
+  /**
+    * Creates an email message for user registration notifications.
+    * Depending on the 'toAdmin' flag, it either notifies an administrator
+    * of a pending account request or acknowledges receipt to the user.
+    *
+    * @param receiverEmail the email address of the receiver (admin or user)
+    * @param userEmail optional; the email address of the user requesting an account (only needed if toAdmin is true)
+    * @param toAdmin flag indicating whether the notification is for the admin (true) or the user (false)
+    * @return an EmailMessage ready to be sent
+    */
+  def userRegistrationNotification(
+      receiverEmail: String,
+      userEmail: Option[String],
+      toAdmin: Boolean
+  ): EmailMessage = {
+    if (toAdmin) {
+      val subject = "New Account Request Pending Approval"
+      val content =
+        s"""
+           |Hello Admin,
+           |
+           |A new user has attempted to log in or register, but their account is not yet approved.
+           |Please review the account request for the following email:
+           |
+           |${userEmail.getOrElse("Unknown")}
+           |
+           |Thanks!
+           |""".stripMargin
+      EmailMessage(subject = subject, content = content, receiver = receiverEmail)
+    } else {
+      val subject = "Account Request Received"
+      val content =
+        s"""
+           |Hello,
+           |
+           |Thank you for submitting your account request.
+           |We have received your request and it is currently under review.
+           |Please be patient during this process. You will be notified once your account has been approved.
+           |
+           |Thank you for your interest!
+           |""".stripMargin
+      EmailMessage(subject = subject, content = content, receiver = receiverEmail)
+    }
+  }
+
+  /**
+    * Creates an email message to notify a user
+    * that their role has been updated.
+    *
+    * @param receiverEmail the user's email address
+    * @param newRole the new role assigned to the user
+    * @return an EmailMessage ready to be sent to the user
+    */
+  def createRoleChangeTemplate(receiverEmail: String, newRole: UserRoleEnum): EmailMessage = {
+    val subject = "Your Role Has Been Updated"
+    val content =
+      s"""
+         |Hello,
+         |
+         |Your user role has been updated to: $newRole.
+         |
+         |If you have any questions, please contact the administrator.
+         |
+         |Thank you!
+         |""".stripMargin
+
+    EmailMessage(subject = subject, content = content, receiver = receiverEmail)
+  }
+}
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
index 7798e3fcb18..3ca408fbb95 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
@@ -2,8 +2,13 @@ package edu.uci.ics.texera.web.resource
 
 import edu.uci.ics.amber.engine.common.AmberConfig
 import edu.uci.ics.texera.auth.SessionUser
-import edu.uci.ics.texera.web.resource.GmailResource.{sendEmail, senderGmail}
+import edu.uci.ics.texera.dao.SqlServer
+import edu.uci.ics.texera.dao.jooq.generated.enums.UserRoleEnum
+import edu.uci.ics.texera.dao.jooq.generated.tables.daos.UserDao
+import edu.uci.ics.texera.web.resource.EmailTemplate.userRegistrationNotification
+import edu.uci.ics.texera.web.resource.GmailResource.{isValidEmail, sendEmail, senderGmail, userDao}
 import io.dropwizard.auth.Auth
+import org.slf4j.LoggerFactory
 
 import javax.annotation.security.RolesAllowed
 import javax.mail.internet.{InternetAddress, MimeMessage}
@@ -14,6 +19,11 @@ import scala.util.{Failure, Success, Try}
 case class EmailMessage(receiver: String, subject: String, content: String)
 
 object GmailResource {
+  final private lazy val context = SqlServer
+    .getInstance()
+    .createDSLContext()
+  final private lazy val userDao = new UserDao(context.configuration)
+
   private lazy val senderGmail: String = AmberConfig.gmail
   private val smtpProperties = Map(
     "mail.smtp.host" -> "smtp.gmail.com",
@@ -54,6 +64,13 @@ object GmailResource {
       emailMessage: EmailMessage,
       recipientEmail: String
   ): Either[String, Unit] = {
+    val logger = LoggerFactory.getLogger(this.getClass)
+
+    if (!isValidEmail(recipientEmail)) {
+      logger.warn(s"Attempted to send email to invalid address: $recipientEmail")
+      return Left("Invalid email format")
+    }
+
     Try {
       val session = createSession()
       val email = createMimeMessage(session, emailMessage, recipientEmail)
@@ -63,6 +80,23 @@ object GmailResource {
       case Failure(exception) => Left(s"Failed to send email: ${exception.getMessage}")
     }
   }
+
+  /**
+    * Validates whether a given email address has a basic correct format.
+    *
+    * This method uses a regular expression to ensure the email:
+    * - Has a valid local part containing letters, numbers, '+', '_', '.', or '-'
+    * - Contains a single '@' character separating the local part and domain
+    * - Has a valid domain containing letters, numbers, '.' or '-'
+    * - Ends with a domain suffix (e.g., '.com', '.net') that is at least two letters long
+    *
+    * @param email the email address to validate
+    * @return true if the email matches the expected format, false otherwise
+    */
+  private def isValidEmail(email: String): Boolean = {
+    val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".r
+    email != null && emailRegex.matches(email)
+  }
 }
 
 @Path("/gmail")
@@ -83,22 +117,48 @@ class GmailResource {
   @POST
   @Path("/notify-unauthorized")
   def notifyUnauthorizedUser(emailMessage: EmailMessage): Unit = {
-    val subject = "New Account Request Pending Approval"
-    val content =
-      s"""
-         |Hello Admin,
-         |
-         |A new user has attempted to log in or register, but their account is not yet approved.
-         |Please review the account request for the following email:
-         |
-         |${emailMessage.receiver}
-         |
-         |Thanks!
-         |""".stripMargin
-
-    sendEmail(
-      EmailMessage(subject = subject, content = content, receiver = senderGmail),
-      senderGmail
-    )
+    val logger = LoggerFactory.getLogger(this.getClass)
+
+    if (!isValidEmail(emailMessage.receiver)) {
+      throw new ForbiddenException("Invalid email address.")
+    }
+
+    val adminUsers = userDao.fetchByRole(UserRoleEnum.ADMIN)
+    val adminUserIterator = adminUsers.iterator()
+
+    while (adminUserIterator.hasNext) {
+      val admin = adminUserIterator.next()
+      val adminEmail = admin.getEmail
+
+      try {
+        sendEmail(
+          userRegistrationNotification(
+            receiverEmail = adminEmail,
+            userEmail = Some(emailMessage.receiver),
+            toAdmin = true
+          ),
+          adminEmail
+        )
+      } catch {
+        case ex: Exception =>
+          logger.warn(s"Failed to send email to admin: $adminEmail. Error: ${ex.getMessage}")
+      }
+    }
+
+    try {
+      sendEmail(
+        userRegistrationNotification(
+          receiverEmail = emailMessage.receiver,
+          userEmail = None,
+          toAdmin = false
+        ),
+        emailMessage.receiver
+      )
+    } catch {
+      case ex: Exception =>
+        logger.warn(
+          s"Failed to send notification to user: ${emailMessage.receiver}. Error: ${ex.getMessage}"
+        )
+    }
   }
 }
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
index 9dcb6958ffc..fe1aa24c9f7 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
@@ -4,6 +4,8 @@ import edu.uci.ics.texera.dao.SqlServer
 import edu.uci.ics.texera.dao.jooq.generated.enums.UserRoleEnum
 import edu.uci.ics.texera.dao.jooq.generated.tables.daos.UserDao
 import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.User
+import edu.uci.ics.texera.web.resource.EmailTemplate.createRoleChangeTemplate
+import edu.uci.ics.texera.web.resource.GmailResource.sendEmail
 import edu.uci.ics.texera.web.resource.dashboard.admin.user.AdminUserResource.userDao
 import edu.uci.ics.texera.web.resource.dashboard.user.quota.UserQuotaResource._
 import org.jasypt.util.password.StrongPasswordEncryptor
@@ -44,11 +46,18 @@ class AdminUserResource {
       throw new WebApplicationException("Email already exists", Response.Status.CONFLICT)
     }
     val updatedUser = userDao.fetchOneByUid(user.getUid)
+    val roleChanged = updatedUser.getRole != user.getRole
     updatedUser.setName(user.getName)
     updatedUser.setEmail(user.getEmail)
     updatedUser.setRole(user.getRole)
     updatedUser.setComment(user.getComment)
     userDao.update(updatedUser)
+
+    if (roleChanged)
+      sendEmail(
+        createRoleChangeTemplate(receiverEmail = updatedUser.getEmail, newRole = user.getRole),
+        updatedUser.getEmail
+      )
   }
 
   @POST


Reviews:
user: login: bobbai00
body: Left some comments
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T19:03:15Z
user: login: GspikeHalo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T04:00:46Z
user: login: GspikeHalo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T04:00:56Z
user: login: GspikeHalo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T04:01:06Z
user: login: bobbai00
body: LGTM. Merge #3394 first and then merge this one.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T17:37:44Z

Review comments:
diff_hunk: @@ -0,0 +1,54 @@
+package edu.uci.ics.texera.web.resource
+
+/**
+  * EmailTemplate provides factory methods to generate email messages
+  * for different user notification scenarios.
+  */
+object EmailTemplate {
+
+  /**
+    * Creates an email message to notify the administrator
+    * that a new user account request is pending approval.
+    *
+    * @param receiverEmail the administrator's email address
+    * @param userEmail the email address of the user requesting an account
+    * @return an EmailMessage ready to be sent to the administrator
+    */
+  def createAdminNotification(receiverEmail: String, userEmail: String): EmailMessage = {
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/EmailTemplate.scala
user: login: bobbai00
body: merge this method with the below into one method

add a boolean paramter called `toAdmin`.

rename this function to be `userRegistrationNotification`
created_at: 2025-04-27T18:58:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,22 +95,17 @@ class GmailResource {
   @POST
   @Path("/notify-unauthorized")
   def notifyUnauthorizedUser(emailMessage: EmailMessage): Unit = {
-    val subject = "New Account Request Pending Approval"
-    val content =
-      s"""
-         |Hello Admin,
-         |
-         |A new user has attempted to log in or register, but their account is not yet approved.
-         |Please review the account request for the following email:
-         |
-         |${emailMessage.receiver}
-         |
-         |Thanks!
-         |""".stripMargin
+    val adminUsers = userDao.fetchByRole(UserRoleEnum.ADMIN)
+    val adminUserIterator = adminUsers.iterator()
 
-    sendEmail(
-      EmailMessage(subject = subject, content = content, receiver = senderGmail),
-      senderGmail
-    )
+    while (adminUserIterator.hasNext) {
+      val admin = adminUserIterator.next()
+      sendEmail(
+        createAdminNotification(receiverEmail = admin.getEmail, userEmail = emailMessage.receiver),
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
user: login: bobbai00
body: Some admins may have invalid emails, can you add a checker here? If invalid, do not send to this admin.
created_at: 2025-04-27T19:01:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,22 +95,17 @@ class GmailResource {
   @POST
   @Path("/notify-unauthorized")
   def notifyUnauthorizedUser(emailMessage: EmailMessage): Unit = {
-    val subject = "New Account Request Pending Approval"
-    val content =
-      s"""
-         |Hello Admin,
-         |
-         |A new user has attempted to log in or register, but their account is not yet approved.
-         |Please review the account request for the following email:
-         |
-         |${emailMessage.receiver}
-         |
-         |Thanks!
-         |""".stripMargin
+    val adminUsers = userDao.fetchByRole(UserRoleEnum.ADMIN)
+    val adminUserIterator = adminUsers.iterator()
 
-    sendEmail(
-      EmailMessage(subject = subject, content = content, receiver = senderGmail),
-      senderGmail
-    )
+    while (adminUserIterator.hasNext) {
+      val admin = adminUserIterator.next()
+      sendEmail(
+        createAdminNotification(receiverEmail = admin.getEmail, userEmail = emailMessage.receiver),
+        admin.getEmail
+      )
+    }
+
+    sendEmail(createUserNotification(receiverEmail = emailMessage.receiver), emailMessage.receiver)
   }
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
user: login: bobbai00
body: Ditto for the user's email.
created_at: 2025-04-27T19:01:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,22 +95,17 @@ class GmailResource {
   @POST
   @Path("/notify-unauthorized")
   def notifyUnauthorizedUser(emailMessage: EmailMessage): Unit = {
-    val subject = "New Account Request Pending Approval"
-    val content =
-      s"""
-         |Hello Admin,
-         |
-         |A new user has attempted to log in or register, but their account is not yet approved.
-         |Please review the account request for the following email:
-         |
-         |${emailMessage.receiver}
-         |
-         |Thanks!
-         |""".stripMargin
+    val adminUsers = userDao.fetchByRole(UserRoleEnum.ADMIN)
+    val adminUserIterator = adminUsers.iterator()
 
-    sendEmail(
-      EmailMessage(subject = subject, content = content, receiver = senderGmail),
-      senderGmail
-    )
+    while (adminUserIterator.hasNext) {
+      val admin = adminUserIterator.next()
+      sendEmail(
+        createAdminNotification(receiverEmail = admin.getEmail, userEmail = emailMessage.receiver),
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
user: login: bobbai00
body: I also suggest add `try-catch` block here. If encountering error, logger.warn the error, and keep trying next admin.
created_at: 2025-04-27T19:02:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+package edu.uci.ics.texera.web.resource
+
+/**
+  * EmailTemplate provides factory methods to generate email messages
+  * for different user notification scenarios.
+  */
+object EmailTemplate {
+
+  /**
+    * Creates an email message to notify the administrator
+    * that a new user account request is pending approval.
+    *
+    * @param receiverEmail the administrator's email address
+    * @param userEmail the email address of the user requesting an account
+    * @return an EmailMessage ready to be sent to the administrator
+    */
+  def createAdminNotification(receiverEmail: String, userEmail: String): EmailMessage = {
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/EmailTemplate.scala
user: login: GspikeHalo
body: resolved
created_at: 2025-04-28T04:00:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,22 +95,17 @@ class GmailResource {
   @POST
   @Path("/notify-unauthorized")
   def notifyUnauthorizedUser(emailMessage: EmailMessage): Unit = {
-    val subject = "New Account Request Pending Approval"
-    val content =
-      s"""
-         |Hello Admin,
-         |
-         |A new user has attempted to log in or register, but their account is not yet approved.
-         |Please review the account request for the following email:
-         |
-         |${emailMessage.receiver}
-         |
-         |Thanks!
-         |""".stripMargin
+    val adminUsers = userDao.fetchByRole(UserRoleEnum.ADMIN)
+    val adminUserIterator = adminUsers.iterator()
 
-    sendEmail(
-      EmailMessage(subject = subject, content = content, receiver = senderGmail),
-      senderGmail
-    )
+    while (adminUserIterator.hasNext) {
+      val admin = adminUserIterator.next()
+      sendEmail(
+        createAdminNotification(receiverEmail = admin.getEmail, userEmail = emailMessage.receiver),
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
user: login: GspikeHalo
body: resolved
created_at: 2025-04-28T04:00:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,22 +95,17 @@ class GmailResource {
   @POST
   @Path("/notify-unauthorized")
   def notifyUnauthorizedUser(emailMessage: EmailMessage): Unit = {
-    val subject = "New Account Request Pending Approval"
-    val content =
-      s"""
-         |Hello Admin,
-         |
-         |A new user has attempted to log in or register, but their account is not yet approved.
-         |Please review the account request for the following email:
-         |
-         |${emailMessage.receiver}
-         |
-         |Thanks!
-         |""".stripMargin
+    val adminUsers = userDao.fetchByRole(UserRoleEnum.ADMIN)
+    val adminUserIterator = adminUsers.iterator()
 
-    sendEmail(
-      EmailMessage(subject = subject, content = content, receiver = senderGmail),
-      senderGmail
-    )
+    while (adminUserIterator.hasNext) {
+      val admin = adminUserIterator.next()
+      sendEmail(
+        createAdminNotification(receiverEmail = admin.getEmail, userEmail = emailMessage.receiver),
+        admin.getEmail
+      )
+    }
+
+    sendEmail(createUserNotification(receiverEmail = emailMessage.receiver), emailMessage.receiver)
   }
path: core/amber/src/main/scala/edu/uci/ics/texera/web/resource/GmailResource.scala
user: login: GspikeHalo
body: resolved
created_at: 2025-04-28T04:01:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: chenlica
created_at: 2025-04-26T04:36:05Z
body: Two questions:

- Do we send an email once a user requests to join the platform?
- What's the email address used to send such notifications?

author_association: CONTRIBUTOR
user: login: GspikeHalo
created_at: 2025-04-26T04:42:02Z
body: > Two questions:
> 
> * Do we send an email once a user requests to join the platform?
> * What's the email address used to send such notifications?

1. Whenever a user attempts to log in via Google Login, an email will be sent.
2. The sender address for the email is configured in application.conf, and it can be set during deployment.
author_association: CONTRIBUTOR