elasticemail/elasticemail-php
ElasticEmail PHP SDK for managing email via the REST API: send campaigns, transactional email, contacts, and templates using a generated Guzzle-based client with API key auth. Requires PHP 7.4+.
Idempotency-Key header on the three send endpoints (POST /emails/transactional, POST /emails, POST /emails/mergefile), which is a contract-level guarantee we can lean on rather than hand-rolling dedupe logic in App\Services.CampaignsApi, ContactsApi, EmailsApi) still map cleanly to a service layer. The new capability concentrates in EmailsApi, so the send path becomes the single place where idempotency policy lives — a good seam for a SendEmailService / EmailDispatcher abstraction.EventsApi/StatisticsApi. One new signal worth emitting: a EmailSendReplayed event when the response carries Idempotency-Replayed: true, so analytics can distinguish “new send” from “retry confirmed” without double-counting volume..env-stored X-ElasticEmail-ApiKey, Http facade compatibility, middleware for rate limits and retries.Idempotency-Replayed: true, 409 while in flight, 422 on body mismatch, 503 + Retry-After, key released on 4xx/5xx failure) maps directly onto Laravel queue retry semantics — with the important caveat that Laravel’s $tries/$backoff window must stay inside 24 hours.random_bytes UUIDv4 recipe, but for correctness we should not generate the key inside the job attempt. Generate once at dispatch time, persist it, and pass it through every retry.$idempotency_key argument sits before the optional $contentType argument on emailsTransactionalPost, emailsPost, emailsMergefilePost and their ...WithHttpInfo, ...Async, ...AsyncWithHttpInfo, ...Request variants. Any code calling these positionally with a content type will silently misbehave (content type becomes the key). Payload-only calls are unaffected.$contentType callers. Silent failure mode (invalid key → 400, or worse, a valid-but-wrong key string) rather than a compile error. Requires a codebase grep before upgrade.422; reusing across environments/accounts is safe (keys are account-scoped, subaccounts separate) but confusing. Needs a documented invariant: one key per logical send, reused only for retries of that send.retryUntil() beyond 24h will retry with an expired key and send a duplicate. Retry policy must be bounded to <24h, with a hard failure/alert path beyond that.409 in-flight handling: Concurrent retries (e.g., overlapping queue workers or a manual re-dispatch) will get 409. Treating 409 as a job failure would be wrong; it needs a delayed release, not a failure.503 + Retry-After: Must be honored explicitly; default Laravel exponential backoff may retry sooner than instructed.TransactionID/MessageID. Downstream code that assumes MessageID uniqueness per response will silently collapse duplicate records — good in principle, but must be tested....Async variants, but async + idempotency means the 409/503 responses surface later; error handling must be attached to the promise resolution path, not just the sync path.$contentType positionally today? Inventory before bumping to ^4.4; migrate to named arguments (idempotencyKey:) to make future signature changes safe.uuid column on the persisted email_messages / outbound_emails table, generated on record creation, immutable thereafter.Idempotency-Replayed: true as a send, a suppressed duplicate, or neither?503 without Retry-After or with an unparseable value — what is our default backoff, and who owns that decision?Idempotency-Replayed, 409, 422, 503 as first-class metrics, distinct from generic HTTP errors?$this->app->singleton(EmailsApi::class, function ($app) {
$config = ElasticEmail\Configuration::getDefaultConfiguration()
->setApiKey('X-ElasticEmail-ApiKey', config('services.elasticemail.key'));
return new EmailsApi(new GuzzleHttp\Client(), $config);
});
SendTransactionalEmailJob should read $this->emailMessage->idempotency_key (persisted at record creation) and pass it as a named argument:
$emails->emailsTransactionalPost(
email_transactional_message_data: $payload,
idempotency_key: $this->emailMessage->idempotency_key,
);
public $tries = 5;, public $backoff = [5, 30, 120, 600];, and a retryUntil() capped well under 24h. Add a failed() handler that flags the record as undeliverable — never re-dispatches with a fresh key automatically.409 handling: Catch the API exception for 409, release($delay) with backoff, do not count as a failure attempt against $tries if practical (or budget for it explicitly).503 handling: Parse Retry-After and honor it; fall back to configured backoff only when the header is absent/unparseable.EmailSendReplayed (from the Idempotency-Replayed: true response header) alongside EmailSent so downstream consumers can dedupe correctly.ElasticEmail facade remains useful, but the key must be a required, explicit parameter on the send helper — no defaulting to null inside the service layer, or the protection silently evaporates.grep -rn "emailsTransactionalPost\|emailsPost\|emailsMergefilePost" across app, packages, and tests.idempotency_key (UUID, unique index) to the outbound email table via migration; backfill existing rows.retryUntil config.Idempotency-Replayed, 409, 422, 503 counters.MessageID.null-key code path (original behavior) available as a kill switch.elasticemail/elasticemail-php: ^4.4. Do not widen to ^4.0 if we rely on the $idempotency_key argument — the argument does not exist below 4.4 and PHP will throw on the named argument (or, worse, on positional calls, not at all).^7.0 still holds.Http::fake() still works, but fakes must now assert the Idempotency-Key header is present and stable across retries — that assertion is the regression test that matters most.random_bytes and named arguments both require PHP 7.4+/8.0+ respectively — named arguments effectively require PHP 8.0+, which should be confirmed against our floor.composer require elasticemail/elasticemail-php:^4.4, lock file review.idempotency_key column, unique index, backfill.409 release semantics, 503 Retry-After handling, terminal failure path.422 mismatch, 409 in-flight, 400 invalid, 503 retry; a fault-injection test that simulates a timeout after the request was accepted.elasticemail/elasticemail-php is now on a path of evolving send-endpoint signatures. Subscribe to release notes for any endpoint we use; re-run the positional-argument audit on every minor bump. Add a composer.json constraint ^4.4 with a documented policy that minor bumps require the audit script to pass.422.400 → our key generator or validation is wrong (code bug, not an API problem).409 → concurrent processing of the same logical send (queue misconfiguration or manual re-dispatch).422 → key reused with a different body/endpoint (code bug: key not scoped to one logical send).503 → upstream could not confirm the key; retry with the same key, honor Retry-After.TransactionID within 24h.409 is more likely at higher concurrency when the same logical send is processed by overlapping workers. Ensure the queue does not double-dispatch the same email record (ShouldBeUnique on the job, keyed by message ID) — idempotency protects correctness, but 409-driven retries cost latency and API quota.TransactionID and one charge. This is the single most valuable load test to add.| Failure Scenario | Mitigation Strategy |
|---|---|
| Duplicate send after timeout | Persist key at record creation; retry with the same key; expect Idempotency-Replayed: true. |
| Key generated per attempt (bug) | Key sourced only from the persisted record; static analysis / unit test asserts stability across retry() invocations. |
| Retry beyond the 24h key window | Cap retryUntil() well under 24h; terminal failure + alert rather than a fresh-key auto-resend. |
422 — same key, different body |
Treat as a code bug (payload mutated between retries); fail the job and page. Do not auto-generate a new key. |
409 — request still in flight |
Release the job with delay; do not count as a hard failure; alert if it recurs for the same message. |
400 — invalid key |
Fail fast, log the key, fix the generator; do not retry. |
503 + Retry-After |
Honor the header exactly; retry with the same key; only fall back to configured backoff if absent. |
| First attempt 5xx → key released | Corrected retry runs as a new send (documented behavior); ensure our failure classification doesn’t assume replay safety here. |
$contentType positional call after upgrade |
Pre-upgrade audit + named arguments + static-analysis rule; covered by Phase 0. |
| Replay response double-counted in analytics | Emit EmailSendReplayed; exclude from volume metrics; reconcile against TransactionID uniqueness. |
| ElasticEmail outage | Queue locally (database driver) and retry within the 24h window with the persisted key. |
| Subaccount key leakage across tenants | Verify key derivation is tenant-scoped; keys are account/subaccount-scoped server-side, so a cross-tenant retry surfaces as a mismatch or a new send. |
409/422/503 behaviors. Engineers should be able to explain why generating the key inside the job attempt is wrong.409 released not failed; 503 honors Retry-After; retry horizon <24h.409 rate, 422 rate (should be zero), terminal-failure rate, and the delta in duplicate TransactionID/MessageID counts against pre-4.4 baseline.422 rate at 0; replay rate stable and explained by known retry volume; retry horizon confirmed <24h in production config.How can I help you explore Laravel packages today?