Skip to content

Commit 71dce00

Browse files
authored
Merge pull request #79 from utopia-php/feat-email-attachments
Feat email attachments
2 parents 7beec07 + 789dcfd commit 71dce00

32 files changed

+746
-571
lines changed

composer.json

+5-5
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@
2424
"require": {
2525
"php": ">=8.0.0",
2626
"ext-curl": "*",
27-
"ext-openssl": "*"
27+
"ext-openssl": "*",
28+
"phpmailer/phpmailer": "6.9.1"
2829
},
2930
"require-dev": {
30-
"phpunit/phpunit": "9.6.10",
31-
"phpmailer/phpmailer": "6.8.*",
32-
"laravel/pint": "1.13.*",
33-
"phpstan/phpstan": "1.10.*"
31+
"phpunit/phpunit": "10.5.10",
32+
"laravel/pint": "1.13.11",
33+
"phpstan/phpstan": "1.10.58"
3434
},
3535
"config": {
3636
"platform": {

composer.lock

+295-412
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

phpunit.xml

+6-8
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
<phpunit backupGlobals="false"
2-
backupStaticAttributes="false"
1+
<?xml version="1.0"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
34
bootstrap="vendor/autoload.php"
4-
colors="true"
5-
convertErrorsToExceptions="true"
6-
convertNoticesToExceptions="true"
7-
convertWarningsToExceptions="true"
8-
processIsolation="false"
5+
cacheDirectory=".phpunit.cache"
96
stopOnFailure="false"
7+
colors="true"
108
>
119
<testsuites>
1210
<testsuite name="Application Test Suite">
1311
<directory>./tests/</directory>
1412
</testsuite>
1513
</testsuites>
16-
</phpunit>
14+
</phpunit>

src/Utopia/Messaging/Adapter.php

+33-4
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function send(Message $message): array
6060
* @param string $method The HTTP method to use.
6161
* @param string $url The URL to send the request to.
6262
* @param array<string> $headers An array of headers to send with the request.
63-
* @param string|null $body The body of the request.
63+
* @param array<string, mixed>|null $body The body of the request.
6464
* @param int $timeout The timeout in seconds.
6565
* @return array{
6666
* url: string,
@@ -75,14 +75,28 @@ protected function request(
7575
string $method,
7676
string $url,
7777
array $headers = [],
78-
string $body = null,
78+
array $body = null,
7979
int $timeout = 30
8080
): array {
8181
$ch = \curl_init();
8282

83+
foreach ($headers as $header) {
84+
if (\str_contains($header, 'application/json')) {
85+
$body = \json_encode($body);
86+
break;
87+
}
88+
if (\str_contains($header, 'application/x-www-form-urlencoded')) {
89+
$body = \http_build_query($body);
90+
break;
91+
}
92+
}
93+
8394
if (!\is_null($body)) {
84-
$headers[] = 'Content-Length: '.\strlen($body);
8595
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
96+
97+
if (\is_string($body)) {
98+
$headers[] = 'Content-Length: '.\strlen($body);
99+
}
86100
}
87101

88102
\curl_setopt_array($ch, [
@@ -117,7 +131,7 @@ protected function request(
117131
*
118132
* @param array<string> $urls
119133
* @param array<string> $headers
120-
* @param array<string> $bodies
134+
* @param array<array<string, mixed>> $bodies
121135
* @return array<array{
122136
* url: string,
123137
* statusCode: int,
@@ -138,6 +152,21 @@ protected function requestMulti(
138152
throw new \Exception('No URLs provided. Must provide at least one URL.');
139153
}
140154

155+
foreach ($headers as $header) {
156+
if (\str_contains($header, 'application/json')) {
157+
foreach ($bodies as $i => $body) {
158+
$bodies[$i] = \json_encode($body);
159+
}
160+
break;
161+
}
162+
if (\str_contains($header, 'application/x-www-form-urlencoded')) {
163+
foreach ($bodies as $i => $body) {
164+
$bodies[$i] = \http_build_query($body);
165+
}
166+
break;
167+
}
168+
}
169+
141170
$sh = \curl_share_init();
142171
$mh = \curl_multi_init();
143172
$ch = \curl_init();

src/Utopia/Messaging/Adapter/Chat/Discord.php

+5-5
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ protected function process(DiscordMessage $message): array
7373
headers: [
7474
'Content-Type: application/json',
7575
],
76-
body: \json_encode([
76+
body: [
7777
'content' => $message->getContent(),
7878
'username' => $message->getUsername(),
7979
'avatar_url' => $message->getAvatarUrl(),
@@ -84,18 +84,18 @@ protected function process(DiscordMessage $message): array
8484
'attachments' => $message->getAttachments(),
8585
'flags' => $message->getFlags(),
8686
'thread_name' => $message->getThreadName(),
87-
]),
87+
],
8888
);
8989

9090
$statusCode = $result['statusCode'];
9191

9292
if ($statusCode >= 200 && $statusCode < 300) {
9393
$response->setDeliveredTo(1);
94-
$response->addResultForRecipient($this->webhookId);
94+
$response->addResult($this->webhookId);
9595
} elseif ($statusCode >= 400 && $statusCode < 500) {
96-
$response->addResultForRecipient($this->webhookId, 'Bad Request.');
96+
$response->addResult($this->webhookId, 'Bad Request.');
9797
} else {
98-
$response->addResultForRecipient($this->webhookId, 'Unknown Error.');
98+
$response->addResult($this->webhookId, 'Unknown Error.');
9999
}
100100

101101
return $response->toArray();

src/Utopia/Messaging/Adapter/Email.php

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ abstract class Email extends Adapter
1010
protected const TYPE = 'email';
1111
protected const MESSAGE_TYPE = EmailMessage::class;
1212

13+
protected const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25MB
14+
1315
public function getType(): string
1416
{
1517
return static::TYPE;

src/Utopia/Messaging/Adapter/Email/Mailgun.php

+42-10
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ protected function process(EmailMessage $message): array
5353
'subject' => $message->getSubject(),
5454
'text' => $message->isHtml() ? null : $message->getContent(),
5555
'html' => $message->isHtml() ? $message->getContent() : null,
56+
'h:Reply-To: '."{$message->getReplyToName()}<{$message->getReplyToEmail()}>",
5657
];
5758

5859
if (!\is_null($message->getCC())) {
@@ -75,33 +76,64 @@ protected function process(EmailMessage $message): array
7576
}
7677
}
7778

79+
$isMultipart = false;
80+
81+
if (!\is_null($message->getAttachments())) {
82+
$size = 0;
83+
84+
foreach ($message->getAttachments() as $attachment) {
85+
$size += \filesize($attachment->getPath());
86+
}
87+
88+
if ($size > self::MAX_ATTACHMENT_BYTES) {
89+
throw new \Exception('Attachments size exceeds the maximum allowed size of ');
90+
}
91+
92+
foreach ($message->getAttachments() as $index => $attachment) {
93+
$isMultipart = true;
94+
95+
$body["attachment[$index]"] = \curl_file_create(
96+
$attachment->getPath(),
97+
$attachment->getType(),
98+
$attachment->getName(),
99+
);
100+
}
101+
}
102+
78103
$response = new Response($this->getType());
79104

105+
$headers = [
106+
'Authorization: Basic ' . \base64_encode("api:$this->apiKey"),
107+
];
108+
109+
if ($isMultipart) {
110+
$headers[] = 'Content-Type: multipart/form-data';
111+
} else {
112+
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
113+
}
114+
80115
$result = $this->request(
81116
method: 'POST',
82-
url: "https://$domain/v3/{$this->domain}/messages",
83-
headers: [
84-
'Authorization: Basic '.base64_encode('api:'.$this->apiKey),
85-
'h:Reply-To: '."{$message->getReplyToName()}<{$message->getReplyToEmail()}>",
86-
],
87-
body: \http_build_query($body),
117+
url: "https://$domain/v3/$this->domain/messages",
118+
headers: $headers,
119+
body: $body,
88120
);
89121

90122
$statusCode = $result['statusCode'];
91123

92124
if ($statusCode >= 200 && $statusCode < 300) {
93125
$response->setDeliveredTo(\count($message->getTo()));
94126
foreach ($message->getTo() as $to) {
95-
$response->addResultForRecipient($to);
127+
$response->addResult($to);
96128
}
97129
} elseif ($statusCode >= 400 && $statusCode < 500) {
98130
foreach ($message->getTo() as $to) {
99131
if (\is_string($result['response'])) {
100-
$response->addResultForRecipient($to, $result['response']);
132+
$response->addResult($to, $result['response']);
101133
} elseif (isset($result['response']['message'])) {
102-
$response->addResultForRecipient($to, $result['response']['message']);
134+
$response->addResult($to, $result['response']['message']);
103135
} else {
104-
$response->addResultForRecipient($to, 'Unknown error');
136+
$response->addResult($to, 'Unknown error');
105137
}
106138
}
107139
}

src/Utopia/Messaging/Adapter/Email/Mock.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,12 @@ protected function process(EmailMessage $message): array
5252

5353
if (!$mail->send()) {
5454
foreach ($message->getTo() as $to) {
55-
$response->addResultForRecipient($to, $mail->ErrorInfo);
55+
$response->addResult($to, $mail->ErrorInfo);
5656
}
5757
} else {
5858
$response->setDeliveredTo(\count($message->getTo()));
5959
foreach ($message->getTo() as $to) {
60-
$response->addResultForRecipient($to);
60+
$response->addResult($to);
6161
}
6262
}
6363

src/Utopia/Messaging/Adapter/Email/SMTP.php

+33-1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,38 @@ protected function process(EmailMessage $message): array
7676
$mail->addAddress($to);
7777
}
7878

79+
if (!empty($message->getCC())) {
80+
foreach ($message->getCC() as $cc) {
81+
$mail->addCC($cc['email'], $cc['name']);
82+
}
83+
}
84+
85+
if (!empty($message->getBCC())) {
86+
foreach ($message->getBCC() as $bcc) {
87+
$mail->addBCC($bcc['email'], $bcc['name']);
88+
}
89+
}
90+
91+
if (!empty($message->getAttachments())) {
92+
$size = 0;
93+
94+
foreach ($message->getAttachments() as $attachment) {
95+
$size += \filesize($attachment->getPath());
96+
}
97+
98+
if ($size > self::MAX_ATTACHMENT_BYTES) {
99+
throw new \Exception('Attachments size exceeds the maximum allowed size of 25MB');
100+
}
101+
102+
foreach ($message->getAttachments() as $attachment) {
103+
$mail->addStringAttachment(
104+
string: \file_get_contents($attachment->getPath()),
105+
filename: $attachment->getName(),
106+
type: $attachment->getType()
107+
);
108+
}
109+
}
110+
79111
$sent = $mail->send();
80112

81113
if ($sent) {
@@ -87,7 +119,7 @@ protected function process(EmailMessage $message): array
87119
? 'Unknown error'
88120
: $mail->ErrorInfo;
89121

90-
$response->addResultForRecipient($to, $sent ? '' : $error);
122+
$response->addResult($to, $sent ? '' : $error);
91123
}
92124

93125
return $response->toArray();

src/Utopia/Messaging/Adapter/Email/Sendgrid.php

+50-21
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,51 @@ protected function process(EmailMessage $message): array
7979
}
8080
}
8181

82+
$attachments = [];
83+
84+
if (!\is_null($message->getAttachments())) {
85+
$size = 0;
86+
87+
foreach ($message->getAttachments() as $attachment) {
88+
$size += \filesize($attachment->getPath());
89+
}
90+
91+
if ($size > self::MAX_ATTACHMENT_BYTES) {
92+
throw new \Exception('Attachments size exceeds the maximum allowed size of 25MB');
93+
}
94+
95+
foreach ($message->getAttachments() as $attachment) {
96+
$attachments[] = [
97+
'content' => \base64_encode(\file_get_contents($attachment->getPath())),
98+
'filename' => $attachment->getName(),
99+
'type' => $attachment->getType(),
100+
'disposition' => 'attachment',
101+
];
102+
}
103+
}
104+
105+
$body = [
106+
'personalizations' => $personalizations,
107+
'reply_to' => [
108+
'name' => $message->getReplyToName(),
109+
'email' => $message->getReplyToEmail(),
110+
],
111+
'from' => [
112+
'name' => $message->getFromName(),
113+
'email' => $message->getFromEmail(),
114+
],
115+
'content' => [
116+
[
117+
'type' => $message->isHtml() ? 'text/html' : 'text/plain',
118+
'value' => $message->getContent(),
119+
],
120+
],
121+
];
122+
123+
if (!empty($attachments)) {
124+
$body['attachments'] = $attachments;
125+
}
126+
82127
$response = new Response($this->getType());
83128
$result = $this->request(
84129
method: 'POST',
@@ -87,40 +132,24 @@ protected function process(EmailMessage $message): array
87132
'Authorization: Bearer '.$this->apiKey,
88133
'Content-Type: application/json',
89134
],
90-
body: \json_encode([
91-
'personalizations' => $personalizations,
92-
'reply_to' => [
93-
'name' => $message->getReplyToName(),
94-
'email' => $message->getReplyToEmail(),
95-
],
96-
'from' => [
97-
'name' => $message->getFromName(),
98-
'email' => $message->getFromEmail(),
99-
],
100-
'content' => [
101-
[
102-
'type' => $message->isHtml() ? 'text/html' : 'text/plain',
103-
'value' => $message->getContent(),
104-
],
105-
],
106-
]),
135+
body: $body,
107136
);
108137

109138
$statusCode = $result['statusCode'];
110139

111140
if ($statusCode === 202) {
112141
$response->setDeliveredTo(\count($message->getTo()));
113142
foreach ($message->getTo() as $to) {
114-
$response->addResultForRecipient($to);
143+
$response->addResult($to);
115144
}
116145
} else {
117146
foreach ($message->getTo() as $to) {
118147
if (\is_string($result['response'])) {
119-
$response->addResultForRecipient($to, $result['response']);
148+
$response->addResult($to, $result['response']);
120149
} elseif (!\is_null($result['response']['errors'][0]['message'] ?? null)) {
121-
$response->addResultForRecipient($to, $result['response']['errors'][0]['message']);
150+
$response->addResult($to, $result['response']['errors'][0]['message']);
122151
} else {
123-
$response->addResultForRecipient($to, 'Unknown error');
152+
$response->addResult($to, 'Unknown error');
124153
}
125154
}
126155
}

0 commit comments

Comments
 (0)