Skip to Content
DocsFeaturesMessageOverview

Message Starter

Provides unified messaging capabilities for Softa applications:

  • Email: send emails, receive emails, and render email templates
  • SMS: send SMS messages, batch send, render templates, and retry on failure
  • Inbox: push in-app notifications to users

Delivery reliability is built on a transactional outbox + optimistic-lock CAS state machine, so broker failures, duplicate deliveries, and in-flight crashes are handled without message loss or double-sends.

Dependency

<dependency> <groupId>io.softa</groupId> <artifactId>message-starter</artifactId> <version>${softa.version}</version> </dependency>

Application API

MessageService is the only message-submission service exposed to business modules:

ChannelSingleBatch
MailsendMail(SendMailDTO)sendMailBatch(List<SendMailDTO>)
SMSsendSms(SendSmsDTO)sendSmsBatch(List<SendSmsDTO>)
InboxsendInbox(SendInboxDTO)sendInboxBatch(List<SendInboxDTO>)

One DTO always represents one independent message. Batch methods accept 1..500 items, join the caller’s transaction, and return record IDs in input order. A mail DTO may address multiple to recipients in one MIME message; an SMS DTO always contains exactly one phone number.

Requirements and Configuration

Apply the following DDL under src/main/resources/sql/:

  • message-starter.sql — email + inbox tables
  • message-starter-sms.sql — SMS tables
  • message-starter-outbox.sql — transactional outbox (shared by mail + SMS)
  • message-starter-dlq.sql — unified dead-letter store (dead_letter_message)

Uses the framework ORM/versionLock path for runtime writes, so outbox publishing does not depend on database-specific row-lock SQL.

Hard dependencies

message-starter deliberately treats Redis and the relational database as hard dependencies. There is no fail-open / local-fallback path for them — if either is unavailable the operation that depends on it surfaces an exception to the caller. This keeps the runtime simple and matches how the rest of the Softa stack already behaves (cache, distributed lock, session, etc.). The trade-off and the operational expectations:

DependencyUsed byFailure behaviourOperational expectation
DatabaseAll paths (records, outbox, framework versionLock)Operation throws; caller sees 5xxHA-Database (replicated MySQL / managed PG); migrations applied.
RedisRateLimiter (per-config delivery rate windows), MailConfigCacheOperation throws; caller sees 5xxSentinel or Cluster setup. K8s readinessProbe should include /actuator/health/redis so the load balancer routes traffic away while Redis is unreachable.
Pulsar brokerOutboxPublisher (publish), consumers (subscribe)Outbox row stays NEW; publisher retries with exponential back-off; eventually marks DEAD after MAX_PUBLISH_ATTEMPTS=10.HA cluster. Failure does not block business writes — outbox absorbs the gap.
SMTP / SMS providerOutbound sendPer-record fails; classified by ErrorClassifier; retried with exponential back-off (ExponentialBackoffPolicy).Configure provider-side rate limits below provider’s quota.

Why no in-process fallback for Redis? A local Guava limiter would silently let one node burst past the cross-node quota during Redis outages, which on a long enough outage can blow through provider day-quotas and cost real money (Twilio / Aliyun / SES). It’s safer to fail closed at the load-balancer level via the readiness probe than to silently fan out under partial failure.

Multi-tenancy

All messaging business tables (mail_*, sms_*, inbox_notification) are multiTenant models: when the platform’s system.enable-multi-tenancy is on, reads are isolated to the caller’s tenant and writes are auto-stamped by the ORM. tenant_id = -1 rows (BaseConstant.PLATFORM_TENANT_ID) form the platform tier — owned by the platform operator and invisible to tenants. The two tiers are fully separate namespaces; there is no overlay:

  • Templates are seeded per tenant, not shared. Each tenant receives its template rows at provisioning from the APPLICATION’s per-tenant seed files (SysPreDataService.loadPreTenantData, driven by the app’s tenant-provisioned seeder with SysPreData-ledger idempotency and rebuild cleanup). From then on the tenant owns and edits its rows freely; seed-file changes do NOT propagate to existing tenants. The platform tier holds only the templates PLATFORM-scoped sends render (billing, security); a code needed on both tiers (e.g. a public password-reset fallback) appears in both seed sets. Send-time resolution is tier-pure: scope = TENANT reads the current scope’s own rows only, scope = PLATFORM reads the platform tier only.
  • Server/provider configs are invisible to tenants. Tenants see and pin only their own configs; the platform tier is reached solely by the dispatchers’ silent fallback (@CrossTenant) when a tenant has none — mail send/receive fall back to the platform default, and SMS routing falls back per country (tiers never interleave within one country).
  • Background jobs are cross-tenant scans that execute per-record in the owning tenant’s context: the scheduled mail fetch runs each receive config inside its config’s tenant, and the zombie sweeper revives each stuck record inside its record’s tenant.
  • The transactional outbox and the dead-letter store are shared infrastructure tables; tenant identity travels inside the message payload (recordId / tenantId / traceId) and is restored by the consumer.

Two more moving parts round the model out:

  • Per-send tier policy: SendMailDTO.scope / SendSmsDTO.scope / MailRequestMessage.scope (MessageScope.TENANT default, PLATFORM = the platform tier for template, server AND quota bucket — for billing/security/compliance messages). MailRequestMessage.tenantId lets the MQ consumer restore the tenant context, so a TENANT-scoped render reaches the tenant’s own template and the send record lands in the tenant’s books.
  • Platform rows are structurally un-writable from a tenant scope (payload guard, insert stamping, and the tenant-filtered pre-read on update/delete); the mail write endpoints additionally turn that silent no-op into an explanatory BusinessException.

With multi-tenancy disabled, no filtering or stamping occurs and everything behaves single-tenant.

Enable / disable (framework active control)

The seven config/template models (MailTemplate, MailSendServerConfig, MailReceiveServerConfig, SmsTemplate, SmsProviderConfig, SmsProviderRegion, SmsTemplateProviderBinding) declare @Model(activeControl = true) with the framework’s active field — no hand-rolled isEnabled switch, and no service hand-writes an active = true condition: WhereBuilder appends it to every FlexQuery read, so resolution, dispatch and list surfaces filter consistently and a new query cannot forget it. Disabling retires a row from every read WITHOUT deleting it, which is what rows referenced by send records need. A disabled row therefore leaves the default list view, so each of the seven admin pages is a two-tab MultiView — Active (framework default) and All (active IN (true, false), which names the field and therefore suppresses the automatic condition); without that second tab, disabling a row would make it vanish from its own admin page. The column-header filter on active works the same way for ad-hoc queries. Reads that must reach disabled rows use FilterControl.bypassActiveControl(): id-addressed replay (findVisibleById, so disabling a config never turns in-flight retries into CONFIG_NOT_RESOLVABLE), authoring tooling (resolveAny — preview and variable extraction work before a template is activated), the platform-row write probe, and default demotion. Plain getById needs no bypass: id lookups never carry the filter.

Monthly send quotas

TenantMessageQuota (deliberately NOT multiTenant — a platform-owned registry ABOUT tenants, writable only from the platform scope) sets per-tenant monthly ceilings for accepted mail/SMS sends. Enforcement is at acceptance time in MonthlyQuotaGuard: an over-quota send is rejected synchronously — a commercial ceiling, not rate limiting — and delivery retries never touch the count. The bucket follows the send’s scope: PLATFORM sends draw on the platform’s own tenantId = -1 row (set it very large; it exists to cap runaway or malicious mass sending); missing rows fall back to softa.message.quota.mail-monthly-default / sms-monthly-default (null = unlimited — the ledger still advances for reporting). The counters live in the database: one TenantMessageUsage row per bucket per calendar month, check-and-incremented via the ORM’s optimistic-lock CAS (versionLock, one REQUIRES_NEW transaction per attempt). There is no reset job — a new month starts a new row — and rows are never expired, so per-tenant consumption history is a plain model query with the ceiling in force snapshotted onto each row. GET /TenantMessageQuota/usage?tenantId= serves one bucket’s usage vs currently-resolved limits (a tenant session may read only its own bucket). The per-config dailySendLimit / rateLimitPerMinute windows are a different axis (delivery-time infrastructure protection) and stay unchanged.

Async delivery (the only delivery model)

Every MessageService.sendMail(...) / sendSms(...) call follows the same path regardless of broker configuration:

  1. A MailSendRecord / SmsSendRecord and an OutboxEntry are written in one DB transaction (status = PENDING, version = 0). The method returns the record id(s) immediately — callers do not block on the SMTP/SMS round-trip.
  2. The scheduled OutboxPublisher (500 ms poll) claims NEW rows as PUBLISHING through framework versionLock, publishes them to the corresponding topic, and flips the outbox row to PUBLISHED.
  3. A @PulsarListener consumer reads the message (carrying only recordId / tenantId / traceId), then drives the channel’s DeliveryProcessor, which CAS-transitions PENDING|RETRY → SENDING before invoking the provider.

If no broker topic is configured, outbox rows stay in NEW state and are retried by the publisher on every poll — nothing is lost; sends just queue up until an operator supplies a topic. When due rows are waiting on an unavailable route the publisher logs a throttled WARN naming the stranded route(s) (at most one line per 5 minutes), so a down broker never leaves records PENDING without log evidence. Spring @Async is not used.

Manual retry

Automatic recovery covers delivery failures (SendFailureHandler: exponential back-off, then FAILED / DEAD_LETTER; non-retryable failures such as auth errors or an unresolvable config skip the back-off and dead-letter on the first attempt) and stuck in-flight states (ZombieRecordSweeper: stale SENDINGRETRY, stale outbox PUBLISHINGNEW) — but not a record whose outbox row died against a broken broker. Two operator endpoints close that gap:

  • POST /MailSendRecord/retry?id= / POST /SmsSendRecord/retry?id= — requeue one record: PENDING / RETRY / FAILED / DEAD_LETTERRETRY plus a fresh outbox row, atomically. SENT and in-flight SENDING are rejected. Safe to call repeatedly — the delivery claim is CAS-guarded, so duplicates no-op and no double email/SMS is possible. retryCount keeps counting: a manual retry grants one new attempt; another failure returns the record to FAILED / DEAD_LETTER instead of re-arming the whole automatic budget.
  • POST /OutboxEntry/requeue?id= — reopen one DEAD outbox entry (NEW, attempts reset, due immediately). Publish attempts are infra failures, so once the broker is fixed the full budget applies again. Other statuses are rejected.

The HCM admin UI surfaces both as status-gated toolbar actions on the Mail Send Record / SMS Send Record / Outbox Entry detail pages, and as row-level actions on the corresponding list pages.

Broker topics

Only the channel topics you actually use need to be declared. Initial delivery and delayed retries share the same topic; retry timing is carried by the transactional outbox’s next_attempt_at rather than encoded as a separate broker route. Send dead-lettering is a terminal record state (DEAD_LETTER) plus a row archived into the unified dead_letter_message store (see Dead letter store below), not a separate queue.

mq: topics: mail-send: topic: dev_demo_mail_send sub: dev_demo_mail_send_sub sms-send: topic: dev_demo_sms_send sub: dev_demo_sms_send_sub cron-task: topic: dev_demo_cron_task mail-fetch-sub: dev_demo_cron_task_mail_fetch_sub

Message-starter properties

Bound under softa.message from MessageProperties, RetryProperties, and DLQ @Value keys:

softa: message: outbox: enabled: true # default true; disable on read-only replicas poll-interval-ms: 500 zombie: enabled: true # default true stale-seconds: 300 # stale SENDING/PUBLISHING claims are revived cron: "0 * * * * *" # every minute retry: default-max-attempts: 5 exponential: base-seconds: 30 max-seconds: 3600 multiplier: 2.0 jitter: 0.5 # ±50% randomisation quota-floor-seconds: 300 # QUOTA errors wait at least 5 min dlq: topic: dev_demo_message_dlq # unset = broker-poison archiving disabled max-redeliver: 5 # broker nacks before dead-lettering alert: recipients: [email protected] # comma-separated; empty = no alert mail mail: debug: false # Jakarta Mail protocol debug — never enable in prod (leaks AUTH) max-body-chars: 1048576 # acceptance-time cap per outbound body field (characters); the columns themselves are unbounded TEXT fetch: batch-limit: 100 # max messages per cron tick per (config, folder) lease-timeout: 1h # abandoned IMAP watermark lease takeover max-message-size: 100MB # RFC822 size cap; oversize → envelope-only + BodyTooLarge max-attachment-size: 20MB # per-part cap; oversize parts skipped archive-eml: false # opt-in raw EML archive via FileService max-mime-depth: 10 # MIME zip-bomb guard max-mime-parts: 100 # attachment-storm guard transport: connection-timeout: 5s # SMTP/IMAP/POP3 connect timeout read-timeout: 30s # SMTP/IMAP/POP3 read timeout sms: transport: connection-timeout: 5s # HTTPS RestClient connect timeout read-timeout: 30s # HTTPS RestClient read timeout

Mail authentication

Mail servers authenticate with username + password. Where a provider issues an API key as its SMTP/IMAP credential, supply that key as the password. Common setups:

  • an ESP / SMTP relay with an API-key credential (SendGrid, Amazon SES, Postmark, Mailgun): set the key as the password on mail_send_server_config;
  • a provider app password, where the account issues one;
  • a self-hosted MTA (e.g. Stalwart, Postfix).

Operations

Metrics

When Micrometer is on the classpath, MessageMetrics emits four counter families:

NameTagsWhen incremented
softa.message.sentchannel (mail/sms), providerProvider call succeeded
softa.message.failedchannel, provider, outcome (retry/failed/dead_letter)Provider call failed
softa.message.outbox.publishedrouteOutbox entry successfully published to broker
softa.message.outbox.deadrouteOutbox entry exceeded publish attempts → DEAD

softa.message.failed{outcome=dead_letter} is emitted by SendFailureHandler when a send record transitions to DEAD_LETTER.

Inbound delivery status

Mail bounce and read-receipt status is derived from inbound mail on the IMAP receive path — DSN report emails (DsnRule / MailerDaemonRuleBounceReceiptLinkermarkBounced) and MDN emails (ReadReceiptRule). No inbound HTTP callback is provided; for provider-pushed delivery events (SMS DLR, ESP mail events) add a controller in your application that calls the record services’ CAS transitions.

Rate limits

MailSendServerConfig and SmsProviderConfig carry two quota columns:

  • daily_send_limit — cumulative sends per day
  • rate_limit_per_minute — sends per minute (smooths bursts)

Either can be left null/zero to disable that window. Counters live in Redis (rl:{channel}:{daily|min}:{tenantId}:{configId}:{window}), so multi-instance deployments share one budget. A quota breach surfaces as a provider-side QUOTA_EXCEEDED error, classified as ErrorCategory.QUOTA — the retry policy applies the configured quotaFloorSeconds (default 5 min) so we don’t hammer the provider.

Zombie record sweeper

ZombieRecordSweeper runs every minute. Records stuck in SENDING whose updated_time is older than softa.message.zombie.stale-seconds (default 300) are versionLock-transitioned back to RETRY with next_retry_at = now and a retry outbox row is written in the same transaction. Stale outbox PUBLISHING claims are reopened to NEW. This covers JVM crashes between claiming a record and finishing the provider/broker call.

Disable via softa.message.zombie.enabled=false on read-only replicas.

Sensitive field encryption

Credential columns on the config tables (mail_*_server_config.password, sms_provider_config.api_secret) are defined wide enough to hold ciphertext. The framework’s transparent encryption (MetaField.isEncrypted()) handles the read/write side — but you must still mark these fields as encrypted in the SysField metadata table during deployment. See the field metadata encrypted attribute for the full procedure; out of the box the columns store plaintext.

Extension Points

Mail transport

Mail sending is SMTP-only. MailSendServerConfig is the complete outgoing server configuration; SmtpMailTransport is stateless and builds a fresh Jakarta Mail sender per send, so config changes only need the Redis config cache evicted (automatic on update/delete).

Mail classification rules

MailClassifier is a chain-of-responsibility over MailClassificationRule beans. The four stock rules (read-receipt → DSN → mailer-daemon → keyword) run in @Order sequence; the first rule that returns a classification wins. Add provider-specific detection — e.g. legacy Exchange NDRs, Chinese ISP bounce shapes — with a new rule:

@Component @Order(25) // between DsnRule (20) and MailerDaemonRule (30) public class ExchangeNdrRule implements MailClassificationRule { @Override public Optional<MailClassification> match(MimeMessage message) throws Exception { // ... return MailClassification.bounce(info) if it matches return Optional.empty(); } }

Dead letter store

Dead letters from both layers converge into a single dead_letter_message table for triage, discriminated by a source column:

  • SendExhausted — a mail / SMS send record exhausted its provider retry budget; archived by SendFailureHandler (record id in event_id, failure detail in the JSON payload).
  • BrokerPoison — a Pulsar consumer could not process a message after the broker’s max redeliveries; the raw envelope is routed to the DLQ topic and archived by DeadLetterConsumer. Opt a listener in with @PulsarListener(deadLetterPolicy = "commonDlqPolicy") and set softa.message.dlq.topic.

Triage the rows via DeadLetterMessageController (status PendingResolved / Discarded). For custom alerting (Slack, PagerDuty), consume the DLQ topic or watch the table — there is no in-process listener SPI.

Retry policy

Failed sends are retried by ExponentialBackoffPolicy — exponential back-off with a configurable base, multiplier, cap, and ±jitter, tuned via softa.message.retry.exponential.*. The error category from ErrorClassifier decides the disposition: TRANSIENT / QUOTA / UNKNOWN retry (QUOTA clamped to quota-floor-seconds) until default-max-attempts is reached and the record is dead-lettered; PERMANENT / INVALID_INPUT / AUTH fail immediately without retry. RetryDecision is a sealed type (Retry / Fail / DeadLetter) so the failure handler’s switch stays exhaustive.

Last updated on