feat(notifications): Phase A foundation — module + schema 0021

This commit is contained in:
Александр Зимин
2026-05-10 15:41:34 +00:00
parent c675c5e310
commit 3b7b1bffe5
24 changed files with 1802 additions and 0 deletions
@@ -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.
*
* <p>UNIQUE {@code (event_id, channel, recipient)} — primary idempotency guard
* (см. migration 0021 + design doc D2). Insert pattern в
* {@code IdempotencyGuard.shouldSend()}:
*
* <pre>{@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;
* }</pre>
*
* <p>Если RETURNING пустой — другой consumer уже claim'ил отправку (или sweep
* job не освободил). Else — мы owner, dispatch + UPDATE status final.
*
* <p>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; }
}
@@ -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<NotificationLog, Long> {
/**
* Idempotency claim. Native query потому что Spring Data JPA не поддерживает
* {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} напрямую (PostgreSQL specific).
*
* <p>Returns inserted row's id если claim succeeded, или empty если конфликт
* (другой consumer уже claim'ил, или sweep job не освободил stuck row).
*
* <p>Используется в {@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<Long> tryClaim(
@Param("eventId") UUID eventId,
@Param("eventType") String eventType,
@Param("draftId") UUID draftId,
@Param("channel") String channel,
@Param("recipient") String recipient);
/**
* Pending sweep — освобождает stuck PENDING rows старше {@code threshold}.
* Returns deleted count для metrics. Использует partial index
* {@code idx_notif_log_pending_sweep}.
*/
@Modifying
@Query(value = """
DELETE FROM notification_log
WHERE status = 'PENDING' AND sent_at < :threshold
""", nativeQuery = true)
int deletePendingOlderThan(@Param("threshold") OffsetDateTime threshold);
/**
* Retention sweep — удаляет terminal status rows старше {@code threshold}.
* FAILED rows preserved bessrochno (для post-mortem). Partial index
* {@code idx_notif_log_retention}.
*/
@Modifying
@Query(value = """
DELETE FROM notification_log
WHERE status IN ('SENT','RATE_LIMITED','SKIPPED_PREF','SKIPPED_TOO_OLD','UNSUBSCRIBED')
AND sent_at < :threshold
""", nativeQuery = true)
int deleteRetainedOlderThan(@Param("threshold") OffsetDateTime threshold);
/**
* UI "Last sent" lookup для {@code GET /api/v1/me/notifications}.
* Cache 60s в service layer.
*/
@Query("""
SELECT MAX(n.sentAt) FROM NotificationLog n
WHERE n.recipient = :recipient
AND n.channel = :channel
AND n.status = cloud.nstart.terravault.ordinis.domain.notification.NotificationLog.Status.SENT
""")
Optional<OffsetDateTime> findLastSentAt(
@Param("recipient") String recipient,
@Param("channel") String channel);
/** Admin endpoint pagination. */
Page<NotificationLog> findByRecipientOrderBySentAtDesc(String recipient, Pageable pageable);
/** Admin endpoint — full audit lookup by event. */
Page<NotificationLog> findByEventIdOrderBySentAtDesc(UUID eventId, Pageable pageable);
}
@@ -0,0 +1,78 @@
package cloud.nstart.terravault.ordinis.domain.notification;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.util.Objects;
/**
* Per-user channel opt-in/out (см. migration 0021 + design doc D3).
*
* <p>Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для
* новых rows; bootstrap loadData может включить email для existing reviewer/maker
* ролей при первом deploy.
*
* <p>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;
}
}
@@ -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<UserNotificationPref, UserNotificationPref.Pk> {
/** Все каналы пользователя — для GET /api/v1/me/notifications page. */
List<UserNotificationPref> findByIdUserId(String userId);
/** Single channel lookup для dispatcher (skip if disabled). */
Optional<UserNotificationPref> findByIdUserIdAndIdChannel(String userId, String channel);
}
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
Notifications v1.2.0 — schema foundation.
Цель: persistent audit trail доставки + per-user channel preferences.
Notification dispatcher leverages existing ordinis-outbox infra (OutboxRecorder
+ OutboxPoller + KafkaTopicResolver). Этот migration добавляет ТОЛЬКО consumer-side
state. События публикуются в outbox_events (existing table).
Decisions made (per design doc):
- D2 idempotency: UNIQUE (event_id, channel, recipient) — primary defence от
consumer crash mid-fan-out duplication. INSERT ON CONFLICT DO NOTHING RETURNING.
- D11 PendingSweepJob: status='PENDING' rows старше 5 min удаляются → освобождают
idempotency lock на crashed dispatch attempts.
- D12 RetentionJob: SENT/RATE_LIMITED/SKIPPED_PREF/SKIPPED_TOO_OLD/UNSUBSCRIBED
старше 90 дней удаляются ежедневно. FAILED rows preserved bessrochno.
- D15 status enum UPPERCASE (Hibernate @Enumerated(EnumType.STRING) lesson из
v1.1.0 P3 — migration 0020).
Indexes:
- Partial idx_notif_log_pending_sweep — для @Scheduled sweep job (только PENDING).
- Partial idx_notif_log_retention — для daily cleanup (terminal status'ы).
- Composite idx_notif_log_recipient_time — для UI "last sent" lookup.
- Single column idx_notif_log_draft / event для cross-references.
Source: ~/.gstack/projects/claude/zimin-main-design-notifications-v1.2.0-20260510-130000.md
-->
<changeSet id="0021-1-notification-log" author="ordinis">
<comment>notification_log — per-recipient delivery audit trail с UNIQUE idempotency guard</comment>
<createTable tableName="notification_log">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- domain event UUID из outbox_events — связывает notification с triggering event -->
<column name="event_id" type="UUID">
<constraints nullable="false"/>
</column>
<column name="event_type" type="VARCHAR(64)">
<constraints nullable="false"/>
</column>
<!-- nullable: некоторые events без draft контекста (future expansion) -->
<column name="draft_id" type="UUID"/>
<column name="channel" type="VARCHAR(16)">
<constraints nullable="false"/>
</column>
<!-- email / express chat_id / telegram chat_id -->
<column name="recipient" type="VARCHAR(256)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="sent_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
<column name="error_msg" type="TEXT"/>
</createTable>
<!-- D15 status enum UPPERCASE (Hibernate @Enumerated lesson) -->
<sql>
ALTER TABLE notification_log
ADD CONSTRAINT notification_log_status_check
CHECK (status IN (
'PENDING', 'SENT', 'FAILED',
'RATE_LIMITED', 'SKIPPED_PREF', 'SKIPPED_TOO_OLD',
'UNSUBSCRIBED'
));
</sql>
<sql>
ALTER TABLE notification_log
ADD CONSTRAINT notification_log_channel_check
CHECK (channel IN ('email', 'express', 'telegram'));
</sql>
<!-- D2 idempotency guard — primary defence от consumer crash mid-fan-out duplication.
(event_id, channel, recipient) UNIQUE: тот же event для того же канала и
recipient'а никогда не send'ится дважды. INSERT ON CONFLICT DO NOTHING RETURNING
в IdempotencyGuard.shouldSend(). -->
<sql>
ALTER TABLE notification_log
ADD CONSTRAINT notification_log_idempotency
UNIQUE (event_id, channel, recipient);
</sql>
<!-- Partial index для PendingSweepJob: только PENDING rows нужно scan'ить
каждые 5 минут. Без partial index full scan на growing table. -->
<sql>
CREATE INDEX idx_notif_log_pending_sweep
ON notification_log (sent_at)
WHERE status = 'PENDING';
</sql>
<!-- Partial index для RetentionJob daily cleanup: только terminal "deletable"
status'ы. FAILED rows preserved → не в индексе. -->
<sql>
CREATE INDEX idx_notif_log_retention
ON notification_log (sent_at)
WHERE status IN ('SENT', 'RATE_LIMITED', 'SKIPPED_PREF', 'SKIPPED_TOO_OLD', 'UNSUBSCRIBED');
</sql>
<createIndex indexName="idx_notif_log_draft" tableName="notification_log">
<column name="draft_id"/>
</createIndex>
<!-- UI "last sent {N} min ago" lookup — recipient + sent_at DESC. -->
<createIndex indexName="idx_notif_log_recipient_time" tableName="notification_log">
<column name="recipient"/>
<column name="sent_at" descending="true"/>
</createIndex>
<createIndex indexName="idx_notif_log_event" tableName="notification_log">
<column name="event_id"/>
</createIndex>
</changeSet>
<changeSet id="0021-2-user-notification-prefs" author="ordinis">
<comment>user_notification_prefs — per-user channel opt-in/out</comment>
<createTable tableName="user_notification_prefs">
<!-- Keycloak subject (sub claim) — same shape as record_drafts.maker_id (VARCHAR 128) -->
<column name="user_id" type="VARCHAR(128)">
<constraints nullable="false"/>
</column>
<column name="channel" type="VARCHAR(16)">
<constraints nullable="false"/>
</column>
<!-- D3: email default ON для existing users применяется bootstrap loadData
(отдельный changeSet опционально per env). Для новых юзеров — explicit
opt-in через UI. -->
<column name="enabled" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</createTable>
<sql>
ALTER TABLE user_notification_prefs
ADD CONSTRAINT user_notification_prefs_pkey
PRIMARY KEY (user_id, channel);
</sql>
<sql>
ALTER TABLE user_notification_prefs
ADD CONSTRAINT user_notification_prefs_channel_check
CHECK (channel IN ('email', 'express', 'telegram'));
</sql>
</changeSet>
</databaseChangeLog>
@@ -30,5 +30,6 @@
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
<include file="changes/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
<include file="changes/0020-fix-draft-status-case.xml" relativeToChangelogFile="true"/>
<include file="changes/0021-notifications.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>ordinis-notifications</artifactId>
<name>Ordinis :: Notifications</name>
<description>
Push-notifications module (email + Express + Telegram) для Approval Workflow v2 events.
Leverages existing ordinis-outbox infra (OutboxRecorder + OutboxPoller +
KafkaTopicResolver). Этот модуль = consumer side: Kafka @KafkaListener,
fan-out per recipient × channel, rate limiter, idempotency guard через PG UNIQUE.
Включается через ordinis.notifications.enabled=true (writer pod).
См. ~/.gstack/projects/claude/zimin-main-design-notifications-v1.2.0-20260510-130000.md
</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-domain</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-events-api</artifactId>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-outbox</artifactId>
</dependency>
<!-- Test scope -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail</artifactId>
<version>2.1.5</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,58 @@
package cloud.nstart.terravault.ordinis.notifications.channel;
/**
* Канал доставки уведомления.
*
* <p><b>Wire format — lowercase</b> ('email' / 'express' / 'telegram'). Такой
* формат используется в:
* <ul>
* <li>DB columns: {@code notification_log.channel}, {@code user_notification_prefs.channel}
* (см. CHECK constraint {@code notification_log_channel_check} в migration 0021)</li>
* <li>API JSON: {@code GET /api/v1/me/notifications}</li>
* <li>Helm config keys: {@code notifications.express.*}</li>
* <li>Metric labels: {@code nsi_notification_sent_total{channel="email"}}</li>
* </ul>
*
* <p><b>NB:</b> {@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).
*
* <p>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);
}
}
@@ -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.
*
* <p>Multipart text+HTML send (RFC 2387) — plain text fallback для accessibility,
* HTML version с link styling. Per design § 8.6.
*
* <p>Sanitization happens upstream в {@code MessageRenderer.render()} —
* {@code Sanitizer.forEmailHeader()} применяется ко всем dynamic params в subject.
* Body тоже sanitized (CRLF strip preserves layout).
*
* <p>Per design § 5 EmailChannel:
* <ul>
* <li>SMTP timeouts через {@code spring.mail.properties.mail.smtp.timeout=10000}.</li>
* <li>Hang >10s → {@code MailSendException} → channel returns failed.</li>
* <li>FAILED row не освобождает idempotency lock (conscious choice — failed =
* "tried, gave up", не долбим SMTP бесконечно).</li>
* </ul>
*/
@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));
}
}
}
@@ -0,0 +1,58 @@
package cloud.nstart.terravault.ordinis.notifications.channel;
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
/**
* Channel adapter contract. Реализации:
* <ul>
* <li>{@code EmailChannel} — JavaMailSender + multipart text+HTML.</li>
* <li>{@code ExpressChannel} — BotX bot API (Phase B).</li>
* <li>{@code TelegramChannel} — Bot API (Phase C).</li>
* </ul>
*
* <p>Caller responsibility (NotificationDispatcher):
* <ol>
* <li>IdempotencyGuard claim — INSERT ON CONFLICT DO NOTHING.</li>
* <li>Pref check — skip если disabled.</li>
* <li>Rate limit check — skip если exceeded.</li>
* <li>Render через MessageRenderer (sanitization built-in).</li>
* <li>Call {@link #dispatch(NotificationEvent, UserRef, RenderedMessage)}.</li>
* <li>Update notification_log status based on result.</li>
* </ol>
*
* <p>Channel responsibility:
* <ul>
* <li>Transport-level send (SMTP / HTTP).</li>
* <li>Channel-specific timeout enforcement.</li>
* <li>Return DispatchResult с error message если fail.</li>
* <li>NOT retry — outbox poller / replay handles retry semantics.</li>
* </ul>
*/
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);
}
}
}
@@ -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)}.
*
* <p>{@code subject} — header / first line (email Subject, Express bold headline).
* Не используется для Telegram (header inline в body).
*
* <p>{@code body} — main content. Channel-specific format (plain text для email,
* BotX markdown для Express, MarkdownV2 для Telegram).
*
* <p>Обе строки уже прошли через {@code Sanitizer.forXxx()} per channel — caller
* НЕ должен снова escape'ить.
*/
public record RenderedMessage(String subject, String body) {}
@@ -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.
*
* <p>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;
};
}
}
@@ -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.
*
* <p>Активна только при {@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).
*
* <p>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;
}
}
@@ -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}).
*
* <p>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; }
}
}
@@ -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.
*
* <p>Использует UNIQUE {@code (event_id, channel, recipient)} на
* {@code notification_log} (см. migration 0021) + PostgreSQL
* {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} pattern.
*
* <p>Concurrent semantics:
* <ul>
* <li>Two consumer threads parallel'но обрабатывают same event → один claim'ит row,
* другой получает empty Optional → skip.</li>
* <li>Consumer crash после email_OK + before slack → replay → idempotency guard
* видит email row (status=SENT) → skip; express row отсутствует → claim + send.</li>
* <li>Pod crash mid-INSERT-pending → row застряла PENDING → {@code PendingSweepJob}
* через 5 min DELETE → next consumer poll → re-claim + send.</li>
* </ul>
*
* <p>{@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<NotificationLog> tryClaim(
NotificationEvent event, ChannelKind channel, String recipient) {
Optional<Long> claimedId = repo.tryClaim(
event.eventId(),
event.eventType(),
event.draft() != null ? event.draft().id() : null,
channel.wireName(),
recipient
);
if (claimedId.isEmpty()) {
log.debug("Idempotency conflict — already claimed: event={}, channel={}, recipient={}",
event.eventId(), channel, recipient);
return Optional.empty();
}
return repo.findById(claimedId.get());
}
/**
* Mark claimed row as SENT. Called после successful channel.dispatch().
* @Transactional(REQUIRES_NEW) для commit'а independent от dispatcher loop.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markSent(Long rowId) {
repo.findById(rowId).ifPresent(row -> {
row.markSent();
repo.save(row);
});
}
/** Mark claimed row as FAILED with error message. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markFailed(Long rowId, String errorMsg) {
repo.findById(rowId).ifPresent(row -> {
row.markFailed(errorMsg);
repo.save(row);
});
}
/** Mark claimed row as RATE_LIMITED — recipient hit per-hour cap. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRateLimited(Long rowId) {
repo.findById(rowId).ifPresent(row -> {
row.markRateLimited();
repo.save(row);
});
}
/** Mark claimed row as SKIPPED_PREF — user opted out. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markSkippedPref(Long rowId) {
repo.findById(rowId).ifPresent(row -> {
row.markSkippedPref();
repo.save(row);
});
}
/** Mark claimed row as SKIPPED_TOO_OLD — backlog burst guard fired. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markSkippedTooOld(Long rowId) {
repo.findById(rowId).ifPresent(row -> {
row.markSkippedTooOld();
repo.save(row);
});
}
}
@@ -0,0 +1,55 @@
package cloud.nstart.terravault.ordinis.notifications.event;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
* Canonical notification event published в Kafka topic {@code ordinis.notifications.v1}
* через existing {@code OutboxRecorder}. Consumer ({@code NotificationDispatcher})
* deserialize'ит и fan-out'ит per recipient × channel.
*
* <p>Three event types:
* <ul>
* <li>{@code draft.submitted} — DraftService.submit() success → notify reviewer pool.</li>
* <li>{@code draft.decision} — approve/reject success → notify maker.</li>
* <li>{@code draft.withdrawn} — withdraw success → notify reviewer pool (clear queue).</li>
* </ul>
*
* <p>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<String, Object> context
) {
public static final String TYPE_SUBMITTED = "draft.submitted";
public static final String TYPE_DECISION = "draft.decision";
public static final String TYPE_WITHDRAWN = "draft.withdrawn";
public record DraftRef(
UUID id,
String businessKey,
String dictionary,
String operation,
String maker
) {}
public record Decision(
String outcome, // 'APPROVE' | 'REJECT'
String decidedBy,
String comment
) {}
}
@@ -0,0 +1,170 @@
package cloud.nstart.terravault.ordinis.notifications.sanitizer;
/**
* Sanitization adapter для user-controlled полей перед template render.
*
* <p><b>Purpose — defence-in-depth:</b> {@code business_key}, {@code maker},
* {@code reviewer}, {@code comment} в notification templates приходят
* непосредственно от пользователей. Без sanitization получаем CVE-class
* injection vulnerabilities per channel:
*
* <ul>
* <li><b>Email subject CRLF injection</b> (RFC 5322): {@code business_key
* = "Союз\nBcc: attacker@evil.com"} → JavaMail {@code setSubject(String)}
* не strip'ает CRLF → headers spoof'ятся → unintended recipients
* получают draft content. Это primary CRITICAL gap из eng review v3.</li>
* <li><b>Express BotX markdown spoof</b>: business_key с {@code *} или
* {@code _} ломает rendering или открывает structure manipulation.</li>
* <li><b>Telegram MarkdownV2 spec violations</b>: per Bot API spec
* reserved chars должны быть escaped или Telegram rejects message
* (или, хуже, рендерит unintended formatting).</li>
* </ul>
*
* <p>Все методы — 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).
*
* <p>Sanitization вызывается дважды (defence-in-depth):
* <ol>
* <li>В {@code MessageRenderer} перед rendering template — params уже
* safe для конкретного channel kind.</li>
* <li>В каждом {@code NotificationChannel.dispatch()} перед фактическим
* send — на случай если где-то забыли первый шаг.</li>
* </ol>
*
* <p>Comprehensive test coverage в {@code SanitizerTest} — особенно
* {@code emailHeaderInjectionRejected*} cases. Эти тесты — CI-gating; если
* сломаются, build падает и CRITICAL gap re-opens.
*
* @see <a href="https://datatracker.ietf.org/doc/html/rfc5322">RFC 5322 §2.2 — header injection vector</a>
* @see <a href="https://core.telegram.org/bots/api#markdownv2-style">Telegram Bot API MarkdownV2 reserved chars</a>
*/
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 длину.
*
* <p>Strips:
* <ul>
* <li>{@code \r} (CR, U+000D)</li>
* <li>{@code \n} (LF, U+000A)</li>
* <li>{@code U+0085} (NEL — Next Line, Outlook treats как line break)</li>
* <li>{@code U+2028} (Line Separator)</li>
* <li>{@code U+2029} (Paragraph Separator)</li>
* </ul>
* Заменяются на single space.
*
* <p>Используется для:
* <ul>
* <li>Email Subject (HIGHEST risk — RFC 5322 header injection).</li>
* <li>Email From/To/Reply-To display names (вторичный risk).</li>
* <li>Body параметров в email plain text (CRLF в body не injection,
* но уродует layout).</li>
* </ul>
*
* @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 длину.
*
* <p>Express BotX поддерживает урезанный Markdown:
* <ul>
* <li>{@code **bold**}</li>
* <li>{@code *italic*}</li>
* <li>{@code `code`}</li>
* <li>{@code ~~strike~~}</li>
* </ul>
*
* <p>Если business_key содержит {@code *} или {@code _} или {@code `} или
* {@code ~} — backslash-escape'аем чтобы:
* <ol>
* <li>Не сломать rendering (template "*Новый draft*" + business_key
* "Союз*М-25" → "*Новый draft*Союз*М-25" → bold-spans поломаются).</li>
* <li>Не открыть structure manipulation вектор (хотя BotX rendering
* sandbox'нутый, defence-in-depth дёшев).</li>
* </ol>
*
* <p>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 длину.
*
* <p>Per <a href="https://core.telegram.org/bots/api#markdownv2-style">
* Telegram Bot API MarkdownV2</a>, reserved chars: <pre>
* _ * [ ] ( ) ~ ` &gt; # + - = | { } . !
* </pre>
* Plus backslash itself ({@code \}) escape character.
*
* <p>Все эти 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).
*
* <p>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]", "");
}
}
@@ -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.
*
* <p>Bundles в classpath {@code messages/notifications_{locale}.properties}.
* Keys имеют shape {@code {eventKey}.{channel}.{slot}}, where:
* <ul>
* <li>{@code eventKey}: 'draft_submitted' | 'draft_decision' | 'draft_withdrawn'</li>
* <li>{@code channel}: 'email' | 'express' | 'telegram'</li>
* <li>{@code slot}: 'subject' | 'body'</li>
* </ul>
*
* <p>Example: {@code draft_submitted.email.subject} = {@code "[НСИ] Новый draft на review — {0} / {1}"}
*
* <p>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.
*
* <p>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;
}
}
@@ -0,0 +1 @@
cloud.nstart.terravault.ordinis.notifications.config.NotificationsAutoConfiguration
@@ -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\\.
@@ -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 сам отозвал\\.
@@ -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:
* <ul>
* <li>Bundles parseable (no MessageFormat syntax errors).</li>
* <li>RU + EN templates exist для всех 9 combinations (3 events × 3 channels).</li>
* <li>Sanitization happens — malicious business_key не ломает rendering и не
* inject'ит в email subject.</li>
* </ul>
*/
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");
}
}
+6
View File
@@ -31,6 +31,7 @@
<module>ordinis-projection-writer</module>
<module>ordinis-cuod-bundle</module>
<module>ordinis-migrations</module>
<module>ordinis-notifications</module>
</modules>
<!--
@@ -113,6 +114,11 @@
<artifactId>ordinis-migrations</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-notifications</artifactId>
<version>${project.version}</version>
</dependency>
<!-- External -->
<dependency>