feat(subscription): add mailcheep integration for contact syncing

Adds mailcheep integration to synchronize contacts with mailcheep's email
marketing platform. Contacts are synced on user creation and during stripe
subscription lifecycle events (paid, churned).

- Add MailcheepService for mailcheep API interactions
- Add SyncMailcheepContactJob for async contact synchronization
- Add sync:mailcheep console command for backfilling contacts
- Integrate contact syncing into user creation workflow
- Integrate contact syncing into stripe subscription event handling
- Add mailcheep configuration (API key and list IDs)
- Add comprehensive feature tests for mailcheep integration
This commit is contained in:
Andras Bacsai committed 2026-03-26 10:49:05 +01:00
1 parent d77e4c864f
commit d3597b5209
7 files changed
+511

No files matched your search

+9
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use App\Jobs\SyncMailcheepContactJob;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@@ -58,6 +59,14 @@ class CreateNewUser implements CreatesNewUsers
$team = $user->teams()->first();
if (isCloud()) {
$user->sendVerificationEmail();
if (config('subscription.mailcheep_api_key')) {
SyncMailcheepContactJob::dispatch(
action: 'create_or_update',
email: $user->email,
name: $user->name,
customFields: ['team_id' => (string) $team->id],
);
}
} else {
$user->markEmailAsVerified();
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SyncMailcheepContactJob;
use App\Models\Team;
use Illuminate\Console\Command;
class SyncMailcheep extends Command
{
protected $signature = 'sync:mailcheep {--dry-run}';
protected $description = 'Backfill all team owners to Mailcheep contact lists';
public function handle(): int
{
if (! isCloud()) {
$this->error('This command can only be run on the cloud instance.');
return self::FAILURE;
}
if (! config('subscription.mailcheep_api_key')) {
$this->error('MAILCHEEP_API_KEY is not configured.');
return self::FAILURE;
}
$dryRun = $this->option('dry-run');
$stats = ['subscribed' => 0, 'churned' => 0, 'no_subscription' => 0];
Team::with(['subscription', 'members'])->chunk(100, function ($teams) use ($dryRun, &$stats) {
foreach ($teams as $team) {
$owner = $team->members->firstWhere('pivot.role', 'owner');
if (! $owner) {
continue;
}
$subscription = $team->subscription;
if ($subscription && $subscription->stripe_invoice_paid && $subscription->stripe_subscription_id) {
$action = 'create_or_update';
$extraFields = [
'team_id' => (string) $team->id,
'plan' => $subscription->billingInterval(),
];
$stats['subscribed']++;
} elseif ($subscription && (! $subscription->stripe_invoice_paid || ! $subscription->stripe_subscription_id)) {
$action = 'add_to_churned';
$extraFields = [];
$stats['churned']++;
} else {
$action = 'create_or_update';
$extraFields = ['team_id' => (string) $team->id];
$stats['no_subscription']++;
}
if ($dryRun) {
$this->line("[DRY RUN] {$action}: {$owner->email} (Team #{$team->id})");
} else {
SyncMailcheepContactJob::dispatch(
action: $action,
email: $owner->email,
name: $owner->name,
customFields: $extraFields,
);
}
}
});
$this->info("Subscribed: {$stats['subscribed']}, Churned: {$stats['churned']}, No subscription: {$stats['no_subscription']}");
return self::SUCCESS;
}
}
+9
View File
@@ -299,6 +299,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$team = data_get($subscription, 'team');
if ($team) {
$team->subscriptionEnded();
SyncMailcheepContactJob::dispatchForTeam($team, 'add_to_churned');
} else {
// send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
@@ -311,6 +312,13 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
'stripe_invoice_paid' => true,
]);
}
$team = data_get($subscription, 'team');
if ($team) {
SyncMailcheepContactJob::dispatchForTeam($team, 'create_or_update', [
'team_id' => (string) $team->id,
'plan' => $subscription->billingInterval(),
]);
}
}
if ($feedback) {
$reason = "Cancellation feedback for {$customerId}: '".$feedback."'";
@@ -328,6 +336,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$team = data_get($subscription, 'team');
if ($team) {
$team->subscriptionEnded();
SyncMailcheepContactJob::dispatchForTeam($team, 'add_to_churned');
} else {
// send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Jobs;
use App\Models\Team;
use App\Services\MailcheepService;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SyncMailcheepContactJob implements ShouldBeEncrypted, ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 10;
/**
* @param array<string, string> $customFields
*/
public function __construct(
public string $action,
public string $email,
public string $name,
public array $customFields = [],
) {
$this->onQueue('high');
}
public function handle(MailcheepService $mailcheep): void
{
match ($this->action) {
'create_or_update' => $mailcheep->createOrUpdateContact(
$this->email,
$this->name,
config('subscription.mailcheep_list_subscribed'),
$this->customFields,
),
'add_to_churned' => $mailcheep->addToChurnedList($this->email),
default => null,
};
}
/**
* @param array<string, string> $extraFields
*/
public static function dispatchForTeam(Team $team, string $action, array $extraFields = []): void
{
if (! config('subscription.mailcheep_api_key')) {
return;
}
$owner = $team->members->firstWhere('pivot.role', 'owner');
if (! $owner) {
return;
}
static::dispatch(
action: $action,
email: $owner->email,
name: $owner->name,
customFields: $extraFields,
);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MailcheepService
{
private string $apiKey;
private string $baseUrl = 'https://api.mailcheep.cloud/v1';
public function __construct()
{
$this->apiKey = config('subscription.mailcheep_api_key');
}
/**
* @return array<string, mixed>|null
*/
public function findContactByEmail(string $email): ?array
{
try {
$response = $this->client()->get("{$this->baseUrl}/contacts", [
'search' => $email,
]);
if ($response->successful()) {
$contacts = $response->json('data', []);
foreach ($contacts as $contact) {
if (data_get($contact, 'email') === $email) {
return $contact;
}
}
}
return null;
} catch (\Exception $e) {
Log::error('Mailcheep: failed to find contact', ['email' => $email, 'error' => $e->getMessage()]);
return null;
}
}
/**
* @param array<string, string> $customFields
* @return array<string, mixed>|null
*/
public function createContact(string $email, string $name, string $listId, array $customFields = []): ?array
{
try {
$response = $this->client()->post("{$this->baseUrl}/contacts", [
'email' => $email,
'name' => $name,
'list_id' => $listId,
'custom_fields' => $customFields,
]);
if ($response->successful()) {
return $response->json('data');
}
Log::warning('Mailcheep: failed to create contact', ['email' => $email, 'status' => $response->status()]);
return null;
} catch (\Exception $e) {
Log::error('Mailcheep: failed to create contact', ['email' => $email, 'error' => $e->getMessage()]);
return null;
}
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>|null
*/
public function updateContact(string $id, array $data): ?array
{
try {
$response = $this->client()->put("{$this->baseUrl}/contacts/{$id}", $data);
if ($response->successful()) {
return $response->json('data');
}
Log::warning('Mailcheep: failed to update contact', ['id' => $id, 'status' => $response->status()]);
return null;
} catch (\Exception $e) {
Log::error('Mailcheep: failed to update contact', ['id' => $id, 'error' => $e->getMessage()]);
return null;
}
}
/**
* @param array<string, string> $customFields
* @return array<string, mixed>|null
*/
public function createOrUpdateContact(string $email, string $name, string $listId, array $customFields = []): ?array
{
$existing = $this->findContactByEmail($email);
if ($existing) {
return $this->updateContact(data_get($existing, 'id'), [
'name' => $name,
'list_id' => $listId,
'custom_fields' => $customFields,
]);
}
return $this->createContact($email, $name, $listId, $customFields);
}
/**
* @return array<string, mixed>|null
*/
public function addToChurnedList(string $email): ?array
{
$existing = $this->findContactByEmail($email);
if ($existing) {
return $this->updateContact(data_get($existing, 'id'), [
'list_id' => config('subscription.mailcheep_list_churned'),
]);
}
return null;
}
private function client(): \Illuminate\Http\Client\PendingRequest
{
return Http::withHeaders([
'X-API-Key' => $this->apiKey,
])->timeout(15)->retry(2, 100);
}
}
+5
View File
@@ -9,4 +9,9 @@ return [
'stripe_excluded_plans' => env('STRIPE_EXCLUDED_PLANS', null),
'stripe_price_id_dynamic_monthly' => env('STRIPE_PRICE_ID_DYNAMIC_MONTHLY', null),
'stripe_price_id_dynamic_yearly' => env('STRIPE_PRICE_ID_DYNAMIC_YEARLY', null),
// Mailcheep
'mailcheep_api_key' => env('MAILCHEEP_API_KEY'),
'mailcheep_list_subscribed' => env('MAILCHEEP_LIST_SUBSCRIBED'),
'mailcheep_list_churned' => env('MAILCHEEP_LIST_CHURNED'),
];
+207
View File
@@ -0,0 +1,207 @@
<?php
use App\Jobs\SyncMailcheepContactJob;
use App\Models\Team;
use App\Models\User;
use App\Services\MailcheepService;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config()->set('subscription.mailcheep_api_key', 'test-api-key');
config()->set('subscription.mailcheep_list_subscribed', 'list-subscribed-id');
config()->set('subscription.mailcheep_list_churned', 'list-churned-id');
});
describe('MailcheepService', function () {
it('finds a contact by email', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts*' => Http::response([
'data' => [
['id' => 'contact-1', 'email' => 'test@example.com', 'name' => 'Test User'],
],
]),
]);
$service = new MailcheepService;
$result = $service->findContactByEmail('test@example.com');
expect($result)->not->toBeNull()
->and($result['email'])->toBe('test@example.com');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/contacts') && $request->data()['search'] === 'test@example.com';
});
});
it('returns null when contact is not found', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts*' => Http::response(['data' => []]),
]);
$service = new MailcheepService;
$result = $service->findContactByEmail('missing@example.com');
expect($result)->toBeNull();
});
it('creates a contact', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts' => Http::response([
'data' => ['id' => 'contact-new', 'email' => 'new@example.com'],
]),
]);
$service = new MailcheepService;
$result = $service->createContact('new@example.com', 'New User', 'list-id', ['team_id' => '1']);
expect($result)->not->toBeNull()
->and($result['email'])->toBe('new@example.com');
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request['email'] === 'new@example.com'
&& $request['list_id'] === 'list-id';
});
});
it('updates a contact', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts/contact-1' => Http::response([
'data' => ['id' => 'contact-1', 'list_id' => 'new-list'],
]),
]);
$service = new MailcheepService;
$result = $service->updateContact('contact-1', ['list_id' => 'new-list']);
expect($result)->not->toBeNull();
Http::assertSent(function ($request) {
return $request->method() === 'PUT' && $request['list_id'] === 'new-list';
});
});
it('creates or updates a contact when existing', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts?search=*' => Http::response([
'data' => [['id' => 'contact-1', 'email' => 'existing@example.com']],
]),
'api.mailcheep.cloud/v1/contacts/contact-1' => Http::response([
'data' => ['id' => 'contact-1', 'name' => 'Updated'],
]),
]);
$service = new MailcheepService;
$result = $service->createOrUpdateContact('existing@example.com', 'Updated', 'list-id');
expect($result)->not->toBeNull();
});
it('returns null on API failure without throwing', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts*' => Http::response('Server Error', 500),
]);
$service = new MailcheepService;
$result = $service->findContactByEmail('fail@example.com');
expect($result)->toBeNull();
});
it('sends X-API-Key header', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts*' => Http::response(['data' => []]),
]);
$service = new MailcheepService;
$service->findContactByEmail('test@example.com');
Http::assertSent(function ($request) {
return $request->hasHeader('X-API-Key', 'test-api-key');
});
});
});
describe('SyncMailcheepContactJob', function () {
it('calls create_or_update on the service', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts?search=*' => Http::response(['data' => []]),
'api.mailcheep.cloud/v1/contacts' => Http::response([
'data' => ['id' => 'new', 'email' => 'job@example.com'],
]),
]);
$job = new SyncMailcheepContactJob(
action: 'create_or_update',
email: 'job@example.com',
name: 'Job User',
customFields: ['team_id' => '1'],
);
$job->handle(app(MailcheepService::class));
Http::assertSent(function ($request) {
return $request->method() === 'POST' && $request['email'] === 'job@example.com';
});
});
it('calls add_to_churned on the service', function () {
Http::fake([
'api.mailcheep.cloud/v1/contacts?search=*' => Http::response([
'data' => [['id' => 'contact-1', 'email' => 'churn@example.com']],
]),
'api.mailcheep.cloud/v1/contacts/contact-1' => Http::response([
'data' => ['id' => 'contact-1'],
]),
]);
$job = new SyncMailcheepContactJob(
action: 'add_to_churned',
email: 'churn@example.com',
name: 'Churn User',
);
$job->handle(app(MailcheepService::class));
Http::assertSent(function ($request) {
return $request->method() === 'PUT' && $request['list_id'] === 'list-churned-id';
});
});
it('dispatchForTeam skips when no API key configured', function () {
config()->set('subscription.mailcheep_api_key', null);
Bus::fake();
$team = Team::factory()->create();
SyncMailcheepContactJob::dispatchForTeam($team, 'create_or_update');
Bus::assertNothingDispatched();
});
it('dispatchForTeam dispatches job for team owner', function () {
Bus::fake();
$user = User::factory()->create();
$team = Team::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
$team->load('members');
SyncMailcheepContactJob::dispatchForTeam($team, 'create_or_update', ['team_id' => (string) $team->id]);
Bus::assertDispatched(SyncMailcheepContactJob::class, function ($job) use ($user) {
return $job->action === 'create_or_update'
&& $job->email === $user->email
&& $job->name === $user->name;
});
});
});
describe('SyncMailcheep command', function () {
it('fails when not on cloud', function () {
// isCloud() returns false by default in tests
$this->artisan('sync:mailcheep')
->assertFailed();
});
});