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 +321 -12 1.3.20 → 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
@@ -30,14 +32,21 @@
30 32 );
31 33
32 34 }, 999, 1);
33 35 // To Admin
36 + // 999 like the rest of this file: custom smartcodes in a customised admin
37 + // body resolve against the payload as it stands when the mail is built, so
38 + // the mail has to run after everything else bound here writes its data —
39 + // integration feeds (11), FluentCRM (20), affiliate referral status (99).
40 + // Cost of running last: this hook forces every feed to run realtime, so the
41 + // mail waits on their outbound HTTP, and IntegrationEventListener catches
42 + // only \Exception — a \Error in a feed loses the mail.
34 43 add_action('fluent_cart/order_paid_done', function ($data) {
35 44 $this->mailEmailsOfEvent(
36 45 'order_paid_done',
37 46 $data
38 47 );
39 - }, 10, 1);
48 + }, 999, 1);
40 49
41 50 // to customer and admin
42 51 add_action('fluent_cart/subscription_renewed', function ($data) {
43 52 $this->mailEmailsOfEvent(
@@ -46,8 +55,16 @@
46 55 );
47 56 }, 999, 1);
48 57
49 58 // to customer and admin
59 + add_action('fluent_cart/subscription_renewal_failed', function ($data) {
60 + $this->mailEmailsOfEvent(
61 + 'subscription_renewal_failed',
62 + $data
63 + );
64 + }, 999, 1);
65 +
66 + // to customer and admin
50 67 add_action('fluent_cart/subscription_canceled', function ($data) {
51 68 $this->mailEmailsOfEvent(
52 69 'subscription_canceled',
53 70 $data
@@ -65,17 +82,67 @@
65 82 add_action('fluent_cart/shipping_status_changed_to_delivered', function ($data) {
66 83 $this->mailEmailsOfEvent('shipping_status_changed_to_delivered', $data);
67 84 }, 999, 1);
68 85
69 - // @todo uncomment when invoice feature is deployed
70 - // add_action('fluent_cart/invoice_reminder_due', function ($data) {
71 - // $this->mailEmailsOfEvent('invoice_reminder_due', $data);
72 - // }, 999, 1);
86 + add_action('fluent_cart/renewal_created', function ($data) {
87 + $this->mailEmailsOfEvent('renewal_created', $data);
88 + }, 999, 1);
73 89
74 - add_action('fluent_cart/invoice_reminder_overdue', function ($data) {
75 - $this->mailEmailsOfEvent('invoice_reminder_overdue', $data);
90 + add_action('fluent_cart/renewal_payment_reminder', function ($data) {
91 + $this->mailEmailsOfEvent('renewal_payment_reminder', $data);
76 92 }, 999, 1);
77 93
94 + // Fired by RenewalService when a system renewal order is created ahead of
95 + // its automatic charge — the customer's advance notice of amount and date.
96 + add_action('fluent_cart/subscriptions/system_renewal_scheduled', function ($data) {
97 + $shouldSend = apply_filters('fluent_cart/subscriptions/upcoming_charge_notification', true, $data);
98 +
99 + if ($shouldSend) {
100 + $this->mailEmailsOfEvent('system_upcoming_charge', $data);
101 + }
102 + }, 999, 1);
103 +
104 + // Fired by SystemChargeService when an automatic (system) renewal charge
105 + // fails and the customer should be notified (first failure by default).
106 + add_action('fluent_cart/subscriptions/system_charge_failed_notification', function ($data) {
107 + $this->mailEmailsOfEvent('system_charge_failed', $data);
108 + }, 999, 1);
109 +
110 + add_action('fluent_cart/subscription_period_skipped', function ($data) {
111 + $this->mailEmailsOfEvent('subscription_period_skipped', $data);
112 + }, 999, 1);
113 +
114 + add_action('fluent_cart/subscription_past_due', function ($data) {
115 + $this->mailEmailsOfEvent('subscription_past_due', $data);
116 + }, 999, 1);
117 +
118 + add_action('fluent_cart/renewal_reminder_due', function ($data) {
119 + $this->mailEmailsOfEvent('renewal_reminder_due', $data);
120 + }, 999, 1);
121 +
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 + }
130 + $this->mailEmailsOfEvent('renewal_reminder_overdue', $data);
131 + }, 999, 1);
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 +
78 145 add_action('fluent_cart/subscription_renewal_reminder', function ($data) {
79 146 $this->mailEmailsOfEvent('subscription_renewal_reminder', $data);
80 147 }, 999, 1);
81 148
@@ -82,10 +149,186 @@
82 149 add_action('fluent_cart/subscription_trial_end_reminder', function ($data) {
83 150 $this->mailEmailsOfEvent('subscription_trial_end_reminder', $data);
84 151 }, 999, 1);
85 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 +
86 259 }
87 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 +
88 331 public function registerAsyncMails()
89 332 {
90 333 //For Async Actions
91 334 add_action('fluent_cart/async_mail/order_created', function ($orderId, $mailName = '') {
@@ -111,8 +354,12 @@
111 354 add_action('fluent_cart/async_mail/subscription_activated', function ($subscriptionId, $mailName = '') {
112 355 (new static())->sendAsyncSubscriptionMail($mailName, $subscriptionId);
113 356 }, 10, 2);
114 357
358 + add_action('fluent_cart/async_mail/subscription_reactivated', function ($subscriptionId, $mailName = '') {
359 + (new static())->sendAsyncSubscriptionMail($mailName, $subscriptionId);
360 + }, 10, 2);
361 +
115 362 add_action('fluent_cart/async_mail/subscription_renewed', function ($subscriptionId, $mailName = '') {
116 363 (new static())->sendAsyncSubscriptionMail($mailName, $subscriptionId);
117 364 }, 10, 2);
118 365
@@ -187,8 +434,16 @@
187 434 $mailer->addAttachment($pdfPath);
188 435 }
189 436 }
190 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 +
191 446 $mailer->send(true);
192 447
193 448 // Clean up temp PDF file after sending
194 449 if ($pdfPath && file_exists($pdfPath)) {
@@ -200,12 +455,13 @@
200 455 }
201 456
202 457 public function mailByEmailName($emailName, $data)
203 458 {
204 - // Extract Order model before formatParsable converts it to array
459 + // $data keeps its Models: parseEmailContent renders emails.parts.order_header,
460 + // which reads $order->invoice_no / $order->orderTaxRates — an array here
461 + // warns and then fatals inside the view (regression of 201a14885 via 44a39aa1d)
205 462 $orderModel = Arr::get($data, 'order');
206 463
207 - $data = $this->formatParsable($data);
208 464 $notification = EmailNotifications::getNotification($emailName);
209 465 $notification = EmailNotifications::formatNotification($notification, $data);
210 466 list($body, $subject, $to) = $this->parseEmailContent($notification, $data);
211 467
@@ -222,8 +478,16 @@
222 478 $mailer->addAttachment($pdfPath);
223 479 }
224 480 }
225 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 +
226 490 $mailer->send(true);
227 491
228 492 if ($pdfPath && file_exists($pdfPath)) {
229 493 @unlink($pdfPath);
@@ -229,8 +493,23 @@
229 493 @unlink($pdfPath);
230 494 }
231 495 }
232 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;
510 + }
511 +
233 512 public function getEmailFooter(): string
234 513 {
235 514
236 515 $footer = "";
@@ -270,9 +549,18 @@
270 549 ]);
271 550 }
272 551
273 552 if (empty($body)) {
274 - $header = App::make('view')->make('emails.parts.order_header', $data);
553 + // order_header reads $order->invoice_no and $order->orderTaxRates, so
554 + // anything that is not an Order model fatals inside the view — an
555 + // array warns and then dies on ->first(). Render it only for a real
556 + // model: notifications that legitimately have no order still get a
557 + // body, they just get no order header.
558 + $orderModel = Arr::get($data, 'order');
559 + $header = ($orderModel instanceof Order)
560 + ? (string)App::make('view')->make('emails.parts.order_header', $data)
561 + : '';
562 +
275 563 $body = (string)App::make('view')->make('emails.general_template', [
276 564 'emailBody' => $rawBody,
277 565 'preheader' => Arr::get($notification, 'pre_header', ''),
278 566 'header' => $header,
@@ -282,11 +570,32 @@
282 570
283 571 $body = ShortcodeTemplateBuilder::make($body, $data);
284 572
285 573 $subject = ShortcodeTemplateBuilder::make(Arr::get($notification, 'subject', ''), $data);
574 + // A notification may declare an array of recipients — wp_mail() accepts
575 + // one — so expand element by element rather than flattening to a string.
286 576 $to = Arr::get($notification, 'to', '');
287 - $to = ShortcodeTemplateBuilder::make($to, $data);
577 + if (is_array($to)) {
578 + foreach ($to as $index => $address) {
579 + $to[$index] = ShortcodeTemplateBuilder::make((string)$address, $data);
580 + }
581 + } else {
582 + $to = ShortcodeTemplateBuilder::make((string)$to, $data);
583 + }
288 584
585 + // Admin-bound notifications only — stores route these to a helpdesk or
586 + // accounting inbox. Customer-bound mail must keep going to the customer,
587 + // so it is deliberately not filterable here. Applied after shortcode
588 + // resolution, so listeners see the address the notification resolved to.
589 + if (Arr::get($notification, 'recipient') === 'admin') {
590 + $to = apply_filters('fluent_cart/admin_email/notification_recipient', $to, [
591 + 'event' => Arr::get($notification, 'event', ''),
592 + 'mail_name' => Arr::get($notification, 'name', ''),
593 + 'notification' => $notification,
594 + 'data' => $data,
595 + ]);
596 + }
597 +
289 598 return [
290 599 0 => $body,
291 600 1 => $subject,
292 601 2 => $to
@@ -294,9 +603,9 @@
294 603 }
295 604
296 605 public function sendAsyncOrderMail($emailName, $orderId)
297 606 {
298 - $order = Order::query()->with(['customer', 'shipping_address', 'billing_address', 'transactions'])->find($orderId);
607 + $order = Order::query()->with(['customer', 'shipping_address', 'billing_address', 'transactions', 'orderTaxRates'])->find($orderId);
299 608
300 609 if ($order) {
301 610 $transaction = [];
302 611 if (!empty($order->transactions)) {