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
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
spring:
mail:
host: smtp.echotechs.net
port: 587
username: ${SMTP_USER}
password: ${SMTP_PASSWORD}
echotechs:
notifications:
email:
from: no-reply@echotechs.net
| Propiedad | Default | Notas |
|---|---|---|
echotechs.notifications.email.from | — | El 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
<!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.