~jan0sch/smederee

Showing details for patch da3551a633de7666cd3f74a1dca87c2873e6fa32.
2022-10-11 (Tue), 10:39 AM - Jens Grassel - da3551a633de7666cd3f74a1dca87c2873e6fa32

Account Validation: Validate the account via email

- BREAKING: rename `verify` to `validate` etc.
- refactor code accordingly
- add local `application.conf` for hub to ignore file
- add ValidationToken
- add routes for sending validation email and actual validation
- add tests for DoobieAccountManagementRepository
Summary of changes
1 files added
  • modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt
20 files modified with 365 lines added and 70 lines removed
  • modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala with 1 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala with 66 added and 10 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala with 59 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 3 added and 3 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/Generators.scala with 5 added and 5 removed lines
  • modules/hub/src/main/resources/assets/css/main.css with 9 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/V1__base_tables.sql with 15 added and 15 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 5 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 50 added and 13 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala with 35 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 52 added and 4 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala with 20 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 12 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 4 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html with 20 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html with 1 added and 1 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 5 added and 5 removed lines
diff -rN -u old-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala new-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala
--- old-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala	2025-02-02 03:52:49.205589316 +0000
@@ -60,6 +60,7 @@
       mailerBuilder
     }
   } yield builder.async().buildMailer()
+
   override def send(email: EmailMessage): IO[Either[String, Unit]] = {
     val sending = for {
       mailer  <- cachedMailer
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-02-02 03:52:49.205589316 +0000
@@ -120,6 +120,8 @@
     *   An optional unlock token to be stored.
     * @param attempts
     *   Optional number of failed login attempts.
+    * @param validationToken
+    *   An optional validation token to be stored.
     * @return
     *   The number of affected database rows.
     */
@@ -127,20 +129,29 @@
   protected def createAccount(
       account: Account,
       hash: PasswordHash,
-      unlockToken: Option[UnlockToken],
-      attempts: Option[Int]
+      unlockToken: Option[UnlockToken] = None,
+      attempts: Option[Int] = None,
+      validationToken: Option[ValidationToken] = None
   ): IO[Int] =
     connectToDb(configuration).use { con =>
       for {
         statement <- IO.delay {
-          unlockToken match {
-            case None =>
+          (unlockToken, validationToken) match {
+            case (None, None) =>
               con.prepareStatement(
-                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, verified_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)"""
+                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, validated_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)"""
               )
-            case Some(_) =>
+            case (Some(_), None) =>
               con.prepareStatement(
-                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, verified_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
+                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
+              )
+            case (None, Some(_)) =>
+              con.prepareStatement(
+                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
+              )
+            case (Some(_), Some(_)) =>
+              con.prepareStatement(
+                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
               )
           }
         }
@@ -149,7 +160,17 @@
         _ <- IO.delay(statement.setString(3, account.email.toString))
         _ <- IO.delay(statement.setString(4, hash.toString))
         _ <- IO.delay(statement.setInt(5, attempts.getOrElse(1)))
-        _ <- IO.delay(statement.setBoolean(6, account.verifiedEmail))
+        _ <- IO.delay(statement.setBoolean(6, account.validatedEmail))
+        _ <- (unlockToken, validationToken) match {
+          case (None, None)     => IO.unit
+          case (Some(ut), None) => IO.delay(statement.setString(7, ut.toString))
+          case (None, Some(vt)) => IO.delay(statement.setString(7, vt.toString))
+          case (Some(ut), Some(vt)) =>
+            IO.delay {
+              statement.setString(7, ut.toString)
+              statement.setString(8, vt.toString)
+            }
+        }
         _ <- IO.delay {
           unlockToken.foreach { token =>
             statement.setString(7, token.toString)
@@ -202,7 +223,7 @@
       for {
         statement <- IO.delay(
           con.prepareStatement(
-            """SELECT uid, name, email, password, created_at, updated_at, verified_email FROM "accounts" WHERE uid = ? LIMIT 1"""
+            """SELECT uid, name, email, password, created_at, updated_at, validated_email FROM "accounts" WHERE uid = ? LIMIT 1"""
           )
         )
         _      <- IO.delay(statement.setObject(1, uid))
@@ -214,7 +235,7 @@
                 uid = uid,
                 name = Username(result.getString("name")),
                 email = Email(result.getString("email")),
-                verifiedEmail = result.getBoolean("verified_email")
+                validatedEmail = result.getBoolean("validated_email")
               )
             )
           } else {
@@ -225,6 +246,41 @@
       } yield account
     }
 
+  /** Load the validation related columns for the account with the given unique user id.
+    *
+    * @param uid
+    *   The unique identifier for the account.
+    * @return
+    *   An option of the columns if it exists.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def loadValidationColumns(uid: UserId): IO[Option[(Boolean, Option[ValidationToken])]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT validated_email, validation_token FROM "accounts" WHERE uid = ? LIMIT 1"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, uid))
+        result <- IO.delay(statement.executeQuery)
+        columns <- IO.delay {
+          if (result.next()) {
+            Option(
+              (
+                result.getBoolean("validated_email"),
+                ValidationToken
+                  .from(result.getString("validation_token"))
+              )
+            )
+          } else {
+            None
+          }
+        }
+        _ <- IO.delay(statement.close())
+      } yield columns
+    }
+
   /** Delete the given directory recursively.
     *
     * @param path
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-02-02 03:52:49.205589316 +0000
@@ -64,6 +64,24 @@
     }
   }
 
+  test("findByValidationToken must return the matching account") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(ac) =>
+        val account  = ac.copy(validatedEmail = true)
+        val token    = ValidationToken.generate
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieAccountManagementRepository[IO](tx)
+        val hash = PasswordHash("Yet another weak password!")
+        val test = for {
+          _ <- createAccount(account, hash, validationToken = token.some)
+          o <- repo.findByValidationToken(token)
+        } yield o
+        test.map(result => assert(result === Some(account)))
+    }
+  }
+
   test("findPasswordHash must return correct hash") {
     genValidAccount.sample match {
       case None => fail("Could not generate data samples!")
@@ -80,4 +98,45 @@
         test.map(result => assert(result.exists(_ === hash), "Unexpected result from database!"))
     }
   }
+
+  test("markAsValidated must clear the validation token and set the validated column to true") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(ac) =>
+        val account  = ac.copy(validatedEmail = true)
+        val token    = ValidationToken.generate
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieAccountManagementRepository[IO](tx)
+        val hash = PasswordHash("Yet another weak password!")
+        val test = for {
+          _    <- createAccount(account, hash, validationToken = token.some)
+          _    <- repo.markAsValidated(account.uid)
+          cols <- loadValidationColumns(account.uid)
+        } yield cols
+        test.map { result =>
+          assert(result === Some((true, None)), "Unexpected result from database!")
+        }
+    }
+  }
+
+  test("setValidationToken must set the validation token") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val token    = ValidationToken.generate
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo = new DoobieAccountManagementRepository[IO](tx)
+        val hash = PasswordHash("Yet another weak password!")
+        val test = for {
+          _    <- createAccount(account, hash)
+          _    <- repo.setValidationToken(account.uid, token)
+          cols <- loadValidationColumns(account.uid)
+        } yield cols
+        test.map { result =>
+          assert(result === Some((account.validatedEmail, Some(token))), "Unexpected result from database!")
+        }
+    }
+  }
 }
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-02-02 03:52:49.205589316 +0000
@@ -212,7 +212,7 @@
             repo.owner.uid,
             repo.owner.name,
             Email(s"${repo.owner.name}@example.com"),
-            verifiedEmail = true
+            validatedEmail = true
           )
         )
         val expectedRepoList = vcsRepositories.filter(_.isPrivate === false)
@@ -249,7 +249,7 @@
             repo.owner.uid,
             repo.owner.name,
             Email(s"${repo.owner.name}@example.com"),
-            verifiedEmail = true
+            validatedEmail = true
           )
         )
         val expectedRepoList = vcsRepositories.filter(_.isPrivate === false)
@@ -289,7 +289,7 @@
             repo.owner.uid,
             repo.owner.name,
             Email(s"${repo.owner.name}@example.com"),
-            verifiedEmail = true
+            validatedEmail = true
           )
         )
         val expectedRepoList = vcsRepositories
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala	2025-02-02 03:52:49.205589316 +0000
@@ -96,11 +96,11 @@
   } yield Username(prefix.toString + chars)
 
   val genValidAccount: Gen[Account] = for {
-    id            <- genUserId
-    email         <- genValidEmail
-    name          <- genValidUsername
-    verifiedEmail <- Gen.oneOf(List(false, true))
-  } yield Account(uid = id, name = name, email = email, verifiedEmail = verifiedEmail)
+    id             <- genUserId
+    email          <- genValidEmail
+    name           <- genValidUsername
+    validatedEmail <- Gen.oneOf(List(false, true))
+  } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail)
 
   given Arbitrary[Account] = Arbitrary(genValidAccount)
 
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-02 03:52:49.209589321 +0000
@@ -8,6 +8,11 @@
   padding: 0px 10px;
 }
 
+.validate-email-form {
+  border: 1px solid black;
+  padding: 0px 10px;
+}
+
 .pure-button {
   background-color: #1f8dd6;
   color: white;
@@ -82,6 +87,10 @@
   background: rgb(202, 60, 60);
 }
 
+.alert-info {
+  background: #1f8dd6;
+}
+
 .alert-secondary {
   background: rgb(66, 184, 221);
 }
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-02-02 03:52:49.209589321 +0000
@@ -1,18 +1,18 @@
 CREATE TABLE "accounts"
 (
-  "uid"             UUID                     NOT NULL,
-  "name"            CHARACTER VARYING(32)    NOT NULL,
-  "email"           CHARACTER VARYING(128)   NOT NULL,
-  "password"        TEXT,
-  "failed_attempts" INTEGER                  DEFAULT 0,
-  "locked_at"       TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-  "unlock_token"    TEXT                     DEFAULT NULL,
-  "reset_expiry"    TIMESTAMP WITH TIME ZONE DEFAULT NULL,
-  "reset_token"     TEXT                     DEFAULT NULL,
-  "created_at"      TIMESTAMP WITH TIME ZONE NOT NULL,
-  "updated_at"      TIMESTAMP WITH TIME ZONE NOT NULL,
-  "verified_email"  BOOLEAN                  DEFAULT FALSE,
-  "verify_token"    TEXT                     DEFAULT NULL,
+  "uid"              UUID                     NOT NULL,
+  "name"             CHARACTER VARYING(32)    NOT NULL,
+  "email"            CHARACTER VARYING(128)   NOT NULL,
+  "password"         TEXT,
+  "failed_attempts"  INTEGER                  DEFAULT 0,
+  "locked_at"        TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+  "unlock_token"     TEXT                     DEFAULT NULL,
+  "reset_expiry"     TIMESTAMP WITH TIME ZONE DEFAULT NULL,
+  "reset_token"      TEXT                     DEFAULT NULL,
+  "created_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
+  "updated_at"       TIMESTAMP WITH TIME ZONE NOT NULL,
+  "validated_email"  BOOLEAN                  DEFAULT FALSE,
+  "validation_token" TEXT                     DEFAULT NULL,
   CONSTRAINT "accounts_pk"           PRIMARY KEY ("uid"),
   CONSTRAINT "accounts_unique_name"  UNIQUE ("name"),
   CONSTRAINT "accounts_unique_email" UNIQUE ("email")
@@ -33,8 +33,8 @@
 COMMENT ON COLUMN "accounts"."reset_token" IS 'A token which can be used for a password reset.';
 COMMENT ON COLUMN "accounts"."created_at" IS 'The timestamp of when the account was created.';
 COMMENT ON COLUMN "accounts"."updated_at" IS 'A timestamp when the account was last changed.';
-COMMENT ON COLUMN "accounts"."verified_email" IS 'This flag indicates if the email address of the user has been verified via a verification email.';
-COMMENT ON COLUMN "accounts"."verify_token" IS 'A token used to verify the email address of the user.';
+COMMENT ON COLUMN "accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.';
+COMMENT ON COLUMN "accounts"."validation_token" IS 'A token used to validate the email address of the user.';
 
 CREATE TABLE "sessions"
 (
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-02 03:52:49.201589311 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-02 03:52:49.205589316 +0000
@@ -10,7 +10,7 @@
 # 2. Grouping continues downward if it makes sense, e.g.
 #    error.forbidden.title, error.forbidden.message.
 #
-errors.account.not-verified=Sorry, but your account has not been verified and is therefore not allowed to perform the desired action. Please verify your account.
+errors.account.not-validated=Sorry, but your account has not been validated and is therefore not allowed to perform the desired action. Please validate your account.
 errors.forbidden.title=403 - Forbidden
 
 # Forms
@@ -18,6 +18,8 @@
 form.account.delete.i-am-sure=Yes, I am sure that I want to delete my account and all related data!
 form.account.delete.notice=If you delete your account then all related data will be permanently removed. This action CANNOT be undone!
 form.account.delete.password=Password
+form.account.validate-email.notice=You have not yet validated your email address, therefore some operations are not yet allowed. If you have not received a validation email from us, please use the button below to send the email.
+form.account.validate-email.button.submit=Send validation email
 form.create-repo.button.submit=Create repository
 form.create-repo.name=Name
 form.create-repo.name.placeholder=Please enter a repository name.
@@ -117,7 +119,7 @@
 landingpage.terms-of-use.title=Terms of Use
 
 landingpage.welcome.image.alt=A neon sign saying: Do something great!
-landingpage.welcome.ribbon.text=Welcome to the smederee! You can now use your credentials to login and start creating. Please note that some functionalities are locked until you have verified your email address.
+landingpage.welcome.ribbon.text=Welcome to the smederee! You can now use your credentials to login and start creating. Please note that some functionalities are locked until you have validated your email address.
 landingpage.welcome.ribbon.title=Thank you and welcome!
 landingpage.welcome.title=Welcome to the Smederee!
 
@@ -155,4 +157,5 @@
 
 # User management / settings
 user.settings.account.delete.title=Delete your account
+user.settings.account.validate-email.title=Validate your email address
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-02-02 03:52:49.209589321 +0000
@@ -36,6 +36,15 @@
     */
   def deleteAccount(uid: UserId): F[Int]
 
+  /** Find the account with the given validation token.
+    *
+    * @param token
+    *   A validation token.
+    * @return
+    *   An option to the account if it exists.
+    */
+  def findByValidationToken(token: ValidationToken): F[Option[Account]]
+
   /** Retrieve the password hash from the database.
     *
     * @param uid
@@ -45,4 +54,30 @@
     */
   def findPasswordHash(uid: UserId): F[Option[PasswordHash]]
 
+  /** Mark the account with the given user id as validated. This includes the following operations in the
+    * database:
+    *
+    * {{{
+    * 1. set the ´validated_email` column to `true`
+    * 2. clear the `validation_token` column
+    * }}}
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def markAsValidated(uid: UserId): F[Int]
+
+  /** Set the validation token for the account with the given user id.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @param token
+    *   The validation token to be stored in the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def setValidationToken(uid: UserId, token: ValidationToken): F[Int]
+
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-02-02 03:52:49.209589321 +0000
@@ -21,6 +21,7 @@
 import cats.data._
 import cats.effect._
 import cats.syntax.all._
+import de.smederee.email._
 import de.smederee.html.LinkTools._
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config._
@@ -39,6 +40,8 @@
   *   The repository providing all needed database functionality for account management.
   * @param configuration
   *   The hub service configuration.
+  * @param emailMiddleware
+  *   Middleware layer needed to send emails.
   * @param signAndValidate
   *   A class providing functions to handle session token signing and validation.
   * @tparam F
@@ -47,6 +50,7 @@
 final class AccountManagementRoutes[F[_]: Async](
     accountManagementRepo: AccountManagementRepository[F],
     configuration: ServiceConfig,
+    emailMiddleware: EmailMiddleware[F],
     signAndValidate: SignAndValidate
 ) extends Http4sDsl[F] {
   private val log = LoggerFactory.getLogger(getClass)
@@ -69,7 +73,10 @@
           userIsSure    <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
           rootUri       <- Sync[F].delay(configuration.external.createFullUri(uri"/"))
           deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
-          passwordHash  <- accountManagementRepo.findPasswordHash(user.uid)
+          validateAction <- Sync[F].delay(
+            configuration.external.createFullUri(uri"user/settings/email/validate")
+          )
+          passwordHash <- accountManagementRepo.findPasswordHash(user.uid)
           passwordCorrect <- Sync[F].delay(
             (passwordField, passwordHash)
               .mapN { case (enteredPassword, hashFromDatabase) =>
@@ -92,26 +99,67 @@
             } else
               BadRequest(
                 views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
-                  deleteAction
+                  deleteAction,
+                  validateAction
                 )
               )
         } yield response
       }
   }
 
+  private val sendValidationEmail: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / "user" / "settings" / "email" / "validate" as user =>
+      for {
+        csrf    <- Sync[F].delay(ar.req.getCsrfToken)
+        token   <- Sync[F].delay(ValidationToken.generate)
+        _       <- accountManagementRepo.setValidationToken(user.uid, token)
+        from    <- Sync[F].delay(FromAddress("noreply@smeder.ee"))
+        to      <- Sync[F].delay(NonEmptyList.of(user.email.toToAddress))
+        uri     <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/email/validate"))
+        subject <- Sync[F].delay(SubjectLine("Smederee - Please validate your email address."))
+        body <- Sync[F].delay(
+          TextBody(views.txt.emails.validate(user, token, uri).toString)
+        ) // TODO extension method?
+        message <- Sync[F].delay(EmailMessage(from, to, List.empty, List.empty, subject, body))
+        result  <- emailMiddleware.send(message)
+        response <- result match {
+          case Left(error) => InternalServerError(s"An error occured: $error")
+          case Right(_)    => SeeOther(Location(configuration.external.createFullUri(uri"user/settings")))
+        }
+      } yield response
+  }
+
   private val showAccountSettings: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "user" / "settings" as user =>
       for {
         csrf         <- Sync[F].delay(ar.req.getCsrfToken)
         deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+        validateAction <- Sync[F].delay(
+          configuration.external.createFullUri(uri"user/settings/email/validate")
+        )
         resp <- Ok(
           views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
-            deleteAction
+            deleteAction,
+            validateAction
           )
         )
       } yield resp
   }
 
-  val protectedRoutes = deleteAccount <+> showAccountSettings
+  private val validateEmailAddress: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / "user" / "settings" / "email" / "validate" / ValidationTokenPathParameter(
+          token
+        ) =>
+      for {
+        csrf    <- Sync[F].delay(req.getCsrfToken)
+        account <- accountManagementRepo.findByValidationToken(token)
+        _       <- account.traverse(user => accountManagementRepo.markAsValidated(user.uid))
+        resp    <- SeeOther(Location(configuration.external.createFullUri(uri"/")))
+      } yield resp
+  }
+
+  val protectedRoutes = deleteAccount <+> sendValidationEmail <+> showAccountSettings
+
+  val routes = validateEmailAddress
 
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-02-02 03:52:49.209589321 +0000
@@ -209,7 +209,8 @@
 
 opaque type ResetToken = String
 object ResetToken {
-  val Length: Int = 128
+  val Format: Regex = "^[a-zA-z0-9]+".r
+  val Length: Int   = 128
 
   given Eq[ResetToken] = Eq.fromUniversalEquals
 
@@ -230,10 +231,7 @@
     *   An option to the successfully converted ResetToken.
     */
   def from(source: String): Option[ResetToken] =
-    Option(source).map(_.size === 128) match {
-      case Some(true) => Option(source)
-      case _          => None
-    }
+    Option(source).filter(s => s.length === Length && Format.matches(s))
 
   /** Generate a new reset token.
     *
@@ -246,7 +244,8 @@
 
 opaque type UnlockToken = String
 object UnlockToken {
-  val Length: Int = 128
+  val Format: Regex = "^[a-zA-z0-9]+".r
+  val Length: Int   = 128
 
   given Eq[UnlockToken] = Eq.fromUniversalEquals
 
@@ -267,10 +266,7 @@
     *   An option to the successfully converted UnlockToken.
     */
   def from(source: String): Option[UnlockToken] =
-    Option(source).map(_.size === 128) match {
-      case Some(true) => Option(source)
-      case _          => None
-    }
+    Option(source).filter(s => s.length === Length && Format.matches(s))
 
   /** Generate a new unlock token.
     *
@@ -350,6 +346,47 @@
     }
 }
 
+opaque type ValidationToken = String
+object ValidationToken {
+  val Format: Regex = "^[a-zA-z0-9]+".r
+  val Length: Int   = 64
+
+  given Eq[ValidationToken] = Eq.fromUniversalEquals
+
+  /** Create an instance of ValidationToken from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a ValidationToken.
+    * @return
+    *   The appropriate instance of ValidationToken.
+    */
+  def apply(source: String): ValidationToken = source
+
+  /** Try to create an instance of ValidationToken from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a ValidationToken.
+    * @return
+    *   An option to the successfully converted ValidationToken.
+    */
+  def from(source: String): Option[ValidationToken] =
+    Option(source).filter(s => s.length === Length && Format.matches(s))
+
+  /** Generate a new unlock token.
+    *
+    * @return
+    *   A randomly generated unlock token.
+    */
+  def generate: ValidationToken = scala.util.Random.alphanumeric.take(Length).mkString
+
+}
+
+/** Extractor to retrieve a validation token from a path parameter.
+  */
+object ValidationTokenPathParameter {
+  def unapply(str: String): Option[ValidationToken] = ValidationToken.from(str)
+}
+
 /** A user account.
   *
   * @param uid
@@ -358,10 +395,10 @@
   *   A unique name which can be used for login and to identify the user.
   * @param email
   *   The email address of the user.
-  * @param verifiedEmail
-  *   This flag indicates if the email address of the user has been verified via a verification email.
+  * @param validatedEmail
+  *   This flag indicates if the email address of the user has been validated via a validation email.
   */
-final case class Account(uid: UserId, name: Username, email: Email, verifiedEmail: Boolean)
+final case class Account(uid: UserId, name: Username, email: Email, validatedEmail: Boolean)
 
 object Account {
   given Eq[Account] =
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-02-02 03:52:49.209589321 +0000
@@ -31,12 +31,30 @@
 
 final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F])
     extends AccountManagementRepository[F] {
-  given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString)
-  given Meta[UserId]       = Meta[UUID].timap(UserId.apply)(_.toUUID)
+  given Meta[Email]           = Meta[String].timap(Email.apply)(_.toString)
+  given Meta[PasswordHash]    = Meta[String].timap(PasswordHash.apply)(_.toString)
+  given Meta[UserId]          = Meta[UUID].timap(UserId.apply)(_.toUUID)
+  given Meta[Username]        = Meta[String].timap(Username.apply)(_.toString)
+  given Meta[ValidationToken] = Meta[String].timap(ValidationToken.apply)(_.toString)
+
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts""""
 
   override def deleteAccount(uid: UserId): F[Int] =
     sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.transact(tx)
 
+  override def findByValidationToken(token: ValidationToken): F[Option[Account]] = {
+    val query = selectAccountColumns ++ fr"""WHERE validation_token = $token""" ++ fr"""LIMIT 1"""
+    query.query[Account].option.transact(tx)
+  }
+
   override def findPasswordHash(uid: UserId): F[Option[PasswordHash]] =
     sql"""SELECT password FROM "accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx)
+
+  override def markAsValidated(uid: UserId): F[Int] =
+    sql"""UPDATE "accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run
+      .transact(tx)
+
+  override def setValidationToken(uid: UserId, token: ValidationToken): F[Int] =
+    sql"""UPDATE "accounts" SET validation_token = $token WHERE uid = $uid""".update.run.transact(tx)
+
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-02-02 03:52:49.209589321 +0000
@@ -36,7 +36,7 @@
 
   private val lockedFilter         = fr"""locked_at IS NOT NULL"""
   private val notLockedFilter      = fr"""locked_at IS NULL"""
-  private val selectAccountColumns = fr"""SELECT uid, name, email, verified_email FROM "accounts""""
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts""""
 
   override def createUserSession(session: Session): F[Int] =
     sql"""INSERT INTO "sessions" (id, uid, created_at, updated_at) VALUES (${session.id}, ${session.uid}, ${session.createdAt}, ${session.updatedAt})""".update.run
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala	2025-02-02 03:52:49.209589321 +0000
@@ -32,7 +32,7 @@
   given Meta[Username]     = Meta[String].timap(Username.apply)(_.toString)
 
   override def createAccount(account: Account, hash: PasswordHash): F[Int] =
-    sql"""INSERT INTO "accounts" (uid, name, email, password, created_at, updated_at, verified_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.verifiedEmail})""".update.run
+    sql"""INSERT INTO "accounts" (uid, name, email, password, created_at, updated_at, validated_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.validatedEmail})""".update.run
       .transact(tx)
 
   override def findEmail(address: Email): F[Option[Email]] =
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-02-02 03:52:49.209589321 +0000
@@ -108,6 +108,7 @@
       accountManagementRoutes = new AccountManagementRoutes[IO](
         accountManagementRepo,
         configuration.service,
+        emailMiddleware,
         signAndValidate
       )
       authenticationRoutes = new AuthenticationRoutes[IO](
@@ -130,11 +131,20 @@
         vcsMetadataRepo
       )
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
-        authenticationRoutes.protectedRoutes <+> accountManagementRoutes.protectedRoutes <+> signUpRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes
+        authenticationRoutes.protectedRoutes <+>
+          accountManagementRoutes.protectedRoutes <+>
+          signUpRoutes.protectedRoutes <+>
+          vcsRepoRoutes.protectedRoutes <+>
+          landingPages.protectedRoutes
       )
       globalRoutes = Router(
         Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes,
-        "/" -> (protectedRoutesWithFallThrough <+> authenticationRoutes.routes <+> signUpRoutes.routes <+> vcsRepoRoutes.routes <+> landingPages.routes)
+        "/" -> (protectedRoutesWithFallThrough <+>
+          authenticationRoutes.routes <+>
+          accountManagementRoutes.routes <+>
+          signUpRoutes.routes <+>
+          vcsRepoRoutes.routes <+>
+          landingPages.routes)
       ).orNotFound
       // Create our ssh server fiber (or a dummy one if disabled).
       sshServerProvider = configuration.service.ssh.enabled match {
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-02-02 03:52:49.209589321 +0000
@@ -120,7 +120,7 @@
                       uid = uid,
                       name = signupForm.name,
                       email = signupForm.email,
-                      verifiedEmail = false
+                      validatedEmail = false
                     )
                   )
                   hash     <- Sync[F].delay(signupForm.password.encode)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-02-02 03:52:49.209589321 +0000
@@ -702,9 +702,9 @@
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
         for {
           csrf <- Sync[F].delay(ar.req.getCsrfToken)
-          _ <- Sync[F].raiseUnless(user.verifiedEmail)(
+          _ <- Sync[F].raiseUnless(user.validatedEmail)(
             new Error(
-              "An unverified account is not allowed to create a repository!"
+              "An unvalidated account is not allowed to create a repository!"
             ) // FIXME Proper error handling!
           )
           formData <- Sync[F].delay {
@@ -811,10 +811,10 @@
     case ar @ GET -> Root / "repo" / "create" as user =>
       for {
         csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- user.verifiedEmail match {
+        resp <- user.validatedEmail match {
           case false =>
             Forbidden(
-              views.html.errors.unverifiedAccount()(csrf, "Smederee - Account not verified!".some, user)
+              views.html.errors.unvalidatedAccount()(csrf, "Smederee - Account not validated!".some, user)
             )
           case true =>
             Ok(
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-02-02 03:52:49.209589321 +0000
@@ -1,8 +1,27 @@
-@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri)
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri, validateAction: Uri)
 @main(baseUri, lang)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
 <div class="content">
   <div class="pure-g">
+    @if(user.validatedEmail) {
+    } else {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="validate-email-form">
+          <h4>@Messages("user.settings.account.validate-email.title")</h4>
+          <form action="@validateAction" class="pure-form" method="POST" accept-charset="UTF-8">
+            <fieldset class="pure-group">
+              <p class="alert alert-info">
+                @Messages("form.account.validate-email.notice")
+              </p>
+              @csrfToken(csrf)
+              <button type="submit" class="button-success pure-button">@Messages("form.account.validate-email.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    }
     <div class="pure-u-1-1 pure-u-md-1-1">
       <div class="l-box">
         <div class="account-delete-form">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt	2025-02-02 03:52:49.209589321 +0000
@@ -0,0 +1,18 @@
+@(user: Account, validationToken: ValidationToken, validationBaseUri: Uri)
+Hello @{user.name},
+
+you have registered an account at the Smederee (https://smeder.ee) and to 
+have fully operational access we have to validate your email address.
+
+Please click on the following link to finish the validation process:
+
+@validationBaseUri.addSegment(validationToken.toString)
+
+With kind regards,
+
+the crew of the Smederee.
+
+-- 
+Smederee - Craft great software!
+
+https://smeder.ee
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html	2025-02-02 03:52:49.209589321 +0000
@@ -0,0 +1,18 @@
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+  <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box">
+          <p class="alert alert-error">
+            <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+            <span class="sr-only">@Messages("global.error"):</span>
+            @Messages("errors.account.not-validated")
+          </p>
+        </div>
+      </div>
+    </div>
+  </div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html	1970-01-01 00:00:00.000000000 +0000
@@ -1,18 +0,0 @@
-@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)
-@main(baseUri, lang)()(csrf, title, user.some) {
-@defining(lang.toLocale) { implicit locale =>
-  <div class="content">
-    <div class="pure-g">
-      <div class="pure-u-1-1 pure-u-md-1-1">
-        <div class="l-box">
-          <p class="alert alert-error">
-            <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-            <span class="sr-only">@Messages("global.error"):</span>
-            @Messages("errors.account.not-verified")
-          </p>
-        </div>
-      </div>
-    </div>
-  </div>
-}
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-02-02 03:52:49.205589316 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-02-02 03:52:49.209589321 +0000
@@ -96,11 +96,11 @@
   } yield Username(prefix.toString + chars)
 
   val genValidAccount: Gen[Account] = for {
-    id            <- genUserId
-    email         <- genValidEmail
-    name          <- genValidUsername
-    verifiedEmail <- Gen.oneOf(List(false, true))
-  } yield Account(uid = id, name = name, email = email, verifiedEmail = verifiedEmail)
+    id             <- genUserId
+    email          <- genValidEmail
+    name           <- genValidUsername
+    validatedEmail <- Gen.oneOf(List(false, true))
+  } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail)
 
   given Arbitrary[Account] = Arbitrary(genValidAccount)