Build and configure

SMTP connection pooling and batch orchestration

Choose who owns SMTP connection reuse, leases, asynchronous work, and shutdown across Simple Java Mail, batch-module, direct pools, Jakarta Mail, Spring, and Camel.

On this page

Choose who should own delivery.

Every path can avoid needless SMTP reconnects. Start with the programming model you want to keep; each card jumps to the performance benefit, ownership boundary, and complete example.

There is one underlying connection-pool implementation and several ways to orchestrate it. The right choice depends less on raw throughput than on which layer already owns your messages, work queue, Jakarta Mail Session, and shutdown boundary.

Single physical-pool owner Exactly one component owns the physical connection pool. Never place batch-module or a direct pool around an smtppool transport.
§

Follow the ownership lane

Each row is a complete lifecycle. Moving across rows mid-flow creates ambiguous release, failure, and shutdown behavior.

Option
Executor
Accepted work
Session
Lease
Pool + shutdown
No pool
Caller / MailerOptional
CallerSequential unit
Mailer / callerOne connection
NoneNo shared lease
Scope ownerCallback / Mailer close
Mailer + batch-module
Simple Java MailUnless supplied
Simple Java MailSend futures
Simple Java MailSelected Mailer
Simple Java MailAutomatic
Mailerclose()
Standalone batch facade
Batch facadeUnless supplied
Batch facadeCallbacks / futures
CallerRegistered Sessions
Batch facadeAutomatic
Batch facadeGraceful / forced
Direct smtp-connection-pool
ApplicationOptional
ApplicationAll tracking
ApplicationPool key
ApplicationExplicit
ApplicationExplicit wait
Jakarta smtppool provider
Application / frameworkOutside provider
Application / frameworkOutside provider
Caller / SpringProvider scoped
Providerconnect / close
Provider registryExplicit shutdown
Camel smtppool adapter
CamelRoutes
CamelExchanges
Camel componentEndpoint config
Adapter / providerAutomatic
Camel / registryRoute lifecycle
§

Compare the six options

OptionBest whenOrchestration ownerLease handlingPooling / clustering
No pool: withOpenConnection / simple batchOne sequential unit of workApplication / Simple Java MailNot applicableNo
Simple Java Mail Mailer + batch-moduleUsing EmailBuilder and MailerSimple Java MailAutomaticYes
Standalone batch-module facadeCreating MimeMessage objects while wanting managed callbacks and futuresBatch facadeAutomaticYes
Direct smtp-connection-poolNeeding exact claim, failure, and shutdown controlApplicationExplicit leaseYes
Jakarta smtppool providerPlain Jakarta Mail or Spring owns Transport callsProviderMapped to connect / closeYes
Camel smtppool: adapterCamel owns endpoints and component lifecycleCamel adapter / providerAutomaticYes
§

1. Reuse one connection without a pool

Owner: callback / MailerSequentialNo clustering

Use this when reconnecting is the only overhead and the work is naturally sequential. The scoped sender keeps one connection open and closes it when the callback ends. There is no shared lease or pool to shut down.

This gives you

Most of the connection-reuse speed-up for a sequential burst, without sizing, sharing, or draining a pool. Stop here when one callback owns one SMTP connection; choose a pooled option when sends must overlap or Sessions must cluster.

Dependency: org.simplejavamail:simple-java-mail:9.3.0

try (Mailer mailer = MailerBuilder
        .withSMTPServer("smtp.example.com", 587, "user", "secret")
        .buildMailer()) {
    mailer.withOpenConnection(sender -> {
        for (Email email : emails) {
            sender.sendMail(email);
        }
    });
}

If you already have all messages, mailer.sendMailsInSimpleBatch(emails, false) is the shorter equivalent. Move to a pool only when independent work must run concurrently or several SMTP Sessions must form a cluster.

§

2. Let Simple Java Mail orchestrate pooled sends

Owner: Simple Java MailAutomatic leasesClusters

This is the normal choice when you use EmailBuilder and Mailer. The optional batch module sections off the upstream pool dependency and lets the Mailer own message conversion, asynchronous work, selected Session, success release, failure invalidation, and shutdown.

This gives you

High-throughput pooled delivery while you stay in Simple Java Mail's fluent EmailBuilder and Mailer model. This is the best default when you want connection reuse, concurrent sends, and clusters without turning lease safety, Session selection, or shutdown into application code.

Dependencies: simple-java-mail:9.3.0 plus batch-module:9.3.0

try (Mailer mailer = MailerBuilder
        .withSMTPServer("smtp.example.com", 587, "user", "secret")
        .withConnectionPoolCoreSize(2)
        .withConnectionPoolMaxSize(10)
        .withConnectionPoolExpireAfterMillis(30_000)
        .buildMailer()) {
    mailer.sendMail(email); // sync or async, as configured
}
  • A default executor belongs to the Mailer; a supplied executor remains caller-owned.
  • The Mailer chooses the actual clustered Session and builds the message for it.
  • Mailer.close() waits for accepted work and closes that Mailer's registered pool.
  • Retry, circuit breaking, and ambiguous-delivery policy remain application concerns.
§

3. Use batch-module without EmailBuilder or Mailer

Owner: BatchTransportExecutorCallbacksCompletableFuture

New in 9.3.0, BatchTransportExecutor<K> is the middle path for applications that own Jakarta Mail message construction but want callback-scoped transports, cluster selection, futures, and deterministic lifecycle. It orchestrates smtp-connection-pool; it is not another pool implementation.

This gives you

The same high-throughput clustered pool and automatic lease safety while your application keeps building MimeMessage objects and owning each unit of work. Choose this boundary when callbacks and futures fit, but adopting EmailBuilder and Mailer does not.

Dependency: org.simplejavamail:batch-module:9.3.0. The main simple-java-mail facade is not required.

<dependency>
    <groupId>org.simplejavamail</groupId>
    <artifactId>batch-module</artifactId>
    <version>9.3.0</version>
</dependency>
Properties properties = new Properties();
properties.setProperty("mail.smtp.host", "smtp.example.com");
properties.setProperty("mail.smtp.port", "587");
properties.setProperty("mail.smtp.auth", "true");

Session session = Session.getInstance(properties, new Authenticator() {
    protected PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication("user", "secret");
    }
});

try (BatchTransportExecutor<String> batch =
             BatchTransportExecutor.<String>builder()
                     .withMaxPoolSize(8)
                     .withClaimTimeoutMillis(30_000)
                     .withExpireAfterMillis(60_000)
                     .build()) {
    batch.registerSession("transactional", session);

    CompletableFuture<Void> sent = batch.submit("transactional",
            (selectedSession, transport) -> {
                MimeMessage message = new MimeMessage(selectedSession);
                message.setFrom("sender@example.com");
                message.setRecipients(Message.RecipientType.TO, "recipient@example.com");
                message.setSubject("Queued report");
                message.setText("The report is ready.");
                transport.sendMessage(message, message.getAllRecipients());
                return null;
            });
    sent.join();
}

A cluster-selected callback receives the Session that actually won selection. When a message was already created for one registered Session, use the exact-Session overload: batch.execute(clusterKey, session, operation).

The callback must not connect or close the Transport. A normal return releases it. Any escaping checked exception, runtime exception, or error invalidates it before the same failure is propagated or placed on the future.

§

4. Claim leases from smtp-connection-pool directly

Owner: ApplicationExplicit leaseMaximum control

Choose the direct API when your application must decide exactly when to claim, release, invalidate, or drain a pool. This is also how the full Simple Java Mail Mailer integrates internally.

This gives you

High-throughput clustered pooling with every lifecycle lever exposed to your code. This boundary fits infrastructure and library code that must define claim, invalidation, draining, and shutdown policy; application teams take on that responsibility too.

Dependency: org.simplejavamail:smtp-connection-pool:4.0.1

SmtpConnectionPool pool =
        new SmtpConnectionPool(new SmtpClusterConfig<Session>());

try (SmtpTransportLease lease = pool.claimTransport(session)) {
    try {
        lease.getTransport().sendMessage(message, message.getAllRecipients());
    } catch (MessagingException | RuntimeException failure) {
        lease.invalidate();
        throw failure;
    }
}

pool.shutDown().get();

The try-with-resources close releases an active lease. Invalidate first when connection health is uncertain. Your code owns interruption policy, active-work tracking, shutdown waiting, and every path that could otherwise leak a lease.

§

5. Present the pool as a Jakarta Mail Transport

Owner: smtppool providerJakarta MailSpring

Use the provider when existing code already speaks the Jakarta Mail Transport.connect() / close() lifecycle. It is the natural fit for plain Jakarta Mail and Spring's JavaMailSenderImpl. The provider maps that lifecycle onto an internal lease.

This gives you

Connection reuse behind the Jakarta Mail Transport contract your code or Spring already understands. Choose this boundary when connect() / close() must stay framework-owned; the provider does not add message building, a work queue, or futures.

Dependencies: smtp-connection-pool-jakarta-provider:4.0.1 and one physical provider such as angus-mail:2.0.5

Properties properties = new Properties();
properties.setProperty(SmtpPoolProperties.DELEGATE_PROTOCOL, "smtp");
Session session = Session.getInstance(properties);

Transport transport = session.getTransport("smtppool");
transport.connect("smtp.example.com", 587, "user", "secret");
try {
    transport.sendMessage(message, message.getAllRecipients());
} finally {
    transport.close(); // release healthy; invalidate unhealthy
}

SmtpPoolRegistry.shutdown(session).get();

Spring configures the same provider with JavaMailSenderImpl#setProtocol("smtppool"). Application/framework executors and queued work remain outside the provider; the Session-scoped registry owns pool draining and forced escalation.

§

6. Let Camel select the pooled provider

Owner: Camel + providerRoutesJava 17+

Choose the Camel adapter when Camel already owns endpoints, exchanges, and component lifecycle. The adapter selects the same provider; it does not contain a second pool.

This gives you

Pooled connection reuse inside existing Camel mail routes while Camel keeps ownership of endpoints, exchanges, and route lifecycle. Choose this boundary for Camel 4.21+ / Java 17+ integration, not as a general-purpose batch API.

Dependency: org.simplejavamail:smtp-connection-pool-camel:4.0.1

from("direct:mail")
    .to("smtppool://smtp.example.com:587"
        + "?username=user"
        + "&password=secret"
        + "&to=recipient@example.com");

Camel 4.21.x and the adapter require Java 17+. The original direct pool, Jakarta provider, and Simple Java Mail batch facade remain Java 8 compatible.

§

Failure, OAuth2, and shutdown semantics

Successful work releases; uncertain failure invalidates

The Mailer and standalone batch facade make this decision automatically around their callback boundary. Direct users make it on the lease. Provider users express it through Transport health and close(). Catch a recipient-level failure inside a callback only when you know the physical SMTP conversation remains synchronized and reusable.

OAuth2 belongs to the selected Session

The standalone facade bridges its fixed-token and supplier properties when each Session is registered. A clustered claim therefore resolves the supplier from the Session actually selected—not from whichever Session happened to submit the work. Suppliers run only when a physical Transport connects or reconnects; reusing an already-connected Transport does not request a new token.

session.getProperties().put(
        BatchTransportExecutor.OAUTH2_TOKEN_PROVIDER_PROPERTY,
        (Supplier<String>) tokenService::currentAccessToken);

JPMS names are stable through the full pool chain

Simple Java Mail 9.3.0 consumes the fixed chain: org.bbottema.genericobjectpool, org.bbottema.clusteredobjectpool, org.simplejavamail.smtpconnectionpool, and org.simplejavamail.batch. The optional provider and Camel adapter add org.simplejavamail.smtpconnectionpool.jakarta and org.simplejavamail.smtpconnectionpool.camel. Release builds compile real module-path consumers so these manifest names cannot silently regress.

Graceful and forced shutdown have different jobs

  • Graceful: stop accepting work, let accepted callbacks finish, then drain physical pools and close connections.
  • Forced: also reject pending claims, invalidate active leases, and attempt to cancel queued module-owned work.
  • Default executor: module-owned and stopped by the facade or Mailer.
  • Supplied executor: caller-owned and left running; it must keep progressing accepted work during graceful shutdown.
§

Invalid double-pooling combinations

batch-module → smtppool

Rejected during Session registration. Both layers would believe they own release and shutdown.

direct pool → smtppool

Do not allocate provider-owned pooled Transports inside a caller-owned direct pool.

Mailer pool → wrapped provider

Do not change a pooled Mailer's physical protocol to smtppool. Use one integration path.

A Spring application should normally choose the provider path if it wants to keep JavaMailSender ownership. A Simple Java Mail application should normally choose the Mailer path. Wrapping either in the standalone facade adds a second owner without adding pooling capability.

§

Executable examples and deeper references

The upstream BatchModuleDemo runs the standalone path against a real dummy SMTP server. It uses only the public batch API; the callback above and the generated batch-module Javadocs remain the complete API references.