Skip to content
Permalink
Browse files

added DKIM feature (#51)

  • Loading branch information...
lukaspijak authored and dg committed Jun 3, 2019
1 parent d109c9e commit 34ae0573f18ab313e9d1c0ba947fcd4f0c5040de
@@ -28,7 +28,8 @@
"nette/di": "<3.0-stable"
},
"suggest": {
"ext-fileinfo": "to detect type of attached files"
"ext-fileinfo": "to detect type of attached files",
"ext-openssl": "to use Nette\\Mail\\DkimSigner"
},
"autoload": {
"classmap": ["src/"],
@@ -0,0 +1,174 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Mail;
use Nette;
class DkimSigner implements Signer
{
use Nette\SmartObject;
private const DEFAULT_SIGN_HEADERS = [
'From',
'To',
'Date',
'Subject',
'Message-ID',
'X-Mailer',
'Content-Type',
];
private const DKIM_SIGNATURE = 'DKIM-Signature';
/** @var string */
private $domain;
/** @var array */
private $signHeaders;
/** @var string */
private $selector;
/** @var string */
private $privateKey;
/** @var string */
private $passPhrase;
/** @var bool */
private $testMode;
/**
* @throws Nette\NotSupportedException
*/
public function __construct(array $options, array $signHeaders = self::DEFAULT_SIGN_HEADERS)
{
if (!extension_loaded('openssl')) {
throw new Nette\NotSupportedException('DkimSigner requires PHP extension openssl which is not loaded.');
}
$this->domain = $options['domain'] ?? '';
$this->selector = $options['selector'] ?? '';
$this->privateKey = $options['privateKey'] ?? '';
$this->passPhrase = $options['passPhrase'] ?? '';
$this->testMode = (bool) ($options['testMode'] ?? false);
$this->signHeaders = count($signHeaders) > 0 ? $signHeaders : self::DEFAULT_SIGN_HEADERS;
}
/**
* @throws SignException
*/
public function generateSignedMessage(Message $message): string
{
if (preg_match("~(.*?\r\n\r\n)(.*)~s", $message->generateMessage(), $parts)) {
[, $header, $body] = $parts;
return rtrim($header, "\r\n") . "\r\n" . $this->getSignature($message, $header, $this->normalizeNewLines($body)) . "\r\n\r\n" . $body;
}
throw new SignException('Malformed email');
}
protected function getSignature(Message $message, string $header, string $body): string
{
$parts = [];
foreach (
[
'v' => '1',
'a' => 'rsa-sha256',
'q' => 'dns/txt',
'l' => strlen($body),
's' => $this->selector,
't' => $this->testMode ? 0 : time(),
'c' => 'relaxed/simple',
'h' => implode(':', $this->getSignedHeaders($message)),
'd' => $this->domain,
'bh' => $this->computeBodyHash($body),
'b' => '',
] as $key => $value
) {
$parts[] = $key . '=' . $value;
}
return $this->computeSignature($header, self::DKIM_SIGNATURE . ': ' . implode('; ', $parts));
}
protected function computeSignature(string $rawHeader, string $signature): string
{
$selectedHeaders = array_merge($this->signHeaders, [self::DKIM_SIGNATURE]);
$rawHeader = preg_replace("/\r\n[ \t]+/", ' ', rtrim($rawHeader, "\r\n") . "\r\n" . $signature);
$parts = [];
foreach ($test = explode("\r\n", $rawHeader) as $key => $header) {
if (strpos($header, ':') !== false) {
[$heading, $value] = explode(':', $header, 2);
if (($index = array_search($heading, $selectedHeaders, true)) !== false) {
$parts[$index] =
trim(strtolower($heading), " \t") . ':' .
trim(preg_replace("/[ \t]{2,}/", ' ', $value), " \t");
}
}
}
ksort($parts);
return $signature . $this->sign(implode("\r\n", $parts));
}
/**
* @throws SignException
*/
protected function sign(string $value): string
{
$privateKey = openssl_pkey_get_private($this->privateKey, $this->passPhrase);
if (!$privateKey) {
throw new SignException('Invalid private key');
}
if (openssl_sign($value, $signature, $privateKey, 'sha256WithRSAEncryption')) {
openssl_pkey_free($privateKey);
return base64_encode($signature);
}
openssl_pkey_free($privateKey);
}
protected function computeBodyHash(string $body): string
{
return base64_encode(
pack(
'H*',
hash('sha256', $body)
)
);
}
protected function normalizeNewLines(string $s): string
{
$s = str_replace(["\r\n", "\n"], "\r", $s);
$s = str_replace("\r", "\r\n", $s);
return rtrim($s, "\r\n") . "\r\n";
}
protected function getSignedHeaders(Message $message): array
{
return array_filter($this->signHeaders, function ($name) use ($message) {
return $message->getHeader($name) !== null;
});
}
}
@@ -22,6 +22,19 @@ class SendmailMailer implements Mailer
/** @var string|null */
public $commandArgs;
/** @var Signer|null */
private $signer;
/**
* @return static
*/
public function setSigner(Signer $signer): self
{
$this->signer = $signer;
return $this;
}
/**
* Sends email.
@@ -36,7 +49,10 @@ public function send(Message $mail): void
$tmp->setHeader('Subject', null);
$tmp->setHeader('To', null);
$parts = explode(Message::EOL . Message::EOL, $tmp->generateMessage(), 2);
$data = $this->signer
? $this->signer->generateSignedMessage($tmp)
: $tmp->generateMessage();
$parts = explode(Message::EOL . Message::EOL, $data, 2);
$args = [
str_replace(Message::EOL, PHP_EOL, $mail->getEncodedHeader('To')),
@@ -0,0 +1,22 @@
<?php
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Nette\Mail;
/**
* Signer interface.
*/
interface Signer
{
/**
* @throws SignException
*/
public function generateSignedMessage(Message $message): string;
}
@@ -19,6 +19,9 @@ class SmtpMailer implements Mailer
{
use Nette\SmartObject;
/** @var Signer|null */
private $signer;
/** @var resource */
private $connection;
@@ -78,6 +81,16 @@ public function __construct(array $options = [])
}
/**
* @return static
*/
public function setSigner(Signer $signer): self
{
$this->signer = $signer;
return $this;
}
/**
* Sends email.
* @throws SmtpException
@@ -86,7 +99,10 @@ public function send(Message $mail): void
{
$tmp = clone $mail;
$tmp->setHeader('Bcc', null);
$data = $tmp->generateMessage();
$data = $this->signer
? $this->signer->generateSignedMessage($tmp)
: $tmp->generateMessage();
try {
if (!$this->connection) {
@@ -33,3 +33,8 @@ class FallbackMailerException extends SendException
/** @var SendException[] */
public $failures;
}
class SignException extends SendException
{
}
@@ -0,0 +1,47 @@
<?php
/**
* Test: Nette\Mail\DkimSigner headers filter.
*/
declare(strict_types=1);
use Nette\Mail\DkimSigner;
use Nette\Mail\Message;
use Tester\Assert;
require __DIR__ . '/../bootstrap.php';
if (!extension_loaded('openssl')) {
Tester\Environment::skip('OpenSSL not installed');
}
$signer = new class ([], [
'From',
'To',
'Date',
'Subject',
'Message-ID',
'X-Mailer',
'Content-Type',
]) extends DkimSigner {
public function getSignedHeaders(Message $message): array
{
return parent::getSignedHeaders($message);
}
};
$mail = new Message;
$mail->setFrom('John Doe <doe@example.com>');
$mail->addTo('Lady Jane <jane@example.com>');
$mail->setSubject('Hello Jane!');
$mail->setBody('Příliš žluťoučký kůň');
Assert::equal([
0 => 'From',
1 => 'To',
2 => 'Date',
3 => 'Subject',
5 => 'X-Mailer',
], $signer->getSignedHeaders($mail));
@@ -0,0 +1,31 @@
<?php
/**
* Test: Nette\Mail\DkimSigner invalid private key.
*/
declare(strict_types=1);
use Nette\Mail\DkimSigner;
use Nette\Mail\Message;
use Nette\Mail\SignException;
use Tester\Assert;
require __DIR__ . '/../bootstrap.php';
if (!extension_loaded('openssl')) {
Tester\Environment::skip('OpenSSL not installed');
}
$signer = new DkimSigner([]);
$mail = new Message;
$mail->setFrom('John Doe <doe@example.com>');
$mail->addTo('Lady Jane <jane@example.com>');
$mail->setSubject('Hello Jane!');
$mail->setBody('Příliš žluťoučký kůň');
Assert::exception(function () use ($signer, $mail) {
$signer->generateSignedMessage($mail);
}, SignException::class);

0 comments on commit 34ae057

Please sign in to comment.
You can’t perform that action at this time.