📮 Коротко: підключити відправку email у Spring Boot можна за 10 хвилин. Зробити так, щоб листи не губились, не дублювались, не блокували користувача і не залежали від одного провайдера, — окрема задача. Розбираю 8 рішень на прикладах з мого блогу: що робити, чому саме так і що це дає на практиці.
📬 Ще не обрали провайдера? Почніть з огляду 7 сервісів для відправки email: ціни, ліміти й коли який брати і порівняння Resend vs SendGrid.
⚡ Головне за 30 секунд
- ✅ SMTP — коли важлива незалежність від провайдера, HTTP API — коли потрібні ідемпотентність, теги і batch-відправка.
- ✅ Бізнес-код не повинен знати провайдера: інтерфейс + реалізації, що перемикаються через
@ConditionalOnProperty.
- ✅ Шаблони листів — на своєму боці (Thymeleaf), щоб переїзд на іншого провайдера не означав переписування шаблонів.
- ✅ Лист — асинхронно і після коміту транзакції, а не всередині неї.
- ✅ Повторювати тільки тимчасові помилки (429, 5xx) і з ключем ідемпотентності.
📑 Зміст статті:
⚖️ SMTP чи HTTP API
Більшість сервісів (Resend, SendGrid, Mailgun, Postmark) дають обидва способи. Вибір між ними — це вибір між гнучкістю і можливостями.
|
SMTP (JavaMailSender) |
HTTP API (SDK) |
| Зміна провайдера |
Зміна хоста і пароля в конфігу |
Нова реалізація в коді |
| Залежності |
spring-boot-starter-mail |
SDK конкретного провайдера |
| Ідемпотентність, теги, batch |
Частково, через кастомні заголовки |
Так, «з коробки» |
| Відповідь |
«Прийнято сервером» |
ID листа, з яким можна працювати далі |
| Коли брати |
Простий проєкт, кілька провайдерів, легасі |
Розсилки, надійні повтори, вебхуки |
У своєму блозі я використовую HTTP API Resend через Java SDK: мені потрібні ID листів і аналітика доставки. Але для невеликого проєкту SMTP — цілком нормальний вибір.
📨 SMTP через JavaMailSender
Що це. Стандартний для Java спосіб відправки пошти: Spring Boot автоматично налаштовує JavaMailSender за властивостями spring.mail.*.
Чому так. SMTP — універсальний протокол, його підтримує будь-який провайдер. Код відправки нічого не знає про Resend чи SendGrid.
Що це дає на практиці. Переїзд з Resend на SendGrid — це зміна трьох рядків конфігурації, без жодного рядка Java-коду:
# Resend
spring.mail.host=smtp.resend.com
spring.mail.port=587
spring.mail.username=resend
spring.mail.password=${RESEND_API_KEY}
# SendGrid: host=smtp.sendgrid.net, username=apikey, password=${SENDGRID_API_KEY}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=10000
spring.mail.properties.mail.smtp.writetimeout=10000
Зверніть увагу на дві деталі. Логін — це буквальний рядок: resend у Resend і apikey у SendGrid, а сам API-ключ іде в пароль. І таймаути: за замовчуванням JavaMail чекає відповіді сервера безкінечно. Якщо SMTP «завис», разом із ним зависне і потік вашого застосунку.
Сама відправка:
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
helper.setFrom("WebCraft <[email protected]>");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(html, true); // true = HTML
mailSender.send(message);
🔌 HTTP API через SDK
Що це. Відправка через REST API провайдера за допомогою його офіційної бібліотеки.
Чому так. API вміє те, чого немає в SMTP: повертає ID листа, підтримує теги для аналітики, відкладену відправку, batch (у Resend — до 100 листів за запит) і ключі ідемпотентності. Про них детальніше в розділі про повтори.
Що це дає на практиці. Більше контролю і надійніші повтори, але код прив'язаний до одного провайдера. Тому SDK варто ховати за інтерфейсом — про це наступний розділ. Ось як виглядає відправка в моєму проєкті:
CreateEmailOptions request = CreateEmailOptions.builder()
.from(fromName + " <" + fromEmail + ">")
.to(email)
.subject("📝 Нова стаття: " + post.getTitle())
.html(html)
.build();
CreateEmailResponse response = resend.emails().send(request);
log.info("Лист відправлено, ID: {}", response.getId());
🧱 Інтерфейс і @ConditionalOnProperty
Що це. Бізнес-код залежить від інтерфейсу EmailSender з одним методом, а конкретна реалізація (Resend, SMTP, лог) обирається властивістю конфігурації.
public interface EmailSender {
void send(EmailMessage message);
}
public record EmailMessage(String to, String subject, String html, String idempotencyKey) {}
@Service
@ConditionalOnProperty(name = "app.email.provider", havingValue = "resend")
public class ResendEmailSender implements EmailSender { ... }
@Service
@ConditionalOnProperty(name = "app.email.provider", havingValue = "smtp")
public class SmtpEmailSender implements EmailSender { ... }
@Service
@ConditionalOnProperty(name = "app.email.provider", havingValue = "log")
public class LoggingEmailSender implements EmailSender { ... } // пише лист у лог
Чому так. Сервіс, який створює рекламну заявку чи публікує статтю, не повинен знати, хто доставить лист. Його задача — сказати «відправ цей лист», а не «збери запит до Resend».
Що це дає на практиці:
- Зміна провайдера — одна змінна оточення (
APP_EMAIL_PROVIDER=smtp на Railway), без зміни коду і без нового релізу.
- Локально листи йдуть у лог (
app.email.provider=log у dev-профілі) — ви не витрачаєте ліміти провайдера і не надсилаєте тестові листи реальним людям.
- Нова реалізація — це один метод
send(), а не переписування всієї логіки листів.
💡 З мого досвіду: у своєму проєкті я тримаю окрему конфігурацію для кожного провайдера — клієнт Resend в одному класі, клієнт MailerLite в іншому. Ту саму умову @ConditionalOnProperty варто поставити і на ці конфігурації: тоді бін клієнта створюється тільки для активного провайдера. Без цього застосунок вимагатиме ключі всіх провайдерів одразу і не запуститься, якщо хоч одного бракує.
Одна порада: не ставте matchIfMissing = true на реалізацію з логом. Якщо на проді забудете задати змінну, застосунок тихо «відправлятиме» листи в лог. Без неї Spring просто не запуститься з помилкою про відсутній бін EmailSender — і ви дізнаєтесь про проблему одразу, а не від користувачів.
🎨 Шаблони листів на Thymeleaf
Що це. HTML листа — це звичайний Thymeleaf-шаблон у templates/email/, який рендериться у рядок перед відправкою.
Context context = new Context(Locale.of("uk"));
context.setVariable("post", post);
context.setVariable("unsubscribeUrl", unsubscribeUrl);
String html = templateEngine.process("email/new-post", context);
emailSender.send(new EmailMessage(email, subject, html, idempotencyKey));
Чому так. Шаблон живе в Git поряд з кодом: з історією змін, рев'ю і можливістю відкотитись. Його можна відкрити в браузері і побачити, як виглядає лист.
Що це дає на практиці. Шаблони не залежать від провайдера. Якщо зберігати їх у Dynamic Templates SendGrid або Templates Resend, при переїзді доведеться переносити й переписувати кожен шаблон. Зі своїм шаблонізатором провайдер отримує вже готовий HTML.
💡 З мого досвіду: зараз HTML листів у мене збирається через String.format прямо в класі провайдера — 200+ рядків CSS і розмітки в Java. Змінити текст кнопки — це правка Java-класу і новий деплой. Саме тому шаблони переїжджають у Thymeleaf.
І практична деталь про верстку: поштові клієнти — не браузери. Gmail вирізає частину CSS, а Outlook не підтримує flexbox. Для листів надійніші вбудовані стилі (style="...") і верстка таблицями.
⏱️ Асинхронна відправка через @Async
Що це. Метод з @Async виконується в окремому потоці, і запит користувача не чекає, поки лист піде до провайдера.
@Async("emailExecutor")
public void sendCampaignCreatedNotification(String email, CampaignDto campaign) {
try {
emailSender.send(buildMessage(email, campaign));
} catch (Exception e) {
log.error("Не вдалося відправити лист для {}", campaign.trackingCode(), e);
}
}
Чому так. SMTP або API провайдера може відповідати секунду і довше, а іноді й не відповідати зовсім. Користувачу, який щойно створив заявку, не треба цього чекати.
Що це дає на практиці. Відповідь приходить одразу, а якщо провайдер упав — заявка все одно створюється, просто лист не дійде (як це виправити, розберемо в розділі про повтори).
Три пастки, на які легко наступити:
- Самовиклик не працює. Якщо метод з
@Async викликати з іншого методу того самого класу, він виконається синхронно: виклик іде повз Spring-проксі. Асинхронний метод має бути в окремому біні — у мене це NotificationService.
- Виняток у
void-методі ніхто не побачить, якщо його не залогувати. Звідси try/catch всередині.
- Задайте свій пул потоків з обмеженою чергою і зрозумілим префіксом імені (
email-), щоб листи не конкурували з іншими фоновими задачами і було видно їх у логах.
🔒 Лист після коміту транзакції
Що це. Замість прямого виклику відправки сервіс публікує подію, а лист відправляє слухач, який спрацьовує тільки після успішного коміту.
Чому так. Ось реальний метод з мого проєкту — створення рекламної заявки:
@Transactional
public CampaignStatusResponse createCampaign(CampaignCreateRequest request) {
AdCampaign saved = campaignRepository.save(campaign);
notificationService.sendCampaignCreatedNotification(request.getContactEmail(), saved);
return adsMapper.toStatusResponse(saved);
}
Виглядає логічно, але відправка стартує всередині транзакції. Асинхронний потік може відправити лист «Заявку успішно створено» ще до коміту. Якщо коміт потім впаде (наприклад, через обмеження унікальності, яке перевіряється при записі в базу), клієнт отримає лист з кодом заявки, якої в базі немає.
Як правильно:
// У сервісі — тільки подія
@Transactional
public CampaignStatusResponse createCampaign(CampaignCreateRequest request) {
AdCampaign saved = campaignRepository.save(campaign);
events.publishEvent(new CampaignCreatedEvent(saved.getId(), request.getContactEmail()));
return adsMapper.toStatusResponse(saved);
}
// Лист — після коміту і в окремому потоці
@Async("emailExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCampaignCreated(CampaignCreatedEvent event) { ... }
Що це дає на практиці. Лист іде тільки тоді, коли дані точно в базі. Відкат транзакції — немає листа.
Дві деталі. У подію передавайте ID і прості значення, а не JPA-сутність: після коміту вона вже «відірвана» від сесії, і звернення до лінивих полів закінчиться винятком. І якщо подію опублікувати поза транзакцією, слухач AFTER_COMMIT її мовчки проігнорує — для такого випадку є параметр fallbackExecution = true.
🔁 Повтори та помилки
Що це. Розділення помилок на тимчасові, які варто повторити, і постійні, які повторювати марно.
Чому так. Помилки провайдера бувають двох типів:
- Тимчасові: 429 і 5xx. Перевищено ліміт запитів або в провайдера збій. Через секунду-дві запит, найімовірніше, пройде.
- Постійні: 400, 401, 403, 422. Неправильна адреса, невірний ключ, неперевірений домен. Повтор дасть ту саму помилку — тільки витратить ліміт.
Тому в реалізації провайдера варто перетворювати помилку на один з двох власних винятків:
if (status == 429 || status >= 500) {
throw new TransientEmailException(e); // можна повторити
}
throw new PermanentEmailException(e); // повторювати марно
І повторювати тільки перший, з паузою, що зростає (backoff):
// Spring Boot 4 / Spring Framework 7 — вбудовано, плюс @EnableResilientMethods у конфігурації
@Retryable(includes = TransientEmailException.class, maxRetries = 3, delay = 1000, multiplier = 2)
public void send(EmailMessage message) { ... }
// Spring Boot 3 — бібліотека Spring Retry, плюс @EnableRetry
@Retryable(retryFor = TransientEmailException.class, maxAttempts = 4,
backoff = @Backoff(delay = 1000, multiplier = 2))
Що це дає на практиці. Короткі збої провайдера «проковтуються» автоматично, а битий email не крутиться в повторах.
Ключ ідемпотентності. Є неприємний випадок: провайдер прийняв лист, але відповідь не дійшла через таймаут. Ви повторюєте — і клієнт отримує два однакові листи. Ключ ідемпотентності це вирішує: Resend пам'ятає ключ 24 години і на повторний запит з тим самим ключем не відправляє лист вдруге. Ключ має однозначно описувати лист:
String key = "campaign-created/" + campaignId; // для API — заголовок Idempotency-Key
// для SMTP — заголовок Resend-Idempotency-Key
Що робити, якщо провайдер лежить довше. Повтори в пам'яті не допоможуть, якщо збій триває годину або застосунок перезапуститься. Тоді надійніше записувати листи в таблицю (статус, кількість спроб, час наступної спроби) і відправляти їх задачею за розкладом. Лист, записаний у тій самій транзакції, що й заявка, не загубиться ні при збої провайдера, ні при деплої.
💡 З мого досвіду: розсилка статей у мене йде через чергу в пам'яті — LinkedBlockingQueue і один лист кожні 2 секунди, щоб не впертися в ліміт запитів. Це працює, але має три слабкі місця: при деплої на Railway непрочитані листи з черги губляться, помилка відправки тільки логується без повтору, а 2 000 підписників — це понад годину розсилки. Як переробити розсилку з Batch API, відписками і вебхуками, я розберу в окремій статті Розсилка у Spring Boot: підписка, відписка, batch і вебхуки.
❓ FAQ
Що обрати для Spring Boot: SMTP чи API провайдера?
SMTP — якщо важлива незалежність від провайдера і потрібна проста відправка. API — якщо потрібні ідемпотентність, batch-відправка, теги та ID листів. В обох випадках ховайте реалізацію за інтерфейсом.
Чому не можна відправляти лист прямо всередині @Transactional методу?
Лист піде навіть тоді, коли транзакція потім відкотиться. Користувач отримає підтвердження дії, якої в базі немає. Використовуйте @TransactionalEventListener з фазою AFTER_COMMIT.
Чому @Async не працює?
Найчастіша причина — виклик з того самого класу: він іде повз Spring-проксі і виконується синхронно. Винесіть асинхронний метод в окремий бін і перевірте, що увімкнено @EnableAsync.
Які помилки провайдера варто повторювати?
Тільки тимчасові: 429 (ліміт запитів) і 5xx (збій на боці провайдера). Помилки 4xx на кшталт неправильної адреси чи ключа повторювати марно.
Spring Retry чи @Retryable зі Spring Framework 7?
У Spring Boot 4 (Spring Framework 7) @Retryable вбудований у сам фреймворк і замінює окремий проєкт Spring Retry. У Spring Boot 3 використовуйте бібліотеку Spring Retry.
✅ Висновки
- SMTP дає незалежність від провайдера, API — більше можливостей. Обидва варіанти ховайте за інтерфейсом.
- Перемикайте провайдера через
@ConditionalOnProperty і змінну оточення — без зміни коду і без нового релізу.
- Тримайте шаблони листів на своєму боці — так переїзд на іншого провайдера не зачепить шаблони.
- Відправляйте асинхронно і тільки після коміту транзакції.
- Повторюйте лише тимчасові помилки, з backoff і ключем ідемпотентності, а для довгих збоїв зберігайте листи в базі.
📚 Джерела