diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLog.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLog.java new file mode 100644 index 0000000..12b1db3 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLog.java @@ -0,0 +1,161 @@ +package cloud.nstart.terravault.ordinis.domain.notification; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Per-recipient notification delivery audit row. + * + *
UNIQUE {@code (event_id, channel, recipient)} — primary idempotency guard + * (см. migration 0021 + design doc D2). Insert pattern в + * {@code IdempotencyGuard.shouldSend()}: + * + *
{@code
+ * INSERT INTO notification_log (event_id, channel, recipient, status, sent_at)
+ * VALUES (?, ?, ?, 'PENDING', NOW())
+ * ON CONFLICT (event_id, channel, recipient) DO NOTHING
+ * RETURNING id;
+ * }
+ *
+ * Если RETURNING пустой — другой consumer уже claim'ил отправку (или sweep + * job не освободил). Else — мы owner, dispatch + UPDATE status final. + * + *
Status enum UPPERCASE (Hibernate {@code @Enumerated(EnumType.STRING)}
+ * lesson из v1.1.0 P3 — migration 0020 fix). DB CHECK constraint matches
+ * Java enum names exactly.
+ */
+@Entity
+@Table(
+ name = "notification_log",
+ uniqueConstraints = {
+ // Mirrors notification_log_idempotency UNIQUE constraint в migration 0021.
+ @UniqueConstraint(
+ name = "notification_log_idempotency",
+ columnNames = {"event_id", "channel", "recipient"}
+ )
+ },
+ indexes = {
+ @Index(name = "idx_notif_log_draft", columnList = "draft_id"),
+ @Index(name = "idx_notif_log_recipient_time", columnList = "recipient,sent_at DESC"),
+ @Index(name = "idx_notif_log_event", columnList = "event_id")
+ }
+)
+public class NotificationLog {
+
+ /** Lifecycle status. UPPERCASE — matches CHECK constraint в 0021. */
+ public enum Status {
+ /** Inserted but not yet dispatched (или crashed mid-dispatch). Pending sweep job
+ * cleans up stuck rows старше {@code notifications.pendingSweep.olderThanMs}. */
+ PENDING,
+ /** Successfully sent через channel. Idempotency lock — не replay'ится. */
+ SENT,
+ /** Channel returned non-retryable error (auth, malformed, etc). */
+ FAILED,
+ /** Rate limiter denied — recipient hit per-hour cap. */
+ RATE_LIMITED,
+ /** User opted out этого channel в user_notification_prefs. */
+ SKIPPED_PREF,
+ /** Event старше maxEventAgeHours — backlog burst guard fired. */
+ SKIPPED_TOO_OLD,
+ /** One-click unsubscribe — токен HMAC validated, prefs.enabled=false. */
+ UNSUBSCRIBED
+ }
+
+ /** Wire format channel — lowercase strings ('email', 'express', 'telegram')
+ * matches CHECK constraint и Helm config keys. NOT EnumType.STRING на channel
+ * потому что хотим lowercase (брендовые токены), не Java UPPERCASE conv. */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "event_id", nullable = false)
+ private UUID eventId;
+
+ @Column(name = "event_type", nullable = false, length = 64)
+ private String eventType;
+
+ /** Nullable: некоторые events без draft контекста (future expansion). */
+ @Column(name = "draft_id")
+ private UUID draftId;
+
+ /** Lower-case wire format. См. {@code ChannelKind.wireName()} в notifications module. */
+ @Column(name = "channel", nullable = false, length = 16)
+ private String channel;
+
+ /** Email / express chat_id (UUID string) / telegram chat_id (numeric string). */
+ @Column(name = "recipient", nullable = false, length = 256)
+ private String recipient;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private Status status;
+
+ @Column(name = "sent_at", nullable = false)
+ private OffsetDateTime sentAt;
+
+ @Column(name = "error_msg", columnDefinition = "TEXT")
+ private String errorMsg;
+
+ protected NotificationLog() {}
+
+ /** Constructs row in PENDING state — typical entry point для IdempotencyGuard. */
+ public NotificationLog(UUID eventId, String eventType, UUID draftId,
+ String channel, String recipient) {
+ this.eventId = eventId;
+ this.eventType = eventType;
+ this.draftId = draftId;
+ this.channel = channel;
+ this.recipient = recipient;
+ this.status = Status.PENDING;
+ this.sentAt = OffsetDateTime.now();
+ }
+
+ public void markSent() {
+ this.status = Status.SENT;
+ this.errorMsg = null;
+ }
+
+ public void markFailed(String error) {
+ this.status = Status.FAILED;
+ this.errorMsg = error;
+ }
+
+ public void markRateLimited() {
+ this.status = Status.RATE_LIMITED;
+ }
+
+ public void markSkippedPref() {
+ this.status = Status.SKIPPED_PREF;
+ }
+
+ public void markSkippedTooOld() {
+ this.status = Status.SKIPPED_TOO_OLD;
+ }
+
+ public void markUnsubscribed() {
+ this.status = Status.UNSUBSCRIBED;
+ }
+
+ // Getters (no setters — entity мутирует через named transitions)
+ public Long getId() { return id; }
+ public UUID getEventId() { return eventId; }
+ public String getEventType() { return eventType; }
+ public UUID getDraftId() { return draftId; }
+ public String getChannel() { return channel; }
+ public String getRecipient() { return recipient; }
+ public Status getStatus() { return status; }
+ public OffsetDateTime getSentAt() { return sentAt; }
+ public String getErrorMsg() { return errorMsg; }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLogRepository.java
new file mode 100644
index 0000000..06e713a
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/NotificationLogRepository.java
@@ -0,0 +1,83 @@
+package cloud.nstart.terravault.ordinis.domain.notification;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.OffsetDateTime;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface NotificationLogRepository extends JpaRepository Returns inserted row's id если claim succeeded, или empty если конфликт
+ * (другой consumer уже claim'ил, или sweep job не освободил stuck row).
+ *
+ * Используется в {@code IdempotencyGuard.shouldSend()}.
+ */
+ @Modifying
+ @Query(value = """
+ INSERT INTO notification_log (event_id, event_type, draft_id, channel, recipient, status, sent_at)
+ VALUES (:eventId, :eventType, :draftId, :channel, :recipient, 'PENDING', NOW())
+ ON CONFLICT (event_id, channel, recipient) DO NOTHING
+ RETURNING id
+ """, nativeQuery = true)
+ Optional Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для
+ * новых rows; bootstrap loadData может включить email для existing reviewer/maker
+ * ролей при первом deploy.
+ *
+ * Channel — lowercase wire format ('email' / 'express' / 'telegram').
+ */
+@Entity
+@Table(name = "user_notification_prefs")
+public class UserNotificationPref {
+
+ @Embeddable
+ public static class Pk implements Serializable {
+
+ @Column(name = "user_id", length = 128, nullable = false)
+ private String userId;
+
+ @Column(name = "channel", length = 16, nullable = false)
+ private String channel;
+
+ protected Pk() {}
+
+ public Pk(String userId, String channel) {
+ this.userId = userId;
+ this.channel = channel;
+ }
+
+ public String getUserId() { return userId; }
+ public String getChannel() { return channel; }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof Pk pk)) return false;
+ return Objects.equals(userId, pk.userId) && Objects.equals(channel, pk.channel);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(userId, channel);
+ }
+ }
+
+ @EmbeddedId
+ private Pk id;
+
+ @Column(name = "enabled", nullable = false)
+ private boolean enabled;
+
+ protected UserNotificationPref() {}
+
+ public UserNotificationPref(String userId, String channel, boolean enabled) {
+ this.id = new Pk(userId, channel);
+ this.enabled = enabled;
+ }
+
+ public Pk getId() { return id; }
+ public String getUserId() { return id.getUserId(); }
+ public String getChannel() { return id.getChannel(); }
+ public boolean isEnabled() { return enabled; }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java
new file mode 100644
index 0000000..721c83f
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java
@@ -0,0 +1,16 @@
+package cloud.nstart.terravault.ordinis.domain.notification;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface UserNotificationPrefRepository
+ extends JpaRepository Wire format — lowercase ('email' / 'express' / 'telegram'). Такой
+ * формат используется в:
+ * NB: {@code channel} column НЕ использует {@code @Enumerated(EnumType.STRING)} —
+ * mapping ручной через {@link #wireName()}, чтобы DB хранила lowercase wire-format.
+ * Это отличается от {@code notification_log.status}, который UPPERCASE
+ * (carry-forward lesson из v1.1.0 P3 — Hibernate {@code @Enumerated(EnumType.STRING)}
+ * пишет UPPERCASE, поэтому status CHECK в 0021 тоже UPPERCASE).
+ *
+ * Persist: всегда {@link #wireName()} (lowercase). Read из DB: {@link #fromWireName(String)}.
+ * Никогда не используй {@link Enum#name()} для DB/API — это вернёт UPPERCASE и
+ * порушит CHECK constraint на insert.
+ */
+public enum ChannelKind {
+ EMAIL("email"),
+ EXPRESS("express"),
+ TELEGRAM("telegram");
+
+ private final String wireName;
+
+ ChannelKind(String wireName) {
+ this.wireName = wireName;
+ }
+
+ /**
+ * Lower-case wire-format. Используется wherever DB / API / config / metrics
+ * касаются channel identity. См. class-level Javadoc для полного списка
+ * usage points.
+ */
+ public String wireName() {
+ return wireName;
+ }
+
+ /** Парсинг из wire-format. Throws IllegalArgumentException на unknown value. */
+ public static ChannelKind fromWireName(String wire) {
+ if (wire == null) {
+ throw new IllegalArgumentException("channel wire name is null");
+ }
+ for (ChannelKind k : values()) {
+ if (k.wireName.equals(wire)) {
+ return k;
+ }
+ }
+ throw new IllegalArgumentException("unknown channel: " + wire);
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/EmailChannel.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/EmailChannel.java
new file mode 100644
index 0000000..880d29b
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/EmailChannel.java
@@ -0,0 +1,107 @@
+package cloud.nstart.terravault.ordinis.notifications.channel;
+
+import cloud.nstart.terravault.ordinis.notifications.config.NotificationsProperties;
+import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.mail.MailException;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+/**
+ * SMTP-based email channel. Phase A delivery target.
+ *
+ * Multipart text+HTML send (RFC 2387) — plain text fallback для accessibility,
+ * HTML version с link styling. Per design § 8.6.
+ *
+ * Sanitization happens upstream в {@code MessageRenderer.render()} —
+ * {@code Sanitizer.forEmailHeader()} применяется ко всем dynamic params в subject.
+ * Body тоже sanitized (CRLF strip preserves layout).
+ *
+ * Per design § 5 EmailChannel:
+ * Caller responsibility (NotificationDispatcher):
+ * Channel responsibility:
+ * {@code subject} — header / first line (email Subject, Express bold headline).
+ * Не используется для Telegram (header inline в body).
+ *
+ * {@code body} — main content. Channel-specific format (plain text для email,
+ * BotX markdown для Express, MarkdownV2 для Telegram).
+ *
+ * Обе строки уже прошли через {@code Sanitizer.forXxx()} per channel — caller
+ * НЕ должен снова escape'ить.
+ */
+public record RenderedMessage(String subject, String body) {}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/UserRef.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/UserRef.java
new file mode 100644
index 0000000..9544305
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/UserRef.java
@@ -0,0 +1,34 @@
+package cloud.nstart.terravault.ordinis.notifications.channel;
+
+import java.util.Locale;
+
+/**
+ * Канал-агностичная ссылка на recipient'а. Все поля nullable — null = recipient
+ * не имеет identifier'а для соответствующего канала → channel skip'ит с
+ * {@code SKIPPED_PREF} status.
+ *
+ * Resolved через {@code RecipientResolver} (Keycloak admin API + cached 5 min).
+ *
+ * @param userId Keycloak subject (always present)
+ * @param email email address (null если у юзера нет email атрибута)
+ * @param expressHuid Express HUID UUID string (null если не configured)
+ * @param telegramChatId Telegram chat_id numeric string (null если не configured)
+ * @param locale предпочитаемая локаль (RU fallback если Keycloak attribute отсутствует)
+ */
+public record UserRef(
+ String userId,
+ String email,
+ String expressHuid,
+ String telegramChatId,
+ Locale locale
+) {
+
+ /** Shortcut для recipient string per channel — используется как {@code notification_log.recipient}. */
+ public String recipientFor(ChannelKind channel) {
+ return switch (channel) {
+ case EMAIL -> email;
+ case EXPRESS -> expressHuid;
+ case TELEGRAM -> telegramChatId;
+ };
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsAutoConfiguration.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsAutoConfiguration.java
new file mode 100644
index 0000000..6d54931
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsAutoConfiguration.java
@@ -0,0 +1,49 @@
+package cloud.nstart.terravault.ordinis.notifications.config;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.support.ReloadableResourceBundleMessageSource;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Auto-configuration для notifications module.
+ *
+ * Активна только при {@code ordinis.notifications.enabled=true}. Без этого
+ * флага consumer не запускается, channels не register'ятся, sweep/retention
+ * jobs не start'ят. Outbox events накапливаются в Kafka topic, на enable
+ * консьюмер начнёт с {@code auto-offset-reset: latest} (backlog burst guard).
+ *
+ * Module включается в writer pod через {@code @ComponentScan} + Helm value
+ * {@code writer.notificationsEnabled}. Read-api / projection-writer — disabled.
+ */
+@Configuration
+@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
+@EnableConfigurationProperties(NotificationsProperties.class)
+@EnableScheduling
+@EnableCaching
+@ComponentScan(basePackages = "cloud.nstart.terravault.ordinis.notifications")
+public class NotificationsAutoConfiguration {
+
+ /**
+ * MessageSource для notifications templates. Отдельный bean (не shared с
+ * application-wide MessageSource) — изолирует наши bundles от других modules.
+ * Filename {@code messages/notifications} → resolves к
+ * {@code messages/notifications_ru.properties} / {@code _en.properties}.
+ */
+ @Bean
+ public MessageSource notificationsMessageSource() {
+ ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource();
+ ms.setBasename("classpath:messages/notifications");
+ ms.setDefaultEncoding(StandardCharsets.UTF_8.name());
+ ms.setFallbackToSystemLocale(false);
+ ms.setUseCodeAsDefaultMessage(false); // throw NoSuchMessageException — fail loud
+ return ms;
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsProperties.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsProperties.java
new file mode 100644
index 0000000..490c03e
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/config/NotificationsProperties.java
@@ -0,0 +1,126 @@
+package cloud.nstart.terravault.ordinis.notifications.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Per-feature config (D9 — никаких magic numbers, всё через
+ * {@code @ConfigurationProperties}).
+ *
+ * Bound из Spring config (application.yml + Helm values), prefix
+ * {@code ordinis.notifications}. Module enables через
+ * {@link #enabled} = true (см. {@code NotificationsAutoConfiguration}
+ * @ConditionalOnProperty).
+ */
+@ConfigurationProperties(prefix = "ordinis.notifications")
+public class NotificationsProperties {
+
+ /** Главный feature flag. False — module disabled, dispatcher не register'ится. */
+ private boolean enabled = false;
+
+ private final Dispatcher dispatcher = new Dispatcher();
+ private final RateLimit rateLimit = new RateLimit();
+ private final Retention retention = new Retention();
+ private final PendingSweep pendingSweep = new PendingSweep();
+ private final Email email = new Email();
+ private final Express express = new Express();
+ private final Telegram telegram = new Telegram();
+
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+ public Dispatcher getDispatcher() { return dispatcher; }
+ public RateLimit getRateLimit() { return rateLimit; }
+ public Retention getRetention() { return retention; }
+ public PendingSweep getPendingSweep() { return pendingSweep; }
+ public Email getEmail() { return email; }
+ public Express getExpress() { return express; }
+ public Telegram getTelegram() { return telegram; }
+
+ public static class Dispatcher {
+ private int concurrency = 2;
+ private int executorPoolSize = 20;
+ private int maxPollRecords = 20;
+ private int maxEventAgeHours = 1;
+ private long sendTimeoutMs = 10_000L;
+
+ public int getConcurrency() { return concurrency; }
+ public void setConcurrency(int v) { this.concurrency = v; }
+ public int getExecutorPoolSize() { return executorPoolSize; }
+ public void setExecutorPoolSize(int v) { this.executorPoolSize = v; }
+ public int getMaxPollRecords() { return maxPollRecords; }
+ public void setMaxPollRecords(int v) { this.maxPollRecords = v; }
+ public int getMaxEventAgeHours() { return maxEventAgeHours; }
+ public void setMaxEventAgeHours(int v) { this.maxEventAgeHours = v; }
+ public long getSendTimeoutMs() { return sendTimeoutMs; }
+ public void setSendTimeoutMs(long v) { this.sendTimeoutMs = v; }
+ }
+
+ public static class RateLimit {
+ private int perHour = 10;
+
+ public int getPerHour() { return perHour; }
+ public void setPerHour(int v) { this.perHour = v; }
+ }
+
+ public static class Retention {
+ private int days = 90;
+ private String cron = "0 0 3 * * *"; // 03:00 UTC daily
+
+ public int getDays() { return days; }
+ public void setDays(int v) { this.days = v; }
+ public String getCron() { return cron; }
+ public void setCron(String v) { this.cron = v; }
+ }
+
+ public static class PendingSweep {
+ private long olderThanMs = 5 * 60 * 1000L; // 5 min
+ private long runEveryMs = 5 * 60 * 1000L;
+
+ public long getOlderThanMs() { return olderThanMs; }
+ public void setOlderThanMs(long v) { this.olderThanMs = v; }
+ public long getRunEveryMs() { return runEveryMs; }
+ public void setRunEveryMs(long v) { this.runEveryMs = v; }
+ }
+
+ public static class Email {
+ /** Used as From header. SMTP server config — через standard spring.mail.* properties. */
+ private String fromAddress;
+ /** Base URL для deep links в email body / unsub links. */
+ private String baseUrl;
+
+ public String getFromAddress() { return fromAddress; }
+ public void setFromAddress(String v) { this.fromAddress = v; }
+ public String getBaseUrl() { return baseUrl; }
+ public void setBaseUrl(String v) { this.baseUrl = v; }
+ }
+
+ public static class Express {
+ /** Corporate Transport Server URL — per-corp on-prem. */
+ private String cts;
+ /** BotX bot UUID — Vault. */
+ private String botId;
+ /** BotX bot HMAC base — Vault. */
+ private String botSecret;
+ /** Group chat UUID per env — Vault или Helm. */
+ private String chatId;
+
+ public String getCts() { return cts; }
+ public void setCts(String v) { this.cts = v; }
+ public String getBotId() { return botId; }
+ public void setBotId(String v) { this.botId = v; }
+ public String getBotSecret() { return botSecret; }
+ public void setBotSecret(String v) { this.botSecret = v; }
+ public String getChatId() { return chatId; }
+ public void setChatId(String v) { this.chatId = v; }
+ }
+
+ public static class Telegram {
+ /** Bot token — Vault. */
+ private String botToken;
+ private String chatId;
+
+ public String getBotToken() { return botToken; }
+ public void setBotToken(String v) { this.botToken = v; }
+ public String getChatId() { return chatId; }
+ public void setChatId(String v) { this.chatId = v; }
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/IdempotencyGuard.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/IdempotencyGuard.java
new file mode 100644
index 0000000..0560291
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/IdempotencyGuard.java
@@ -0,0 +1,131 @@
+package cloud.nstart.terravault.ordinis.notifications.dispatcher;
+
+import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
+import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
+import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
+import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+/**
+ * Закрывает CRITICAL gap (idempotency) из v3 review.
+ *
+ * Использует UNIQUE {@code (event_id, channel, recipient)} на
+ * {@code notification_log} (см. migration 0021) + PostgreSQL
+ * {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} pattern.
+ *
+ * Concurrent semantics:
+ * {@code @Transactional(REQUIRES_NEW)} — claim isolated от outer dispatcher transaction
+ * чтобы commit'ить claim immediately, before actual send. Send fail'ит → UPDATE status='FAILED'
+ * via {@code markFailed()} в отдельной tx.
+ */
+// Gate must mirror NotificationsAutoConfiguration. Writer's @SpringBootApplication
+// scanBasePackages = "cloud.nstart.terravault.ordinis" — superset, picks up
+// этот @Component даже когда notifications module dormant. Без явного
+// ConditionalOnProperty bean регается до того, как notification_log table
+// создан → при первом invocation NPE / table-not-found.
+@Component
+@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
+public class IdempotencyGuard {
+
+ private static final Logger log = LoggerFactory.getLogger(IdempotencyGuard.class);
+
+ private final NotificationLogRepository repo;
+
+ public IdempotencyGuard(NotificationLogRepository repo) {
+ this.repo = repo;
+ }
+
+ /**
+ * Try to claim the (event_id, channel, recipient) slot. Returns the claimed
+ * row if INSERT succeeded, empty if already claimed (другой consumer / replay).
+ *
+ * @param event source event для idempotency key + audit context
+ * @param channel target channel
+ * @param recipient target recipient identifier (email / huid / chat_id)
+ * @return populated NotificationLog if claimed (PENDING status), empty if conflict
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public Optional Three event types:
+ * Schema versioned via {@code v1} suffix в topic name. Future schema changes —
+ * new topic {@code v2}, dual-publish during migration.
+ *
+ * @param eventId UUID of the originating outbox event (idempotency key)
+ * @param eventType wire string ('draft.submitted' / 'draft.decision' / 'draft.withdrawn')
+ * @param occurredAt timestamp
+ * @param draft draft context (always present для draft.* events)
+ * @param decision present только для 'draft.decision' (APPROVE/REJECT + decidedBy + comment)
+ * @param context routing hints — approvalMinRole, dictDisplayName ru/en
+ */
+public record NotificationEvent(
+ UUID eventId,
+ String eventType,
+ OffsetDateTime occurredAt,
+ DraftRef draft,
+ Decision decision,
+ Map Purpose — defence-in-depth: {@code business_key}, {@code maker},
+ * {@code reviewer}, {@code comment} в notification templates приходят
+ * непосредственно от пользователей. Без sanitization получаем CVE-class
+ * injection vulnerabilities per channel:
+ *
+ * Все методы — pure static, no Spring DI, fully unit-testable. Trade-off:
+ * {@code MAX_LEN} cap в 200 chars применяется ко всем channels uniformly
+ * (email subject + chat message длинные ключи всё равно truncate'ятся в
+ * клиентах, поэтому 200 chars — sane upper bound).
+ *
+ * Sanitization вызывается дважды (defence-in-depth):
+ * Comprehensive test coverage в {@code SanitizerTest} — особенно
+ * {@code emailHeaderInjectionRejected*} cases. Эти тесты — CI-gating; если
+ * сломаются, build падает и CRITICAL gap re-opens.
+ *
+ * @see RFC 5322 §2.2 — header injection vector
+ * @see Telegram Bot API MarkdownV2 reserved chars
+ */
+public final class Sanitizer {
+
+ /**
+ * Maximum length применяется ко всем sanitized output strings. 200 chars —
+ * email subject reasonable cap (Outlook truncates ~150-200), Express BotX
+ * body line readable limit, Telegram hard limit 4096 (но за 200 chars
+ * scannable format ломается).
+ */
+ public static final int MAX_LEN = 200;
+
+ // Prevent instantiation.
+ private Sanitizer() {}
+
+ /**
+ * Email-safe sanitization: strip CRLF (RFC 5322 §2.2 header injection
+ * vector) + cap длину.
+ *
+ * Strips:
+ * Используется для:
+ * Express BotX поддерживает урезанный Markdown:
+ * Если business_key содержит {@code *} или {@code _} или {@code `} или
+ * {@code ~} — backslash-escape'аем чтобы:
+ * Backslash сам тоже escape'ится — иначе input "test\n" станет литерал
+ * "test\\n" в output (escaping issue).
+ *
+ * @param raw произвольный user-controlled input, может быть null
+ * @return sanitized string, never null
+ */
+ public static String forExpressText(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ String capped = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw;
+ // Order matters: escape backslash first, иначе двойной escape на
+ // уже-escaped chars. Pattern matches: \ * _ ` ~
+ return capped.replaceAll("([\\\\*_`~])", "\\\\$1");
+ }
+
+ /**
+ * Telegram MarkdownV2-safe sanitization: escape reserved chars per Bot
+ * API spec + cap длину.
+ *
+ * Per
+ * Telegram Bot API MarkdownV2, reserved chars: Все эти chars в сыром user input должны быть prefix'нуты {@code \\}
+ * иначе Telegram API rejects message (или хуже — рендерит unintended
+ * formatting).
+ *
+ * @param raw произвольный user-controlled input, может быть null
+ * @return sanitized string, never null
+ */
+ public static String forTelegramMarkdown(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ String capped = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw;
+ // Order matters: backslash first.
+ return capped.replaceAll("([\\\\_*\\[\\]()~`>#+=|{}.!\\-])", "\\\\$1");
+ }
+
+ /**
+ * Plain-text body sanitization: NOT escape markdown, но strip control chars.
+ * Используется для plain text email body, plain Telegram (если не MarkdownV2),
+ * plain Express messages (если не markdown mode).
+ *
+ * Strips: ASCII control chars (0x00-0x1F кроме {@code \t \r \n}) + DEL (0x7F).
+ * НЕ truncate'ит до MAX_LEN — bodies больше subjects, размер контролируется
+ * на template level или channel-specific limits.
+ *
+ * @param raw произвольный user-controlled input, может быть null
+ * @return sanitized string, never null
+ */
+ public static String forPlainTextBody(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ return raw.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "");
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRenderer.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRenderer.java
new file mode 100644
index 0000000..d571ab7
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRenderer.java
@@ -0,0 +1,107 @@
+package cloud.nstart.terravault.ordinis.notifications.template;
+
+import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
+import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
+import cloud.nstart.terravault.ordinis.notifications.sanitizer.Sanitizer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.MessageSource;
+import org.springframework.context.NoSuchMessageException;
+import org.springframework.stereotype.Component;
+
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Renders notification messages из Spring {@code MessageSource} bundles.
+ *
+ * Bundles в classpath {@code messages/notifications_{locale}.properties}.
+ * Keys имеют shape {@code {eventKey}.{channel}.{slot}}, where:
+ * Example: {@code draft_submitted.email.subject} = {@code "[НСИ] Новый draft на review — {0} / {1}"}
+ *
+ * Sanitization built-in: каждый dynamic param проходит {@code Sanitizer.forXxx()}
+ * per channel kind ДО {@code MessageSource.getMessage()}. Defence-in-depth — даже
+ * если template содержит markdown, sanitized params не сломают rendering.
+ */
+// Gate must mirror NotificationsAutoConfiguration. Writer's
+// @SpringBootApplication scanBasePackages — superset, может ловить этот
+// @Component до того как notifications сконфигурированы. Plus
+// @Qualifier на конструкторе — Spring Boot auto-creates default
+// `messageSource` bean (для app-wide i18n) → ambiguous-by-type injection без
+// явного qualifier'а.
+@Component
+@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
+public class MessageRenderer {
+
+ private static final Logger log = LoggerFactory.getLogger(MessageRenderer.class);
+
+ /** Default locale если recipient.locale=null или unsupported. */
+ public static final Locale DEFAULT_LOCALE = new Locale("ru");
+
+ private final MessageSource messageSource;
+
+ public MessageRenderer(@Qualifier("notificationsMessageSource") MessageSource messageSource) {
+ this.messageSource = messageSource;
+ }
+
+ /**
+ * Render subject + body для (eventKey, channel, locale) с sanitized params.
+ *
+ * Params порядок MUST соответствовать {0} {1} {2}... placeholders в template
+ * (Java MessageFormat conventions). См. notifications_ru.properties для reference.
+ *
+ * @param eventKey 'draft_submitted' | 'draft_decision' | 'draft_withdrawn'
+ * @param channel target channel — определяет sanitization rules
+ * @param locale recipient locale (RU/EN), fallback DEFAULT_LOCALE
+ * @param params positional params {0..N} (sanitization применяется автоматически
+ * через {@link #sanitizeForChannel})
+ * @return RenderedMessage с subject + body
+ * @throws NoSuchMessageException если template missing — лучше fail loud чем
+ * silent empty render
+ */
+ public RenderedMessage render(
+ String eventKey, ChannelKind channel, Locale locale, Object... params) {
+
+ Locale effectiveLocale = (locale != null) ? locale : DEFAULT_LOCALE;
+ Object[] safe = sanitizeForChannel(channel, params);
+
+ String channelKey = channel.wireName();
+ String subjectKey = eventKey + "." + channelKey + ".subject";
+ String bodyKey = eventKey + "." + channelKey + ".body";
+
+ String subject = messageSource.getMessage(subjectKey, safe, effectiveLocale);
+ String body = messageSource.getMessage(bodyKey, safe, effectiveLocale);
+
+ return new RenderedMessage(subject, body);
+ }
+
+ /**
+ * Apply per-channel sanitization. String params get channel-appropriate escape;
+ * non-string params (numbers, dates) pass through.
+ */
+ private Object[] sanitizeForChannel(ChannelKind channel, Object[] params) {
+ if (params == null) return new Object[0];
+ Object[] out = new Object[params.length];
+ for (int i = 0; i < params.length; i++) {
+ Object p = params[i];
+ if (p instanceof String s) {
+ out[i] = switch (channel) {
+ case EMAIL -> Sanitizer.forEmailHeader(s);
+ case EXPRESS -> Sanitizer.forExpressText(s);
+ case TELEGRAM -> Sanitizer.forTelegramMarkdown(s);
+ };
+ } else {
+ out[i] = p;
+ }
+ }
+ return out;
+ }
+}
diff --git a/ordinis-notifications/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ordinis-notifications/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000..edd4d3b
--- /dev/null
+++ b/ordinis-notifications/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+cloud.nstart.terravault.ordinis.notifications.config.NotificationsAutoConfiguration
diff --git a/ordinis-notifications/src/main/resources/messages/notifications_en.properties b/ordinis-notifications/src/main/resources/messages/notifications_en.properties
new file mode 100644
index 0000000..d92dd25
--- /dev/null
+++ b/ordinis-notifications/src/main/resources/messages/notifications_en.properties
@@ -0,0 +1,35 @@
+# Notifications message bundle — English.
+# Same key shape as notifications_ru.properties.
+
+# === draft.submitted ===
+
+draft_submitted.email.subject=[NSI] New draft for review — {0} / {1}
+draft_submitted.email.body=Maker {2} submitted {3} on {0}, key {1}.\n\nOpen queue: {4}/reviews\nDirect link: {4}/reviews/{5}\n\n—\nAutomated NSI notification. To unsubscribe, visit {4}/me/notifications.
+
+draft_submitted.express.subject=*🔔 New draft for review*
+draft_submitted.express.body=*🔔 New draft for review*\n«{0}» / `{1}`\nMaker: {2} ({3})\n\nOpen queue: {4}/reviews\nDirect link: {4}/reviews/{5}
+
+draft_submitted.telegram.subject=
+draft_submitted.telegram.body=*🔔 New draft for review*\n«{0}» / `{1}`\nMaker: {2} \\({3}\\)\n\nOpen: {4}/reviews/{5}
+
+# === draft.decision ===
+
+draft_decision.email.subject=[NSI] {6,choice,0#Draft rejected|1#Draft approved} — {0} / {1}
+draft_decision.email.body=Your draft {3} on {0} (key {1}) has been {6,choice,0#rejected|1#approved} by {7}.{8,choice,0#|1# Comment: {9}}\n\nOpen: {4}/my-drafts/{5}
+
+draft_decision.express.subject={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}
+draft_decision.express.body={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}\n«{0}» / `{1}`\nReviewer: {7}{8,choice,0#|1#\nComment: {9}}\n\nOpen: {4}/my-drafts/{5}
+
+draft_decision.telegram.subject=
+draft_decision.telegram.body={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}\n«{0}» / `{1}`\nReviewer: {7}\n\nOpen: {4}/my\\-drafts/{5}
+
+# === draft.withdrawn ===
+
+draft_withdrawn.email.subject=[NSI] Draft withdrawn by maker — {0} / {1}
+draft_withdrawn.email.body=Maker {2} withdrew their draft {3} on {0}, key {1}.\nNo action needed — already removed from queue.\n\n{4}/reviews
+
+draft_withdrawn.express.subject=*↩ Draft withdrawn*
+draft_withdrawn.express.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker withdrew — removed from queue.
+
+draft_withdrawn.telegram.subject=
+draft_withdrawn.telegram.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker withdrew\\.
diff --git a/ordinis-notifications/src/main/resources/messages/notifications_ru.properties b/ordinis-notifications/src/main/resources/messages/notifications_ru.properties
new file mode 100644
index 0000000..f188782
--- /dev/null
+++ b/ordinis-notifications/src/main/resources/messages/notifications_ru.properties
@@ -0,0 +1,51 @@
+# Notifications message bundle — Russian (default).
+#
+# Key shape: {eventKey}.{channel}.{slot}
+# Params (positional, MessageFormat style):
+# {0} dictDisplayName (e.g. "Космические аппараты")
+# {1} businessKey (e.g. "Союз-МС-25")
+# {2} maker user_id (e.g. "user.tech@nstart.space")
+# {3} operation (e.g. "UPDATE")
+# {4} baseUrl (e.g. "https://ordinis.k8s.../")
+# {5} draftId
+# {6} decision outcome (0=REJECT, 1=APPROVE — ChoiceFormat)
+# {7} reviewer user_id
+# {8} comment present (0=no, 1=yes)
+# {9} comment text
+#
+# All dynamic params уже sanitized per channel в MessageRenderer.
+
+# === draft.submitted (reviewer pool ping) ===
+
+draft_submitted.email.subject=[НСИ] Новый draft на review — {0} / {1}
+draft_submitted.email.body=Maker {2} отправил {3} на {0}, ключ {1}.\n\nОткрыть очередь: {4}/reviews\nПрямая ссылка: {4}/reviews/{5}\n\n—\nЭто автоматическое уведомление НСИ. Чтобы отписаться, зайдите в {4}/me/notifications.
+
+draft_submitted.express.subject=*🔔 Новый draft на review*
+draft_submitted.express.body=*🔔 Новый draft на review*\n«{0}» / `{1}`\nMaker: {2} ({3})\n\nОткрыть очередь: {4}/reviews\nПрямая ссылка: {4}/reviews/{5}
+
+draft_submitted.telegram.subject=
+draft_submitted.telegram.body=*🔔 Новый draft на review*\n«{0}» / `{1}`\nMaker: {2} \\({3}\\)\n\nОткрыть: {4}/reviews/{5}
+
+# === draft.decision (notify maker) ===
+# {6} = 0 (REJECT) or 1 (APPROVE) — ChoiceFormat
+# {8} = 0 (no comment) or 1 (has comment) — ChoiceFormat
+
+draft_decision.email.subject=[НСИ] {6,choice,0#Draft отклонён|1#Draft одобрен} — {0} / {1}
+draft_decision.email.body=Ваш draft {3} на {0} (ключ {1}) был {6,choice,0#отклонён|1#одобрен} пользователем {7}.{8,choice,0#|1# Комментарий: {9}}\n\nОткрыть: {4}/my-drafts/{5}
+
+draft_decision.express.subject={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}
+draft_decision.express.body={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}\n«{0}» / `{1}`\nReviewer: {7}{8,choice,0#|1#\nКомментарий: {9}}\n\nОткрыть: {4}/my-drafts/{5}
+
+draft_decision.telegram.subject=
+draft_decision.telegram.body={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}\n«{0}» / `{1}`\nReviewer: {7}\n\nОткрыть: {4}/my\\-drafts/{5}
+
+# === draft.withdrawn (clear queue) ===
+
+draft_withdrawn.email.subject=[НСИ] Draft отозван maker'ом — {0} / {1}
+draft_withdrawn.email.body=Maker {2} отозвал свой draft {3} на {0}, ключ {1}.\nУбрать из очереди не требуется — уже убран автоматически.\n\n{4}/reviews
+
+draft_withdrawn.express.subject=*↩ Draft withdrawn*
+draft_withdrawn.express.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker сам отозвал — убрано из очереди.
+
+draft_withdrawn.telegram.subject=
+draft_withdrawn.telegram.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker сам отозвал\\.
diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/SanitizerTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/SanitizerTest.java
new file mode 100644
index 0000000..3445427
Binary files /dev/null and b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/sanitizer/SanitizerTest.java differ
diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRendererTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRendererTest.java
new file mode 100644
index 0000000..e579d04
--- /dev/null
+++ b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/template/MessageRendererTest.java
@@ -0,0 +1,220 @@
+package cloud.nstart.terravault.ordinis.notifications.template;
+
+import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
+import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.MessageSource;
+import org.springframework.context.support.ReloadableResourceBundleMessageSource;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration test для MessageRenderer + bundle templates + Sanitizer chain.
+ * Verifies:
+ *
+ *
+ *
+ *
+ *
+ */
+@Component
+@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
+// Без JavaMailSender bean (т.е. spring.mail.host не задан) ChannelKind.EMAIL
+// не активируется. Это позволяет включить notifications.enabled=true для
+// Express-only deploy без обязательной SMTP-конфигурации, и не вешает Spring
+// boot на UnsatisfiedDependencyException когда mail starter в classpath, но
+// инфра почты не подведена.
+@ConditionalOnBean(JavaMailSender.class)
+public class EmailChannel implements NotificationChannel {
+
+ private static final Logger log = LoggerFactory.getLogger(EmailChannel.class);
+
+ private final JavaMailSender mailSender;
+ private final NotificationsProperties props;
+ private final Timer dispatchTimer;
+
+ public EmailChannel(
+ JavaMailSender mailSender,
+ NotificationsProperties props,
+ MeterRegistry meterRegistry) {
+ this.mailSender = mailSender;
+ this.props = props;
+ this.dispatchTimer = Timer.builder("nsi_notification_dispatch_seconds")
+ .description("Per-channel dispatch latency")
+ .tag("channel", "email")
+ .publishPercentileHistogram()
+ .register(meterRegistry);
+ }
+
+ @Override
+ public ChannelKind kind() {
+ return ChannelKind.EMAIL;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ String from = props.getEmail().getFromAddress();
+ return from != null && !from.isBlank();
+ }
+
+ @Override
+ public DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg) {
+ if (recipient.email() == null || recipient.email().isBlank()) {
+ return DispatchResult.failure("recipient has no email address");
+ }
+ long start = System.nanoTime();
+ try {
+ MimeMessage mime = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(mime, true, StandardCharsets.UTF_8.name());
+ helper.setFrom(props.getEmail().getFromAddress());
+ helper.setTo(recipient.email());
+ helper.setSubject(msg.subject());
+ // For Phase A: plain text body. HTML multipart можно добавить позже
+ // через помощник setText(text, html) в MimeMessageHelper.
+ helper.setText(msg.body(), false);
+
+ mailSender.send(mime);
+ return DispatchResult.success();
+ } catch (MailException e) {
+ log.warn("Email send failed для {}: {}", recipient.email(), e.getMessage());
+ return DispatchResult.failure("MailException: " + e.getMessage());
+ } catch (MessagingException e) {
+ log.warn("Email build failed для {}: {}", recipient.email(), e.getMessage());
+ return DispatchResult.failure("MessagingException: " + e.getMessage());
+ } finally {
+ dispatchTimer.record(Duration.ofNanos(System.nanoTime() - start));
+ }
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/NotificationChannel.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/NotificationChannel.java
new file mode 100644
index 0000000..0473116
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/NotificationChannel.java
@@ -0,0 +1,58 @@
+package cloud.nstart.terravault.ordinis.notifications.channel;
+
+import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
+
+/**
+ * Channel adapter contract. Реализации:
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+public interface NotificationChannel {
+
+ ChannelKind kind();
+
+ /** True если channel конфигурирован (creds present, etc). False → channel skip'ится. */
+ boolean isEnabled();
+
+ /**
+ * Send the rendered message к recipient.
+ *
+ * @param event original event для context (event_id для tracing, etc)
+ * @param recipient resolved user ref (channel-specific identifier через
+ * {@code recipient.recipientFor(kind())})
+ * @param msg pre-rendered, pre-sanitized message
+ * @return success/failure with optional error message
+ */
+ DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg);
+
+ record DispatchResult(boolean ok, String errorMsg) {
+ public static DispatchResult success() {
+ return new DispatchResult(true, null);
+ }
+
+ public static DispatchResult failure(String errorMsg) {
+ return new DispatchResult(false, errorMsg);
+ }
+ }
+}
diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/RenderedMessage.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/RenderedMessage.java
new file mode 100644
index 0000000..6cdbfed
--- /dev/null
+++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/channel/RenderedMessage.java
@@ -0,0 +1,16 @@
+package cloud.nstart.terravault.ordinis.notifications.channel;
+
+/**
+ * Pre-rendered message ready для dispatch. Result of
+ * {@code MessageRenderer.render(eventKey, channel, locale, params)}.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * Заменяются на single space.
+ *
+ *
+ *
+ *
+ * @param raw произвольный user-controlled input, может быть null
+ * @return sanitized string, never null (на null → empty string)
+ */
+ public static String forEmailHeader(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ String stripped = raw.replaceAll("[\\r\\n\\u0085\\u2028\\u2029]", " ");
+ return stripped.length() > MAX_LEN ? stripped.substring(0, MAX_LEN) : stripped;
+ }
+
+ /**
+ * Express BotX-safe sanitization: escape Markdown subset reserved chars
+ * + cap длину.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * _ * [ ] ( ) ~ ` > # + - = | { } . !
+ *
+ * Plus backslash itself ({@code \}) escape character.
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+class MessageRendererTest {
+
+ private MessageRenderer renderer;
+
+ @BeforeEach
+ void setUp() {
+ ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource();
+ ms.setBasename("classpath:messages/notifications");
+ ms.setDefaultEncoding(StandardCharsets.UTF_8.name());
+ ms.setFallbackToSystemLocale(false);
+ ms.setUseCodeAsDefaultMessage(false);
+ renderer = new MessageRenderer(ms);
+ }
+
+ // === draft.submitted ===
+
+ @Test
+ @DisplayName("draft_submitted email RU rendered с business_key и link")
+ void draftSubmittedEmailRu() {
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.EMAIL, new Locale("ru"),
+ "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ assertTrue(msg.subject().contains("Космические аппараты"));
+ assertTrue(msg.subject().contains("Союз-МС-25"));
+ assertTrue(msg.subject().startsWith("[НСИ]"));
+ assertTrue(msg.body().contains("UPDATE"));
+ assertTrue(msg.body().contains("https://ordinis.example/reviews/123"));
+ }
+
+ @Test
+ @DisplayName("draft_submitted email EN rendered correctly")
+ void draftSubmittedEmailEn() {
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.EMAIL, Locale.ENGLISH,
+ "Spacecraft", "Soyuz-MS-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ assertTrue(msg.subject().startsWith("[NSI]"));
+ assertTrue(msg.subject().contains("Soyuz-MS-25"));
+ assertTrue(msg.body().contains("Maker user.tech@nstart.space"));
+ }
+
+ // === draft.decision ChoiceFormat ===
+
+ @Test
+ @DisplayName("draft_decision APPROVE с comment renders ✅")
+ void draftDecisionApproveWithComment() {
+ RenderedMessage msg = renderer.render(
+ "draft_decision", ChannelKind.EMAIL, new Locale("ru"),
+ "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L,
+ 1, // {6} = 1 → APPROVE
+ "reviewer.tech@nstart.space", // {7}
+ 1, // {8} = 1 → has comment
+ "Согласовано"); // {9}
+
+ assertTrue(msg.subject().contains("одобрен"),
+ "RU APPROVE subject должен содержать 'одобрен', got: " + msg.subject());
+ assertTrue(msg.body().contains("Согласовано"));
+ assertTrue(msg.body().contains("reviewer.tech@nstart.space"));
+ }
+
+ @Test
+ @DisplayName("draft_decision REJECT без comment не показывает comment field")
+ void draftDecisionRejectNoComment() {
+ RenderedMessage msg = renderer.render(
+ "draft_decision", ChannelKind.EMAIL, new Locale("ru"),
+ "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L,
+ 0, // REJECT
+ "reviewer.tech@nstart.space",
+ 0, // no comment
+ "");
+
+ assertTrue(msg.subject().contains("отклонён"));
+ assertFalse(msg.body().contains("Комментарий:"),
+ "При no-comment не должен show 'Комментарий:' field");
+ }
+
+ // === Express channel ===
+
+ @Test
+ @DisplayName("Express body содержит status icon + bold + business_key code")
+ void expressSubmittedFormat() {
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.EXPRESS, new Locale("ru"),
+ "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ assertTrue(msg.body().contains("🔔"), "должен иметь bell emoji status");
+ // BotX bold = single asterisks (not CommonMark **double**). Template начинается с *🔔...*
+ assertTrue(msg.body().startsWith("*"), "должен начинаться с BotX bold marker (*)");
+ // Sanitizer.forExpressText escape's [* _ ` ~ \\] — НЕ hyphen, так что Союз-МС-25 остаётся as-is
+ assertTrue(msg.body().contains("`Союз-МС-25`"),
+ "business_key должен быть в code marker (BotX backticks). Body: " + msg.body());
+ }
+
+ // === Telegram channel ===
+
+ @Test
+ @DisplayName("Telegram body уважает MarkdownV2 escape (no raw dot in URL)")
+ void telegramMarkdownEscape() {
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.TELEGRAM, new Locale("ru"),
+ "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ // В template /reviews/{5} — без escape \., но URL contains "/"
+ // (pass через ChoiceFormat) — тут проверяем что хотя бы template без issue.
+ assertNotNull(msg.body());
+ assertTrue(msg.body().contains("🔔"));
+ }
+
+ // === withdrawn ===
+
+ @Test
+ @DisplayName("draft_withdrawn renders RU")
+ void draftWithdrawnEmailRu() {
+ RenderedMessage msg = renderer.render(
+ "draft_withdrawn", ChannelKind.EMAIL, new Locale("ru"),
+ "Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ assertTrue(msg.subject().contains("отозван"));
+ assertTrue(msg.body().contains("user.tech@nstart.space"));
+ }
+
+ // === SECURITY: Email header injection через malicious business_key ===
+
+ @Test
+ @DisplayName("CRITICAL: malicious business_key с CRLF не может inject email headers")
+ void emailSubjectImmuneToHeaderInjection() {
+ String malicious = "Союз\nBcc: attacker@evil.com\nFrom: spoof@evil.com";
+
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.EMAIL, new Locale("ru"),
+ "Космические аппараты", malicious, "user@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ // Critical assertions:
+ assertFalse(msg.subject().contains("\n"),
+ "Email subject не должен содержать LF — иначе header injection");
+ assertFalse(msg.subject().contains("\r"),
+ "Email subject не должен содержать CR — иначе header injection");
+
+ // String "Bcc:" может остаться (это просто текст в subject), но не на отдельной
+ // line — что и проверяет no-newline assert выше.
+ String[] lines = msg.subject().split("\\r?\\n");
+ assertEquals(1, lines.length, "Subject должен быть single line");
+ }
+
+ @Test
+ @DisplayName("Express body sanitization escape's markdown-confusing chars в business_key")
+ void expressMarkdownInjectionEscaped() {
+ // Maker создал business_key с *_ — без escape это сломает bold rendering
+ String malicious = "test*key_with*markers";
+
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.EXPRESS, new Locale("ru"),
+ "Космические аппараты", malicious, "user@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ // В rendered body должны быть escape'нутые \* \_:
+ assertTrue(msg.body().contains("\\*"), "asterisk должен быть escaped");
+ assertTrue(msg.body().contains("\\_"), "underscore должен быть escaped");
+ }
+
+ // === missing key fail-loud ===
+
+ @Test
+ @DisplayName("missing template key → throws NoSuchMessageException (не silent empty)")
+ void missingKeyFailsLoud() {
+ assertThrows(
+ org.springframework.context.NoSuchMessageException.class,
+ () -> renderer.render(
+ "nonexistent_event", ChannelKind.EMAIL, new Locale("ru"),
+ "x", "y", "z", "w", "u", 1L));
+ }
+
+ // === fallback locale ===
+
+ @Test
+ @DisplayName("null locale → DEFAULT_LOCALE (RU) fallback")
+ void nullLocaleFallsBackToRu() {
+ RenderedMessage msg = renderer.render(
+ "draft_submitted", ChannelKind.EMAIL, null,
+ "Космические аппараты", "Союз-МС-25", "user@nstart.space",
+ "UPDATE", "https://ordinis.example", 123L);
+
+ assertTrue(msg.subject().startsWith("[НСИ]"),
+ "Null locale → RU bundle, subject должен быть RU");
+ }
+}
diff --git a/pom.xml b/pom.xml
index fb5a8f3..7ed5c14 100644
--- a/pom.xml
+++ b/pom.xml
@@ -31,6 +31,7 @@