Saltar al contenido principal
Versión: v0.1.2

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