Skip to content

Commit 7edd0fc

Browse files
committed
feat: add idempotency support to EmailEndpoint
Introduced support for idempotency keys in the EmailEndpoint to prevent duplicate email processing by the API. Updated tests to verify behavior with and without idempotency keys. Enhanced the README with examples and a detailed explanation of idempotency usage.
1 parent 628da5d commit 7edd0fc

4 files changed

Lines changed: 143 additions & 54 deletions

File tree

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ You can install the package via composer:
2020
composer require lettermint/lettermint-php
2121
```
2222

23-
2423
## Usage
2524

2625
Initialize the Lettermint client with your API token:
26+
2727
```php
2828
$lettermint = new Lettermint\Lettermint('your-api-token');
2929
```
3030

31-
3231
### Sending Emails
3332

3433
The SDK provides a fluent interface for composing and sending emails:
34+
3535
```php
3636
$response = $lettermint->email
3737
->from('sender@example.com')
@@ -57,9 +57,28 @@ $lettermint->email
5757
->headers(['X-Custom-Header' => 'Value'])
5858
->attach('document.pdf', base64_encode($fileContent))
5959
->route('my-route-id')
60+
->idempotencyKey('unique-request-id-123')
6061
->send();
6162
```
6263

64+
### Idempotency
65+
66+
To ensure that duplicate requests are not processed, you can use an idempotency key:
67+
68+
```php
69+
$response = $lettermint->email
70+
->from('sender@example.com')
71+
->to('recipient@example.com')
72+
->subject('Hello from Lettermint!')
73+
->text('Hello! This is a test email.')
74+
->idempotencyKey('unique-request-id-123')
75+
->send();
76+
```
77+
78+
The idempotency key should be a unique string that you generate for each unique email you want to send. If you make the
79+
same request with the same idempotency key, the API will return the same response without sending a duplicate email.
80+
81+
For more information, refer to the [documentation](https://docs.lettermint.co/platform/emails/idempotency).
6382

6483
## Testing
6584

src/Client/HttpClient.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
class HttpClient
99
{
1010
private string $apiToken;
11+
1112
private string $baseUrl;
13+
1214
private Client $client;
1315

1416
public function __construct(string $apiToken, string $baseUrl)
@@ -17,7 +19,7 @@ public function __construct(string $apiToken, string $baseUrl)
1719
$this->baseUrl = rtrim($baseUrl, '/');
1820
$this->client = new Client([
1921
'base_uri' => $this->baseUrl,
20-
'timeout' => 15,
22+
'timeout' => 15,
2123
'headers' => [
2224
'Content-Type' => 'application/json',
2325
'x-lettermint-token' => $this->apiToken,
@@ -28,26 +30,31 @@ public function __construct(string $apiToken, string $baseUrl)
2830
/**
2931
* Fluent-style usage with dedicated endpoints (builder pattern)
3032
*
31-
* @param string $path
32-
* @param array $data
33+
* @param array $headers Additional headers for this request
3334
* @return array Resulting API response
35+
*
3436
* @throws \Exception On HTTP or decode failure
3537
*/
36-
public function post(string $path, array $data): array
38+
public function post(string $path, array $data, array $headers = []): array
3739
{
3840
try {
39-
$response = $this->client->post($path, [
40-
'json' => $data
41-
]);
41+
$requestOptions = ['json' => $data];
42+
43+
if (! empty($headers)) {
44+
$requestOptions['headers'] = $headers;
45+
}
46+
47+
$response = $this->client->post($path, $requestOptions);
4248
$body = $response->getBody()->getContents();
4349
$result = json_decode($body, true);
4450

4551
if (json_last_error() !== JSON_ERROR_NONE) {
4652
throw new \Exception('Could not decode API response');
4753
}
54+
4855
return $result;
4956
} catch (GuzzleException $e) {
50-
throw new \Exception('API request failed: ' . $e->getMessage(), 0, $e);
57+
throw new \Exception('API request failed: '.$e->getMessage(), 0, $e);
5158
}
5259
}
5360
}

src/Endpoints/EmailEndpoint.php

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,22 @@ class EmailEndpoint extends Endpoint
99
*/
1010
private array $payload = [];
1111

12+
/**
13+
* @var string|null The idempotency key for the request.
14+
*/
15+
private ?string $idempotencyKey = null;
16+
1217
/**
1318
* Set the custom headeres.
1419
*
1520
* @example ["Key" => "Value"]
1621
*
17-
* @param array<string, string> $headers The custom headers.
18-
* @return self
22+
* @param array<string, string> $headers The custom headers.
1923
*/
2024
public function headers(array $headers): self
2125
{
2226
$this->payload['headers'] = $headers;
27+
2328
return $this;
2429
}
2530

@@ -31,135 +36,154 @@ public function headers(array $headers): self
3136
* @example John Doe <john@acme.com>
3237
* @example john@acme.com
3338
*
34-
* @param string $email The sender's email address.
35-
* @return self
39+
* @param string $email The sender's email address.
3640
*/
3741
public function from(string $email): self
3842
{
3943
$this->payload['from'] = $email;
44+
4045
return $this;
4146
}
4247

4348
/**
4449
* Set one or more recipient email addresses.
4550
*
46-
* @param string ...$emails One or more recipient email addresses.
47-
* @return self
51+
* @param string ...$emails One or more recipient email addresses.
4852
*/
4953
public function to(string ...$emails): self
5054
{
5155
$this->payload['to'] = $emails;
56+
5257
return $this;
5358
}
5459

5560
/**
5661
* Set the subject of the email.
5762
*
58-
* @param string $subject The subject line.
59-
* @return self
63+
* @param string $subject The subject line.
6064
*/
6165
public function subject(string $subject): self
6266
{
6367
$this->payload['subject'] = $subject;
68+
6469
return $this;
6570
}
6671

6772
/**
6873
* Set the HTML body of the email.
6974
*
70-
* @param string|null $html The HTML content for the email body.
71-
* @return self
75+
* @param string|null $html The HTML content for the email body.
7276
*/
7377
public function html(?string $html): self
7478
{
7579
$this->payload['html'] = $html;
80+
7681
return $this;
7782
}
7883

7984
/**
8085
* Set the plain text body of the email.
8186
*
82-
* @param string|null $text The plain text content for the email body.
83-
* @return self
87+
* @param string|null $text The plain text content for the email body.
8488
*/
8589
public function text(?string $text): self
8690
{
8791
$this->payload['text'] = $text;
92+
8893
return $this;
8994
}
9095

9196
/**
9297
* Set one or more CC email addresses.
9398
*
94-
* @param string ...$emails Email addresses to be CC'd.
95-
* @return self
99+
* @param string ...$emails Email addresses to be CC'd.
96100
*/
97101
public function cc(string ...$emails): self
98102
{
99103
$this->payload['cc'] = $emails;
104+
100105
return $this;
101106
}
102107

103108
/**
104109
* Set one or more BCC email addresses.
105110
*
106-
* @param string ...$emails Email addresses to be BCC'd.
107-
* @return self
111+
* @param string ...$emails Email addresses to be BCC'd.
108112
*/
109113
public function bcc(string ...$emails): self
110114
{
111115
$this->payload['bcc'] = $emails;
116+
112117
return $this;
113118
}
114119

115120
/**
116121
* Set one or more Reply-To email addresses.
117122
*
118-
* @param string ...$emails Reply-To email addresses.
119-
* @return self
123+
* @param string ...$emails Reply-To email addresses.
120124
*/
121125
public function replyTo(string ...$emails): self
122126
{
123127
$this->payload['reply_to'] = $emails;
128+
124129
return $this;
125130
}
126131

127132
/**
128133
* Attach a file to the email.
129134
*
130-
* @param string $filename The attachment filename.
131-
* @param string $base64Content The base64-encoded file content.
132-
* @return self
135+
* @param string $filename The attachment filename.
136+
* @param string $base64Content The base64-encoded file content.
133137
*/
134138
public function attach(string $filename, string $base64Content): self
135139
{
136140
$this->payload['attachments'][] = [
137141
'filename' => $filename,
138142
'content' => $base64Content,
139143
];
144+
140145
return $this;
141146
}
142147

143148
/**
144149
* Set the route id for the email.
145150
*
146-
* @param string $route The route id to use for sending.
147-
* @return self
151+
* @param string $route The route id to use for sending.
148152
*/
149153
public function route(string $route): self
150154
{
151155
$this->payload['route'] = $route;
156+
157+
return $this;
158+
}
159+
160+
/**
161+
* Set the idempotency key for the request.
162+
*
163+
* @param string $key The idempotency key to ensure request uniqueness.
164+
*/
165+
public function idempotencyKey(string $key): self
166+
{
167+
$this->idempotencyKey = $key;
168+
152169
return $this;
153170
}
154171

155172
/**
156173
* Send the composed email using the current payload.
157174
*
158175
* @return array The API response as an associative array.
176+
*
159177
* @throws \Exception On HTTP or API failure.
160178
*/
161179
public function send(): array
162180
{
163-
return $this->httpClient->post('/v1/send', $this->payload);
181+
$headers = [];
182+
183+
if ($this->idempotencyKey !== null) {
184+
$headers['Idempotency-Key'] = $this->idempotencyKey;
185+
}
186+
187+
return $this->httpClient->post('/v1/send', $this->payload, $headers);
164188
}
165189
}

0 commit comments

Comments
 (0)