PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Services / EmailClaimService.php

EmailClaimService.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Services/EmailClaimService.php

595 lines 19.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services;
4
5 use FluentSupport\App\Models\Activity;
6 use FluentSupport\App\Models\Attachment;
7 use FluentSupport\App\Models\Conversation;
8 use FluentSupport\App\Models\Customer;
9 use FluentSupport\App\Models\Meta;
10 use FluentSupport\App\Models\Notification;
11 use FluentSupport\App\Models\Ticket;
12 use FluentSupport\App\Services\Notifications\NotificationSettings;
13
14 /**
15 * Moves a customer record onto its WordPress account's new email address once
16 * the customer proves they own it.
17 *
18 * ProfileInfoService won't follow an unverified email change, so the record
19 * keeps the old address. The confirmation link goes to the new address and
20 * only works for the signed-in account that requested it, so neither the inbox
21 * nor the account can move the record alone.
22 */
23 class EmailClaimService
24 {
25 /**
26 * How long a claim link stays valid. A fresh one can always be requested
27 * from the portal.
28 */
29 const TTL_SECONDS = DAY_IN_SECONDS;
30
31 /**
32 * Mixed into the signature so these tokens can't be replayed against
33 * another feature that signs with the same salt.
34 */
35 const SIGNING_CONTEXT = 'fluent_support_email_claim_v1';
36
37 /**
38 * @return bool
39 */
40 public static function isEnabled()
41 {
42 /*
43 * Filter whether customers may move their support address to their
44 * WordPress account address from the portal.
45 *
46 * @since v2.4.1
47 * @param bool $enabled
48 */
49 return (bool) apply_filters('fluent_support/enable_email_claim', true);
50 }
51
52 /**
53 * Describe how the signed-in account's address differs from its customer
54 * record, or null when there is nothing to reconcile.
55 *
56 * An address already held by another linked record also returns null. That
57 * is a conflict between two accounts and is left for an agent.
58 *
59 * Pass the customer if you already have it. The portal calls this on every
60 * page load.
61 *
62 * @param \FluentSupport\App\Models\Customer|null $customer
63 * @return array|null
64 */
65 public static function getDivergence($customer = null)
66 {
67 if (!self::isEnabled()) {
68 return null;
69 }
70
71 $userId = (int) get_current_user_id();
72
73 if (!$userId) {
74 return null;
75 }
76
77 $user = get_user_by('ID', $userId);
78
79 if (!$user || !$user->user_email) {
80 return null;
81 }
82
83 $customer = $customer ?: Helper::getCurrentCustomer();
84
85 if (!$customer || (int) $customer->user_id !== $userId) {
86 return null;
87 }
88
89 if (self::isSame($customer->email, $user->user_email)) {
90 return null;
91 }
92
93 if (self::heldByLinkedCustomer($user->user_email, $customer->id)) {
94 return null;
95 }
96
97 // Stray records on the new address aren't loaded here. apply() reads
98 // them when it acts.
99 return [
100 'customer' => $customer,
101 'from' => $customer->email,
102 'to' => $user->user_email
103 ];
104 }
105
106 /**
107 * Mail a confirmation link to the address being claimed.
108 *
109 * @param array $divergence As returned by getDivergence()
110 * @return string '' on success, otherwise an error slug
111 */
112 public static function issue($divergence)
113 {
114 $customer = $divergence['customer'];
115 $target = $divergence['to'];
116
117 // Per account, to stop one account mailing many inboxes. Per address,
118 // to stop many accounts mailing one inbox.
119 $accountKey = 'fs_email_claim_user_' . (int) $customer->user_id;
120 $targetKey = 'fs_email_claim_to_' . wp_hash(self::normalize($target));
121
122 if (Helper::hitRateLimit($accountKey, 5, HOUR_IN_SECONDS)
123 || Helper::hitRateLimit($targetKey, 5, HOUR_IN_SECONDS)
124 ) {
125 return 'throttled';
126 }
127
128 $link = self::buildConfirmUrl($customer, $target);
129
130 if (!$link) {
131 return 'no_portal';
132 }
133
134 $siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
135
136 $subject = apply_filters(
137 'fluent_support/email_claim_mail_subject',
138 // translators: %s is the site name
139 sprintf(__('[%s] Confirm your support email address', 'fluent-support'), $siteName),
140 $customer
141 );
142
143 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
144
145 $body = $pStart . sprintf(
146 // translators: %s is the customer's first name
147 __('Hello %s,', 'fluent-support'),
148 // WordPress doesn't sanitize first_name on save.
149 esc_html($customer->first_name)
150 ) . '</p>' .
151 $pStart . sprintf(
152 // translators: 1: site name, 2: the email address being confirmed
153 __('Someone asked to use this address for support messages on %1$s. Confirming will send future support notifications to %2$s and bring any tickets opened from it into your account.', 'fluent-support'),
154 $siteName,
155 $target
156 ) . '</p>' .
157 $pStart . '<a style="display: inline-block; background: #2271b1; color: #fff; text-decoration: none; padding: 10px 24px; border-radius: 3px;" href="' . esc_url($link) . '">' .
158 esc_html__('Confirm this address', 'fluent-support') . '</a></p>' .
159 $pStart . __('You will be asked to sign in first, so this link only works for the account that requested it.', 'fluent-support') . '</p>' .
160 $pStart . __('If you did not ask for this, no action is needed and nothing has changed. Your support messages will keep going to the address they go to now.', 'fluent-support') . '</p>';
161
162 /*
163 * Filter the body of the support address confirmation email.
164 *
165 * @since v2.4.1
166 * @param string $body
167 * @param \FluentSupport\App\Models\Customer $customer
168 * @param string $target The address being confirmed
169 * @param string $link
170 */
171 $body = apply_filters('fluent_support/email_claim_mail_body', $body, $customer, $target, $link);
172
173 $message = Helper::loadView('notification', [
174 'body' => $body,
175 'pre_header' => __('Confirm your support email address', 'fluent-support'),
176 'show_footer' => false
177 ]);
178
179 $sent = wp_mail($target, $subject, $message, ['Content-Type: text/html; charset=UTF-8']);
180
181 if (!$sent) {
182 return 'send_failed';
183 }
184
185 return '';
186 }
187
188 /**
189 * Validate a confirmation token against current state.
190 *
191 * The token binds the record's current address, so applying a claim retires
192 * the link. It is not strictly single-use: if the record is moved back to
193 * that address before expiry, the link works again.
194 *
195 * @param string $token
196 * @return array ['status' => slug, 'customer' => Customer|null, 'email' => string]
197 */
198 public static function resolveClaim($token)
199 {
200 if (!self::isEnabled()) {
201 return ['status' => 'disabled'];
202 }
203
204 $claim = self::parseToken($token);
205
206 if (!$claim) {
207 return ['status' => 'invalid'];
208 }
209
210 if ($claim['expires'] < time()) {
211 return ['status' => 'expired'];
212 }
213
214 // Inbox access alone isn't enough. The claiming account must be signed in.
215 if ((int) get_current_user_id() !== $claim['user_id']) {
216 return ['status' => 'wrong_account'];
217 }
218
219 $customer = Customer::where('id', $claim['customer_id'])->first();
220
221 if (!$customer || (int) $customer->user_id !== $claim['user_id']) {
222 return ['status' => 'invalid'];
223 }
224
225 if (!self::isSame($customer->email, $claim['from'])) {
226 return ['status' => 'stale'];
227 }
228
229 $user = get_user_by('ID', $claim['user_id']);
230
231 if (!$user || !self::isSame($user->user_email, $claim['to'])) {
232 return ['status' => 'stale'];
233 }
234
235 if (self::heldByLinkedCustomer($claim['to'], $customer->id)) {
236 return ['status' => 'conflict'];
237 }
238
239 return [
240 'status' => 'ok',
241 'customer' => $customer,
242 'email' => $claim['to']
243 ];
244 }
245
246 /**
247 * Move the record onto the confirmed address and absorb any unlinked record
248 * that was already collecting tickets there.
249 *
250 * @param \FluentSupport\App\Models\Customer $customer
251 * @param string $email
252 * @return array ['merged' => int, 'tickets' => int]
253 */
254 public static function apply($customer, $email)
255 {
256 $previousEmail = $customer->email;
257
258 $merged = 0;
259 $moved = 0;
260
261 foreach (self::unlinkedRecordsHolding($email, $customer->id) as $duplicate) {
262 $moved += self::absorb($duplicate, $customer);
263 $merged++;
264 }
265
266 $customer->email = $email;
267 $customer->save();
268
269 // Shared with the profile_update path: rotates ticket hashes and logs activity.
270 ProfileInfoService::onProvenEmailChange($customer, $previousEmail, 'claimed');
271
272 return ['merged' => $merged, 'tickets' => $moved];
273 }
274
275 /**
276 * Reparent everything hanging off one customer record onto another, then
277 * remove the record it came from.
278 *
279 * The source is deleted only if a re-read shows it empty. The tables may be
280 * MyISAM, so a transaction can't be relied on.
281 *
282 * @param \FluentSupport\App\Models\Customer $source
283 * @param \FluentSupport\App\Models\Customer $target
284 * @return int Number of tickets moved
285 */
286 protected static function absorb($source, $target)
287 {
288 $tickets = Ticket::where('customer_id', $source->id)->get();
289
290 foreach ($tickets as $ticket) {
291 $ticket->customer_id = $target->id;
292 // Saved one by one so Ticket's updating event rotates the hash.
293 // A query-builder update would skip it.
294 $ticket->save();
295 }
296
297 Conversation::where('person_id', $source->id)->update(['person_id' => $target->id]);
298 Attachment::where('person_id', $source->id)->update(['person_id' => $target->id]);
299
300 Activity::where('person_type', 'customer')
301 ->where('person_id', $source->id)
302 ->update(['person_id' => $target->id]);
303
304 Activity::where('object_type', 'customer')
305 ->where('object_id', $source->id)
306 ->update(['object_id' => $target->id]);
307
308 // Notification tables only exist once internal notifications have been
309 // enabled, so check before querying them.
310 $hasNotifications = (new NotificationSettings())->notificationTablesExist();
311
312 if ($hasNotifications) {
313 Notification::where('actor_id', $source->id)
314 ->update(['actor_id' => $target->id]);
315 }
316
317 self::mergeMeta($source, $target);
318
319 /*
320 * Fires while one customer record is merged into another, after core
321 * rows have moved and before the source record is removed. Move any
322 * rows keyed by person id here. Pro moves time tracking on this hook.
323 *
324 * @since v2.4.1
325 * @param \FluentSupport\App\Models\Customer $source Record being absorbed
326 * @param \FluentSupport\App\Models\Customer $target Record it is folded into
327 */
328 do_action('fluent_support/merging_customer_records', $source, $target);
329
330 $sourceId = $source->id;
331 $sourceEmail = $source->email;
332 $ticketsMoved = count($tickets);
333
334 $remaining = Ticket::where('customer_id', $sourceId)->count()
335 + Conversation::where('person_id', $sourceId)->count()
336 + Attachment::where('person_id', $sourceId)->count();
337
338 if ($hasNotifications) {
339 $remaining += Notification::where('actor_id', $sourceId)->count();
340 }
341
342 if ($remaining === 0) {
343 $source->deleteAllMeta();
344 $source->delete();
345 }
346
347 Activity::create([
348 'event_type' => 'fluent_support/customer_records_merged',
349 'person_id' => $target->id,
350 'person_type' => 'customer',
351 'object_id' => $target->id,
352 'object_type' => 'customer',
353 'description' => $remaining === 0
354 ? sprintf(
355 // translators: 1: merged customer record id, 2: that record's email address, 3: number of tickets moved
356 _n(
357 'Merged customer record #%1$s (%2$s) into this one after the address was confirmed. %3$s ticket moved and its shared link was reissued.',
358 'Merged customer record #%1$s (%2$s) into this one after the address was confirmed. %3$s tickets moved and their shared links were reissued.',
359 $ticketsMoved,
360 'fluent-support'
361 ),
362 $sourceId,
363 $sourceEmail,
364 $ticketsMoved
365 )
366 : sprintf(
367 // translators: 1: customer record id, 2: that record's email address, 3: number of tickets moved
368 _n(
369 'Moved %3$s ticket from customer record #%1$s (%2$s) into this one after the address was confirmed. That record still holds other data and was kept.',
370 'Moved %3$s tickets from customer record #%1$s (%2$s) into this one after the address was confirmed. That record still holds other data and was kept.',
371 $ticketsMoved,
372 'fluent-support'
373 ),
374 $sourceId,
375 $sourceEmail,
376 $ticketsMoved
377 )
378 ]);
379
380 return $ticketsMoved;
381 }
382
383 /**
384 * Move person meta the target doesn't already have. The target's own keys win.
385 *
386 * @param \FluentSupport\App\Models\Customer $source
387 * @param \FluentSupport\App\Models\Customer $target
388 * @return void
389 */
390 protected static function mergeMeta($source, $target)
391 {
392 $sourceMeta = Meta::where('object_type', 'person_meta')
393 ->where('object_id', $source->id)
394 ->get();
395
396 if (!$sourceMeta || count($sourceMeta) === 0) {
397 return;
398 }
399
400 $existing = Meta::where('object_type', 'person_meta')
401 ->where('object_id', $target->id)
402 ->get()
403 ->pluck('key')
404 ->toArray();
405
406 foreach ($sourceMeta as $meta) {
407 if (in_array($meta->key, $existing, true)) {
408 continue;
409 }
410
411 $meta->object_id = $target->id;
412 $meta->save();
413 }
414 }
415
416 /**
417 * @param \FluentSupport\App\Models\Customer $customer
418 * @param string $target
419 * @return string
420 */
421 public static function buildConfirmUrl($customer, $target)
422 {
423 $baseUrl = Helper::getPortalBaseUrl();
424
425 if (!$baseUrl) {
426 return '';
427 }
428
429 $token = self::buildToken(
430 $customer->id,
431 $customer->user_id,
432 $customer->email,
433 $target,
434 time() + self::TTL_SECONDS
435 );
436
437 return add_query_arg([
438 'fs_view' => 'email_claim',
439 'fs_claim' => $token
440 ], $baseUrl);
441 }
442
443 /**
444 * URL the portal notice points at to request a confirmation email.
445 *
446 * Nonced because following it sends mail.
447 *
448 * @return string
449 */
450 public static function buildRequestUrl()
451 {
452 $baseUrl = Helper::getPortalBaseUrl();
453
454 if (!$baseUrl) {
455 return '';
456 }
457
458 return add_query_arg([
459 'fs_view' => 'email_claim',
460 'fs_claim_action' => 'send',
461 '_wpnonce' => wp_create_nonce('fs_email_claim_send')
462 ], $baseUrl);
463 }
464
465 /**
466 * @param int $customerId
467 * @param int $userId
468 * @param string $from Address the record holds now
469 * @param string $to Address being claimed
470 * @param int $expires
471 * @return string
472 */
473 public static function buildToken($customerId, $userId, $from, $to, $expires)
474 {
475 // Encoded because is_email() accepts '|' in the local part.
476 $payload = implode('|', [
477 (int) $customerId,
478 (int) $userId,
479 rawurlencode(self::normalize($from)),
480 rawurlencode(self::normalize($to)),
481 (int) $expires
482 ]);
483
484 $raw = $payload . '|' . self::sign($payload);
485
486 return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
487 }
488
489 /**
490 * @param string $token
491 * @return array|null
492 */
493 protected static function parseToken($token)
494 {
495 $token = (string) $token;
496
497 if (!$token || !preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
498 return null;
499 }
500
501 $raw = base64_decode(strtr($token, '-_', '+/'), true);
502
503 if (!$raw) {
504 return null;
505 }
506
507 $parts = explode('|', $raw);
508
509 if (count($parts) !== 6) {
510 return null;
511 }
512
513 list($customerId, $userId, $from, $to, $expires, $signature) = $parts;
514
515 $payload = implode('|', [$customerId, $userId, $from, $to, $expires]);
516
517 if (!hash_equals(self::sign($payload), $signature)) {
518 return null;
519 }
520
521 // Decode only after verifying the signature over the encoded string.
522 return [
523 'customer_id' => (int) $customerId,
524 'user_id' => (int) $userId,
525 'from' => rawurldecode($from),
526 'to' => rawurldecode($to),
527 'expires' => (int) $expires
528 ];
529 }
530
531 /**
532 * @param string $payload
533 * @return string
534 */
535 protected static function sign($payload)
536 {
537 return hash_hmac('sha256', self::SIGNING_CONTEXT . '|' . $payload, wp_salt('auth'));
538 }
539
540 /**
541 * Records on this address with no WordPress account. Only these may be
542 * absorbed by a claim.
543 *
544 * @param string $email
545 * @param int $excludeId
546 * @return \FluentSupport\Framework\Support\Collection
547 */
548 protected static function unlinkedRecordsHolding($email, $excludeId)
549 {
550 return Customer::where('email', $email)
551 ->where('id', '!=', $excludeId)
552 ->unclaimed()
553 ->orderBy('id', 'ASC')
554 ->get();
555 }
556
557 /**
558 * Whether another account's customer record already holds this address.
559 *
560 * @param string $email
561 * @param int $excludeId
562 * @return bool
563 */
564 protected static function heldByLinkedCustomer($email, $excludeId)
565 {
566 if (!$email) {
567 return true;
568 }
569
570 return (bool) Customer::where('email', $email)
571 ->where('id', '!=', $excludeId)
572 ->claimed()
573 ->first();
574 }
575
576 /**
577 * @param string $email
578 * @return string
579 */
580 protected static function normalize($email)
581 {
582 return strtolower(trim((string) $email));
583 }
584
585 /**
586 * @param string $left
587 * @param string $right
588 * @return bool
589 */
590 protected static function isSame($left, $right)
591 {
592 return self::normalize($left) === self::normalize($right);
593 }
594 }
595