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 +244 -1 1.6.0 → 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
@@ -102,11 +119,30 @@
102 119 $this->mailEmailsOfEvent('renewal_reminder_due', $data);
103 120 }, 999, 1);
104 121
105 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 + }
106 130 $this->mailEmailsOfEvent('renewal_reminder_overdue', $data);
107 131 }, 999, 1);
108 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 +
109 145 add_action('fluent_cart/subscription_renewal_reminder', function ($data) {
110 146 $this->mailEmailsOfEvent('subscription_renewal_reminder', $data);
111 147 }, 999, 1);
112 148
@@ -113,10 +149,186 @@
113 149 add_action('fluent_cart/subscription_trial_end_reminder', function ($data) {
114 150 $this->mailEmailsOfEvent('subscription_trial_end_reminder', $data);
115 151 }, 999, 1);
116 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 +
117 259 }
118 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 +
119 331 public function registerAsyncMails()
120 332 {
121 333 //For Async Actions
122 334 add_action('fluent_cart/async_mail/order_created', function ($orderId, $mailName = '') {
@@ -222,8 +434,16 @@
222 434 $mailer->addAttachment($pdfPath);
223 435 }
224 436 }
225 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 +
226 446 $mailer->send(true);
227 447
228 448 // Clean up temp PDF file after sending
229 449 if ($pdfPath && file_exists($pdfPath)) {
@@ -258,13 +478,36 @@
258 478 $mailer->addAttachment($pdfPath);
259 479 }
260 480 }
261 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 +
262 490 $mailer->send(true);
263 491
264 492 if ($pdfPath && file_exists($pdfPath)) {
265 493 @unlink($pdfPath);
266 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;
267 510 }
268 511
269 512 public function getEmailFooter(): string
270 513 {