Completed
Push — master ( b0caa8...6ea842 )
by Lars
14:17 queued 12s
created

EmailCheck::getMailParts()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 8

Importance

Changes 0
Metric Value
dl 0
loc 46
ccs 17
cts 17
cp 1
rs 7.9337
c 0
b 0
f 0
cc 8
nc 6
nop 1
crap 8
1
<?php
2
3
declare(strict_types=1);
4
5
namespace voku\helper;
6
7
/**
8
 * E-Mail Check Class
9
 *
10
 * -> use "EmailCheck::isValid()" to validate a email-address
11
 *
12
 * @copyright   Copyright (c) 2018, Lars Moelleken (http://moelleken.org/)
13
 * @license     http://opensource.org/licenses/MIT	MIT License
14
 */
15
class EmailCheck
16
{
17
    /**
18
     * @var array|null
19
     */
20
    protected static $domainsExample = null;
21
22
    /**
23
     * @var array|null
24
     */
25
    protected static $domainsTemporary = null;
26
27
    /**
28
     * @var array|null
29
     */
30
    protected static $domainsTypo = null;
31
32
    /**
33
     * @var bool
34
     */
35
    protected static $useDnsCheck = true;
36
37
    /**
38
     * Check if the domain has a MX- or A-record in the DNS.
39
     *
40
     * @param string $domain
41
     *
42
     * @throws \Exception
43
     *
44
     * @return bool
45
     */
46 4
    public static function isDnsError(string $domain): bool
47
    {
48 4
        if (\function_exists('checkdnsrr')) {
49
            /** @noinspection IfReturnReturnSimplificationInspection */
50 4
            $mxFound = \checkdnsrr($domain . '.', 'MX');
51 4
            if ($mxFound === true) {
52 1
                return false;
53
            }
54
55 4
            $aFound = \checkdnsrr($domain . '.', 'A');
56
            /** @noinspection IfReturnReturnSimplificationInspection */
57 4
            if ($aFound === true) {
58
                return false;
59
            }
60
61 4
            return true;
62
        }
63
64
        throw new \Exception(' Can\'t call checkdnsrr');
65
    }
66
67
    /**
68
     * Check if the domain is a example domain.
69
     *
70
     * @param string $domain
71
     *
72
     * @return bool
73
     */
74 2 View Code Duplication
    public static function isExampleDomain(string $domain): bool
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
75
    {
76 2
        if (self::$domainsExample === null) {
77 1
            self::$domainsExample = self::getData('domainsExample');
78
        }
79
80 2
        if (\in_array($domain, self::$domainsExample, true)) {
81 2
            return true;
82
        }
83
84 2
        return false;
85
    }
86
87
    /**
88
     * Check if the domain is a temporary domain.
89
     *
90
     * @param string $domain
91
     *
92
     * @return bool
93
     */
94 2
    public static function isTemporaryDomain(string $domain): bool
95
    {
96 2
        if (self::$domainsTemporary === null) {
97 1
            self::$domainsTemporary = self::getData('domainsTemporary');
98
        }
99
100 2
        if (\in_array($domain, self::$domainsTemporary, true)) {
101 1
            return true;
102
        }
103
104 2
        if (\preg_match('/.*\.(?:tk|ml|ga|cf|gq)$/si', $domain)) {
105 1
            return true;
106
        }
107
108 2
        return false;
109
    }
110
111
    /**
112
     * Check if the domain has a typo.
113
     *
114
     * @param string $domain
115
     *
116
     * @return bool
117
     */
118 3 View Code Duplication
    public static function isTypoInDomain(string $domain): bool
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
119
    {
120 3
        if (self::$domainsTypo === null) {
121 1
            self::$domainsTypo = self::getData('domainsTypo');
122
        }
123
124 3
        if (\in_array($domain, self::$domainsTypo, true)) {
125 2
            return true;
126
        }
127
128 3
        return false;
129
    }
130
131
    /**
132
     * @param string $email
133
     *
134
     * @return false|array{local: string, domain: string}
0 ignored issues
show
Documentation introduced by
The doc-type false|array{local: could not be parsed: Unknown type name "array{local:" at position 6. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
135
     */
136
    public static function getMailParts(string $email)
137
    {
138
        if ($email === '') {
139
            return false;
140
        }
141
142 151
        $email = \str_replace(
143
            [
144 151
                '.', // non-Latin chars are also allowed | https://tools.ietf.org/html/rfc6530
145 2
                '@', // non-Latin chars are also allowed | https://tools.ietf.org/html/rfc6530
146
            ],
147
            [
148
                '.',
149 150
                '@',
150
            ],
151 150
            $email
152
        );
153 150
154
        if (
155 3
            (\strpos($email, '@') === false) // "at" is needed
156
            ||
157 149
            (\strpos($email, '.') === false && \strpos($email, ':') === false) // "dot" or "colon" is needed
158
        ) {
159 149
            return false;
160
        }
161 149
162
        if (!\preg_match('/^(?<local>.*<?)(?:.*)@(?<domain>.*)(?:>?)$/', $email, $parts)) {
163
            return false;
164
        }
165 149
166
        $local = $parts['local'];
167
        $domain = $parts['domain'];
168 149
169
        if (!$local) {
170
            return false;
171
        }
172 149
173
        if (!$domain) {
174 149
            return false;
175
        }
176 24
177
        return [
178
            'local' => $local,
179 128
            'domain' => $domain,
180 9
        ];
181
    }
182
183 119
    /**
184 119
     * Check if the email is valid.
185
     *
186 119
     * @param string $email
187 2
     * @param bool   $useExampleDomainCheck
188
     * @param bool   $useTypoInDomainCheck
189
     * @param bool   $useTemporaryDomainCheck
190 118
     * @param bool   $useDnsCheck             (do not use, if you don't need it)
191
     *
192
     * @return bool
193
     */
194
    public static function isValid(string $email, bool $useExampleDomainCheck = false, bool $useTypoInDomainCheck = false, bool $useTemporaryDomainCheck = false, bool $useDnsCheck = false): bool
195 118
    {
196
        if (!isset($email[0])) {
197
            return false;
198 118
        }
199 118
200 17
        // make sure string length is limited to avoid DOS attacks
201 17
        $emailStringLength = \strlen($email);
202 17
        if (
203 17
            $emailStringLength >= 320
204 17
            ||
205 17
            $emailStringLength <= 2 // i@y //
206
        ) {
207 17
            return false;
208
        }
209
        unset($emailStringLength);
210
211
        $parts = self::getMailParts($email);
212 118
        if ($parts === false) {
213
            return false;
214 118
        }
215
216 13
        $local = $parts['local'];
217
        $domain = $parts['domain'];
218
219 106
        // Escaped spaces are allowed in the "local"-part.
220
        $local = \str_replace('\\ ', '', $local);
221 106
222 17
        // Spaces in quotes e.g. "firstname lastname"@foo.bar are also allowed in the "local"-part.
223
        $quoteHelperForIdn = false;
224
        if (\preg_match('/^"(?<inner>[^"]*)"$/mU', $local, $parts)) {
225 106
            $quoteHelperForIdn = true;
226
            $local = \trim(
227 106
                \str_replace(
228 63
                    $parts['inner'],
229
                    \str_replace(' ', '', $parts['inner']),
230
                    $local
231 44
                ),
232 2
                '"'
233
            );
234
        }
235 44
236 1
        if (
237
            \strpos($local, ' ') !== false // no spaces allowed, anymore
238
            ||
239 44
            \strpos($local, '".') !== false // no quote + dot allowed
240
        ) {
241
            return false;
242
        }
243 44
244 3
        list($local, $domain) = self::punnycode($local, $domain);
245
246
        if ($quoteHelperForIdn === true) {
247 41
            $local = '"' . $local . '"';
248
        }
249
250
        $email = $local . '@' . $domain;
251
252
        if (!\filter_var($email, \FILTER_VALIDATE_EMAIL)) {
253
            return false;
254
        }
255
256
        if ($useExampleDomainCheck === true && self::isExampleDomain($domain) === true) {
257 1
            return false;
258
        }
259 1
260 1
        if ($useTypoInDomainCheck === true && self::isTypoInDomain($domain) === true) {
261
            return false;
262 1
        }
263
264
        if ($useTemporaryDomainCheck === true && self::isTemporaryDomain($domain) === true) {
265
            return false;
266
        }
267
268
        if ($useDnsCheck === true && self::isDnsError($domain) === true) {
269
            return false;
270
        }
271
272
        return true;
273
    }
274 106
275
    /**
276
     * get data from "/data/*.php"
277
     *
278
     * @param string $file
279
     *
280
     * @return array|bool|int|string <p>Will return false on error.</p>
281
     */
282
    protected static function getData(string $file)
283 106
    {
284
        $file = __DIR__ . '/data/' . $file . '.php';
285 106
        if (\file_exists($file)) {
286
            /** @noinspection PhpIncludeInspection */
287
            return require $file;
288
        }
289
290 106
        return false;
291
    }
292 106
293
    /**
294
     * @param string $local
295
     * @param string $domain
296
     *
297 106
     * @return array
298 99
     */
299
    private static function punnycode(string $local, string $domain): array
300 106
    {
301
302 106
        // https://git.ispconfig.org/ispconfig/ispconfig3/blob/master/interface/lib/classes/functions.inc.php#L305
303
        if (
304 106
            \defined('IDNA_NONTRANSITIONAL_TO_ASCII')
305
            &&
306
            \defined('INTL_IDNA_VARIANT_UTS46')
307
            &&
308
            \constant('IDNA_NONTRANSITIONAL_TO_ASCII')
309 106
        ) {
310 102
            $useIdnaUts46 = true;
311
        } else {
312 106
            $useIdnaUts46 = false;
313
        }
314 106
315
        if ($useIdnaUts46 === true) {
316
            /** @noinspection PhpComposerExtensionStubsInspection */
317
            $localTmp = \idn_to_ascii($local, \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
318
        } else {
319
            /** @noinspection PhpComposerExtensionStubsInspection */
320
            $localTmp = \idn_to_ascii($local);
321
        }
322
        if ($localTmp) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $localTmp of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
323
            $local = $localTmp;
324
        }
325
        unset($localTmp);
326
327
        if ($useIdnaUts46 === true) {
328
            /** @noinspection PhpComposerExtensionStubsInspection */
329
            $domainTmp = \idn_to_ascii($domain, \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
330
        } else {
331
            /** @noinspection PhpComposerExtensionStubsInspection */
332
            $domainTmp = \idn_to_ascii($domain);
333
        }
334
        if ($domainTmp) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $domainTmp of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
335
            $domain = $domainTmp;
336
        }
337
        unset($domainTmp);
338
339
        return [$local, $domain];
340
    }
341
}
342