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.
NotificationProperties no es @Validated (a diferencia de otros módulos)La sección 1.10 del spec pide fallar rápido en propiedades obligatorias — core-auth, core-authz y
core-storage lo hacen con @Validated + @NotBlank. Acá no: CoreNotificationsAutoConfiguration no
tiene @ConditionalOnProperty a nivel de clase, así que NotificationProperties se enlaza siempre
que core-notifications esté en el classpath, la uses o no. Marcar email.from obligatorio ahí rompería
justamente el diseño de arriba (arrancar sin SMTP configurado). El error por from sin configurar sigue
apareciendo, pero recién al intentar enviar un email, no al arrancar.
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.