diff --git a/README.md b/README.md index 307c067..118862c 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ $env:ADMIN_PASSWORD = "secret" $env:DB_URL = "jdbc:postgresql://localhost:5432/linux_distro_roguelite_promotion" $env:DB_USERNAME = "postgres" $env:DB_PASSWORD = "postgres" +$env:TOKEN_SECRET = "" +$env:SMTP_HOST = "mail.fretux.ch" +$env:SMTP_PORT = "587" +$env:SMTP_SECURE = "0" +$env:SMTP_USER = "no-reply@fretux.ch" +$env:SMTP_PASS = "" +$env:MAIL_FROM = "Fretux " +$env:BASE_URL = "localhost:3000" mvn quarkus:dev ``` @@ -67,6 +75,23 @@ Die PostgreSQL-Verbindung wird ueber Umgebungsvariablen konfiguriert: - `DB_USERNAME`, Standard: `postgres` - `DB_PASSWORD`, Standard: `postgres` +## Mailversand + +`POST /users` verschickt nach erfolgreicher Registrierung eine Bestaetigungsmail. Die E-Mail-Adresse ist Pflicht und kann wegen des eindeutigen Datenbank-Constraints nur einmal verwendet werden. Wenn der Mailversand fehlschlaegt, wird der neu angelegte Nutzer wieder entfernt und die API antwortet mit `502`. + +Der SMTP-Zugang wird ueber Umgebungsvariablen konfiguriert: + +- `TOKEN_SECRET`: Secret fuer den signierten Registrierungslink +- `SMTP_HOST`, Standard: `mail.fretux.ch` +- `SMTP_PORT`, Standard: `587` +- `SMTP_SECURE`, Standard: `false`; fuer Port 587 kann `0` verwendet werden +- `SMTP_USER`, Standard: `no-reply@fretux.ch` +- `SMTP_PASS`: SMTP-Passwort +- `MAIL_FROM`, Standard: `Fretux ` +- `BASE_URL`, Standard: `http://localhost:3000` + +In Tests ist `quarkus.mailer.mock=true` gesetzt, damit keine echte E-Mail verschickt wird. + Das Schema entspricht dem Klassendiagramm: - `users`: `id`, `username`, `email`, `newsletter_optin`, `created_at` diff --git a/pom.xml b/pom.xml index f5ac8c8..bc64955 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,10 @@ io.quarkus quarkus-smallrye-openapi + + io.quarkus + quarkus-mailer + io.quarkus quarkus-junit diff --git a/src/main/java/dev/frederik/promotion/PromotionResource.java b/src/main/java/dev/frederik/promotion/PromotionResource.java index d3736fb..457f922 100644 --- a/src/main/java/dev/frederik/promotion/PromotionResource.java +++ b/src/main/java/dev/frederik/promotion/PromotionResource.java @@ -24,10 +24,12 @@ import java.util.Map; @Produces(MediaType.APPLICATION_JSON) public class PromotionResource { private final DatabaseService database; + private final RegistrationEmailService registrationEmailService; @Inject - public PromotionResource(DatabaseService database) { + public PromotionResource(DatabaseService database, RegistrationEmailService registrationEmailService) { this.database = database; + this.registrationEmailService = registrationEmailService; } @GET @@ -52,6 +54,14 @@ public class PromotionResource { } catch (RuntimeException exception) { throw mapConflict(exception, "username or email already exists."); } + + try { + registrationEmailService.sendRegistrationMail(id, username, email); + } catch (RuntimeException exception) { + database.execute("DELETE FROM users WHERE id = ?", id); + throw new ApiException(502, "Registration email could not be sent. Please try again later."); + } + return Response.status(Response.Status.CREATED).entity(userById(id)).build(); } diff --git a/src/main/resources/META-INF/resources/register.js b/src/main/resources/META-INF/resources/register.js index 2ca4e93..b0737ba 100644 --- a/src/main/resources/META-INF/resources/register.js +++ b/src/main/resources/META-INF/resources/register.js @@ -95,7 +95,7 @@ registerForm.addEventListener("submit", async (event) => { ); registerForm.reset(); - setMessage("Pre-registration complete. Use Auth to unlock private game intel."); + setMessage("Pre-registration complete. Check your inbox, then use Auth to unlock private game intel."); await refreshStats(); } catch (error) { setMessage(error.message, true); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3fbe28c..89358f8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,6 +7,16 @@ quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/linux_dist quarkus.datasource.jdbc.max-size=8 quarkus.log.category."dev.frederik.promotion".level=INFO +quarkus.mailer.host=${SMTP_HOST:mail.fretux.ch} +quarkus.mailer.port=${SMTP_PORT:587} +quarkus.mailer.username=${SMTP_USER:no-reply@fretux.ch} +quarkus.mailer.password=${SMTP_PASS:} +quarkus.mailer.from=${MAIL_FROM:Fretux } +quarkus.mailer.tls=${SMTP_SECURE:false} +quarkus.mailer.start-tls=REQUIRED +registration.base-url=${BASE_URL:http://localhost:3000} +registration.token-secret=${TOKEN_SECRET:} + auth.admin.username=${ADMIN_USERNAME:admin} auth.admin.password=${ADMIN_PASSWORD:admin} diff --git a/src/test/java/dev/frederik/promotion/PromotionIntegrationTest.java b/src/test/java/dev/frederik/promotion/PromotionIntegrationTest.java index 6dc089e..5777aef 100644 --- a/src/test/java/dev/frederik/promotion/PromotionIntegrationTest.java +++ b/src/test/java/dev/frederik/promotion/PromotionIntegrationTest.java @@ -1,7 +1,10 @@ package dev.frederik.promotion; +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.MockMailbox; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import javax.sql.DataSource; @@ -21,6 +24,14 @@ class PromotionIntegrationTest { @Inject DataSource dataSource; + @Inject + MockMailbox mailbox; + + @BeforeEach + void clearMailbox() { + mailbox.clear(); + } + @Test void platformsEndpointReturnsCurrentLaunchTargetsFromIsolatedDatabase() throws Exception { // Given: the Quarkus app started with the isolated test database and seeded launch platforms. @@ -67,6 +78,48 @@ class PromotionIntegrationTest { Map row = findUserInDatabase(username); assertEquals(email, row.get("email")); assertEquals(Boolean.TRUE, row.get("newsletter_optin")); + + List sent = mailbox.getMailsSentTo(email); + assertEquals(1, sent.size()); + assertEquals("Kernel Panic pre-registration", sent.get(0).getSubject()); + assertTrue(sent.get(0).getText().contains("http://localhost:3000/auth.html?registration_token=")); + } + + @Test + void creatingSecondUserWithSameEmailIsRejected() throws Exception { + // Given: an email address already belongs to a pre-registered user. + String suffix = Long.toString(System.nanoTime()); + String email = "unique_" + suffix + "@example.com"; + Map firstPayload = Map.of( + "username", "first_" + suffix, + "email", email, + "newsletter_optin", false); + + given() + .contentType("application/json") + .body(firstPayload) + .when() + .post("/users") + .then() + .statusCode(201); + + // When: another username tries to reuse the same email address. + Map secondPayload = Map.of( + "username", "second_" + suffix, + "email", email, + "newsletter_optin", false); + + given() + .contentType("application/json") + .body(secondPayload) + .when() + .post("/users") + .then() + .statusCode(409); + + // Then: the email address is still stored only once and no second mail is sent. + assertEquals(1, countUsersByEmail(email)); + assertEquals(1, mailbox.getMailsSentTo(email).size()); } private int countCurrentPlatformsInDatabase() throws Exception { @@ -98,4 +151,19 @@ class PromotionIntegrationTest { } } } + + private int countUsersByEmail(String email) throws Exception { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT COUNT(*) + FROM users + WHERE email = ? + """)) { + statement.setString(1, email); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getInt(1); + } + } + } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index d550119..e28c5c8 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -4,6 +4,11 @@ quarkus.datasource.password=sa quarkus.datasource.jdbc.url=jdbc:h2:mem:kernel_panic_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1 quarkus.datasource.jdbc.max-size=4 +quarkus.mailer.mock=true +quarkus.mailer.from=Fretux +registration.base-url=http://localhost:3000 +registration.token-secret=test-token-secret + auth.admin.username=admin auth.admin.password=secret