Skip to content

Commit e5f7958

Browse files
lukaspijakdg
authored andcommitted
added DKIM feature (#51)
1 parent d109c9e commit e5f7958

12 files changed

+479
-4
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"nette/di": "<3.0-stable"
2929
},
3030
"suggest": {
31-
"ext-fileinfo": "to detect type of attached files"
31+
"ext-fileinfo": "to detect type of attached files",
32+
"ext-openssl": "to use Nette\\Mail\\DkimSigner"
3233
},
3334
"autoload": {
3435
"classmap": ["src/"],

src/Mail/DkimSigner.php

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the Nette Framework (https://nette.org)
5+
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Nette\Mail;
11+
12+
use Nette;
13+
14+
15+
class DkimSigner implements Signer
16+
{
17+
use Nette\SmartObject;
18+
19+
private const DEFAULT_SIGN_HEADERS = [
20+
'From',
21+
'To',
22+
'Date',
23+
'Subject',
24+
'Message-ID',
25+
'X-Mailer',
26+
'Content-Type',
27+
];
28+
29+
private const DKIM_SIGNATURE = 'DKIM-Signature';
30+
31+
/** @var string */
32+
private $domain;
33+
34+
/** @var array */
35+
private $signHeaders;
36+
37+
/** @var string */
38+
private $selector;
39+
40+
/** @var string */
41+
private $privateKey;
42+
43+
/** @var string */
44+
private $passPhrase;
45+
46+
/** @var bool */
47+
private $testMode;
48+
49+
50+
/**
51+
* @throws Nette\NotSupportedException
52+
*/
53+
public function __construct(array $options, array $signHeaders = self::DEFAULT_SIGN_HEADERS)
54+
{
55+
if (!extension_loaded('openssl')) {
56+
throw new Nette\NotSupportedException('DkimSigner requires PHP extension openssl which is not loaded.');
57+
}
58+
$this->domain = $options['domain'] ?? '';
59+
$this->selector = $options['selector'] ?? '';
60+
$this->privateKey = $options['privateKey'] ?? '';
61+
$this->passPhrase = $options['passPhrase'] ?? '';
62+
$this->testMode = (bool) ($options['testMode'] ?? false);
63+
$this->signHeaders = count($signHeaders) > 0 ? $signHeaders : self::DEFAULT_SIGN_HEADERS;
64+
}
65+
66+
67+
/**
68+
* @throws SignException
69+
*/
70+
public function generateSignedMessage(Message $message): string
71+
{
72+
$message = $message->build();
73+
74+
if (preg_match("~(.*?\r\n\r\n)(.*)~s", $message->getEncodedMessage(), $parts)) {
75+
[, $header, $body] = $parts;
76+
77+
return rtrim($header, "\r\n") . "\r\n" . $this->getSignature($message, $header, $this->normalizeNewLines($body)) . "\r\n\r\n" . $body;
78+
}
79+
throw new SignException('Malformed email');
80+
}
81+
82+
83+
protected function getSignature(Message $message, string $header, string $body): string
84+
{
85+
$parts = [];
86+
foreach (
87+
[
88+
'v' => '1',
89+
'a' => 'rsa-sha256',
90+
'q' => 'dns/txt',
91+
'l' => strlen($body),
92+
's' => $this->selector,
93+
't' => $this->testMode ? 0 : time(),
94+
'c' => 'relaxed/simple',
95+
'h' => implode(':', $this->getSignedHeaders($message)),
96+
'd' => $this->domain,
97+
'bh' => $this->computeBodyHash($body),
98+
'b' => '',
99+
] as $key => $value
100+
) {
101+
$parts[] = $key . '=' . $value;
102+
}
103+
104+
return $this->computeSignature($header, self::DKIM_SIGNATURE . ': ' . implode('; ', $parts));
105+
}
106+
107+
108+
protected function computeSignature(string $rawHeader, string $signature): string
109+
{
110+
$selectedHeaders = array_merge($this->signHeaders, [self::DKIM_SIGNATURE]);
111+
112+
$rawHeader = preg_replace("/\r\n[ \t]+/", ' ', rtrim($rawHeader, "\r\n") . "\r\n" . $signature);
113+
114+
$parts = [];
115+
foreach ($test = explode("\r\n", $rawHeader) as $key => $header) {
116+
if (strpos($header, ':') !== false) {
117+
[$heading, $value] = explode(':', $header, 2);
118+
119+
if (($index = array_search($heading, $selectedHeaders, true)) !== false) {
120+
$parts[$index] =
121+
trim(strtolower($heading), " \t") . ':' .
122+
trim(preg_replace("/[ \t]{2,}/", ' ', $value), " \t");
123+
}
124+
}
125+
}
126+
127+
ksort($parts);
128+
129+
return $signature . $this->sign(implode("\r\n", $parts));
130+
}
131+
132+
133+
/**
134+
* @throws SignException
135+
*/
136+
protected function sign(string $value): string
137+
{
138+
$privateKey = openssl_pkey_get_private($this->privateKey, $this->passPhrase);
139+
if (!$privateKey) {
140+
throw new SignException('Invalid private key');
141+
}
142+
143+
if (openssl_sign($value, $signature, $privateKey, 'sha256WithRSAEncryption')) {
144+
openssl_pkey_free($privateKey);
145+
return base64_encode($signature);
146+
}
147+
openssl_pkey_free($privateKey);
148+
}
149+
150+
151+
protected function computeBodyHash(string $body): string
152+
{
153+
return base64_encode(
154+
pack(
155+
'H*',
156+
hash('sha256', $body)
157+
)
158+
);
159+
}
160+
161+
162+
protected function normalizeNewLines(string $s): string
163+
{
164+
$s = str_replace(["\r\n", "\n"], "\r", $s);
165+
$s = str_replace("\r", "\r\n", $s);
166+
return rtrim($s, "\r\n") . "\r\n";
167+
}
168+
169+
170+
protected function getSignedHeaders(Message $message): array
171+
{
172+
return array_filter($this->signHeaders, function ($name) use ($message) {
173+
return $message->getHeader($name) !== null;
174+
});
175+
}
176+
}

src/Mail/Message.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ public function generateMessage(): string
334334
* Builds email. Does not modify itself, but returns a new object.
335335
* @return static
336336
*/
337-
protected function build()
337+
public function build()
338338
{
339339
$mail = clone $this;
340340
$mail->setHeader('Message-ID', $this->getRandomId());

src/Mail/SendmailMailer.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ class SendmailMailer implements Mailer
2222
/** @var string|null */
2323
public $commandArgs;
2424

25+
/** @var Signer|null */
26+
private $signer;
27+
28+
29+
/**
30+
* @return static
31+
*/
32+
public function setSigner(Signer $signer): self
33+
{
34+
$this->signer = $signer;
35+
return $this;
36+
}
37+
2538

2639
/**
2740
* Sends email.
@@ -36,7 +49,10 @@ public function send(Message $mail): void
3649
$tmp->setHeader('Subject', null);
3750
$tmp->setHeader('To', null);
3851

39-
$parts = explode(Message::EOL . Message::EOL, $tmp->generateMessage(), 2);
52+
$data = $this->signer
53+
? $this->signer->generateSignedMessage($tmp)
54+
: $tmp->generateMessage();
55+
$parts = explode(Message::EOL . Message::EOL, $data, 2);
4056

4157
$args = [
4258
str_replace(Message::EOL, PHP_EOL, $mail->getEncodedHeader('To')),

src/Mail/Signer.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/**
4+
* This file is part of the Nette Framework (https://nette.org)
5+
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Nette\Mail;
11+
12+
13+
/**
14+
* Signer interface.
15+
*/
16+
interface Signer
17+
{
18+
/**
19+
* @throws SignException
20+
*/
21+
public function generateSignedMessage(Message $message): string;
22+
}

src/Mail/SmtpMailer.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class SmtpMailer implements Mailer
1919
{
2020
use Nette\SmartObject;
2121

22+
/** @var Signer|null */
23+
private $signer;
24+
2225
/** @var resource */
2326
private $connection;
2427

@@ -78,6 +81,16 @@ public function __construct(array $options = [])
7881
}
7982

8083

84+
/**
85+
* @return static
86+
*/
87+
public function setSigner(Signer $signer): self
88+
{
89+
$this->signer = $signer;
90+
return $this;
91+
}
92+
93+
8194
/**
8295
* Sends email.
8396
* @throws SmtpException
@@ -86,7 +99,10 @@ public function send(Message $mail): void
8699
{
87100
$tmp = clone $mail;
88101
$tmp->setHeader('Bcc', null);
89-
$data = $tmp->generateMessage();
102+
103+
$data = $this->signer
104+
? $this->signer->generateSignedMessage($tmp)
105+
: $tmp->generateMessage();
90106

91107
try {
92108
if (!$this->connection) {

src/Mail/exceptions.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,8 @@ class FallbackMailerException extends SendException
3333
/** @var SendException[] */
3434
public $failures;
3535
}
36+
37+
38+
class SignException extends SendException
39+
{
40+
}

tests/Mail/Mail.dkim.headers.phpt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/**
4+
* Test: Nette\Mail\DkimSigner headers filter.
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Mail\DkimSigner;
10+
use Nette\Mail\Message;
11+
use Tester\Assert;
12+
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
16+
if (!extension_loaded('openssl')) {
17+
Tester\Environment::skip('OpenSSL not installed');
18+
}
19+
20+
$signer = new class ([], [
21+
'From',
22+
'To',
23+
'Date',
24+
'Subject',
25+
'Message-ID',
26+
'X-Mailer',
27+
'Content-Type',
28+
]) extends DkimSigner {
29+
public function getSignedHeaders(Message $message): array
30+
{
31+
return parent::getSignedHeaders($message);
32+
}
33+
};
34+
35+
$mail = new Message;
36+
$mail->setFrom('John Doe <[email protected]>');
37+
$mail->addTo('Lady Jane <[email protected]>');
38+
$mail->setSubject('Hello Jane!');
39+
$mail->setBody('Příliš žluťoučký kůň');
40+
41+
Assert::equal([
42+
0 => 'From',
43+
1 => 'To',
44+
2 => 'Date',
45+
3 => 'Subject',
46+
5 => 'X-Mailer',
47+
], $signer->getSignedHeaders($mail));

tests/Mail/Mail.dkim.invalidKey.phpt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/**
4+
* Test: Nette\Mail\DkimSigner invalid private key.
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Mail\DkimSigner;
10+
use Nette\Mail\Message;
11+
use Nette\Mail\SignException;
12+
use Tester\Assert;
13+
14+
15+
require __DIR__ . '/../bootstrap.php';
16+
17+
if (!extension_loaded('openssl')) {
18+
Tester\Environment::skip('OpenSSL not installed');
19+
}
20+
21+
$signer = new DkimSigner([]);
22+
23+
$mail = new Message;
24+
$mail->setFrom('John Doe <[email protected]>');
25+
$mail->addTo('Lady Jane <[email protected]>');
26+
$mail->setSubject('Hello Jane!');
27+
$mail->setBody('Příliš žluťoučký kůň');
28+
29+
Assert::exception(function () use ($signer, $mail) {
30+
$signer->generateSignedMessage($mail);
31+
}, SignException::class);

0 commit comments

Comments
 (0)