Email team invitations when they are created

Java · Spring Boot · advanced · modification

Invite emails, decoupled from the invite logic: `InvitationService` publishes an `InvitationCreated` event and `InvitationMailer` turns it into an email, so the Slack notifier can subscribe to the same event next sprint without touching the service. Mail errors are caught and logged in the listener, so an SMTP outage does not break inviting; the admin just resends from the members page. Tested locally against Mailpit: invited two addresses, got two emails with working accept links.

Team-collaboration SaaS on Spring Boot 3.3 (Java 21, Gradle with the Spring Boot plugin), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16, `spring-boot-starter-mail` with a configured `MailSender` (SMTP). Existing `Invitation` entity: `Long id` from a database sequence (`@GeneratedValue(strategy = GenerationType.SEQUENCE)`), `Long teamId`, `String email`, `String token`, `Long invitedBy`, with getters. A partial unique index on `(team_id, email)` for pending invitations rejects a second pending invite for the same address; the existing `@ExceptionHandler(DataIntegrityViolationException.class)` turns it into 409 "already invited", and admins do double-submit the invite form. Tokens are 32 random bytes, URL-safe, from the existing `InviteTokens`; the accept endpoint rejects tokens older than 7 days. Team names are validated on write (1 to 80 printable characters, no line breaks). `AppProperties.baseUrl()` is the public app URL; invitations go out from the product's fixed no-reply address. The `MailSender` has 5-second connect, read and write timeouts configured (`spring.mail.properties.mail.smtp.connectiontimeout` / `timeout` / `writetimeout`). The invite controller checks that the caller is a team admin. No `@EnableAsync` and no custom `ApplicationEventMulticaster`.

Requirements

Files touched

--- src/main/java/com/acme/teams/invite/InvitationService.java
 import java.util.Locale;
 
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.http.HttpStatus;
 import org.springframework.stereotype.Service;
     private final InvitationRepository invitations;
     private final InviteTokens tokens;
+    private final ApplicationEventPublisher events;
 
-    public InvitationService(TeamRepository teams, InvitationRepository invitations, InviteTokens tokens) {
+    public InvitationService(TeamRepository teams, InvitationRepository invitations, InviteTokens tokens,
+            ApplicationEventPublisher events) {
         this.teams = teams;
         this.invitations = invitations;
         this.tokens = tokens;
+        this.events = events;
     }
 
                 .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
         String normalized = email.trim().toLowerCase(Locale.ROOT);
-        return invitations.save(new Invitation(team.getId(), normalized, tokens.newToken(), invitedBy));
+        Invitation invitation = invitations.save(
+                new Invitation(team.getId(), normalized, tokens.newToken(), invitedBy));
+        events.publishEvent(new InvitationCreated(
+                invitation.getId(), team.getName(), invitation.getEmail(), invitation.getToken()));
+        return invitation;
     }
 }

Review this PR

Java practice