Passed
Push — master ( 82f564...b2a72e )
by John
03:51
created

Rfc5322Validator::validateDotAtom()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 18
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 18
rs 10
cc 4
nc 4
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace EmailValidator\Validator;
6
7
use EmailValidator\EmailAddress;
8
9
/**
10
 * Validates email addresses according to RFC 5322 standards
11
 *
12
 * This validator implements strict validation rules from RFC 5322 (superseding RFC 822),
13
 * including proper handling of:
14
 * - Quoted strings in local part
15
 * - Comments
16
 * - Domain literals
17
 * - Special characters
18
 * - Length restrictions
19
 */
20
class Rfc5322Validator extends AValidator
21
{
22
    // Maximum lengths defined by RFC 5322
23
    private const MAX_LOCAL_PART_LENGTH = 64;
24
    private const MAX_DOMAIN_LABEL_LENGTH = 63;
25
    private const MAX_DOMAIN_LENGTH = 255;
26
27
    // Character sets for unquoted local part
28
    private const LOCAL_PART_ALLOWED_CHARS = '!#$%&\'*+-/=?^_`{|}~.';
29
30
    /**
31
     * Validates an email address according to RFC 5322 rules
32
     *
33
     * @param EmailAddress $email The email address to validate
34
     * @return bool True if the email address is valid according to RFC 5322
35
     */
36
    public function validate(EmailAddress $email): bool
37
    {
38
        $localPart = $email->getLocalPart();
39
        $domain = $email->getDomain();
40
41
        if ($localPart === null || $domain === null) {
42
            return false;
43
        }
44
45
        return $this->validateLocalPart($localPart) && $this->validateDomain($domain);
46
    }
47
48
    /**
49
     * Validates the local part of an email address
50
     *
51
     * @param string $localPart The local part to validate
52
     * @return bool True if the local part is valid
53
     */
54
    private function validateLocalPart(string $localPart): bool
55
    {
56
        // Check length
57
        if (strlen($localPart) > self::MAX_LOCAL_PART_LENGTH) {
58
            return false;
59
        }
60
61
        // Empty local part is invalid
62
        if ($localPart === '') {
63
            return false;
64
        }
65
66
        // Handle quoted string
67
        if ($localPart[0] === '"') {
68
            return $this->validateQuotedString($localPart);
69
        }
70
71
        // Handle dot-atom format
72
        return $this->validateDotAtom($localPart);
73
    }
74
75
    /**
76
     * Validates a dot-atom format local part
77
     *
78
     * @param string $localPart The unquoted local part to validate
79
     * @return bool True if the unquoted local part is valid
80
     */
81
    private function validateDotAtom(string $localPart): bool
82
    {
83
        // Split into atoms
84
        $atoms = explode('.', $localPart);
85
86
        // Check each atom
87
        foreach ($atoms as $atom) {
88
            if ($atom === '') {
89
                return false;
90
            }
91
92
            // Check for valid characters in each atom
93
            if (!preg_match('/^[a-zA-Z0-9!#$%&\'*+\-\/=?^_`{|}~]+$/', $atom)) {
94
                return false;
95
            }
96
        }
97
98
        return true;
99
    }
100
101
    /**
102
     * Validates a quoted string local part
103
     *
104
     * @param string $localPart The quoted string to validate
105
     * @return bool True if the quoted string is valid
106
     */
107
    private function validateQuotedString(string $localPart): bool
108
    {
109
        // Must start and end with quotes
110
        if (!preg_match('/^".*"$/', $localPart)) {
111
            return false;
112
        }
113
114
        // Remove outer quotes for content validation
115
        $content = substr($localPart, 1, -1);
116
117
        // Empty quoted strings are valid
118
        if ($content === '') {
119
            return true;
120
        }
121
122
        $inEscape = false;
123
        for ($i = 0, $iMax = strlen($content); $i < $iMax; $i++) {
124
            $char = $content[$i];
125
            $charCode = ord($char);
126
127
            // Non-printable characters are never allowed
128
            if ($charCode < 32 || $charCode > 126) {
129
                return false;
130
            }
131
132
            if ($inEscape) {
133
                // Only quotes and backslashes must be escaped
134
                // Other characters may be escaped but it's not required
135
                $inEscape = false;
136
                continue;
137
            }
138
139
            if ($char === '\\') {
140
                $inEscape = true;
141
                continue;
142
            }
143
144
            // Unescaped quotes are not allowed
145
            if ($char === '"') {
146
                return false;
147
            }
148
        }
149
150
        // Can't end with a lone backslash
151
        return !$inEscape;
152
    }
153
154
    /**
155
     * Validates the domain part of an email address
156
     *
157
     * @param string $domain The domain to validate
158
     * @return bool True if the domain is valid
159
     */
160
    private function validateDomain(string $domain): bool
161
    {
162
        // Check for empty domain
163
        if ($domain === '') {
164
            return false;
165
        }
166
167
        // Check total length
168
        if (strlen($domain) > self::MAX_DOMAIN_LENGTH) {
169
            return false;
170
        }
171
172
        // Handle domain literal
173
        if ($domain[0] === '[') {
174
            return $this->validateDomainLiteral($domain);
175
        }
176
177
        // Validate regular domain
178
        return $this->validateDomainName($domain);
179
    }
180
181
    /**
182
     * Validates a domain literal (IP address in brackets)
183
     *
184
     * @param string $domain The domain literal to validate
185
     * @return bool True if the domain literal is valid
186
     */
187
    private function validateDomainLiteral(string $domain): bool
188
    {
189
        // Must be enclosed in brackets
190
        if (!preg_match('/^\[(.*)]$/', $domain, $matches)) {
191
            return false;
192
        }
193
194
        $content = $matches[1];
195
196
        // Handle IPv6
197
        if (stripos($content, 'IPv6:') === 0) {
198
            $ipv6 = substr($content, 5);
199
            // Remove any whitespace
200
            $ipv6 = trim($ipv6);
201
202
            // Handle compressed notation
203
            if (strpos($ipv6, '::') !== false) {
204
                // Only one :: allowed
205
                if (substr_count($ipv6, '::') > 1) {
206
                    return false;
207
                }
208
209
                // Split on ::
210
                $parts = explode('::', $ipv6);
211
                if (count($parts) !== 2) {
212
                    return false;
213
                }
214
215
                // Count segments on each side
216
                $leftSegments = $parts[0] ? explode(':', $parts[0]) : [];
217
                $rightSegments = $parts[1] ? explode(':', $parts[1]) : [];
218
219
                // Calculate missing segments
220
                $totalSegments = count($leftSegments) + count($rightSegments);
221
                if ($totalSegments >= 8) {
222
                    return false;
223
                }
224
225
                // Fill in missing segments
226
                $middleSegments = array_fill(0, 8 - $totalSegments, '0');
227
228
                // Combine all segments
229
                $segments = array_merge($leftSegments, $middleSegments, $rightSegments);
230
            } else {
231
                $segments = explode(':', $ipv6);
232
                if (count($segments) !== 8) {
233
                    return false;
234
                }
235
            }
236
237
            // Validate each segment
238
            foreach ($segments as $segment) {
239
                if (!preg_match('/^[0-9A-Fa-f]{1,4}$/', $segment)) {
240
                    return false;
241
                }
242
            }
243
244
            // Convert to standard format for final validation
245
            $ipv6 = implode(':', array_map(function ($segment) {
246
                return str_pad($segment, 4, '0', STR_PAD_LEFT);
247
            }, $segments));
248
249
            // Final validation using filter_var
250
            if (!filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
251
                return false;
252
            }
253
254
            return true;
255
        }
256
257
        // Handle IPv4
258
        $ipv4 = trim($content);
259
260
        // Split into octets
261
        $octets = explode('.', $ipv4);
262
        if (count($octets) !== 4) {
263
            return false;
264
        }
265
266
        // Validate each octet
267
        foreach ($octets as $octet) {
268
            // Remove leading zeros
269
            $octet = ltrim($octet, '0');
270
            if ($octet === '') {
271
                $octet = '0';
272
            }
273
274
            // Check numeric value
275
            if (!is_numeric($octet) || intval($octet) < 0 || intval($octet) > 255) {
276
                return false;
277
            }
278
        }
279
280
        // Convert to standard format for final validation
281
        $ipv4 = implode('.', array_map(function ($octet) {
282
            return ltrim($octet, '0') ?: '0';
283
        }, $octets));
284
285
        return filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
286
    }
287
288
    /**
289
     * Validates a domain name
290
     *
291
     * @param string $domain The domain name to validate
292
     * @return bool True if the domain name is valid
293
     */
294
    private function validateDomainName(string $domain): bool
295
    {
296
        // Split into labels
297
        $labels = explode('.', $domain);
298
299
        // Must have at least two labels
300
        if (count($labels) < 2) {
301
            return false;
302
        }
303
304
        // Validate each label
305
        foreach ($labels as $label) {
306
            if (!$this->validateDomainLabel($label)) {
307
                return false;
308
            }
309
        }
310
311
        return true;
312
    }
313
314
    /**
315
     * Validates a single domain label
316
     *
317
     * @param string $label The domain label to validate
318
     * @return bool True if the domain label is valid
319
     */
320
    private function validateDomainLabel(string $label): bool
321
    {
322
        // Check length
323
        if (strlen($label) > self::MAX_DOMAIN_LABEL_LENGTH || $label === '') {
324
            return false;
325
        }
326
327
        // Must start and end with alphanumeric
328
        if (!ctype_alnum($label[0]) || !ctype_alnum(substr($label, -1))) {
329
            return false;
330
        }
331
332
        // Check for valid characters (alphanumeric and hyphen)
333
        if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/', $label)) {
334
            return false;
335
        }
336
337
        // Check for consecutive hyphens
338
        if (strpos($label, '--') !== false) {
339
            return false;
340
        }
341
342
        return true;
343
    }
344
}
345