-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathParseOptions.php
More file actions
477 lines (429 loc) · 18.7 KB
/
ParseOptions.php
File metadata and controls
477 lines (429 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
<?php
namespace Email;
class ParseOptions
{
/** @var array<string, bool> */
private array $bannedChars = [];
/** @var array<string, bool> */
private array $separators = [];
private bool $useWhitespaceAsSeparator;
private LengthLimits $lengthLimits;
/**
* Construct a parser configuration.
*
* The first four positional parameters preserve the v2.x / v3.0 signature for
* backward compatibility. The 15 rule properties following them are readonly
* (PHP 8.1) — mutate via the `withX()` fluent builders, which return new
* instances with the change applied.
*
* Default values match legacy (v2.x) parser behavior so `new ParseOptions()`
* preserves existing call sites.
*
* @param array<string> $bannedChars
* @param array<string> $separators
* @param LengthLimits|null $lengthLimits Email length limits; RFC defaults when null.
*
* @param bool $allowUtf8LocalPart Allow UTF-8 in local-part (RFC 6531 §3.3, 6532 §3.2).
* @param bool $allowObsLocalPart Allow obs-local-part (RFC 5322 §4.4): leading/trailing/consecutive dots.
* @param bool $allowQuotedString Allow quoted-string local-part (RFC 5322 §3.2.4, 5321 §4.1.2).
* @param bool $validateQuotedContent Validate qtext/quoted-pair rules in quoted strings.
* @param bool $rejectEmptyQuotedLocalPart Reject `""@domain` (RFC 5321 EID 5414).
* @param bool $allowUtf8Domain Allow U-label domains (RFC 6531 §3.3, 5890/5891).
* @param bool $allowDomainLiteral Allow `[IP]` / `[IPv6:addr]` (RFC 5321 §4.1.3).
* @param bool $requireFqdn Require fully-qualified domain name (RFC 5321 §2.3.5).
* @param bool $validateIpGlobalRange Validate IP literals are in the global range.
* @param bool $rejectC0Controls Reject C0 control chars U+0000-U+001F (RFC 5321 §4.1.2).
* @param bool $rejectC1Controls Reject C1 control chars U+0080-U+009F (RFC 6530 §10.1, 6532 §3.2).
* @param bool $applyNfcNormalization Apply NFC Unicode normalization (RFC 6532 §3.1).
* @param bool $enforceLengthLimits Enforce RFC 5321 §4.5.3.1 length limits.
* @param bool $includeDomainAscii Emit punycode domain in output.
* @param bool $validateDisplayNamePhrase Enforce RFC 5322 §3.2.5 phrase syntax for unquoted display names (atext + WSP only).
* @param bool $strictIdna Apply full IDNA2008 conformance on U-label domains (CONTEXTJ/O, Bidi rule, STD3, nontransitional mapping).
* @param bool $allowObsRoute Accept RFC 5322 §4.4 obs-route source-route prefix inside angle-addr (e.g. `<@host1,@host2:user@host3>`); the route is captured and the real addr-spec is used ("accept and discard" per spec).
* @param ?\Closure $localPartNormalizer Optional callback `fn(string $localPart, string $domain): string` invoked after local-part validation succeeds. The returned string replaces `local_part_parsed` in the output (and is re-quoted if needed). Typical uses: Gmail dot-insensitivity, `+tag` plus-addressing.
*/
public function __construct(
array $bannedChars = [],
array $separators = [','],
bool $useWhitespaceAsSeparator = true,
?LengthLimits $lengthLimits = null,
public readonly bool $allowUtf8LocalPart = true,
public readonly bool $allowObsLocalPart = false,
public readonly bool $allowQuotedString = true,
public readonly bool $validateQuotedContent = false,
public readonly bool $rejectEmptyQuotedLocalPart = false,
public readonly bool $allowUtf8Domain = true,
public readonly bool $allowDomainLiteral = true,
public readonly bool $requireFqdn = false,
public readonly bool $validateIpGlobalRange = true,
public readonly bool $rejectC0Controls = false,
public readonly bool $rejectC1Controls = false,
public readonly bool $applyNfcNormalization = false,
public readonly bool $enforceLengthLimits = true,
public readonly bool $includeDomainAscii = false,
public readonly bool $validateDisplayNamePhrase = false,
public readonly bool $strictIdna = false,
public readonly bool $allowObsRoute = false,
public readonly ?\Closure $localPartNormalizer = null,
) {
foreach ($bannedChars as $char) {
$this->bannedChars[$char] = true;
}
foreach ($separators as $sep) {
$this->separators[$sep] = true;
}
$this->useWhitespaceAsSeparator = $useWhitespaceAsSeparator;
$this->lengthLimits = $lengthLimits ?? LengthLimits::createDefault();
}
// ===== RFC Preset Factory Methods =====
/**
* RFC 5321 Mailbox — strict ASCII-only, matching what SMTP servers must accept.
*
* Follows RFC 5321 §4.1.2 (Local-part), §4.1.3 (domain literals),
* §4.5.3.1 (length limits), and §2.3.5 (FQDN). No obs-local-part, no UTF-8.
*/
public static function rfc5321(): self
{
return new self(
allowUtf8LocalPart: false,
allowObsLocalPart: false,
allowQuotedString: true,
validateQuotedContent: true,
rejectEmptyQuotedLocalPart: true,
allowUtf8Domain: false,
allowDomainLiteral: true,
requireFqdn: true,
validateIpGlobalRange: true,
rejectC0Controls: true,
rejectC1Controls: false,
applyNfcNormalization: false,
enforceLengthLimits: true,
includeDomainAscii: false,
);
}
/**
* RFC 6531/6532 — full internationalized email (EAI), strictest validation.
*
* Extends RFC 5321 Mailbox per RFC 6531 §3.3 and RFC 6532 §3 (UTF-8 in
* addr-spec and headers). Adds NFC normalization (RFC 6532 §3.1),
* C1-control rejection (RFC 6530 §10.1), and punycode output for IDNs.
*/
public static function rfc6531(): self
{
return new self(
allowUtf8LocalPart: true,
allowObsLocalPart: false,
allowQuotedString: true,
validateQuotedContent: true,
rejectEmptyQuotedLocalPart: true,
allowUtf8Domain: true,
allowDomainLiteral: true,
requireFqdn: true,
validateIpGlobalRange: true,
rejectC0Controls: true,
rejectC1Controls: true,
applyNfcNormalization: true,
enforceLengthLimits: true,
includeDomainAscii: true,
strictIdna: true,
);
}
/**
* RFC 5322 addr-spec — recommended default for new code.
*
* Follows RFC 5322 §3.4.1 including obs-local-part (§4.4): permissive dot
* placement. Generators MUST NOT produce obs-local-part, but parsers MUST
* accept it. ASCII only; no UTF-8 in local-part or domain.
*/
public static function rfc5322(): self
{
return new self(
allowUtf8LocalPart: false,
allowObsLocalPart: true,
allowQuotedString: true,
validateQuotedContent: false,
rejectEmptyQuotedLocalPart: false,
allowUtf8Domain: false,
allowDomainLiteral: true,
requireFqdn: false,
validateIpGlobalRange: true,
rejectC0Controls: true,
rejectC1Controls: false,
applyNfcNormalization: false,
enforceLengthLimits: true,
includeDomainAscii: false,
allowObsRoute: true,
);
}
/**
* RFC 2822 — maximum compatibility with older software.
*
* Like rfc5322() but also permits C0 controls, which were not explicitly
* prohibited by RFC 2822. Use only when accepting addresses from very old
* or non-conforming systems.
*/
public static function rfc2822(): self
{
return new self(
allowUtf8LocalPart: false,
allowObsLocalPart: true,
allowQuotedString: true,
validateQuotedContent: false,
rejectEmptyQuotedLocalPart: false,
allowUtf8Domain: false,
allowDomainLiteral: true,
requireFqdn: false,
validateIpGlobalRange: true,
rejectC0Controls: false,
rejectC1Controls: false,
applyNfcNormalization: false,
enforceLengthLimits: true,
includeDomainAscii: false,
allowObsRoute: true,
);
}
// ===== Fluent builders =====
//
// The readonly rule properties cannot be reassigned. Each `withX()` method
// returns a new ParseOptions instance with the single field replaced and
// every other field preserved. The four non-readonly state fields
// (bannedChars, separators, useWhitespaceAsSeparator, lengthLimits) also
// have `withX()` builders for symmetry; they will become readonly in v4.0.
/** @param array<string> $bannedChars */
public function withBannedChars(array $bannedChars): self
{
return $this->cloneWith(['bannedChars' => $bannedChars]);
}
/** @param array<string> $separators */
public function withSeparators(array $separators): self
{
return $this->cloneWith(['separators' => $separators]);
}
public function withUseWhitespaceAsSeparator(bool $value): self
{
return $this->cloneWith(['useWhitespaceAsSeparator' => $value]);
}
public function withLengthLimits(LengthLimits $limits): self
{
return $this->cloneWith(['lengthLimits' => $limits]);
}
public function withAllowUtf8LocalPart(bool $value): self
{
return $this->cloneWith(['allowUtf8LocalPart' => $value]);
}
public function withAllowObsLocalPart(bool $value): self
{
return $this->cloneWith(['allowObsLocalPart' => $value]);
}
public function withAllowQuotedString(bool $value): self
{
return $this->cloneWith(['allowQuotedString' => $value]);
}
public function withValidateQuotedContent(bool $value): self
{
return $this->cloneWith(['validateQuotedContent' => $value]);
}
public function withRejectEmptyQuotedLocalPart(bool $value): self
{
return $this->cloneWith(['rejectEmptyQuotedLocalPart' => $value]);
}
public function withAllowUtf8Domain(bool $value): self
{
return $this->cloneWith(['allowUtf8Domain' => $value]);
}
public function withAllowDomainLiteral(bool $value): self
{
return $this->cloneWith(['allowDomainLiteral' => $value]);
}
public function withRequireFqdn(bool $value): self
{
return $this->cloneWith(['requireFqdn' => $value]);
}
public function withValidateIpGlobalRange(bool $value): self
{
return $this->cloneWith(['validateIpGlobalRange' => $value]);
}
public function withRejectC0Controls(bool $value): self
{
return $this->cloneWith(['rejectC0Controls' => $value]);
}
public function withRejectC1Controls(bool $value): self
{
return $this->cloneWith(['rejectC1Controls' => $value]);
}
public function withApplyNfcNormalization(bool $value): self
{
return $this->cloneWith(['applyNfcNormalization' => $value]);
}
public function withEnforceLengthLimits(bool $value): self
{
return $this->cloneWith(['enforceLengthLimits' => $value]);
}
public function withIncludeDomainAscii(bool $value): self
{
return $this->cloneWith(['includeDomainAscii' => $value]);
}
public function withValidateDisplayNamePhrase(bool $value): self
{
return $this->cloneWith(['validateDisplayNamePhrase' => $value]);
}
public function withStrictIdna(bool $value): self
{
return $this->cloneWith(['strictIdna' => $value]);
}
public function withAllowObsRoute(bool $value): self
{
return $this->cloneWith(['allowObsRoute' => $value]);
}
/**
* Supply a local-part normalizer callback, or `null` to clear any current one.
*
* The callback is invoked after local-part validation succeeds with
* `fn(string $localPart, string $domain): string`. Its return value
* replaces `local_part_parsed` in the output — typical uses are Gmail
* dot-insensitivity (`john.doe` → `johndoe`) and plus-addressing
* (`user+tag` → `user`), typically gated on the domain.
*
* $opts = ParseOptions::rfc5322()->withLocalPartNormalizer(
* fn(string $local, string $domain): string =>
* $domain === 'gmail.com'
* ? strtolower(strstr(str_replace('.', '', $local), '+', true) ?: str_replace('.', '', $local))
* : $local,
* );
*/
public function withLocalPartNormalizer(?callable $normalizer): self
{
return $this->cloneWith([
'localPartNormalizer' => $normalizer === null ? null : \Closure::fromCallable($normalizer),
]);
}
/**
* Build a new ParseOptions preserving every current value except those
* listed in $overrides.
*
* @param array<string, mixed> $overrides
*/
private function cloneWith(array $overrides): self
{
$get = fn (string $name, mixed $default): mixed => $overrides[$name] ?? $default;
return new self(
bannedChars: $get('bannedChars', array_keys($this->bannedChars)),
separators: $get('separators', array_keys($this->separators)),
useWhitespaceAsSeparator: $get('useWhitespaceAsSeparator', $this->useWhitespaceAsSeparator),
lengthLimits: $get('lengthLimits', $this->lengthLimits),
allowUtf8LocalPart: $get('allowUtf8LocalPart', $this->allowUtf8LocalPart),
allowObsLocalPart: $get('allowObsLocalPart', $this->allowObsLocalPart),
allowQuotedString: $get('allowQuotedString', $this->allowQuotedString),
validateQuotedContent: $get('validateQuotedContent', $this->validateQuotedContent),
rejectEmptyQuotedLocalPart: $get('rejectEmptyQuotedLocalPart', $this->rejectEmptyQuotedLocalPart),
allowUtf8Domain: $get('allowUtf8Domain', $this->allowUtf8Domain),
allowDomainLiteral: $get('allowDomainLiteral', $this->allowDomainLiteral),
requireFqdn: $get('requireFqdn', $this->requireFqdn),
validateIpGlobalRange: $get('validateIpGlobalRange', $this->validateIpGlobalRange),
rejectC0Controls: $get('rejectC0Controls', $this->rejectC0Controls),
rejectC1Controls: $get('rejectC1Controls', $this->rejectC1Controls),
applyNfcNormalization: $get('applyNfcNormalization', $this->applyNfcNormalization),
enforceLengthLimits: $get('enforceLengthLimits', $this->enforceLengthLimits),
includeDomainAscii: $get('includeDomainAscii', $this->includeDomainAscii),
validateDisplayNamePhrase: $get('validateDisplayNamePhrase', $this->validateDisplayNamePhrase),
strictIdna: $get('strictIdna', $this->strictIdna),
allowObsRoute: $get('allowObsRoute', $this->allowObsRoute),
localPartNormalizer: array_key_exists('localPartNormalizer', $overrides)
? $overrides['localPartNormalizer']
: $this->localPartNormalizer,
);
}
// ===== Legacy deprecated setters =====
//
// These remain as mutating setters for the four non-readonly state fields
// only. They continue to work for v2.x callers; they will be removed in v4.0.
/**
* @deprecated v3.0 — Use constructor param or withBannedChars(). Removed in v4.0.
* @param array<string> $bannedChars
*/
public function setBannedChars(array $bannedChars): void
{
$this->bannedChars = [];
foreach ($bannedChars as $char) {
$this->bannedChars[$char] = true;
}
}
/** @return array<string, bool> */
public function getBannedChars(): array
{
return $this->bannedChars;
}
/**
* @deprecated v3.0 — Use constructor param or withSeparators(). Removed in v4.0.
* @param array<string> $separators
*/
public function setSeparators(array $separators): void
{
$this->separators = [];
foreach ($separators as $sep) {
$this->separators[$sep] = true;
}
}
/** @return array<string, bool> */
public function getSeparators(): array
{
return $this->separators;
}
/** @deprecated v3.0 — Use constructor param or withUseWhitespaceAsSeparator(). Removed in v4.0. */
public function setUseWhitespaceAsSeparator(bool $value): void
{
$this->useWhitespaceAsSeparator = $value;
}
public function getUseWhitespaceAsSeparator(): bool
{
return $this->useWhitespaceAsSeparator;
}
/** @deprecated v3.0 — Use constructor param or withLengthLimits(). Removed in v4.0. */
public function setLengthLimits(LengthLimits $limits): void
{
$this->lengthLimits = $limits;
}
public function getLengthLimits(): LengthLimits
{
return $this->lengthLimits;
}
/** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */
public function setMaxLocalPartLength(int $value): void
{
$this->lengthLimits = new LengthLimits(
$value,
$this->lengthLimits->maxTotalLength,
$this->lengthLimits->maxDomainLabelLength,
);
}
public function getMaxLocalPartLength(): int
{
return $this->lengthLimits->maxLocalPartLength;
}
/** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */
public function setMaxTotalLength(int $value): void
{
$this->lengthLimits = new LengthLimits(
$this->lengthLimits->maxLocalPartLength,
$value,
$this->lengthLimits->maxDomainLabelLength,
);
}
public function getMaxTotalLength(): int
{
return $this->lengthLimits->maxTotalLength;
}
/** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */
public function setMaxDomainLabelLength(int $value): void
{
$this->lengthLimits = new LengthLimits(
$this->lengthLimits->maxLocalPartLength,
$this->lengthLimits->maxTotalLength,
$value,
);
}
public function getMaxDomainLabelLength(): int
{
return $this->lengthLimits->maxDomainLabelLength;
}
}