@Mail Domain Check

Spring Boot / registration / @Async / verification email

Spring Boot Says Verification Email Sent, but No Email Was Sent

A registration response can return before an asynchronous mail method starts. Make the verification secret, database commit, worker execution, SMTP result, receiver visibility, and single-use verification six separate facts.

Generate the release gate

Browser-local release gate

Turn four observed states into one truthful API claim

No address, code, link, hash, message, SMTP credential, provider identifier or production log is requested or transmitted.

Use the weakest claim that the evidence actually proves

Prepared
A hashed verification artifact exists. No handoff to a worker has been proved.
Scheduled
An in-memory executor accepted a task. A restart can still lose it.
Queued
A durable job committed and can survive process exit.
Processing
The worker started. JavaMailSender has not yet returned.
Submitted
The SMTP server accepted the message. Receiver delivery and inbox visibility still follow.
Verified
The newest valid secret changed the account state exactly once.

The word sent collapses several asynchronous boundaries. For an API response, prefer an explicit machine state such as verification.status = "queued" and reserve emailVerified = true for successful code consumption.

What the current Fuel source proves

At Fuel revision b39bf524f3b893e19b635e64d243a8e0e15d42c6, AuthService.register() persists a user, authentication provider and goals, then issues tokens and returns Registration successful. Verification email sent. The constructor has no verification-code or email collaborator, and the corresponding unit test asserts the success string without asserting code creation or a mail call.

Fuel issue 32 documents the intended implementation: hashed 24-hour codes, verification and resend endpoints, Redis-backed rate limits, Spring Mail, local Mailpit, and asynchronous delivery. The extra release concern is the boundary between transaction commit and the asynchronous task: adding @Async can make registration faster, but it does not by itself make the response truthful or the handoff durable.

This is evidence for the linked revision, not a claim about a later branch or deployment. Recheck the current source, migration and test results before applying the gate.

Do not send a verification link for a rolled-back user

A call to an asynchronous method inside @Transactional register() can race the database commit. The mail thread may look for data that is not visible yet, or send a usable-looking link before a later exception rolls the transaction back.

For a production path that must survive restarts, commit a durable outbox row in the same transaction as the user and hashed code. A separate worker claims the row after commit, records each attempt, and retries with an idempotency key. When occasional loss is acceptable, a separate Spring bean using @TransactionalEventListener(AFTER_COMMIT) plus @Async avoids the rollback race but remains an in-memory handoff.

@Transactional
public RegistrationResult register(UserRegisterRequest request) {
    User user = saveUser(request);
    VerificationCode code = verificationCodes.createHashed(user);
    notificationOutbox.enqueue(
        "verify-email:" + code.getId(),
        user.getId(),
        code.getId()
    );
    return RegistrationResult.queued(user);
}

Spring's default @Async advice is proxy-based. A local call from one method to another method in the same class is not intercepted. Keep the worker or listener in a separate Spring-managed bean and test the actual application context.

Persist state, not secrets or optimistic prose

A minimal notification ledger needs an immutable event key, internal account and verification references, state, attempt count, retry time, bounded error class and optional provider reference. It does not need the raw code, verification URL, password, SMTP credential or rendered body.

notification_outbox
  id                  UUID primary key
  event_key           VARCHAR unique not null
  user_id             UUID not null
  verification_id     UUID not null
  state               VARCHAR not null
  attempt_count       INTEGER not null default 0
  last_error_class    VARCHAR
  provider_reference  VARCHAR
  next_attempt_at     TIMESTAMPTZ
  created_at          TIMESTAMPTZ not null
  updated_at          TIMESTAMPTZ not null

Useful states are queued, claimed, smtp_accepted, retry_wait and terminal_failed. Delivery webhooks can update a separate per-recipient delivery record; do not overwrite submission history with the latest mailbox outcome.

Make void asynchronous failures observable

Spring documents that a caller returns immediately after submitting an @Async task. For a void method, an exception cannot be transmitted back to that caller; configure an AsyncUncaughtExceptionHandler and, more importantly, update the durable job state inside the worker.

  1. Give the mail executor a name, bounded queue, rejection policy and shutdown behavior.
  2. Mark the outbox row claimed with a lease so a crashed worker can be recovered.
  3. Catch Spring MailException subclasses into bounded operational categories.
  4. On successful JavaMailSender.send, record SMTP submission acceptance, not inbox delivery.
  5. Retry with backoff and idempotency; never create another active verification secret merely because a send attempt failed.

Release tests that prove the contract

  • Registration: creates one hashed code and one outbox event; raw code is absent from persistence and logs.
  • Rollback: an exception after artifact creation leaves no deliverable outbox event and sends no message.
  • Restart: a committed queued job is claimed after worker restart and a completed event is not sent twice.
  • Mail failure: registration follows its documented contract while the job records a bounded failure and retry.
  • Resend: unknown, verified, rate-limited and eligible addresses receive the same HTTP response; only an eligible account gets a new job.
  • Single use: the newest unexpired code verifies once; older, expired, invalidated and reused codes fail.
Need reusable incident, change, rollback, and weekly-review worksheets?

Read the three-section sample first. The complete 13-section Web3 Email Authentication Operations Kit costs exactly 1 native USDC on Base only when it is useful to you.

Preview free sample

The production-ready proof

Call the registration path ready when one controlled request creates one hashed secret and one post-commit durable job, the worker records one bounded SMTP result, the visible newest message verifies the account exactly once, and every response string matches the strongest state actually known at that moment.

After SMTP submission works, use the verification-email triage map to follow provider and receiver states, or check the visible From domain's SPF, DKIM, DMARC, PTR and MTA-STS controls above.

This independent guide is not affiliated with Fuel, Spring or its contributors. The current source revision, committed database state, worker ledger, SMTP result, controlled mailbox and verification endpoint remain the sources of truth for their respective boundaries.

Primary references