jaanbv / 2mail-php-api
This library connects to the 2mail api using php
Requires
- php: >=8.1.0
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^9
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-06 11:05:17 UTC
README
This library connects to the 2mail API using PHP — https://www.2mail.eu/e-mail-api
Send email from your own application, follow what happens to it, and provision sending domains, SMTP credentials, pooled limit groups, API keys and webhooks.
Installation
The package is installed straight from GitHub, so Composer needs to know where the repository is. From your project directory:
composer config repositories.2mail vcs https://github.com/jaanteam/2mail-php-api.git composer require jaanbv/2mail-php-api:^1.0
Or add both to your composer.json yourself and run composer update jaanbv/2mail-php-api:
{
"require": {
"jaanbv/2mail-php-api": "^1.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/jaanteam/2mail-php-api.git"
}
]
}
Requires PHP 8.1 with ext-curl and ext-json. No other dependencies.
The PHP namespace is
JaanBV\TwoMail, notJaanBV\2mail: a PHP identifier cannot start with a digit. The composer package and the repository keep the2mailname.
Getting a key
Keys are created in the 2mail control panel under Settings → API keys, and come in three scopes. The API enforces them server-side, so a key can never do more than its scope allows — including on endpoints added after the key was issued.
| Scope | What it reaches |
|---|---|
| Mailbox (send-only) | send, messageStatus, cancelMessage, events, stats — for one mailbox. Everything else is a 403. |
| Pool | Everything inside one limit group: its mailboxes, their credentials, its webhooks and its send-only keys. |
| Admin | Everything, across all pools. |
Put a mailbox key in your application. It cannot provision domains, issue SMTP passwords, mint keys, read another tenant's mail or change anything — so a leak from an application server stays a sending problem rather than an account takeover.
Sending
use JaanBV\TwoMail\TwoMail; $api = new TwoMail($apiKey); $result = $api->sendMail( 'no-reply@acme.com', // from address — must be an allowed domain of the mailbox 'customer@example.com', // to: one address, or an array 'Your order', '<p>Thanks!</p>' // html; add text as the next argument ); echo $result->id(); // poll this, or wait for a webhook
Anything beyond that — copies, reply-to, scheduling, test mode — goes through a Message:
use JaanBV\TwoMail\Message; $result = $api->send( Message::create() ->from('no-reply@acme.com', 'Acme') ->to(['customer@example.com', 'second@example.com']) ->cc('manager@example.com') ->bcc('archive@example.com') ->replyTo('support@example.com') ->subject('Your order') ->html('<p>Thanks!</p>') ->text('Thanks!') ->tracking(false) ->sendAt(new DateTimeImmutable('+2 hours')) );
The sender address and the sender name are two arguments
->from('no-reply@acme.com', 'Acme') // recipient sees: Acme <no-reply@acme.com>
The API also accepts the combined "Acme <no-reply@acme.com>" spelling for older clients,
but sending a display name in both places with different values is refused
(ConflictingFromNameException) rather than one being silently chosen for you. Going
through from($email, $name) that situation cannot arise.
Leave the name empty and the recipient sees the bare address. The mailbox's campaign sender name is deliberately not inherited, so transactional mail never goes out signed with a marketing identity.
A 202 is "accepted", not "delivered"
send() returns as soon as 2mail has taken the message; the relay delivers it a moment
later. The outcome arrives through messageStatus(), events(), or a webhook:
$status = $api->messageStatus($result->id()); $status->isQueued(); // still waiting — the only state in which it can be cancelled $status->isSent(); // handed to the receiving server (which can still bounce it) $status->isFailed(); $status->isFinished(); // stop polling
A scheduled message can be withdrawn while it is still queued:
$api->cancelMessage($result->id());
Retrying safely
If a send times out you cannot tell whether it was queued: retrying risks a duplicate order confirmation, not retrying risks losing it. An idempotency key removes the choice.
The key names the attempt, not the message. It answers "have I already tried to send this?", not "is this email unique?". So you create it once, where you decide to send, and pass that same value on every retry of that attempt:
// Generated ONCE, outside the loop — that is the whole point. $key = bin2hex(random_bytes(16)); for ($attempt = 1; $attempt <= 3; $attempt++) { try { $result = $api->send($message, $key); break; // queued — or replayed from attempt 1 } catch (IdempotencyInProgressException $e) { sleep($attempt); // attempt 1 still running: same key again } catch (TransportException $e) { sleep($attempt * 2); // timeout or network error: same key again } }
If attempt 1 queued the message but its response never reached you, attempt 2 returns that
original result — same message id, nothing sent twice. Move the $key = line inside the
loop and you have protected nothing at all: three keys, three emails.
It is also not a duplicate filter over content. Sending the same message again on purpose — a customer asking you to resend — needs a new key; reusing the old one replays the first result and mails nothing. Two identical bodies under two different keys are two emails, exactly as you asked.
- Replaying the key → the original result, no second message.
- Same key, different message →
IdempotencyKeyReuseException(a client bug, not a retry). - Replay while the first call is still running →
IdempotencyInProgressException. Wait and retry the same key; do not switch to a new one. - A failed call releases its key, so retrying after a transient error is a fresh attempt rather than a replay of the failure.
- Longer than 255 characters →
IdempotencyKeyInvalidException.
Anything opaque works, as long as it is created before the first attempt and survives every
retry. A UUID stored on the job row is the safest default; a natural id must name the
purpose — order-4711-confirmation, never plain order-4711, which collides with the
shipping notice for that same order and earns a reuse error.
Keys are scoped per API key and kept for 7 days.
Trying it out without sending
$result = $api->send($message->test()); $result->isTest(); // true $result->status(); // "validated" $result->id(); // null — nothing was stored, so there is nothing to look up
Every check runs — key scope, from-domain, recipients, body, schedule — and nothing is queued or sent. This is the right way to exercise your integration in a test suite: it runs the real authorization and validation, which a local mock cannot.
Events
use JaanBV\TwoMail\Event\EventType; foreach ($api->events(['event' => EventType::BOUNCED, 'begin' => '2026-08-01']) as $event) { $event->recipient(); $event->detail(); // the receiving server's response $event->isFailure(); }
Filters: event, mailbox, recipient, begin, end, limit (max 300). Defaults to the
last 7 days. A mailbox key always sees only its own mailbox — asking for another one is
refused, not quietly ignored.
Statistics
$stats = $api->stats(); // this month, per day $stats = $api->stats(['group_by' => 'month']); // the last 12 months $stats = $api->stats(['begin' => '2026-10-01', 'end' => '2026-10-31']); $stats['quota']; // ['month' => '2026-11', 'limit' => 50000, 'used' => 4050, 'left' => 45950, 'pooled' => false] $stats['totals']; // sent, delivered, deferred, bounced, rejected, bounce_hard, bounce_soft, // bounce_auto_reply, opened (per day only), delivery_rate $stats['series']; // the same fields per 'date' or per 'month'
The same figures as the dashboard, and a mailbox key can read them. Per day covers at most 31
days and per month at most 24 months. Per day reads the detailed logs. Those are only kept
for a limited time: a day that is older comes back as null, not 0, and available_from
says from which day each affected figure is complete. For anything further back, use
group_by=month, which reads permanent monthly totals.
sent counts messages; delivered, deferred and bounced count recipients, so a message
with a Cc counts once and twice respectively. The bounce_* figures are bounce reports
received afterwards and overlap with bounced: do not add them up.
Webhooks
The push side of the same log. Register a URL, keep the secret, verify every delivery:
$webhook = $api->createWebhook('https://example.com/2mail/webhook', [ EventType::DELIVERED, EventType::BOUNCED, ]); $webhook['secret']; // shown ONCE — store it now
Your webhook URL is public, so anyone who learns it can POST to it. The signature is what separates a real event from someone else's invention — verify before acting on the payload:
use JaanBV\TwoMail\Exception\InvalidWebhookSignatureException; use JaanBV\TwoMail\WebhookSignature; $payload = file_get_contents('php://input'); // the RAW body try { $event = WebhookSignature::event($secret, $_SERVER['HTTP_X_2MAIL_SIGNATURE'] ?? '', $payload); } catch (InvalidWebhookSignatureException $exception) { http_response_code(403); exit; }
Sign the raw body byte for byte: decoding the JSON and re-encoding it changes spacing and escaping, and the signature will never match. The check also rejects a delivery whose timestamp is more than 5 minutes old, which is what stops a captured request being replayed forever.
Answer 2xx quickly and process afterwards — anything else is retried with backoff, so a
slow handler collects duplicates. Make the handler idempotent on messageId() + event().
Provisioning
Admin and pool keys only.
$created = $api->createMailbox('acme.com', 'Acme', 'news@acme.com', [ 'allowed_domains' => 'acme.com,shop.acme.com', 'monthly_limit' => 100000, ]); $created['credential']['password']; // shown once $api->mailboxDnsRecords($created['mailbox']['id']); // SPF/DKIM/DMARC + verified flags $api->mailboxUsage($created['mailbox']['id']); // this month, per credential $api->createCredential($mailboxId, 'billing-app'); // one SMTP login per application $api->createMailboxApiKey($mailboxId, 'Webshop orders');
A key can never hand out more authority than it holds: an admin key may create pool and mailbox keys, a pool key only mailbox keys inside its own pool, and no key can create an admin key. That stays a control-panel action.
Address validation
$check = $api->validateEmail('user@gmial.com'); $check['result']; // deliverable | undeliverable | risky | unknown $check['did_you_mean']; // "user@gmail.com" $api->isValidEmail($email); // true only for "deliverable" $api->validateEmail($email, true); // deep: adds an SMTP probe — slower, may be inconclusive
Errors
Every failure raises an exception named after what went wrong, so you branch on the class rather than on a message that may be reworded:
use JaanBV\TwoMail\Exception\QuotaExceededException; use JaanBV\TwoMail\Exception\TwoMailException; try { $api->send($message); } catch (QuotaExceededException $exception) { // out of monthly credit — queue it for next month } catch (TwoMailException $exception) { // anything else the API or the transport refused }
Every exception in this library implements TwoMailException, and getCode() is the HTTP
status. $api->lastRequestId() returns the id 2mail logged the call under — quote it in a
support request and the exact call can be found.
Two failures come from settings you choose on the key itself, and each has its own class so an application can tell the operator exactly what to fix:
ApiKeyExpiredException— the key has passed the expiry date set in the control panel. Extend it or create a new one; the message names the date.IpNotAllowedException— the key has an IP restriction and this server's outgoing address is not on it. The message names the address 2mail saw: add that one.
InvalidResponseCodeException means the API returned an error this library does not know
yet: the call was fine, the SDK is simply older than the API.
Redirects are not followed. cURL would replay the Authorization header on the
redirect target, handing your API key to whatever host the Location points at — so a
misconfigured base URL raises InvalidHttpResponseCodeException instead.
TwoMail functions
Sending
- send($message [Message], $idempotencyKey = null) : Result
- sendMail($fromEmail, $to, $subject, $html = null, $text = null, $fromName = '', $idempotencyKey = null) : Result
- messageStatus($messageId) : Status
- cancelMessage($messageId) : bool
- events($filters = []) : Event[]
- stats($filters = []) : array
Mailboxes
- mailboxes() / mailbox($mailboxId)
- createMailbox($domain, $fromName, $fromEmail, $options = [])
- mailboxUsage($mailboxId) / mailboxDnsRecords($mailboxId)
- assignMailboxToGroup($mailboxId, $groupId) / removeMailboxFromGroup($mailboxId)
SMTP credentials
- credentials($mailboxId)
- createCredential($mailboxId, $label)
- deleteCredential($mailboxId, $credentialId)
Limit groups (pools)
- groups() / createGroup($name, $monthlyLimit)
- groupUsers($groupId)
- grantGroupAccess($groupId, $userId = null, $email = null, $demoMode = false)
- revokeGroupAccess($groupId, $userId)
API keys
- apiKeys() / apiKey($keyId)
- createMailboxApiKey($mailboxId, $label) / createPoolApiKey($groupId, $label)
- enableApiKey($keyId, $enabled) / deleteApiKey($keyId)
Webhooks
- webhooks() / webhook($webhookId)
- createWebhook($url, $events = [], $groupId = null)
- updateWebhook($webhookId, $changes) / rotateWebhookSecret($webhookId)
- deleteWebhook($webhookId)
Validation and diagnostics
- validateEmail($email, $deep = false) / isValidEmail($email, $deep = false)
- ping() / serviceInfo() / lastRequestId() / enableDebugging()
Message functions
- create()
- from($email, $name = '')
- to($email) / cc($email) / bcc($email) — a string or an array, and they accumulate
- replyTo($email)
- subject($subject) / html($html) / text($text)
- sendAt($dateTime [DateTimeInterface])
- tracking($on) — per-message open-tracking override
- test($test = true) — validate only, send nothing
Examples
- Sending mail
- Copies, scheduling, idempotency, test mode
- Delivery status
- Reading events
- Sending statistics
- Managing webhooks
- Receiving a webhook
- Creating a mailbox
- SMTP credentials
- Limit groups
- API keys
- Validating an address
- Checking a key
Add your API key in examples/credentials.php first, then:
# Send an e-mail
php examples/example-sendMail.php
PHPUnit
Executing all tests:
./vendor/bin/phpunit tests
Interactive API docs
Swagger UI, with a "try it out" console that makes real calls against the live API using a key you paste: https://www.2mail.eu/2mail/api/docs