• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

grueneschweiz / mailchimpservice / 17108989360

20 Aug 2025 08:00PM UTC coverage: 68.598% (-1.6%) from 70.247%
17108989360

push

github

Michael-Schaer
[FEAT] Sync from mailchimp to crm in specific cases

197 of 333 new or added lines in 10 files covered. (59.16%)

4 existing lines in 2 files now uncovered.

959 of 1398 relevant lines covered (68.6%)

11.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.14
/app/Http/MailChimpClient.php
1
<?php
2

3
namespace App\Http;
4

5
use App\Exceptions\AlreadyInListException;
6
use App\Exceptions\ArchivedException;
7
use App\Exceptions\CleanedEmailException;
8
use App\Exceptions\EmailComplianceException;
9
use App\Exceptions\FakeEmailException;
10
use App\Exceptions\InvalidEmailException;
11
use App\Exceptions\MailchimpClientException;
12
use App\Exceptions\MailchimpTooManySubscriptionsException;
13
use App\Exceptions\MemberDeleteException;
14
use App\Exceptions\MergeFieldException;
15
use App\Exceptions\UnsubscribedEmailException;
16
use DrewM\MailChimp\MailChimp;
17

18
class MailChimpClient
19
{
20
    private const MC_GET_LIMIT = 1000;
21
    private const API_WRITE_TIMEOUT = 30; // seconds
22

23
    /**
24
     * The Mailchimp client object
25
     *
26
     * @see https://github.com/drewm/mailchimp-api
27
     *
28
     * @var MailChimp
29
     */
30
    private $client;
31

32
    /**
33
     * The MailChimp api key
34
     *
35
     * @var string
36
     */
37
    private $apiKey;
38

39
    /**
40
     * The list we're working with
41
     *
42
     * @var string
43
     */
44
    private $listId;
45

46
    /**
47
     * In memory cache for all subscriber.
48
     *
49
     * Key: email, value: crmId
50
     *
51
     * @var array
52
     */
53
    private $subscribers;
54

55
    /**
56
     * @param string $api_key MailChimp api key
57
     *
58
     * @throws \Exception
59
     */
60
    public function __construct(string $api_key, string $listId)
61
    {
62
        $this->apiKey = $api_key;
43✔
63
        $this->listId = $listId;
43✔
64
        $this->client = new MailChimp($api_key);
43✔
65
    }
66

67
    /**
68
     * Get subscriber by email
69
     *
70
     * @param string $email
71
     *
72
     * @return array|false
73
     * @throws MailchimpClientException
74
     */
75
    public function getSubscriber(string $email)
76
    {
77
        $id = self::calculateSubscriberId($email);
21✔
78

79
        $get = $this->client->get("lists/{$this->listId}/members/$id", [], 30);
21✔
80
        $this->validateResponseStatus('GET subscriber', $get);
21✔
81
        $this->validateResponseContent('GET subscriber', $get);
21✔
82

83
        return $get;
20✔
84
    }
85

86
    /**
87
     * Calculate the id of the contact in mailchimp
88
     *
89
     * @see https://developer.mailchimp.com/documentation/mailchimp/guides/manage-subscribers-with-the-mailchimp-api/
90
     *
91
     * @param string $email
92
     *
93
     * @return string MD5 hash of the lowercase email address
94
     */
95
    public static function calculateSubscriberId(string $email)
96
    {
97
        $email = trim($email);
31✔
98
        $email = strtolower($email);
31✔
99

100
        return md5($email);
31✔
101
    }
102

103
    /**
104
     * Throw exception if we get a falsy response.
105
     *
106
     * @param string $method
107
     * @param $response
108
     *
109
     * @throws MailchimpClientException
110
     */
111
    private function validateResponseStatus(string $method, $response)
112
    {
113
        if (!$response) {
27✔
114
            throw new MailchimpClientException("$method request against Mailchimp failed: {$this->client->getLastError()}");
×
115
        }
116
    }
117

118
    /**
119
     * Throw exception if we get response with erroneous content.
120
     *
121
     * @param string $method
122
     * @param $response
123
     *
124
     * @throws MailchimpClientException
125
     */
126
    private function validateResponseContent(string $method, $response)
127
    {
128
        if (isset($response['status']) && is_numeric($response['status']) && $response['status'] !== 200) {
27✔
129
            $message = "$method request against Mailchimp failed (status code: {$response['status']}): {$response['detail']}";
7✔
130

131
            if (array_key_exists('errors', $response)) {
7✔
132
                foreach ($response['errors'] as $k => $v) {
×
133
                    $message .= " Errors[$k] => {$v['message']}";
×
134
                }
135
            }
136

137
            throw new MailchimpClientException($message);
7✔
138
        }
139
    }
140

141
    /**
142
     * Return the email address of the first subscriber that has the given value in the given merge tag field.
143
     *
144
     * Note: This function is really costly, since mailchimp's api doesn't allow to search by merge tag by april 2019.
145
     *
146
     * @param string $crmId
147
     * @param string $crmIdKey
148
     *
149
     * @return false|string email address on match else false
150
     * @throws MailchimpClientException
151
     */
152
    public function getSubscriberEmailByCrmId(string $crmId, string $crmIdKey)
153
    {
154
        $subscribers = $this->getAllSubscribers($crmIdKey);
18✔
155

156
        return array_search($crmId, $subscribers);
18✔
157
    }
158

159
    /**
160
     * Return cached array of all mailchimp entries with their email address as key and the crm id as value.
161
     *
162
     * Note: This function is really costly. We use it since mailchimp's api doesn't allow to search by merge tag by april 2019.
163
     *
164
     * @param string $crmIdKey
165
     *
166
     * @return array [email => crmId, ...]
167
     * @throws MailchimpClientException
168
     */
169
    private function getAllSubscribers(string $crmIdKey): array
170
    {
171
        if (null !== $this->subscribers) {
18✔
172
            return $this->subscribers;
18✔
173
        }
174

175
        $offset = 0;
18✔
176

177
        while (true) {
18✔
178
            $get = $this->client->get("lists/{$this->listId}/members?count=" . self::MC_GET_LIMIT . "&offset=$offset&fields=members.email_address,members.merge_fields", [], 30);
18✔
179

180
            $this->validateResponseStatus('GET multiple subscribers', $get);
18✔
181
            $this->validateResponseContent('GET multiple subscribers', $get);
18✔
182

183
            if (0 === count($get['members'])) {
18✔
184
                break;
18✔
185
            }
186

187
            foreach ($get['members'] as $member) {
18✔
188
                $this->subscribers[$member['email_address']] = $member['merge_fields'][$crmIdKey];
18✔
189
            }
190

191
            $offset += self::MC_GET_LIMIT;
18✔
192
        }
193

194
        if (!$this->subscribers) {
18✔
195
            $this->subscribers = [];
×
196
        }
197

198
        return $this->subscribers;
18✔
199
    }
200

201
    /**
202
     * Upsert subscriber
203
     *
204
     * @param array $mcData
205
     * @param string $email provide old email to update subscribers email address
206
     * @param string $id the mailchimp id of the subscriber
207
     *
208
     * @return array|false
209
     * @throws \InvalidArgumentException
210
     * @throws InvalidEmailException
211
     * @throws MailchimpClientException
212
     * @throws EmailComplianceException
213
     * @throws AlreadyInListException
214
     * @throws CleanedEmailException
215
     * @throws FakeEmailException
216
     * @throws UnsubscribedEmailException
217
     * @throws MergeFieldException
218
     * @throws MailchimpTooManySubscriptionsException
219
     * @throws ArchivedException
220
     */
221
    public function putSubscriber(array $mcData, string $email = null, string $id = null)
222
    {
223
        if (empty($mcData['email_address'])) {
27✔
224
            throw new \InvalidArgumentException('Missing email_address.');
×
225
        }
226

227
        if (!isset($mcData['status']) && !isset($mcData['status_if_new'])) {
27✔
228
            $mcData['status_if_new'] = 'subscribed';
5✔
229
        }
230

231
        if (!$email) {
27✔
232
            $email = $mcData['email_address'];
27✔
233
        }
234

235
        // it is possible, that the subscriber id differs from the lowercase email md5-hash (why?)
236
        // so we need a possibility to provide it manually.
237
        if (!$id) {
27✔
238
            $id = self::calculateSubscriberId($email);
27✔
239
        }
240

241
        $endpoint = "lists/{$this->listId}/members/$id";
27✔
242
        $put = $this->client->put($endpoint, $mcData, self::API_WRITE_TIMEOUT);
27✔
243

244
        $this->validateResponseStatus('PUT subscriber', $put);
27✔
245
        if (isset($put['status']) && is_numeric($put['status']) && $put['status'] !== 200) {
27✔
246
            $errorMsg = $put['errors'][0]['message'] ?? $put['detail'] ?? print_r($put, true);
3✔
247

248
            if (
249
                str_starts_with($errorMsg, 'Invalid email address')
3✔
250
                || strpos($errorMsg, 'provide a valid email address.')
3✔
251
            ) {
252
                throw new InvalidEmailException($errorMsg);
×
253
            }
254
            if (
255
                str_starts_with($errorMsg, 'This member\'s status is "cleaned."') ||
3✔
256
                (strpos($errorMsg, 'is already in this list with a status of "Cleaned".'))
3✔
257
            ) {
UNCOV
258
                throw new CleanedEmailException($errorMsg);
×
259
            }
260
            if (
261
                str_starts_with($errorMsg, 'This member\'s status is "unsubscribed."') ||
3✔
262
                (strpos($errorMsg, 'is already in this list with a status of "Unsubscribed".')) ||
3✔
263
                (strpos($errorMsg, 'has previously unsubscribed from this list and must opt in again.')) ||
3✔
264
                (strpos($errorMsg, "was previously removed from this audience. To rejoin, they'll need to sign up using a Mailchimp form.")) ||
3✔
265
                (strpos($errorMsg, "was permanently deleted and cannot be re-imported. The contact must re-subscribe to get back on the list."))
3✔
266
            ) {
267
                throw new UnsubscribedEmailException($errorMsg);
×
268
            }
269
            if (
270
                strpos($errorMsg, 'is already a list member') ||
3✔
271
                (strpos($errorMsg, 'is already in this list with a status of "Deleted".')) ||
3✔
272
                (strpos($errorMsg, 'is already in this list with a status of "Subscribed".'))
3✔
273
            ) {
274
                throw new AlreadyInListException("{$errorMsg} Email used for id calc: $email. Called endpoint: $endpoint. Data: " . str_replace("\n", ', ', print_r($mcData, true)));
2✔
275
            }
276
            if (strpos($errorMsg, 'status is "archived."')) {
1✔
UNCOV
277
                throw new ArchivedException($errorMsg);
×
278
            }
279
            if (strpos($errorMsg, 'compliance state')) {
1✔
280
                throw new EmailComplianceException($errorMsg);
×
281
            }
282
            if (strpos($errorMsg, 'looks fake or invalid, please enter a real email address.')) {
1✔
283
                throw new FakeEmailException($errorMsg);
1✔
284
            }
285
            if (strpos($errorMsg, 'merge fields were invalid')) {
×
286
                throw new MergeFieldException($errorMsg);
×
287
            }
NEW
288
            if (strpos($errorMsg, "has signed up to a lot of lists very recently; we're not allowing more signups for now.")) {
×
UNCOV
289
                throw new MailchimpTooManySubscriptionsException($errorMsg);
×
290
            }
291
        }
292
        $this->validateResponseContent('PUT subscriber', $put);
26✔
293

294
        // this is needed for updates
295
        $this->updateSubscribersTags($put['id'], $mcData['tags']);
26✔
296

297
        return $put;
26✔
298
    }
299

300
    /**
301
     * Add or update tags for a subscriber
302
     *
303
     * @param string $subscriberId The MailChimp subscriber ID
304
     * @param array $tags Array of tag names (strings) or tag objects with 'name' and 'status'
305
     *
306
     * @throws MailchimpClientException
307
     */
308
    public function addTagsToSubscriber(string $subscriberId, array $tags)
309
    {
310
        if (empty($tags)) {
1✔
311
            return;
×
312
        }
313

314
        $formattedTags = [];
1✔
315
        foreach ($tags as $tag) {
1✔
316
            if (is_string($tag)) {
1✔
317
                $formattedTags[] = (object)['name' => $tag, 'status' => 'active'];
1✔
318
            }
319
        }
320

321
        $this->postSubscriberTags($subscriberId, ['tags' => $formattedTags]);
1✔
322
    }
323

324
    /**
325
     * Update tags of subscriber
326
     *
327
     * This must be done extra, because mailchimp doesn't allow to change the tags on subscriber update
328
     *
329
     * @param string $id
330
     * @param array $new the tags the subscriber should have
331
     *
332
     * @throws MailchimpClientException
333
     */
334
    private function updateSubscribersTags(string $id, array $new)
335
    {
336
        $get = $this->getSubscriberTags($id);
26✔
337

338
        $current = array_column($get['tags'], 'name');
26✔
339

340
        $update = [];
26✔
341
        foreach ($current as $currentTag) {
26✔
342
            if (!in_array($currentTag, $new)) {
20✔
343
                $update[] = (object)['name' => $currentTag, 'status' => 'inactive'];
2✔
344
            }
345
        }
346

347
        foreach ($new as $newTag) {
26✔
348
            // if we update a subscriber our $new array is two dimensional
349
            // if we insert, the $new array simply contains the tags
350
            if (is_array($newTag)) {
20✔
351
                $newTag = $newTag['name'];
1✔
352
            }
353
            if (!in_array($newTag, $current)) {
20✔
354
                $update[] = (object)['name' => $newTag, 'status' => 'active'];
×
355
            }
356
        }
357

358
        if (empty($update)) {
26✔
359
            return;
26✔
360
        }
361

362
        $this->postSubscriberTags($id, ['tags' => $update]);
2✔
363
    }
364

365
    /**
366
     * Get subscriber tags
367
     *
368
     * @param string $id
369
     *
370
     * @return array|false
371
     * @throws MailchimpClientException
372
     */
373
    public function getSubscriberTags(string $id)
374
    {
375
        // somehow we had a lot of timeouts when requesting the tags, therefore we increased the timeout
376
        $get = $this->client->get("lists/{$this->listId}/members/$id/tags", [], 30);
26✔
377

378
        $this->validateResponseStatus('GET tags', $get);
26✔
379
        $this->validateResponseContent('GET tags', $get);
26✔
380

381
        return $get;
26✔
382
    }
383

384
    /**
385
     * Update subscriber tags
386
     *
387
     * @param string $id
388
     * @param array $tags the tags that should be activated and the ones that should be deactivated
389
     *
390
     * @return array|false
391
     * @throws MailchimpClientException
392
     * @see https://developer.mailchimp.com/documentation/mailchimp/reference/lists/members/tags/#%20
393
     *
394
     */
395
    private function postSubscriberTags(string $id, array $tags)
396
    {
397
        $post = $this->client->post("lists/{$this->listId}/members/$id/tags", $tags, self::API_WRITE_TIMEOUT);
3✔
398

399
        $this->validateResponseStatus('POST tags', $post);
3✔
400
        $this->validateResponseContent('POST tags', $post);
3✔
401

402
        return $post;
3✔
403
    }
404

405
    /**
406
     * Remove a tag from a subscriber
407
     *
408
     * @param string $subscriberId The MailChimp subscriber ID
409
     * @param string $tagName The name of the tag to remove
410
     *
411
     * @return array|false
412
     * @throws MailchimpClientException
413
     */
414
    public function removeTagFromSubscriber(string $subscriberId, string $tagName)
415
    {
NEW
416
        return $this->postSubscriberTags($subscriberId, [
×
NEW
417
            'tags' => [
×
NEW
418
                (object)['name' => $tagName, 'status' => 'inactive']
×
NEW
419
            ]
×
NEW
420
        ]);
×
421
    }
422

423
    /**
424
     * Update subscriber interests
425
     *
426
     * @param string $subscriberId The MailChimp subscriber ID
427
     * @param string $interestCategoryId The interest category ID
428
     * @param string $interestId The interest ID to set as active
429
     * @param array $mergeFields Optional merge fields to update at the same time
430
     *
431
     * @return array|false
432
     * @throws MailchimpClientException
433
     */
434
    public function updateMergeFields(string $subscriberId, array $mergeFields)
435
    {
NEW
436
        $data = [
×
NEW
437
            'merge_fields' => $mergeFields,
×
NEW
438
        ];
×
439

NEW
440
        $patch = $this->client->patch("lists/{$this->listId}/members/$subscriberId", $data, self::API_WRITE_TIMEOUT);
×
441

NEW
442
        $this->validateResponseStatus('PATCH subscriber interests', $patch);
×
NEW
443
        $this->validateResponseContent('PATCH subscriber interests', $patch);
×
444

NEW
445
        return $patch;
×
446
    }
447

448
    /**
449
     * Delete subscriber
450
     *
451
     * @param string $email
452
     *
453
     * @throws MailchimpClientException
454
     * @throws MemberDeleteException
455
     */
456
    public function deleteSubscriber(string $email)
457
    {
458
        $id = self::calculateSubscriberId($email);
11✔
459
        $delete = $this->client->delete("lists/{$this->listId}/members/$id", [], self::API_WRITE_TIMEOUT);
11✔
460

461
        $this->validateResponseStatus('DELETE subscriber', $delete);
11✔
462
        if (isset($delete['status']) && is_numeric($delete['status']) && $delete['status'] !== 200) {
11✔
463
            if (isset($delete['detail']) && strpos($delete['detail'], 'member cannot be removed')) {
×
464
                throw new MemberDeleteException($delete['detail']);
×
465
            }
466
        }
467
        if (isset($delete['status']) && $delete['status'] === 404) {
11✔
468
            // the record we wanted to delete does not exist and hence our request is satisfied.
469
            return;
×
470
        }
471

472
        $this->validateResponseContent('DELETE subscriber', $delete);
11✔
473
    }
474

475
    /**
476
     * Delete subscriber permanently
477
     *
478
     * @param string $email
479
     *
480
     * @throws MailchimpClientException
481
     */
482
    public function permanentlyDeleteSubscriber(string $email)
483
    {
484
        $id = self::calculateSubscriberId($email);
10✔
485
        $delete = $this->client->post("lists/{$this->listId}/members/$id/actions/delete-permanent", [], self::API_WRITE_TIMEOUT);
10✔
486

487
        $this->validateResponseStatus('DELETE subscriber permanently', $delete);
10✔
488
        $this->validateResponseContent('DELETE subscriber permanently', $delete);
10✔
489
    }
490

491
    /**
492
     * Get members from the configured list, filtered by specified filters
493
     *
494
     * @param int $count Number of members to return per page
495
     * @param int $offset Offset for pagination
496
     * @param array $filterParams Filters to include in the response
497
     *
498
     * @return array Members that have changed since the specified time
499
     * @throws MailchimpClientException
500
     */
501
    public function getListMembers(int $count, int $offset, array $filterParams): array
502
    {
NEW
503
        $filterParams = array_merge([
×
NEW
504
            'count' => $count,
×
NEW
505
            'offset' => $offset,
×
NEW
506
        ], $filterParams);
×
507

NEW
508
        $get = $this->client->get("lists/{$this->listId}/members", $filterParams);
×
509

NEW
510
        $this->validateResponseStatus('GET changed members', $get);
×
NEW
511
        $this->validateResponseContent('GET changed members', $get);
×
512

NEW
513
        return $get['members'] ?? [];
×
514
    }
515
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc