Skip to content

Commit 25ef84a

Browse files
committed
Mailer
1 parent 0c5b4f4 commit 25ef84a

File tree

10 files changed

+249
-70
lines changed

10 files changed

+249
-70
lines changed

config/parameters.yml.dist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ parameters:
2626
list_table_prefix: '%%env(LIST_TABLE_PREFIX)%%'
2727
env(LIST_TABLE_PREFIX): 'listattr_'
2828
app.dev_version: '%%env(APP_DEV_VERSION)%%'
29-
env(APP_DEV_VERSION): 0
29+
env(APP_DEV_VERSION): '0'
3030
app.dev_email: '%%env(APP_DEV_EMAIL)%%'
3131
env(APP_DEV_EMAIL): '[email protected]'
3232
app.powered_by_phplist: '%%env(APP_POWERED_BY_PHPLIST)%%'

resources/translations/messages.en.xlf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,14 @@ Thank you.</target>
738738
<source>Value must be an AttributeTypeEnum or string.</source>
739739
<target>__Value must be an AttributeTypeEnum or string.</target>
740740
</trans-unit>
741+
<trans-unit id="1cRuI31" resname="Campaign started">
742+
<source>Campaign started</source>
743+
<target>__Campaign started</target>
744+
</trans-unit>
745+
<trans-unit id="Y4qXXak" resname="phplist has started sending the campaign with subject %s">
746+
<source>phplist has started sending the campaign with subject %s</source>
747+
<target>__phplist has started sending the campaign with subject %s</target>
748+
</trans-unit>
741749
</body>
742750
</file>
743751
</xliff>

src/Domain/Common/Html2Text.php

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,24 @@ public function __invoke(string $html): string
3939

4040
// find all URLs and replace them back
4141
preg_match_all('~\[URLTEXT\](.*)\[ENDURLTEXT\]\[LINK\](.*)\[ENDLINK\]~Umis', $text, $links);
42-
foreach ($links[0] as $matchindex => $fullmatch) {
43-
$linktext = $links[1][$matchindex];
44-
$linkurl = $links[2][$matchindex];
42+
foreach ($links[0] as $matchIndex => $fullMatch) {
43+
$linkText = $links[1][$matchIndex];
44+
$linkUrl = $links[2][$matchIndex];
4545
// check if the text linked is a repetition of the URL
46-
if (trim($linktext) == trim($linkurl) ||
47-
'https://'.trim($linktext) == trim($linkurl) ||
48-
'http://'.trim($linktext) == trim($linkurl)
46+
if (trim($linkText) == trim($linkUrl) ||
47+
'https://'.trim($linkText) == trim($linkUrl) ||
48+
'http://'.trim($linkText) == trim($linkUrl)
4949
) {
50-
$linkreplace = $linkurl;
50+
$linkReplace = $linkUrl;
5151
} else {
5252
//# if link is an anchor only, take it out
53-
if (strpos($linkurl, '#') === 0) {
54-
$linkreplace = $linktext;
53+
if (str_starts_with($linkUrl, '#')) {
54+
$linkReplace = $linkText;
5555
} else {
56-
$linkreplace = $linktext.' <'.$linkurl.'>';
56+
$linkReplace = $linkText.' <'.$linkUrl.'>';
5757
}
5858
}
59-
$text = str_replace($fullmatch, $linkreplace, $text);
59+
$text = str_replace($fullMatch, $linkReplace, $text);
6060
}
6161
$text = preg_replace(
6262
"/<a href=[\"\'](.*?)[\"\'][^>]*>(.*?)<\/a>/is",
@@ -78,8 +78,8 @@ public function __invoke(string $html): string
7878
while (preg_match("/\n\s*\n\s*\n/", $text)) {
7979
$text = preg_replace("/\n\s*\n\s*\n/", "\n\n", $text);
8080
}
81-
$ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP;
81+
$wordWrap = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP;
8282

83-
return wordwrap($text, $ww);
83+
return wordwrap($text, (int) $wordWrap);
8484
}
8585
}

src/Domain/Common/RemotePageFetcher.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,8 @@ private function fetchUrlDirect(string $url): string
9090
{
9191
try {
9292
$response = $this->httpClient->request('GET', $url, [
93-
// 'timeout' => 10,
94-
'timeout' => 600,
95-
'allowRedirects' => 1,
96-
'method' => 'HEAD',
93+
'max_redirects' => 5,
94+
'timeout' => 10,
9795
]);
9896

9997
return $response->getContent(false);

src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace PhpList\Core\Domain\Messaging\MessageHandler;
66

7+
use DateTime;
8+
use DateTimeImmutable;
9+
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
710
use Doctrine\ORM\EntityManagerInterface;
811
use PhpList\Core\Domain\Configuration\Service\UserPersonalizer;
912
use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException;
@@ -13,9 +16,11 @@
1316
use PhpList\Core\Domain\Messaging\Model\Message;
1417
use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
1518
use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
19+
use PhpList\Core\Domain\Messaging\Model\MessageData;
1620
use PhpList\Core\Domain\Messaging\Model\UserMessage;
1721
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
1822
use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
23+
use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder;
1924
use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler;
2025
use PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager;
2126
use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter;
@@ -29,7 +34,10 @@
2934
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
3035
use Psr\Log\LoggerInterface;
3136
use Psr\SimpleCache\CacheInterface;
37+
use Symfony\Component\Mailer\Envelope;
38+
use Symfony\Component\Mailer\MailerInterface;
3239
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
40+
use Symfony\Component\Mime\Address;
3341
use Symfony\Component\Mime\Email;
3442
use Symfony\Contracts\Translation\TranslatorInterface;
3543
use Throwable;
@@ -44,7 +52,8 @@ class CampaignProcessorMessageHandler
4452
private ?int $maxMailSize;
4553

4654
public function __construct(
47-
private readonly RateLimitedCampaignMailer $mailer,
55+
private readonly MailerInterface $mailer,
56+
private readonly RateLimitedCampaignMailer $rateLimitedCampaignMailer,
4857
private readonly EntityManagerInterface $entityManager,
4958
private readonly SubscriberProvider $subscriberProvider,
5059
private readonly MessageProcessingPreparator $messagePreparator,
@@ -61,6 +70,8 @@ public function __construct(
6170
private readonly MessagePrecacheService $precacheService,
6271
private readonly UserPersonalizer $userPersonalizer,
6372
private readonly MessageDataLoader $messageDataLoader,
73+
private readonly EmailBuilder $emailBuilder,
74+
private readonly string $messageEnvelope,
6475
?int $maxMailSize = null,
6576
) {
6677
$this->maxMailSize = $maxMailSize ?? 0;
@@ -99,18 +110,71 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $
99110
// $userSelection = $loadedMessageData['userselection'];
100111

101112
$cacheKey = sprintf('messaging.message.base.%d', $campaign->getId());
102-
$messagePrecached = $this->precacheService->precacheMessage($campaign, $loadedMessageData);
103-
if (!$messagePrecached) {
113+
if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData)) {
104114
$this->updateMessageStatus($campaign, MessageStatus::Suspended);
105115

106116
return;
107117
}
108118

119+
if (!empty($loadedMessageData['notify_start']) && !isset($loadedMessageData['start_notified'])) {
120+
$notifications = explode(',', $loadedMessageData['notify_start']);
121+
foreach ($notifications as $notification) {
122+
$email = $this->emailBuilder->buildPhplistEmail(
123+
messageId: $campaign->getId(),
124+
to: $notification,
125+
subject: $this->translator->trans('Campaign started'),
126+
message: $this->translator->trans(
127+
'phplist has started sending the campaign with subject %s',
128+
$loadedMessageData['subject']
129+
),
130+
inBlast: false,
131+
);
132+
133+
// todo: check if from name should be from config
134+
$envelope = new Envelope(
135+
sender: new Address($this->messageEnvelope, 'PHPList'),
136+
recipients: [new Address($email->getTo()[0]->getAddress())],
137+
);
138+
$this->mailer->send(message: $email, envelope: $envelope);
139+
}
140+
$messageData = new MessageData();
141+
$messageData->setName('start_notified');
142+
$messageData->setId($message->getMessageId());
143+
$messageData->setData((new DateTimeImmutable())->format('Y-m-d H:i:s'));
144+
145+
try {
146+
$this->entityManager->persist($messageData);
147+
$this->entityManager->flush();
148+
} catch (UniqueConstraintViolationException $e) {
149+
// equivalent to IGNORE — do nothing
150+
}
151+
}
152+
109153
$this->updateMessageStatus($campaign, MessageStatus::Prepared);
110154
$subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign);
111155

112156
$this->updateMessageStatus($campaign, MessageStatus::InProcess);
113157

158+
// if (USE_LIST_EXCLUDE) {
159+
// if (VERBOSE) {
160+
// processQueueOutput(s('looking for users who can be excluded from this mailing'));
161+
// }
162+
// if (count($msgdata['excludelist'])) {
163+
// $query
164+
// = ' select userid'
165+
// .' from '.$GLOBALS['tables']['listuser']
166+
// .' where listid in ('.implode(',', $msgdata['excludelist']).')';
167+
// if (VERBOSE) {
168+
// processQueueOutput('Exclude query '.$query);
169+
// }
170+
// $req = Sql_Query($query);
171+
// while ($row = Sql_Fetch_Row($req)) {
172+
// $um = Sql_Query(sprintf('replace into %s (entered,userid,messageid,status) values(now(),%d,%d,"excluded")',
173+
// $tables['usermessage'], $row[0], $messageid));
174+
// }
175+
// }
176+
// }
177+
114178
$this->timeLimiter->start();
115179
$stoppedEarly = false;
116180

@@ -157,6 +221,9 @@ private function unconfirmSubscriber(Subscriber $subscriber): void
157221

158222
private function updateMessageStatus(Message $message, MessageStatus $status): void
159223
{
224+
if ($status === MessageStatus::InProcess && $message->getMetadata()->getSendStart() === null) {
225+
$message->getMetadata()->setSendStart(new DateTime());
226+
}
160227
$message->getMetadata()->setStatus($status);
161228
$this->entityManager->flush();
162229
}
@@ -195,7 +262,7 @@ private function handleEmailSending(
195262
$processed->footer = $this->userPersonalizer->personalize($processed->footer, $subscriber->getEmail());
196263

197264
try {
198-
$email = $this->mailer->composeEmail($campaign, $subscriber, $processed);
265+
$email = $this->rateLimitedCampaignMailer->composeEmail($campaign, $subscriber, $processed);
199266
$this->mailer->send($email);
200267
$this->checkMessageSizeOrSuspendCampaign($campaign, $email, $subscriber->hasHtmlEmail());
201268
$this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent);

src/Domain/Messaging/Repository/MessageRepository.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public function incrementBounceCount(int $messageId): void
7777
->execute();
7878
}
7979

80+
/** @return Message[] */
8081
public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array
8182
{
8283
return $this->createQueryBuilder('m')
@@ -88,7 +89,7 @@ public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImm
8889
->getResult();
8990
}
9091

91-
public function findByIdAndStatus(int $id, Message\MessageStatus $status)
92+
public function findByIdAndStatus(int $id, Message\MessageStatus $status): ?Message
9293
{
9394
return $this->createQueryBuilder('m')
9495
->where('m.id = :id')

src/Domain/Messaging/Service/Builder/EmailBuilder.php

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,101 @@
44

55
namespace PhpList\Core\Domain\Messaging\Service\Builder;
66

7+
use PhpList\Core\Domain\Configuration\Model\ConfigOption;
8+
use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
9+
use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;
10+
use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor;
11+
use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder;
12+
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
13+
use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository;
14+
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
15+
use Symfony\Component\Mime\Address;
716
use Symfony\Component\Mime\Email;
817

918
class EmailBuilder
1019
{
1120
public function __construct(
21+
private readonly ConfigProvider $configProvider,
22+
private readonly EventLogManager $eventLogManager,
23+
private readonly UserBlacklistRepository $blacklistRepository,
24+
private readonly SubscriberHistoryManager $subscriberHistoryManager,
25+
private readonly SubscriberRepository $subscriberRepository,
26+
private readonly SystemMailConstructor $systemMailConstructor,
27+
private readonly TemplateImageEmbedder $templateImageEmbedder,
1228
private readonly string $googleSenderId,
1329
private readonly bool $useAmazonSes,
1430
private readonly bool $usePrecedenceHeader,
31+
private readonly bool $devVersion = true,
32+
private readonly ?string $devEmail = null,
1533
) {
1634
}
1735

1836
public function buildPhplistEmail(
19-
string $messageId,
20-
string $destinationEmail,
21-
bool $inBlast = true,
22-
): Email {
37+
int $messageId,
38+
?string $to = null,
39+
?string $subject = null,
40+
?string $message = null,
41+
?bool $skipBlacklistCheck = false,
42+
?bool $inBlast = true,
43+
): ?Email {
44+
if (preg_match("/\n/", $to)) {
45+
$this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked');
46+
47+
return null;
48+
}
49+
if (preg_match("/\n/", $subject)) {
50+
$this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked');
51+
52+
return null;
53+
}
54+
if (!$to) {
55+
$this->eventLogManager->log('', sprintf('Error: empty To: in message with subject %s to send', $subject));
56+
57+
return null;
58+
}
59+
if (!$subject) {
60+
$this->eventLogManager->log('', "Error: empty Subject: in message to send to $to");
61+
62+
return null;
63+
}
64+
if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) {
65+
$this->eventLogManager->log('', sprintf('Error, %s is blacklisted, not sending', $to));
66+
$subscriber = $this->subscriberRepository->findOneByEmail($to);
67+
$subscriber->setBlacklisted(true);
68+
69+
$this->subscriberHistoryManager->addHistory(
70+
subscriber: $subscriber,
71+
message: 'Marked Blacklisted',
72+
details: 'Found user in blacklist while trying to send an email, marked black listed',
73+
);
74+
75+
return null;
76+
}
77+
78+
$fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress);
79+
$fromName = $this->configProvider->getValue(ConfigOption::MessageFromName);
80+
$messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress);
81+
if ($messageReplyToAddress) {
82+
$reply_to = $messageReplyToAddress;
83+
} else {
84+
$reply_to = $fromEmail;
85+
}
86+
$destinationEmail = '';
87+
88+
if ($this->devVersion) {
89+
$message = "To: $to\n".$message;
90+
if ($this->devEmail) {
91+
$destinationEmail = $this->devEmail;
92+
}
93+
} else {
94+
$destinationEmail = $to;
95+
}
96+
97+
list($htmlMessage, $textMessage) = ($this->systemMailConstructor)($message, $subject);
98+
2399
$email = (new Email());
24100

25-
$email->getHeaders()->addTextHeader('X-MessageID', $messageId);
101+
$email->getHeaders()->addTextHeader('X-MessageID', (string)$messageId);
26102
$email->getHeaders()->addTextHeader('X-ListMember', $destinationEmail);
27103
if ($this->googleSenderId !== '') {
28104
$email->getHeaders()->addTextHeader('Feedback-ID', sprintf('%s:%s', $messageId, $this->googleSenderId));
@@ -36,6 +112,37 @@ public function buildPhplistEmail(
36112
$email->getHeaders()->addTextHeader('X-Blast', '1');
37113
}
38114

115+
$removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl);
116+
$sep = !str_contains($removeUrl, '?') ? '?' : '&';
117+
$email->getHeaders()->addTextHeader(
118+
'List-Unsubscribe',
119+
'<' . $removeUrl . $sep . 'email=' . $destinationEmail . '&jo=1>'
120+
);
121+
122+
123+
if ($this->devEmail && $destinationEmail !== $this->devEmail) {
124+
$email->getHeaders()->addTextHeader('X-Originally to', $destinationEmail);
125+
}
126+
127+
$newWrap = $this->configProvider->getValue(ConfigOption::WordWrap);
128+
if ($newWrap) {
129+
$textMessage = wordwrap($textMessage, (int)$newWrap);
130+
}
131+
132+
if (!empty($htmlMessage)) {
133+
$email->html($htmlMessage);
134+
$email->text($textMessage);
135+
($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId);
136+
//# In the above phpMailer strips all tags, which removes the links which are wrapped in < and > by HTML2text
137+
//# so add it again
138+
$email->text($textMessage);
139+
}
140+
$email->text($textMessage);
141+
142+
$email->to($destinationEmail);
143+
$email->from(new Address($fromEmail, $fromName));
144+
$email->subject($subject);
145+
39146
return $email;
40147
}
41148
}

0 commit comments

Comments
 (0)