PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
← All changes | app/Services/Email/EmailNotificationMailer.php +228 -0 1.6.2 → 1.7.0 View file →
@@ -4,10 +4,12 @@
4 4
5 5 use FluentCart\App\App;
6 6 use FluentCart\App\Models\Model;
7 7 use FluentCart\App\Models\Order;
8 +use FluentCart\App\Models\ProductReview;
8 9 use FluentCart\App\Models\Subscription;
9 10 use FluentCart\App\Services\OrderService;
11 +use FluentCart\App\Services\ProductReviewService;
10 12 use FluentCart\App\Services\ShortCodeParser\ShortcodeTemplateBuilder;
11 13 use FluentCart\Framework\Support\Arr;
12 14
13 15 class EmailNotificationMailer
@@ -117,11 +119,30 @@
117 119 $this->mailEmailsOfEvent('renewal_reminder_due', $data);
118 120 }, 999, 1);
119 121
120 122 add_action('fluent_cart/renewal_reminder_overdue', function ($data) {
123 + // Set only by the deprecated bridge in RenewalReminderService::send():
124 + // the staged email (renewal_overdue_first/followup/final) already went
125 + // out for this payload, so mailing here would duplicate it. Direct
126 + // dispatches of this hook never carry the flag and still deliver.
127 + if (!empty(Arr::get($data, 'staged_email_dispatched'))) {
128 + return;
129 + }
121 130 $this->mailEmailsOfEvent('renewal_reminder_overdue', $data);
122 131 }, 999, 1);
123 132
133 + add_action('fluent_cart/renewal_overdue_first', function ($data) {
134 + $this->mailEmailsOfEvent('renewal_overdue_first', $data);
135 + }, 999, 1);
136 +
137 + add_action('fluent_cart/renewal_overdue_followup', function ($data) {
138 + $this->mailEmailsOfEvent('renewal_overdue_followup', $data);
139 + }, 999, 1);
140 +
141 + add_action('fluent_cart/renewal_overdue_final', function ($data) {
142 + $this->mailEmailsOfEvent('renewal_overdue_final', $data);
143 + }, 999, 1);
144 +
124 145 add_action('fluent_cart/subscription_renewal_reminder', function ($data) {
125 146 $this->mailEmailsOfEvent('subscription_renewal_reminder', $data);
126 147 }, 999, 1);
127 148
@@ -128,10 +149,186 @@
128 149 add_action('fluent_cart/subscription_trial_end_reminder', function ($data) {
129 150 $this->mailEmailsOfEvent('subscription_trial_end_reminder', $data);
130 151 }, 999, 1);
131 152
153 + add_action('fluent_cart/review_created', function ($data) {
154 + $review = Arr::get($data, 'review');
155 + if ($review && empty($review->parent_id)) {
156 + $this->mailEmailsOfEvent('review_created', $data);
157 + }
158 + }, 999, 1);
159 +
160 + // To the reviewer, on the settled hook — the queued job has re-read
161 + // the review and it is still approved. The job fires once per
162 + // approval, and two requests can make the same approval; the author
163 + // is told once — barring a worker killed in the one UPDATE between
164 + // the transport accepting a message and the record of it, which
165 + // repeats that one message after the lease timeout: at-least-once at
166 + // that edge, once everywhere else. So the send is behind a lease on
167 + // the review's
168 + // other_info: one conditional UPDATE, handed to exactly one caller,
169 + // taken before the send and settled after it — delivered closes the
170 + // notice, anything else releases the lease so a retry can send. Taken
171 + // only when a notification is switched on and there is someone to
172 + // send to — a lease spent on either could never be told about later.
173 + add_action('fluent_cart/review_approved_done', function ($data) {
174 + $review = Arr::get($data, 'review');
175 + if (!$review instanceof ProductReview || $review->parent_id) {
176 + return;
177 + }
178 +
179 + $mailNames = EmailNotifications::activeNotificationNamesOfEvent('review_approved_done');
180 + if (!$mailNames) {
181 + return;
182 + }
183 +
184 + if (ProductReviewService::resolveNotificationRecipient($review) === '') {
185 + // Nobody to send to. Not leased, so a corrected address and
186 + // a fresh approval can still send.
187 + return;
188 + }
189 +
190 + $leaseToken = ProductReviewService::claimApprovalNotice($review);
191 + if (!$leaseToken) {
192 + return;
193 + }
194 +
195 + $this->sendUnderNoticeLease(
196 + $mailNames,
197 + $data,
198 + ProductReviewService::deliveredApprovalNotices((int) $review->id),
199 + function (array $deliveredNames) use ($review, $leaseToken) {
200 + ProductReviewService::recordApprovalNoticeDeliveries((int) $review->id, $leaseToken, $deliveredNames);
201 + },
202 + function (bool $allDelivered, array $deliveredNames) use ($review, $leaseToken) {
203 + ProductReviewService::settleApprovalNotice((int) $review->id, $leaseToken, $allDelivered, $deliveredNames);
204 + }
205 + );
206 + }, 999, 1);
207 +
208 + // To the reviewer, when the store answers them. Same shape as the
209 + // approval notice: on the settled hook, behind a once-per-reply
210 + // lease, taken only when there is a notification switched on and
211 + // someone to send to. One more reason not to send: the person who
212 + // wrote the reply is the reviewer — a moderator answering their own
213 + // review is not told about it.
214 + add_action('fluent_cart/review_replied_done', function ($data) {
215 + $reply = Arr::get($data, 'reply');
216 + $review = Arr::get($data, 'review');
217 + if (!$reply instanceof ProductReview || !$review instanceof ProductReview || $review->parent_id) {
218 + return;
219 + }
220 +
221 + $mailNames = EmailNotifications::activeNotificationNamesOfEvent('review_replied_done');
222 + if (!$mailNames) {
223 + return;
224 + }
225 +
226 + $recipient = ProductReviewService::resolveNotificationRecipient($review);
227 + if ($recipient === '') {
228 + return;
229 + }
230 +
231 + // The reply's author, compared as who they are — the account the
232 + // reply was written from, or failing that the address it
233 + // carries — never as a role: a customer who happens to be an
234 + // administrator still gets told about replies to their review.
235 + $sameAccount = (int) $reply->user_id && (int) $reply->user_id === (int) $review->user_id;
236 + $sameAddress = strcasecmp(trim((string) $reply->reviewer_email), $recipient) === 0;
237 + if ($sameAccount || $sameAddress) {
238 + return;
239 + }
240 +
241 + $leaseToken = ProductReviewService::claimReplyNotice($reply);
242 + if (!$leaseToken) {
243 + return;
244 + }
245 +
246 + $this->sendUnderNoticeLease(
247 + $mailNames,
248 + $data,
249 + ProductReviewService::deliveredReplyNotices((int) $reply->id),
250 + function (array $deliveredNames) use ($reply, $leaseToken) {
251 + ProductReviewService::recordReplyNoticeDeliveries((int) $reply->id, $leaseToken, $deliveredNames);
252 + },
253 + function (bool $allDelivered, array $deliveredNames) use ($reply, $leaseToken) {
254 + ProductReviewService::settleReplyNotice((int) $reply->id, $leaseToken, $allDelivered, $deliveredNames);
255 + }
256 + );
257 + }, 999, 1);
258 +
132 259 }
133 260
261 + /**
262 + * Send a set of notifications under a once-only lease, and settle it.
263 + *
264 + * Delivery is the transport's own word. wp_mail() fires wp_mail_succeeded
265 + * once the message is accepted and wp_mail_failed when it is not — and
266 + * fires neither when something short-circuits it, a pre_wp_mail filter
267 + * returning false, say, or a mailer filter that stripped every recipient.
268 + * So a send counts as delivered only when the success action fired for
269 + * it; silence is not success. (The action is WordPress 5.9's; the plugin
270 + * requires 6.7.) Watched only for the duration of this send, so another
271 + * email elsewhere in the request is not read as ours.
272 + *
273 + * One lease covers every notification switched on for the event — an
274 + * add-on can register a second — so each is judged on its own: the ones
275 + * already delivered under an earlier, released lease are skipped; each
276 + * one that goes out is recorded at once, before the next is tried, so a
277 + * worker killed after a delivery leaves at most that one delivery
278 + * unrecorded; and the loop stops at the first failure to leave the rest
279 + * for the retry. A throw anywhere in a send still reaches the settle on
280 + * the way out, with what went out before it, and then the caller: the
281 + * queued job records the failure and a re-run can send.
282 + *
283 + * @param string[] $mailNames notifications to send, in order
284 + * @param array $data the event payload the templates read
285 + * @param string[] $alreadyDelivered names delivered under an earlier lease
286 + * @param callable $record fn(string[] $deliveredNames): records deliveries so far, lease still held
287 + * @param callable $settle fn(bool $allDelivered, string[] $deliveredNames): settles the lease
288 + */
289 + protected function sendUnderNoticeLease(array $mailNames, array $data, array $alreadyDelivered, callable $record, callable $settle): void
290 + {
291 + $deliveredNow = [];
292 + $allDelivered = false;
293 + $deliveryFailed = false;
294 + $deliverySucceeded = false;
295 +
296 + $onMailFailed = function () use (&$deliveryFailed) {
297 + $deliveryFailed = true;
298 + };
299 + $onMailSucceeded = function () use (&$deliverySucceeded) {
300 + $deliverySucceeded = true;
301 + };
302 + add_action('wp_mail_failed', $onMailFailed);
303 + add_action('wp_mail_succeeded', $onMailSucceeded);
304 +
305 + try {
306 + foreach ($mailNames as $mailName) {
307 + if (in_array($mailName, $alreadyDelivered, true)) {
308 + continue;
309 + }
310 +
311 + $deliveryFailed = false;
312 + $deliverySucceeded = false;
313 + $this->mailByEmailName($mailName, $data);
314 +
315 + if (!$deliverySucceeded || $deliveryFailed) {
316 + $deliveryFailed = true;
317 + break;
318 + }
319 +
320 + $deliveredNow[] = $mailName;
321 + $record(array_merge($alreadyDelivered, $deliveredNow));
322 + }
323 + $allDelivered = !$deliveryFailed;
324 + } finally {
325 + remove_action('wp_mail_failed', $onMailFailed);
326 + remove_action('wp_mail_succeeded', $onMailSucceeded);
327 + $settle($allDelivered, array_merge($alreadyDelivered, $deliveredNow));
328 + }
329 + }
330 +
134 331 public function registerAsyncMails()
135 332 {
136 333 //For Async Actions
137 334 add_action('fluent_cart/async_mail/order_created', function ($orderId, $mailName = '') {
@@ -237,8 +434,16 @@
237 434 $mailer->addAttachment($pdfPath);
238 435 }
239 436 }
240 437
438 + $mailer = $this->applyMailerFilter($mailer, [
439 + 'event' => $event,
440 + 'mail_name' => $mailName,
441 + 'recipient' => Arr::get($notification, 'recipient'),
442 + 'notification' => $notification,
443 + 'data' => $data,
444 + ]);
445 +
241 446 $mailer->send(true);
242 447
243 448 // Clean up temp PDF file after sending
244 449 if ($pdfPath && file_exists($pdfPath)) {
@@ -273,13 +478,36 @@
273 478 $mailer->addAttachment($pdfPath);
274 479 }
275 480 }
276 481
482 + $mailer = $this->applyMailerFilter($mailer, [
483 + 'event' => Arr::get($notification, 'event', ''),
484 + 'mail_name' => $emailName,
485 + 'recipient' => Arr::get($notification, 'recipient'),
486 + 'notification' => $notification,
487 + 'data' => $data,
488 + ]);
489 +
277 490 $mailer->send(true);
278 491
279 492 if ($pdfPath && file_exists($pdfPath)) {
280 493 @unlink($pdfPath);
281 494 }
495 + }
496 +
497 + /**
498 + * Let third-party code adjust the fully prepared Mailer (recipients,
499 + * subject, body, attachments already set) immediately before it sends.
500 + *
501 + * @param Mailer $mailer
502 + * @param array $context event, mail_name, recipient, notification, data
503 + * @return Mailer
504 + */
505 + private function applyMailerFilter(Mailer $mailer, array $context): Mailer
506 + {
507 + $filtered = apply_filters('fluent_cart/email_notification/mailer', $mailer, $context);
508 +
509 + return $filtered instanceof Mailer ? $filtered : $mailer;
282 510 }
283 511
284 512 public function getEmailFooter(): string
285 513 {