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 {
/** Síncrono: despacha y propaga cualquier fallo al llamador. */
public void send(NotificationMessage message);
/** Best-effort: entrega después del commit de la transacción actual (o enseguida si no hay tx). */
public void sendAfterCommit(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.
sendAfterCommit desacopla la entrega de la notificación del resultado de la transacción de negocio:
cuando hay una transacción activa, registra una TransactionSynchronization y el envío se ejecuta recién
en afterCommit() — después de que la transacción confirmó, así que un fallo de SMTP nunca la revierte.
Si no hay transacción activa, envía enseguida en el hilo del llamador. La entrega es best-effort: un fallo
(Outage de SMTP, canal sin sender, …) se loguea y nunca se propaga al llamador — una vez commiteado es
demasiado tarde para rollback, y un side-effect que falla post-commit no debe mostrarle un 500 a un cliente
cuya request ya tuvo éxito. Usá send cuando quieras entrega síncrona con los errores propagados.
sendAfterCommit no es un outbox — no sobrevive a un crash entre commit y envíoBest-effort post-commit significa "si el envío falla después del commit, lo logueo y sigo". No hay tabla ni
reintento: si el proceso muere entre el commit y el afterCommit, o el SMTP estaba caído justo en ese
instante, la notificación se pierde (queda en el log). Para entrega durable y reintentable — que aguante
crashes y outages — hace falta un patrón outbox (fila en la misma tx, un relayer/poller la entrega aparte).
sendAfterCommit cierra el gap del rollback-acoplado; el outbox es un paso más grande, distinto, que este
modo no reemplaza.
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.
* @param attachments archivos binarios a adjuntar (p.ej. un PDF generado); los canales que no los
* soportan los ignoran. Vacío por defecto.
*/
public record NotificationMessage(
NotificationChannel channel,
String recipient,
String subject,
String templateName,
Map<String, Object> templateModel,
List<NotificationAttachment> attachments
) {
// Constructor compacto: normaliza templateModel y attachments a listas/mapas inmutables no nulos.
/** Conveniencia para el caso común: un mensaje sin adjuntos. */
public 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; lo
mismo con attachments (List.of() / List.copyOf). El constructor de 5 argumentos delega con
List.of() — es el que usás cuando no hay adjuntos, y mantiene compatibilidad con código existente.
Notá que templateName no lleva la extensión: "welcome" resuelve a
classpath:notifications/templates/welcome.html.
NotificationAttachment
public record NotificationAttachment(String fileName, String contentType, byte[] content) {}
Un archivo para adjuntar al mensaje — p.ej. una cotización en PDF. fileName y contentType son
obligatorios (no blank/null) y content no puede ser null. Los canales que no soportan adjuntos (push,
cuando exista) ignoran la lista entera.
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.
Cuando el mensaje trae adjuntos, arma un multipart y los agrega con MimeMessageHelper.addAttachment
(ByteArrayDataSource); sin adjuntos queda un simple part text/html (y getContent() sigue siendo un
String). 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")));
}
}
Enviar después del commit (desacoplado de la transacción)
El caso típico: un método de negocio @Transactional que confirma una reserva y manda el email. Con
send() síncrono, si el SMTP tira excepción dentro de la transacción, todo el negocio rollbaquea — la
reserva nunca avanza aunque la regla de negocio ya pasó. sendAfterCommit() resuelve eso: el envío se
dispara recién después del commit, y si falla se loguea y se traga (la operación de negocio ya confirmó):
@Service
public class ReservationService {
private final ReservationRepository repository;
private final NotificationService notificationService;
@Transactional
public void confirm(UUID reservationId) {
Reservation reservation = repository.findById(reservationId).orElseThrow();
reservation.confirm(); // transición DRAFT → CONFIRMED
// Si el SMTP falla acá, la transacción ya commitió — la reserva queda confirmada,
// y el fallo de entrega queda en el log en vez de devolver un 500 al cliente.
notificationService.sendAfterCommit(new NotificationMessage(
NotificationChannel.EMAIL,
reservation.getGuestEmail(),
"Reserva confirmada",
"reservation-confirmed",
Map.of("name", reservation.getGuestName(), "orgName", "EchoTechs")));
}
}
No hace falta armar tu propio AfterCommitNotifier con TransactionSynchronization — eso es justamente
lo que sendAfterCommit ya hace adentro de la librería. Cuando no hay transacción activa (p.ej. un
controller sin @Transactional), sendAfterCommit envía enseguida y sigue siendo best-effort.
Enviar con un adjunto
Cuando el mensaje lleva un archivo (p.ej. una cotización en PDF que generaste con
core-reporting), pasalo como NotificationAttachment:
public void sendQuote(String email, String clientName, byte[] quotePdf) {
notificationService.send(new NotificationMessage(
NotificationChannel.EMAIL,
email,
"Tu cotización",
"event-quote",
Map.of("name", clientName, "orgName", "EchoTechs"),
List.of(new NotificationAttachment("cotizacion.pdf", "application/pdf", quotePdf))));
}
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.
sendAfterCommit() también envía en el hilo del llamador (en el callback de afterCommit), así que la
request no vuelve hasta que el SMTP responde — pero al menos ya no revierte la transacción de negocio. 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ó. Con send() un fallo de SMTP se
propaga como excepción al llamador y el mensaje se pierde; con sendAfterCommit() el fallo se loguea y se
traga (la operación de negocio ya commitó), pero igual se pierde — no hay outbox que reintente.
sendAfterCommit desacopla del rollback, no hace entrega durable. Desde 0.1.5 NotificationService
ofrece sendAfterCommit(NotificationMessage), que registra una TransactionSynchronization y envía en
afterCommit() cuando hay una transacción activa (o enseguida si no la hay), tragando+logueando fallos.
Resuelve el gap reportado — un envío de SMTP dentro de un método @Transactional que revierte la
operación de negocio — pero no es un outbox: no hay fila en la misma transacción ni relayer que
reintente, así que un crash o un outage entre el commit y el envío pierde la notificación (sólo queda en el
log). Un patrón outbox propiamente dicho es un paso más grande, distinto, que este modo no reemplaza.
El envío es síncrono y no transaccional. Si llamás a send() desde un @EventListener de
WorkflowTransitionedEvent dentro de una transacción que después hace rollback, el email ya salió. Para
ese caso usá @TransactionalEventListener — o sendAfterCommit(), que para el caso de "enviar sóla si el
negocio confirmó" es más directo.