From 04f783c7b3394ace90fd90cbe1d2c9e24fca1e8f Mon Sep 17 00:00:00 2001 From: Shavonn Brown Date: Wed, 9 Sep 2026 18:19:10 -0400 Subject: [PATCH 1/2] feat: active campaign integration --- README.md | 35 +- config/waitlist.php | 10 + .../Drivers/ActiveCampaignDriver.php | 332 ++++++++++++++++++ src/MailingList/MailingListManager.php | 21 ++ tests/Feature/ActiveCampaignTest.php | 154 ++++++++ 5 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 src/MailingList/Drivers/ActiveCampaignDriver.php create mode 100644 tests/Feature/ActiveCampaignTest.php diff --git a/README.md b/README.md index f483989..702650f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ implement your own controllers, views, and API endpoints. - **Status tracking** — Pending, invited, and rejected states - **Email verification** — Optional opt-in verification before inviting users - **Event-driven notifications** — Automatic invite + verification notifications, fully customizable -- **Mailing list sync** — Push entries to Mailchimp, Kit (ConvertKit), Audienceful or Constant Contact, or plug in a driver of your own +- **Mailing list sync** — Push entries to Mailchimp, Kit (ConvertKit), Audienceful, Constant Contact or ActiveCampaign, or plug in a driver of your own - **Events** — Hook into every step of the lifecycle, from sign up to invite - **Metadata support** — Store custom data with each entry - **Invite-only integration** — Optional bridge into `offload-project/laravel-invite-only` for token-based flows @@ -436,8 +436,8 @@ class CustomVerifyEmail extends Notification ### Mailing List Integration -Sync entries into a newsletter service as they join. Mailchimp, Kit (ConvertKit), Audienceful and Constant Contact -ship with the package, and you can register a driver for anything else. +Sync entries into a newsletter service as they join. Mailchimp, Kit (ConvertKit), Audienceful, Constant Contact and +ActiveCampaign ship with the package, and you can register a driver for anything else. ```dotenv WAITLIST_MAILING_LIST_ENABLED=true @@ -493,9 +493,31 @@ day should not sit in front of your mail — but an unread queue looks exactly l | `kit` | a form id, or a tag id with `list_type` set to `tag` | Kit unsubscribes are account-wide; double opt-in follows the form's setting | | `audienceful` | a publication id, or a tag name with `list_type` set to `tag` | Unsubscribing withdraws consent for that publication — or, for a tag, workspace wide | | `constant_contact` | a contact list id (a UUID) | OAuth2 — see below. Unsubscribing leaves the one list, not the account | +| `activecampaign` | a numeric list id | Contacts are upserted by email; unsubscribing sets the list status rather than deleting | | `log` | anything | Writes what would have been sent to the log — handy for local work | | `array` | anything | In-memory, used by `MailingList::fake()` in tests | +##### ActiveCampaign + +Take both values from Settings → Developer in your ActiveCampaign account: + +```dotenv +ACTIVECAMPAIGN_API_KEY=... +ACTIVECAMPAIGN_API_URL=https://youraccount.api-us1.com +ACTIVECAMPAIGN_LIST_ID=7 +``` + +`ACTIVECAMPAIGN_API_URL` is the account URL exactly as shown there; `/api/3` is +appended for you, so do not include it. + +Everything in ActiveCampaign is addressed by id — lists, tags and custom fields +— so the driver resolves names to ids for you. Tags are searched before they are +created, because ActiveCampaign refuses a duplicate and treats tag names +case-insensitively: a blind create would work on the first sign-up and fail on +every one after it. An attribute with no matching field on the account is +dropped rather than sent, since a rejected contact costs the sign-up and a +missing annotation does not. + ##### Constant Contact needs authorising by hand Constant Contact's V3 API is OAuth2 and **none of its flows work without a @@ -657,7 +679,7 @@ return [ // Mailing list integration 'mailing_list' => [ 'enabled' => false, // Turn syncing on - 'default' => 'log', // mailchimp, kit, audienceful, constant_contact, log, array — or your own + 'default' => 'log', // mailchimp, kit, audienceful, constant_contact, activecampaign, log, array — or your own 'auto_subscribe' => true, // Subscribe entries automatically 'double_optin' => false, // Mailchimp and Audienceful; Kit follows the form's setting 'tags' => [], // Applied to every subscriber the package creates @@ -685,6 +707,11 @@ return [ 'list_type' => env('AUDIENCEFUL_LIST_TYPE', 'publication'), // publication or tag 'list_id' => env('AUDIENCEFUL_PUBLICATION_ID'), ], + 'activecampaign' => [ + 'key' => env('ACTIVECAMPAIGN_API_KEY'), + 'url' => env('ACTIVECAMPAIGN_API_URL'), // https://youraccount.api-us1.com — /api/3 is appended + 'list_id' => env('ACTIVECAMPAIGN_LIST_ID'), // Numeric + ], 'constant_contact' => [ 'client_id' => env('CONSTANT_CONTACT_CLIENT_ID'), 'client_secret' => env('CONSTANT_CONTACT_CLIENT_SECRET'), diff --git a/config/waitlist.php b/config/waitlist.php index 7b10610..5771700 100644 --- a/config/waitlist.php +++ b/config/waitlist.php @@ -195,6 +195,16 @@ 'list_id' => env('CONSTANT_CONTACT_LIST_ID'), ], + 'activecampaign' => [ + 'key' => env('ACTIVECAMPAIGN_API_KEY'), + // The account API URL exactly as ActiveCampaign shows it under + // Settings → Developer, e.g. https://youraccount.api-us1.com. + // `/api/3` is appended for you. + 'url' => env('ACTIVECAMPAIGN_API_URL'), + // A numeric list id. + 'list_id' => env('ACTIVECAMPAIGN_LIST_ID'), + ], + 'log' => [ 'channel' => env('WAITLIST_MAILING_LIST_LOG_CHANNEL'), 'list_id' => 'log', diff --git a/src/MailingList/Drivers/ActiveCampaignDriver.php b/src/MailingList/Drivers/ActiveCampaignDriver.php new file mode 100644 index 0000000..52020aa --- /dev/null +++ b/src/MailingList/Drivers/ActiveCampaignDriver.php @@ -0,0 +1,332 @@ +|null Field name (lowercased) to field id. */ + private ?array $fieldIds = null; + + /** @var array Tag name (lowercased) to tag id. */ + private array $tagIds = []; + + public function __construct( + private readonly string $key, + private readonly string $url, + private readonly int $timeout = 10, + private readonly int $retries = 2, + ) {} + + public function name(): string + { + return 'activecampaign'; + } + + public function subscribe(WaitlistEntry $entry, string $listId, array $options = []): Subscriber + { + /* + * `contact/sync` upserts on the email address, so a returning sign-up + * updates rather than duplicating. Custom fields go in the same call — + * they are part of the contact, not a second request. + */ + $response = $this->request()->post('/contact/sync', [ + 'contact' => array_filter([ + 'email' => $entry->email, + 'firstName' => $this->firstName($entry), + 'fieldValues' => $this->fieldValues($entry, $options), + ]), + ]); + + $this->throwUnlessSuccessful($response); + + $contactId = (string) $response->json('contact.id', ''); + + /* + * Membership is its own resource, so the sync above creates the contact + * and this puts them on the list. Status 1 re-activates somebody who + * had unsubscribed from this list before, which is the correct reading + * of them signing up again. + */ + $this->throwUnlessSuccessful( + $this->request()->post('/contactLists', [ + 'contactList' => [ + 'list' => $listId, + 'contact' => $contactId, + 'status' => self::STATUS_ACTIVE, + ], + ]) + ); + + $tags = $this->tags($options); + + if ($tags !== []) { + $this->applyTags($contactId, $tags); + } + + return new Subscriber( + id: $contactId, + email: $entry->email, + status: 'active', + raw: $response->json('contact') ?? [], + ); + } + + /** + * Takes them off this list, leaving the contact and any other lists alone. + * + * Status 2 rather than a delete: ActiveCampaign keeps the membership row so + * the unsubscribe is a fact about this list, and deleting the contact would + * remove them from every other one as well. + */ + public function unsubscribe(WaitlistEntry $entry, string $listId): void + { + $contactId = $this->contactId($entry->email); + + if ($contactId === null) { + return; + } + + $this->throwUnlessSuccessful( + $this->request()->post('/contactLists', [ + 'contactList' => [ + 'list' => $listId, + 'contact' => $contactId, + 'status' => self::STATUS_UNSUBSCRIBED, + ], + ]) + ); + } + + public function tag(WaitlistEntry $entry, string $listId, array $tags): void + { + $contactId = $this->contactId($entry->email); + + if ($contactId === null) { + return; + } + + $this->applyTags($contactId, $tags); + } + + public function find(string $email, string $listId): ?Subscriber + { + $contact = $this->contact($email); + + if ($contact === null) { + return null; + } + + return new Subscriber( + id: (string) ($contact['id'] ?? ''), + email: $email, + status: isset($contact['status']) ? (string) $contact['status'] : null, + raw: $contact, + ); + } + + /** + * @param list $tags + */ + private function applyTags(string $contactId, array $tags): void + { + foreach ($tags as $tag) { + $this->throwUnlessSuccessful( + $this->request()->post('/contactTags', [ + 'contactTag' => ['contact' => $contactId, 'tag' => $this->tagId($tag)], + ]) + ); + } + } + + /** + * @return array|null + */ + private function contact(string $email): ?array + { + $response = $this->request()->get('/contacts', ['email' => $email]); + + $this->throwUnlessSuccessful($response); + + /** @var array> $contacts */ + $contacts = $response->json('contacts', []); + + return $contacts[0] ?? null; + } + + private function contactId(string $email): ?string + { + $id = $this->contact($email)['id'] ?? null; + + return is_string($id) && $id !== '' ? $id : null; + } + + /** + * Attributes as ActiveCampaign wants them: field id and value. + * + * An attribute with no matching field on the account is skipped rather than + * sent. The alternative is a rejected contact, and losing the annotation is + * better than losing the sign-up. + * + * @param array{tags?: array, double_optin?: bool, attributes?: array} $options + * @return list + */ + private function fieldValues(WaitlistEntry $entry, array $options): array + { + $attributes = $this->attributes($entry, $options); + + if ($attributes === []) { + return []; + } + + $values = []; + + foreach ($attributes as $name => $value) { + $id = $this->fieldId((string) $name); + + if ($id !== null) { + $values[] = ['field' => $id, 'value' => (string) $value]; + } + } + + return $values; + } + + private function fieldId(string $name): ?string + { + if ($this->fieldIds === null) { + $response = $this->request()->get('/fields', ['limit' => 100]); + + $this->throwUnlessSuccessful($response); + + $this->fieldIds = []; + + /** @var array $fields */ + $fields = $response->json('fields', []); + + foreach ($fields as $field) { + $id = $field['id'] ?? null; + + // Title is what a person sees; perstag is the merge tag they + // may have written into the config instead. + foreach ([$field['title'] ?? null, $field['perstag'] ?? null] as $key) { + if (is_string($key) && is_string($id)) { + $this->fieldIds[mb_strtolower($key)] = $id; + } + } + } + } + + return $this->fieldIds[mb_strtolower($name)] ?? null; + } + + /** + * Resolve a tag name to its id, creating the tag when it does not exist. + * + * Searched before created because ActiveCampaign refuses a duplicate, and + * matched case-insensitively because it treats tags that way itself — so a + * blind create would fail on the second sign-up rather than returning the + * existing id. + */ + private function tagId(string $tag): string + { + $key = mb_strtolower($tag); + + if (isset($this->tagIds[$key])) { + return $this->tagIds[$key]; + } + + $response = $this->request()->get('/tags', ['search' => $tag, 'limit' => 100]); + + $this->throwUnlessSuccessful($response); + + /** @var array $existing */ + $existing = $response->json('tags', []); + + foreach ($existing as $candidate) { + if (isset($candidate['tag'], $candidate['id']) && mb_strtolower($candidate['tag']) === $key) { + return $this->tagIds[$key] = (string) $candidate['id']; + } + } + + $created = $this->request()->post('/tags', [ + 'tag' => ['tag' => $tag, 'tagType' => 'contact'], + ]); + + $this->throwUnlessSuccessful($created); + + return $this->tagIds[$key] = (string) $created->json('tag.id'); + } + + private function request(): PendingRequest + { + return Http::baseUrl(mb_rtrim($this->url, '/').'/api/3') + ->withHeaders(['Api-Token' => $this->key]) + ->timeout($this->timeout) + ->retry(max(1, $this->retries), 250, throw: false) + ->acceptJson(); + } + + private function throwUnlessSuccessful(Response $response): void + { + if ($response->successful()) { + return; + } + + /* + * Errors come back as a list of objects under `errors`, each with a + * `title`. Assembled rather than imploded: imploding a list of arrays + * reports "Array to string conversion" and says nothing about what was + * actually refused. + */ + /* + * Deliberately untyped beyond "a list of something": this is an error + * path, and a response shaped differently from the documented one is + * exactly the case it has to survive. + * + * @var array|null $errors + */ + $errors = $response->json('errors'); + + $message = is_array($errors) + ? implode(' ', array_map( + static fn (mixed $error): string => is_array($error) + ? (string) ($error['title'] ?? json_encode($error)) + : (string) $error, + $errors, + )) + : $response->body(); + + throw MailingListException::requestFailed('activecampaign', $message, $response->status()); + } +} diff --git a/src/MailingList/MailingListManager.php b/src/MailingList/MailingListManager.php index 70ecf14..0df5da2 100644 --- a/src/MailingList/MailingListManager.php +++ b/src/MailingList/MailingListManager.php @@ -11,6 +11,7 @@ use OffloadProject\Waitlist\MailingList\ConstantContact\AccessToken; use OffloadProject\Waitlist\MailingList\ConstantContact\CacheRefreshTokenStore; use OffloadProject\Waitlist\MailingList\ConstantContact\RefreshTokenStore; +use OffloadProject\Waitlist\MailingList\Drivers\ActiveCampaignDriver; use OffloadProject\Waitlist\MailingList\Drivers\ArrayDriver; use OffloadProject\Waitlist\MailingList\Drivers\AudiencefulDriver; use OffloadProject\Waitlist\MailingList\Drivers\ConstantContactDriver; @@ -152,6 +153,7 @@ private function resolve(string $name): MailingListDriver 'kit' => $this->createKitDriver($config), 'audienceful' => $this->createAudiencefulDriver($config), 'constant_contact' => $this->createConstantContactDriver($config), + 'activecampaign' => $this->createActiveCampaignDriver($config), 'log' => new LogDriver(isset($config['channel']) ? (string) $config['channel'] : null), 'array' => new ArrayDriver(), default => throw MailingListException::driverNotSupported($name), @@ -209,6 +211,25 @@ private function createAudiencefulDriver(array $config): AudiencefulDriver ); } + /** + * @param array $config + */ + private function createActiveCampaignDriver(array $config): ActiveCampaignDriver + { + foreach (['key', 'url'] as $required) { + if (blank($config[$required] ?? null)) { + throw MailingListException::missingCredentials('activecampaign', $required); + } + } + + return new ActiveCampaignDriver( + key: (string) $config['key'], + url: (string) $config['url'], + timeout: $this->timeout($config), + retries: $this->retries($config), + ); + } + /** * @param array $config */ diff --git a/tests/Feature/ActiveCampaignTest.php b/tests/Feature/ActiveCampaignTest.php new file mode 100644 index 0000000..8c9d63b --- /dev/null +++ b/tests/Feature/ActiveCampaignTest.php @@ -0,0 +1,154 @@ + true, + 'waitlist.mailing_list.default' => 'activecampaign', + 'waitlist.mailing_list.queue.enabled' => false, + 'waitlist.mailing_list.drivers.activecampaign.key' => 'ac-key', + 'waitlist.mailing_list.drivers.activecampaign.url' => AC_URL, + 'waitlist.mailing_list.drivers.activecampaign.list_id' => '7', + ]); +}); + +function acAccepts(array $overrides = []): void +{ + Http::fake(array_merge([ + '*/api/3/contact/sync' => Http::response(['contact' => ['id' => '115', 'email' => 'john@example.com']], 201), + '*/api/3/contactLists' => Http::response(['contactList' => ['id' => '9']], 201), + '*/api/3/contacts*' => Http::response(['contacts' => [['id' => '115', 'status' => '1']]]), + '*/api/3/fields*' => Http::response(['fields' => [ + ['id' => '3', 'title' => 'Persona', 'perstag' => 'PERSONA'], + ]]), + '*/api/3/tags*' => Http::response(['tags' => []]), + '*/api/3/contactTags' => Http::response(['contactTag' => ['id' => '55']], 201), + ], $overrides)); +} + +test('it upserts the contact and puts them on the list', function () { + acAccepts(); + + $entry = Waitlist::add('John Doe', 'john@example.com'); + + Http::assertSent(fn ($request): bool => str_contains($request->url(), '/contact/sync') + && $request['contact']['email'] === 'john@example.com' + && $request['contact']['firstName'] === 'John'); + + Http::assertSent(fn ($request): bool => str_contains($request->url(), '/contactLists') + && $request['contactList']['list'] === '7' + && $request['contactList']['contact'] === '115' + && $request['contactList']['status'] === 1); + + expect($entry->fresh()->mailing_list_driver)->toBe('activecampaign') + ->and($entry->fresh()->mailing_list_subscriber_id)->toBe('115'); +}); + +test('it authenticates with the Api-Token header', function () { + acAccepts(); + + Waitlist::add('John Doe', 'john@example.com'); + + Http::assertSent(fn ($request): bool => $request->hasHeader('Api-Token', 'ac-key')); +}); + +test('it appends the api version to the account url', function () { + acAccepts(); + + Waitlist::add('John Doe', 'john@example.com'); + + Http::assertSent(fn ($request): bool => str_starts_with($request->url(), AC_URL.'/api/3/')); +}); + +/* + * ActiveCampaign refuses a duplicate tag and treats tags case-insensitively, so + * a blind create works once and fails on every sign-up after it. + */ +test('it reuses an existing tag rather than recreating it', function () { + config(['waitlist.mailing_list.tags' => ['yhy-waitlist']]); + + acAccepts(['*/api/3/tags*' => Http::response(['tags' => [ + ['id' => '20', 'tag' => 'YHY-Waitlist'], + ]])]); + + Waitlist::add('John Doe', 'john@example.com'); + + Http::assertNotSent(fn ($request): bool => $request->method() === 'POST' + && str_ends_with($request->url(), '/api/3/tags')); + + Http::assertSent(fn ($request): bool => str_contains($request->url(), '/contactTags') + && $request['contactTag']['tag'] === '20' + && $request['contactTag']['contact'] === '115'); +}); + +test('it creates a tag it has not seen', function () { + config(['waitlist.mailing_list.tags' => ['yhy-waitlist']]); + + acAccepts(['*/api/3/tags' => Http::response(['tag' => ['id' => '31']], 201)]); + + Waitlist::add('John Doe', 'john@example.com'); + + Http::assertSent(fn ($request): bool => $request->method() === 'POST' + && str_ends_with($request->url(), '/api/3/tags') + && $request['tag']['tag'] === 'yhy-waitlist' + && $request['tag']['tagType'] === 'contact'); +}); + +/* + * Custom fields are addressed by id. An attribute the account has no field for + * is dropped rather than sent — losing an annotation beats losing the sign-up. + */ +test('it maps attributes onto field ids and drops the rest', function () { + config(['waitlist.mailing_list.attributes' => fn () => ['Persona' => 'System Hopper', 'Nonesuch' => 'x']]); + + acAccepts(); + + Waitlist::add('John Doe', 'john@example.com'); + + Http::assertSent(function ($request): bool { + if (! str_contains($request->url(), '/contact/sync')) { + return false; + } + + return $request['contact']['fieldValues'] === [['field' => '3', 'value' => 'System Hopper']]; + }); +}); + +/* + * Status 2 rather than a delete: the unsubscribe is a fact about this list, and + * deleting the contact would take every other list with it. + */ +test('unsubscribing sets the list status rather than deleting the contact', function () { + acAccepts(); + + $entry = Waitlist::add('John Doe', 'john@example.com'); + + Waitlist::unsubscribeFromMailingList($entry); + + Http::assertSent(fn ($request): bool => str_contains($request->url(), '/contactLists') + && $request['contactList']['status'] === 2); + + Http::assertNotSent(fn ($request): bool => $request->method() === 'DELETE'); +}); + +test('it needs a key and an account url', function () { + config(['waitlist.mailing_list.drivers.activecampaign.url' => null]); + + expect(fn () => MailingList::driver('activecampaign')) + ->toThrow(MailingListException::class, 'url'); +}); + +test('it carries the reason into the failure', function () { + acAccepts(['*/api/3/contact/sync' => Http::response(['errors' => [['title' => 'Email address is not valid.']]], 422)]); + + expect(fn () => Waitlist::add('John Doe', 'john@example.com')) + ->toThrow(MailingListException::class, 'Email address is not valid.'); +}); From ed9a1c0531dac0767fc24831766ceddc6f52108b Mon Sep 17 00:00:00 2001 From: Shavonn Brown Date: Wed, 9 Sep 2026 18:34:41 -0400 Subject: [PATCH 2/2] fix: test failure - mb_string --- pint.json | 1 - src/MailingList/Drivers/ActiveCampaignDriver.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pint.json b/pint.json index 8e2af1b..05928d4 100644 --- a/pint.json +++ b/pint.json @@ -19,7 +19,6 @@ "import_constants": true, "import_functions": true }, - "mb_str_functions": true, "modernize_types_casting": true, "new_with_parentheses": false, "no_superfluous_elseif": true, diff --git a/src/MailingList/Drivers/ActiveCampaignDriver.php b/src/MailingList/Drivers/ActiveCampaignDriver.php index 52020aa..31a4515 100644 --- a/src/MailingList/Drivers/ActiveCampaignDriver.php +++ b/src/MailingList/Drivers/ActiveCampaignDriver.php @@ -290,7 +290,7 @@ private function tagId(string $tag): string private function request(): PendingRequest { - return Http::baseUrl(mb_rtrim($this->url, '/').'/api/3') + return Http::baseUrl(rtrim($this->url, '/').'/api/3') ->withHeaders(['Api-Token' => $this->key]) ->timeout($this->timeout) ->retry(max(1, $this->retries), 250, throw: false)