Skip to content

Commit b95d566

Browse files
authored
Merge pull request #275 from mark-dropbear/upgrade-punycoder
Upgrade punycoder to v0.3.0 and refactor IDNA/Address handling
2 parents 8c717c4 + d52ae67 commit b95d566

4 files changed

Lines changed: 32 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 7.2.0
2+
* Upgrade `punycoder` to v0.3.0.
3+
* Refactor IDNA module to use `domainToAscii` and `domainToUnicode` from `punycoder`.
4+
* Refactor `Address.encodedAddress` to use `emailToAscii` from `punycoder`.
5+
* Maintain `IdnaException` for backward compatibility by wrapping `punycoder`'s `FormatException`.
6+
17
## 7.1.0
28
* allow mime 2.0.0 and intl up to 0.21.0
39
Thanks https://github.com/karelklic

lib/src/core/address.dart

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import '../idna/idna.dart';
1+
import 'package:punycoder/punycoder.dart';
2+
import 'package:unorm_dart/unorm_dart.dart' as unorm;
23

34
final _quotableNameRegExp = RegExp(r'[",]');
45

@@ -26,21 +27,19 @@ class Address {
2627
///
2728
/// This properly handles internationalized domain names by:
2829
/// 1. Normalizing Unicode to NFC
29-
/// 2. Converting to lowercase
30-
/// 3. Encoding non-ASCII labels with Punycode (xn-- prefix)
30+
/// 2. Converting the domain to Punycode using [emailToAscii]
3131
///
32-
/// The local-part (before @) is not modified, as SMTP does not support
33-
/// UTF-8 in local-parts without the SMTPUTF8 extension.
32+
/// The local-part (before @) is not modified by [emailToAscii].
3433
String get encodedAddress {
3534
try {
36-
final lastAt = mailAddress.lastIndexOf('@');
37-
if (lastAt == -1) {
35+
if (!mailAddress.contains('@')) {
3836
return mailAddress;
3937
}
40-
final localPart = mailAddress.substring(0, lastAt);
41-
final domain = mailAddress.substring(lastAt + 1);
42-
final encodedDomain = idnaEncode(domain);
43-
return '$localPart@$encodedDomain';
38+
39+
// Normalize to NFC before encoding
40+
final normalized = unorm.nfc(mailAddress);
41+
42+
return emailToAscii(normalized);
4443
} catch (_) {
4544
return mailAddress;
4645
}

lib/src/idna/idna.dart

Lines changed: 14 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,13 @@ class IdnaException implements Exception {
3434
String toString() => 'IdnaException: $message';
3535
}
3636

37-
/// The Punycode codec instance used for encoding/decoding.
38-
const _punycodeCodec = PunycodeCodec();
39-
4037
/// Encodes a domain name to its ASCII-compatible (Punycode) form.
4138
///
4239
/// This function:
43-
/// 1. Splits the domain into labels (parts between dots)
44-
/// 2. Normalizes each label to NFC (Normalization Form Canonical Composition)
45-
/// 3. Performs case folding (converts to lowercase)
46-
/// 4. Applies Punycode encoding with 'xn--' prefix for non-ASCII labels
47-
/// 5. Validates label lengths (≤63 characters)
40+
/// 1. Normalizes the domain to NFC
41+
/// 2. Applies IDNA encoding using [domainToAscii]
4842
///
49-
/// Throws [IdnaException] if:
50-
/// - A label exceeds 63 characters after encoding
51-
/// - The domain exceeds 253 characters
52-
/// - A label is empty
53-
/// - The Punycode encoding fails
43+
/// Throws [IdnaException] if encoding or validation fails.
5444
///
5545
/// Example:
5646
/// ```dart
@@ -63,68 +53,21 @@ String idnaEncode(String domain) {
6353
return domain;
6454
}
6555

66-
final labels = domain.split('.');
67-
final encodedLabels = <String>[];
68-
69-
for (final label in labels) {
70-
final encoded = _encodeLabel(label);
71-
encodedLabels.add(encoded);
72-
}
73-
74-
final result = encodedLabels.join('.');
75-
76-
// Validate total domain length
77-
if (result.length > maxDomainLength) {
78-
throw IdnaException('Encoded domain exceeds maximum length of $maxDomainLength characters: '
79-
'${result.length} characters');
80-
}
81-
82-
return result;
83-
}
84-
85-
/// Encodes a single domain label using IDNA rules.
86-
String _encodeLabel(String label) {
87-
if (label.isEmpty) {
88-
// Empty labels can occur with trailing dots (e.g., "example.com.")
89-
// Return as-is to preserve the structure
90-
return label;
91-
}
92-
93-
// 1. Normalize to NFC (Normalization Form Canonical Composition)
94-
// This ensures that characters like 'ü' (U+00FC) and 'u' + '̈' (U+0308)
95-
// are treated identically.
96-
String normalized = unorm.nfc(label);
56+
// Normalize to NFC (Normalization Form Canonical Composition)
57+
// punycoder expects input to be normalized.
58+
final normalized = unorm.nfc(domain);
9759

98-
// 2. Case folding: convert to lowercase
99-
// Domain names are case-insensitive per DNS specifications.
100-
normalized = normalized.toLowerCase();
101-
102-
// 3. Encode using PunycodeCodec
103-
// The codec automatically:
104-
// - Returns unchanged if already ASCII
105-
// - Adds 'xn--' prefix for non-ASCII labels
10660
try {
107-
final encoded = _punycodeCodec.encode(normalized);
108-
109-
// 4. Validate encoded label length
110-
if (encoded.length > maxLabelLength) {
111-
throw IdnaException('Encoded label exceeds maximum length of $maxLabelLength characters: '
112-
'"$encoded" (${encoded.length} characters)');
113-
}
114-
115-
return encoded;
116-
} catch (e) {
117-
if (e is IdnaException) rethrow;
118-
throw IdnaException('Failed to encode label "$label": $e');
61+
// Use domainToAscii which handles splitting, lowercase, prefix and validation
62+
return domainToAscii(normalized);
63+
} on FormatException catch (e) {
64+
throw IdnaException(e.message);
11965
}
12066
}
12167

12268
/// Decodes a Punycode-encoded domain name back to Unicode.
12369
///
124-
/// This function:
125-
/// 1. Splits the domain into labels
126-
/// 2. Detects 'xn--' prefixed labels and decodes them
127-
/// 3. Returns the decoded Unicode domain
70+
/// This function uses [domainToUnicode] to handle 'xn--' prefixed labels.
12871
///
12972
/// Throws [IdnaException] if Punycode decoding fails.
13073
///
@@ -137,28 +80,10 @@ String idnaDecode(String domain) {
13780
return domain;
13881
}
13982

140-
final labels = domain.split('.');
141-
final decodedLabels = <String>[];
142-
143-
for (final label in labels) {
144-
final decoded = _decodeLabel(label);
145-
decodedLabels.add(decoded);
146-
}
147-
148-
return decodedLabels.join('.');
149-
}
150-
151-
/// Decodes a single Punycode label.
152-
String _decodeLabel(String label) {
153-
if (label.isEmpty) {
154-
return label;
155-
}
156-
157-
// PunycodeCodec.decode handles 'xn--' prefixed labels automatically
15883
try {
159-
return _punycodeCodec.decode(label);
160-
} catch (e) {
161-
throw IdnaException('Failed to decode Punycode label "$label": $e');
84+
return domainToUnicode(domain);
85+
} on FormatException catch (e) {
86+
throw IdnaException(e.message);
16287
}
16388
}
16489

pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: mailer
2-
version: 7.1.0
2+
version: 7.2.0
33
description: >
44
Compose and send emails from Dart.
55
Supports file attachments and HTML emails.
@@ -17,7 +17,7 @@ dependencies:
1717
mime: '>=1.0.5 <3.0.0'
1818
path: '^1.8.3'
1919
meta: '^1.11.0'
20-
punycoder: '^0.2.2'
20+
punycoder: '^0.3.0'
2121
unorm_dart: '^0.3.2'
2222

2323
dev_dependencies:

0 commit comments

Comments
 (0)