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:
| Channel | Single | Batch |
|---|---|---|
sendMail(SendMailDTO) | sendMailBatch(List<SendMailDTO>) | |
| SMS | sendSms(SendSmsDTO) | sendSmsBatch(List<SendSmsDTO>) |
| Inbox | sendInbox(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 tablesmessage-starter-sms.sql— SMS tablesmessage-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:
| Dependency | Used by | Failure behaviour | Operational expectation |
|---|---|---|---|
| Database | All paths (records, outbox, framework versionLock) | Operation throws; caller sees 5xx | HA-Database (replicated MySQL / managed PG); migrations applied. |
| Redis | RateLimiter (per-config delivery rate windows), MailConfigCache | Operation throws; caller sees 5xx | Sentinel or Cluster setup. K8s readinessProbe should include /actuator/health/redis so the load balancer routes traffic away while Redis is unreachable. |
| Pulsar broker | OutboxPublisher (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 provider | Outbound send | Per-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 = TENANTreads the current scope’s own rows only,scope = PLATFORMreads 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.TENANTdefault,PLATFORM= the platform tier for template, server AND quota bucket — for billing/security/compliance messages).MailRequestMessage.tenantIdlets 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:
- A
MailSendRecord/SmsSendRecordand anOutboxEntryare 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. - The scheduled
OutboxPublisher(500 ms poll) claimsNEWrows asPUBLISHINGthrough frameworkversionLock, publishes them to the corresponding topic, and flips the outbox row toPUBLISHED. - A
@PulsarListenerconsumer reads the message (carrying onlyrecordId/tenantId/traceId), then drives the channel’sDeliveryProcessor, which CAS-transitionsPENDING|RETRY → SENDINGbefore 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 SENDING → RETRY, stale outbox PUBLISHING → NEW) — 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_LETTER→RETRYplus a fresh outbox row, atomically.SENTand in-flightSENDINGare rejected. Safe to call repeatedly — the delivery claim is CAS-guarded, so duplicates no-op and no double email/SMS is possible.retryCountkeeps counting: a manual retry grants one new attempt; another failure returns the record toFAILED/DEAD_LETTERinstead of re-arming the whole automatic budget.POST /OutboxEntry/requeue?id=— reopen oneDEADoutbox 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_subMessage-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 timeoutMail 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
passwordonmail_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:
| Name | Tags | When incremented |
|---|---|---|
softa.message.sent | channel (mail/sms), provider | Provider call succeeded |
softa.message.failed | channel, provider, outcome (retry/failed/dead_letter) | Provider call failed |
softa.message.outbox.published | route | Outbox entry successfully published to broker |
softa.message.outbox.dead | route | Outbox 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 / MailerDaemonRule →
BounceReceiptLinker → markBounced) 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 dayrate_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 bySendFailureHandler(record id inevent_id, failure detail in the JSONpayload).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 byDeadLetterConsumer. Opt a listener in with@PulsarListener(deadLetterPolicy = "commonDlqPolicy")and setsofta.message.dlq.topic.
Triage the rows via DeadLetterMessageController (status Pending →
Resolved / 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.