Saltar al contenido principal
Versión: v0.1.5

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.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.

Por qué 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ío

Best-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​

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")));
}
}

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.