Kampanie w panelu admina naprawde sie wysylaja
Deploy Polska Lokalnie / deploy (push) Successful in 10s

Outbox byl budowany, ale nic go nie przetwarzalo - kampania wchodzila
w status SENDING i tak zostawala. CampaignSender co minute bierze
kampanie gotowe do wyslania i wysyla je porcjami po 25, pilnujac
dziennego limitu kampanii.

Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie (zgoda
mogla zostac cofnieta po zbudowaniu outboxu) - taki dostaje OPTED_OUT.
Kazda wiadomosc ma link rezygnacji. Bez skonfigurowanej skrzynki
kolejka czeka, zamiast spalic sie na bledach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 12:07:54 +02:00
parent 036cb59331
commit b5d121e24e
3 changed files with 345 additions and 2 deletions
@@ -0,0 +1,203 @@
package pl.polskalokalnie.campaign;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Limit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.util.HtmlUtils;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
import pl.polskalokalnie.send.SmsSender;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/**
* Wysylka kampanii z outboxu. Nic nie rusza sama z siebie - wysyla wylacznie to, co administrator
* zaplanowal w panelu, porcjami i w granicach dziennego limitu kampanii.
*
* <p>Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie: zgoda mogla zostac cofnieta,
* a rezygnacja przyjeta juz po zbudowaniu outboxu. Taki odbiorca dostaje status OPTED_OUT.</p>
*/
@Component
public class CampaignSender {
private static final Logger log = LoggerFactory.getLogger(CampaignSender.class);
/** Ile pozycji outboxu przetwarzamy na jeden przebieg - reszta poczeka minute. */
private static final int BATCH_SIZE = 25;
private final CampaignRepository campaignRepository;
private final CampaignRecipientRepository recipientRepository;
private final LeadRepository leadRepository;
private final MailConfigService mailConfigService;
private final EmailSender emailSender;
private final SmsSender smsSender;
private final UnsubscribeService unsubscribeService;
private final String baseUrl;
public CampaignSender(CampaignRepository campaignRepository,
CampaignRecipientRepository recipientRepository,
LeadRepository leadRepository,
MailConfigService mailConfigService,
EmailSender emailSender,
SmsSender smsSender,
UnsubscribeService unsubscribeService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.campaignRepository = campaignRepository;
this.recipientRepository = recipientRepository;
this.leadRepository = leadRepository;
this.mailConfigService = mailConfigService;
this.emailSender = emailSender;
this.smsSender = smsSender;
this.unsubscribeService = unsubscribeService;
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
}
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
@Transactional
public void processOutbox() {
Instant now = Instant.now();
campaignRepository.findByStatus(CampaignStatus.SCHEDULED).stream()
.filter(campaign -> campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now))
.forEach(campaign -> {
campaign.setStatus(CampaignStatus.SENDING);
campaignRepository.save(campaign);
});
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
try {
sendBatch(campaign);
} catch (RuntimeException ex) {
log.warn("Kampania {}: przebieg wysylki nie powiodl sie: {}", campaign.getId(), ex.getMessage());
}
}
}
private void sendBatch(Campaign campaign) {
int remainingToday = remainingToday(campaign);
if (remainingToday <= 0) {
return;
}
List<CampaignRecipient> batch = recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(
campaign.getId(), RecipientStatus.QUEUED, Limit.of(Math.min(BATCH_SIZE, remainingToday)));
if (batch.isEmpty()) {
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
return;
}
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
// Bez skonfigurowanej skrzynki (albo bramki SMS) kolejka czeka - inaczej caly outbox
// spalilby sie na bledach, a kampanii nie da sie wyslac drugi raz.
if (!senderConfigured(smtp, sms)) {
log.warn("Kampania {}: brak konfiguracji kanalu {} - wysylka czeka.", campaign.getId(), campaign.getChannel());
return;
}
int sent = 0;
for (CampaignRecipient recipient : batch) {
if (stillOptedOut(recipient)) {
recipient.setStatus(RecipientStatus.OPTED_OUT);
recipientRepository.save(recipient);
continue;
}
SendResult result = campaign.getChannel() == Channel.EMAIL
? emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), emailBody(campaign, recipient))
: smsSender.send(sms, recipient.getAddress(), smsBody(campaign, recipient));
if (result.ok()) {
recipient.setStatus(RecipientStatus.SENT);
recipient.setSentAt(Instant.now());
recipient.setProviderMessageId(result.providerMessageId());
recipient.setError(null);
sent++;
} else {
recipient.setStatus(RecipientStatus.FAILED);
recipient.setError(trim(result.error()));
}
recipientRepository.save(recipient);
}
campaign.setSentToday(todaysCount(campaign) + sent);
campaign.setSentTodayDate(LocalDate.now());
if (!recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(1)).isEmpty()) {
campaignRepository.save(campaign);
return;
}
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
}
static boolean senderConfigured(SmtpConfig smtp, SmsGatewayConfig sms) {
if (smtp != null) {
return smtp.isEnabled() && smtp.getHost() != null && !smtp.getHost().isBlank();
}
return sms != null && sms.isEnabled() && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
}
/** Ile jeszcze mozna dzis wyslac w tej kampanii - licznik zeruje sie z nowym dniem. */
private int remainingToday(Campaign campaign) {
int limit = campaign.getDailyLimit() == null || campaign.getDailyLimit() <= 0 ? Integer.MAX_VALUE : campaign.getDailyLimit();
return limit - todaysCount(campaign);
}
private int todaysCount(Campaign campaign) {
boolean isToday = LocalDate.now().equals(campaign.getSentTodayDate());
return isToday && campaign.getSentToday() != null ? campaign.getSentToday() : 0;
}
/** Zgoda mogla zostac cofnieta po zbudowaniu outboxu - sprawdzamy ja tuz przed wyslaniem. */
private boolean stillOptedOut(CampaignRecipient recipient) {
Lead lead = recipient.getLeadId() == null ? null : leadRepository.findById(recipient.getLeadId()).orElse(null);
if (lead == null) {
return false;
}
return recipient.getChannel() == Channel.EMAIL
? !lead.isEmailConsent() || lead.isEmailOptOut()
: !lead.isSmsConsent() || lead.isSmsOptOut();
}
/** Kazda wiadomosc marketingowa musi dac sie wypisac jednym kliknieciem. */
private String unsubscribeLink(CampaignRecipient recipient) {
String channel = recipient.getChannel() == Channel.EMAIL ? "email" : "sms";
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(recipient.getLeadId(), channel);
}
private String emailBody(Campaign campaign, CampaignRecipient recipient) {
String body = campaign.getBody() == null ? "" : campaign.getBody();
String greeting = recipient.getName() == null || recipient.getName().isBlank() ? ""
: "<p>Cześć " + HtmlUtils.htmlEscape(recipient.getName().trim().split("\\s+")[0]) + ",</p>";
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
String footer = link == null ? ""
: "<hr style=\"border:0;border-top:1px solid #dde5ee;margin:20px 0\">"
+ "<p style=\"color:#8793a3;font-size:12px\">Otrzymujesz tę wiadomość, bo zgodziłeś(-aś) się na"
+ " informacje marketingowe od serwisu Polska Lokalnie. "
+ "<a href=\"" + link + "\" style=\"color:#12a764\">Zrezygnuj z tych wiadomości</a>.</p>";
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa\">"
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b\">"
+ greeting + body + footer + "</div></body></html>";
}
private String smsBody(Campaign campaign, CampaignRecipient recipient) {
String body = campaign.getBody() == null ? "" : campaign.getBody().trim();
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
return link == null ? body : body + " Rezygnacja: " + link;
}
private static String trim(String value) {
if (value == null) {
return null;
}
return value.length() > 240 ? value.substring(0, 240) : value;
}
}
@@ -85,8 +85,8 @@ public class CampaignService {
/**
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera
* wysylajacego dodajemy w kolejnej fazie.
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
* Wysylke z outboxu obsluguje {@link CampaignSender}.
*/
@Transactional
public CampaignResponse schedule(Long id) {
@@ -0,0 +1,140 @@
package pl.polskalokalnie.campaign;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Limit;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
import pl.polskalokalnie.send.SmsSender;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
class CampaignSenderTest {
private CampaignRepository campaignRepository;
private CampaignRecipientRepository recipientRepository;
private LeadRepository leadRepository;
private EmailSender emailSender;
private CampaignSender sender;
private Campaign campaign;
@BeforeEach
void setUp() {
campaignRepository = mock(CampaignRepository.class);
recipientRepository = mock(CampaignRecipientRepository.class);
leadRepository = mock(LeadRepository.class);
emailSender = mock(EmailSender.class);
MailConfigService mailConfigService = mock(MailConfigService.class);
UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
SmtpConfig smtp = new SmtpConfig();
smtp.setEnabled(true);
smtp.setHost("smtp.example.com");
when(mailConfigService.getSmtp()).thenReturn(smtp);
when(unsubscribeService.generateToken(any(), anyString())).thenReturn("token");
when(emailSender.send(any(), anyString(), any(), anyString())).thenReturn(new SendResult(true, null, null));
campaign = new Campaign();
campaign.setChannel(Channel.EMAIL);
campaign.setSubject("Nowe oferty");
campaign.setBody("<p>Treść</p>");
campaign.setDailyLimit(200);
campaign.setStatus(CampaignStatus.SENDING);
when(campaignRepository.findByStatus(CampaignStatus.SCHEDULED)).thenReturn(List.of());
when(campaignRepository.findByStatus(CampaignStatus.SENDING)).thenReturn(List.of(campaign));
sender = new CampaignSender(campaignRepository, recipientRepository, leadRepository, mailConfigService,
emailSender, mock(SmsSender.class), unsubscribeService, "https://polskalokalnie.pl");
}
private CampaignRecipient recipient(long leadId, String address) {
CampaignRecipient recipient = new CampaignRecipient();
recipient.setLeadId(leadId);
recipient.setChannel(Channel.EMAIL);
recipient.setAddress(address);
recipient.setStatus(RecipientStatus.QUEUED);
return recipient;
}
private Lead lead(boolean consent, boolean optOut) {
Lead lead = new Lead();
lead.setEmailConsent(consent);
lead.setEmailOptOut(optOut);
return lead;
}
private void queue(List<CampaignRecipient> batch) {
when(recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class)))
.thenReturn(batch, List.of());
}
@Test
void sendsQueuedRecipientAndClosesCampaign() {
CampaignRecipient recipient = recipient(1L, "kupujacy@example.com");
queue(List.of(recipient));
when(leadRepository.findById(1L)).thenReturn(Optional.of(lead(true, false)));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.SENT);
assertThat(recipient.getSentAt()).isNotNull();
// Pusta kolejka po przebiegu = kampania rozliczona.
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.COMPLETED);
assertThat(campaign.getSentToday()).isEqualTo(1);
}
@Test
void skipsRecipientWhoWithdrewConsentAfterBuildingOutbox() {
CampaignRecipient recipient = recipient(2L, "rezygnacja@example.com");
queue(List.of(recipient));
when(leadRepository.findById(2L)).thenReturn(Optional.of(lead(true, true)));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.OPTED_OUT);
verify(emailSender, never()).send(any(), anyString(), any(), anyString());
}
@Test
void marksFailureWithoutLosingTheRest() {
CampaignRecipient recipient = recipient(3L, "blad@example.com");
queue(List.of(recipient));
when(leadRepository.findById(3L)).thenReturn(Optional.of(lead(true, false)));
when(emailSender.send(any(), anyString(), any(), anyString()))
.thenReturn(new SendResult(false, null, "Nie udało się połączyć"));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.FAILED);
assertThat(recipient.getError()).contains("połączyć");
assertThat(campaign.getSentToday()).isZero();
}
@Test
void waitsWhenDailyLimitIsUsedUp() {
campaign.setDailyLimit(1);
campaign.setSentToday(1);
campaign.setSentTodayDate(java.time.LocalDate.now());
sender.processOutbox();
verify(recipientRepository, never())
.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class));
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.SENDING);
}
}