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
- When an admin invites someone to a team, the invitee gets an email with the accept link `{baseUrl}/invitations/{token}` and the team's name. Invitation creation itself (`InvitationService.invite`, its controller, the admin check) already exists; its validation and persistence stay as they are.
- An email goes out only for an invitation that was actually saved. Whenever creating the invitation fails, including a duplicate pending invite rejected by the database, nobody gets an email.
- A mail failure never undoes or fails the invitation: it is logged, the invitation stays pending, and the admin can resend it from the members page (that button already exists).
- Keep invitation logic and mail sending decoupled, so the upcoming Slack notification can react to the same event. Sending in the request thread is fine for now: admins invite a few people at a time.
Files touched
- src/main/java/com/acme/teams/invite/InvitationService.java
- src/main/java/com/acme/teams/invite/InvitationCreated.java
- src/main/java/com/acme/teams/invite/InvitationMailer.java
--- 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;
}
}