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

grueneschweiz / mailchimpservice / 16117443907

07 Jul 2025 12:50PM UTC coverage: 71.551% (+0.09%) from 71.466%
16117443907

push

github

Michael-Schaer
Add Tags for new members, fix route

11 of 14 new or added lines in 3 files covered. (78.57%)

835 of 1167 relevant lines covered (71.55%)

11.38 hits per line

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

84.17
/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;
30✔
63
        $this->listId = $listId;
30✔
64
        $this->client = new MailChimp($api_key);
30✔
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);
23✔
78
        
79
        $get = $this->client->get("lists/{$this->listId}/members/$id", [], 30);
23✔
80
        $this->validateResponseStatus('GET subscriber', $get);
23✔
81
        $this->validateResponseContent('GET subscriber', $get);
23✔
82
        
83
        return $get;
22✔
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);
28✔
98
        $email = strtolower($email);
28✔
99
        
100
        return md5($email);
28✔
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 (str_starts_with($errorMsg, 'Invalid email address')
3✔
249
                || strpos($errorMsg, 'provide a valid email address.')
3✔
250
            ) {
251
                throw new InvalidEmailException($errorMsg);
×
252
            }
253
            if (str_starts_with($errorMsg, 'This member\'s status is "cleaned."') ||
3✔
254
                (strpos($errorMsg, 'is already in this list with a status of "Cleaned".'))) {
3✔
255
                throw new CleanedEmailException($errorMsg);
×
256
            }
257
            if (str_starts_with($errorMsg, 'This member\'s status is "unsubscribed."') ||
3✔
258
                (strpos($errorMsg, 'is already in this list with a status of "Unsubscribed".')) ||
3✔
259
                (strpos($errorMsg, 'has previously unsubscribed from this list and must opt in again.')) ||
3✔
260
                (strpos($errorMsg, "was previously removed from this audience. To rejoin, they'll need to sign up using a Mailchimp form.")) ||
3✔
261
                (strpos($errorMsg, "was permanently deleted and cannot be re-imported. The contact must re-subscribe to get back on the list."))
3✔
262
            ) {
263
                throw new UnsubscribedEmailException($errorMsg);
×
264
            }
265
            if (strpos($errorMsg, 'is already a list member') ||
3✔
266
                (strpos($errorMsg, 'is already in this list with a status of "Deleted".')) ||
3✔
267
                (strpos($errorMsg, 'is already in this list with a status of "Subscribed".'))
3✔
268
            ) {
269
                throw new AlreadyInListException("{$errorMsg} Email used for id calc: $email. Called endpoint: $endpoint. Data: " . str_replace("\n", ', ', print_r($mcData, true)));
2✔
270
            }
271
            if (strpos($errorMsg, 'status is "archived."')
1✔
272
            ) {
273
                throw new ArchivedException($errorMsg);
×
274
            }
275
            if (strpos($errorMsg, 'compliance state')) {
1✔
276
                throw new EmailComplianceException($errorMsg);
×
277
            }
278
            if (strpos($errorMsg, 'looks fake or invalid, please enter a real email address.')) {
1✔
279
                throw new FakeEmailException($errorMsg);
1✔
280
            }
281
            if (strpos($errorMsg, 'merge fields were invalid')) {
×
282
                throw new MergeFieldException($errorMsg);
×
283
            }
284
            if (strpos($errorMsg, "has signed up to a lot of lists very recently; we're not allowing more signups for now.")
×
285
            ) {
286
                throw new MailchimpTooManySubscriptionsException($errorMsg);
×
287
            }
288
        }
289
        $this->validateResponseContent('PUT subscriber', $put);
26✔
290
        
291
        // this is needed for updates
292
        $this->updateSubscribersTags($put['id'], $mcData['tags']);
26✔
293
        
294
        return $put;
26✔
295
    }
296

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

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

318
        $this->postSubscriberTags($subscriberId, ['tags' => $formattedTags]);
1✔
319
    }
320
    
321
    /**
322
     * Update tags of subscriber
323
     *
324
     * This must be done extra, because mailchimp doesn't allow to change the tags on subscriber update
325
     *
326
     * @param string $id
327
     * @param array $new the tags the subscriber should have
328
     *
329
     * @throws MailchimpClientException
330
     */
331
    private function updateSubscribersTags(string $id, array $new)
332
    {
333
        $get = $this->getSubscriberTags($id);
26✔
334
        
335
        $current = array_column($get['tags'], 'name');
26✔
336
        
337
        $update = [];
26✔
338
        foreach ($current as $currentTag) {
26✔
339
            if (!in_array($currentTag, $new)) {
20✔
340
                $update[] = (object)['name' => $currentTag, 'status' => 'inactive'];
2✔
341
            }
342
        }
343
        
344
        foreach ($new as $newTag) {
26✔
345
            // if we update a subscriber our $new array is two dimensional
346
            // if we insert, the $new array simply contains the tags
347
            if (is_array($newTag)) {
20✔
348
                $newTag = $newTag['name'];
1✔
349
            }
350
            if (!in_array($newTag, $current)) {
20✔
351
                $update[] = (object)['name' => $newTag, 'status' => 'active'];
×
352
            }
353
        }
354
        
355
        if (empty($update)) {
26✔
356
            return;
26✔
357
        }
358
        
359
        $this->postSubscriberTags($id, ['tags' => $update]);
2✔
360
    }
361
    
362
    /**
363
     * Get subscriber tags
364
     *
365
     * @param string $id
366
     *
367
     * @return array|false
368
     * @throws MailchimpClientException
369
     */
370
    private function getSubscriberTags(string $id)
371
    {
372
        // somehow we had a lot of timeouts when requesting the tags, therefore we increased the timeout
373
        $get = $this->client->get("lists/{$this->listId}/members/$id/tags", [], 30);
26✔
374
        
375
        $this->validateResponseStatus('GET tags', $get);
26✔
376
        $this->validateResponseContent('GET tags', $get);
26✔
377
        
378
        return $get;
26✔
379
    }
380
    
381
    /**
382
     * Update subscriber tags
383
     *
384
     * @param string $id
385
     * @param array $tags the tags that should be activated and the ones that should be deactivated
386
     *
387
     * @return array|false
388
     * @throws MailchimpClientException
389
     * @see https://developer.mailchimp.com/documentation/mailchimp/reference/lists/members/tags/#%20
390
     *
391
     */
392
    private function postSubscriberTags(string $id, array $tags)
393
    {
394
        $post = $this->client->post("lists/{$this->listId}/members/$id/tags", $tags, self::API_WRITE_TIMEOUT);
3✔
395
        
396
        $this->validateResponseStatus('POST tags', $post);
3✔
397
        $this->validateResponseContent('POST tags', $post);
3✔
398
        
399
        return $post;
3✔
400
    }
401
    
402
    /**
403
     * Delete subscriber
404
     *
405
     * @param string $email
406
     *
407
     * @throws MailchimpClientException
408
     * @throws MemberDeleteException
409
     */
410
    public function deleteSubscriber(string $email)
411
    {
412
        $id = self::calculateSubscriberId($email);
11✔
413
        $delete = $this->client->delete("lists/{$this->listId}/members/$id", [], self::API_WRITE_TIMEOUT);
11✔
414
    
415
        $this->validateResponseStatus('DELETE subscriber', $delete);
11✔
416
        if (isset($delete['status']) && is_numeric($delete['status']) && $delete['status'] !== 200) {
11✔
417
            if (isset($delete['detail']) && strpos($delete['detail'], 'member cannot be removed')) {
×
418
                throw new MemberDeleteException($delete['detail']);
×
419
            }
420
        }
421
        if (isset($delete['status']) && $delete['status'] === 404) {
11✔
422
            // the record we wanted to delete does not exist and hence our request is satisfied.
423
            return;
×
424
        }
425
    
426
        $this->validateResponseContent('DELETE subscriber', $delete);
11✔
427
    }
428
    
429
    /**
430
     * Delete subscriber permanently
431
     *
432
     * @param string $email
433
     *
434
     * @throws MailchimpClientException
435
     */
436
    public function permanentlyDeleteSubscriber(string $email)
437
    {
438
        $id = self::calculateSubscriberId($email);
10✔
439
        $delete = $this->client->post("lists/{$this->listId}/members/$id/actions/delete-permanent", [], self::API_WRITE_TIMEOUT);
10✔
440
        
441
        $this->validateResponseStatus('DELETE subscriber permanently', $delete);
10✔
442
        $this->validateResponseContent('DELETE subscriber permanently', $delete);
10✔
443
    }
444
}
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