Відправка email у Spring Boot: 8 рішень, щоб листи не губились

Оновлено:
Мова: 🇺🇦
Відправка email у Spring Boot: 8 рішень, щоб листи не губились

📮 Коротко: підключити відправку 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 і ключем ідемпотентності, а для довгих збоїв зберігайте листи в базі.

📚 Джерела