Saltar al contenido principal
Versión: main (sin publicar)

core-notifications

Qué problema resuelve

Mandar un email desde una aplicación Spring es fácil de hacer mal: el TemplateEngine de Thymeleaf que autoconfigura spring-boot-starter-thymeleaf resuelve contra classpath:/templates/, que es exactamente donde un proyecto pone sus vistas web — así que las plantillas de email y las de UI terminan compartiendo namespace y pisándose.

core-notifications da una abstracción mínima (NotificationSender por canal, NotificationService que despacha) con una implementación de email que trae su propio TemplateEngine apuntado a classpath:/notifications/templates/. Las plantillas se versionan junto al código, nunca en base de datos.

Es el módulo más chico e independiente del core: no depende de ningún otro módulo core-*.

Cómo se agrega

build.gradle.kts
dependencies {
implementation("dev.echotechs.core:core-notifications:0.1.0-SNAPSHOT")
}

Arrastra spring-boot-starter-mail y thymeleaf-spring6 (no existe thymeleaf-spring7; el artefacto de Spring 6 funciona bien forzado sobre el BOM de Spring 7, y es el que el propio starter de Boot 4.1 usa).

No tiene tablas — no hay changelog que incluir.

Configuración

application.yml
spring:
mail:
host: smtp.echotechs.net
port: 587
username: ${SMTP_USER}
password: ${SMTP_PASSWORD}

echotechs:
notifications:
email:
from: no-reply@echotechs.net
PropiedadDefaultNotas
echotechs.notifications.email.fromEl From: de todo email saliente. Sin default sensato
spring.mail.*Estándar de Spring Boot. Activa el sender de email

EmailNotificationSender sólo se registra si existe un bean JavaMailSender, o sea una vez que configuraste spring.mail.host. Un proyecto sin SMTP configurado arranca igual, simplemente sin sender de email.

API pública

NotificationService

public class NotificationService {
public void send(NotificationMessage message);
}

Despacha al NotificationSender que maneje el canal del mensaje. Si no hay ninguno registrado para ese canal, tira IllegalStateException con un mensaje explícito.

NotificationMessage

/**
* @param recipient dirección específica del canal — un email para NotificationChannel.EMAIL.
* @param subject ignorado por canales que no tienen (push); obligatorio para email.
* @param templateName se resuelve contra classpath:notifications/templates/<templateName>.html
* @param templateModel variables con las que se renderiza la plantilla.
*/
public record NotificationMessage(
NotificationChannel channel,
String recipient,
String subject,
String templateName,
Map<String, Object> templateModel
) {}

El constructor compacto normaliza templateModel a Map.of() cuando llega null, y hace Map.copyOf, así que el mapa queda inmutable.

Notá que templateName no lleva la extensión: "welcome" resuelve a classpath:notifications/templates/welcome.html.

NotificationChannel

public enum NotificationChannel {
EMAIL,
/** Reservado para más adelante — este módulo no trae ningún NotificationSender para PUSH. */
PUSH
}

NotificationSender

public interface NotificationSender {
NotificationChannel channel();
void send(NotificationMessage message);
}

Una implementación por canal. NotificationService las recoge por inyección de List<NotificationSender>, así que basta con registrar un bean para que se enganche.

EmailNotificationSender

public class EmailNotificationSender implements NotificationSender {
public EmailNotificationSender(JavaMailSender mailSender, ITemplateEngine templateEngine,
NotificationProperties properties);
}

Renderiza la plantilla con Thymeleaf y manda un MimeMessage HTML (helper.setText(html, true)) en UTF-8. Un fallo al armar el mensaje se envuelve en MailSendException.

Ejemplo de uso

La plantilla

src/main/resources/notifications/templates/purchase-request-approved.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<p>Hola <span th:text="${name}">Nombre</span>,</p>
<p>Tu solicitud <strong th:text="${requestTitle}">título</strong> fue aprobada.</p>
<p th:text="${orgName}">organización</p>
</body>
</html>

Enviar

Adaptado de EmailNotificationSenderIntegrationTest:

@Service
public class PurchaseRequestNotifier {

private final NotificationService notificationService;

public PurchaseRequestNotifier(NotificationService notificationService) {
this.notificationService = notificationService;
}

public void notifyApproved(String email, String name, String requestTitle) {
notificationService.send(new NotificationMessage(
NotificationChannel.EMAIL,
email,
"Tu solicitud fue aprobada",
"purchase-request-approved",
Map.of("name", name, "requestTitle", requestTitle, "orgName", "EchoTechs")));
}
}

Agregar un canal propio

@Component
public class FirebasePushSender implements NotificationSender {

@Override
public NotificationChannel channel() {
return NotificationChannel.PUSH;
}

@Override
public void send(NotificationMessage message) {
// message.recipient() es el device token en este canal
}
}

NotificationService lo toma automáticamente; no hace falta registrarlo en ningún lado más.

Test con un SMTP real embebido

De EmailNotificationSenderIntegrationTest, que usa GreenMail (servidor SMTP real, en la misma JVM) y lee de vuelta lo que efectivamente llegó:

@SpringBootTest(classes = TestApplication.class)
class EmailNotificationSenderIntegrationTest {

@RegisterExtension
static final GreenMailExtension GREEN_MAIL = new GreenMailExtension(ServerSetupTest.SMTP);

@DynamicPropertySource
static void mailProperties(DynamicPropertyRegistry registry) {
registry.add("spring.mail.host", () -> "localhost");
registry.add("spring.mail.port", () -> ServerSetupTest.SMTP.getPort());
registry.add("echotechs.notifications.email.from", () -> "no-reply@echotechs.dev");
}

@Autowired
private NotificationService notificationService;

@Test
void sendingAnEmailNotificationActuallyDeliversARenderedMessage() throws Exception {
notificationService.send(new NotificationMessage(
NotificationChannel.EMAIL,
"someone@example.com",
"Bienvenido",
"test-welcome",
Map.of("name", "Ada", "orgName", "EchoTechs")));

MimeMessage[] received = GREEN_MAIL.getReceivedMessages();
assertThat(received).hasSize(1);

MimeMessage message = received[0];
assertThat(message.getSubject()).isEqualTo("Bienvenido");
assertThat(message.getAllRecipients()[0].toString()).isEqualTo("someone@example.com");
assertThat(message.getFrom()[0].toString()).contains("no-reply@echotechs.dev");

String body = (String) message.getContent();
assertThat(body).contains("Hola").contains("Ada").contains("EchoTechs");
}
}

Errores comunes

IllegalStateException: No NotificationSender registered for channel PUSH. El módulo no trae sender de push. Registrá un bean propio con channel() == PUSH. Es un error explícito y verificado por test, no un fallo silencioso.

No se manda nada y no hay ningún bean EmailNotificationSender. Está condicionado a que exista un JavaMailSender, que a su vez requiere spring.mail.host. Sin eso, NotificationService existe pero no tiene sender para EMAIL — y tirará el IllegalStateException de arriba.

TemplateInputException: Error resolving template. La plantilla va en src/main/resources/notifications/templates/<nombre>.html, y templateName se pasa sin la extensión .html.

El HTML llega como texto plano, o las variables no se sustituyen. Falta el namespace de Thymeleaf: <html xmlns:th="http://www.thymeleaf.org">.

Los cambios en la plantilla no se ven en desarrollo. El resolver tiene setCacheable(true) fijo, sin propiedad para desactivarlo. Hay que reiniciar la aplicación para ver una plantilla modificada.

MailSendException: Failed to prepare email. Se lanza al armar el MimeMessage, antes de contactar el servidor SMTP. Lo más común es que echotechs.notifications.email.from esté sin configurar (queda null) o que el recipient no sea una dirección válida.

El envío bloquea la request. send() es síncrono: hace el envío SMTP en el hilo del llamador. No hay cola, ni reintento, ni @Async. Para un flujo sensible a latencia, envolvelo vos.

Notas de implementación

Diferencias entre platform-core-spec.md y lo que hace el código:

PUSH está declarado pero no implementado. El spec 1.6 pide "implementación de email... y espacio para push más adelante". El enum tiene la constante PUSH, pero no existe ningún NotificationSender para ese canal en este módulo — usarlo tira IllegalStateException en runtime, no un error de compilación.

No hay reintentos, cola, ni registro de envíos. El spec no los pide, pero conviene ser explícito: el módulo no tiene tabla propia, así que no queda constancia de qué se mandó. Un fallo de SMTP se propaga como excepción al llamador y el mensaje se pierde.

El envío es síncrono y no transaccional. Si lo llamás desde un @EventListener de WorkflowTransitionedEvent dentro de una transacción que después hace rollback, el email ya salió. Para ese caso usá @TransactionalEventListener.