canonical-emails 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e8ad63767c830088daf91f1b1b442a4c2bddc209a4ed720a1b6e493c0b038f55
4
+ data.tar.gz: ac6f3c6327e206c0f7313c87fcb78a9d74176d42c7042e933ad837ed2abd4a82
5
+ SHA512:
6
+ metadata.gz: 68b08b165db5f62e1aa2e0c227013279047991cb08cfc607219e3029614423e862e2ae4ffd10c541e9f92df37feb8d4b05ee194d6501f143b6d72eb584b00710
7
+ data.tar.gz: f1c8199c48581cfbfe6b8b79da5d1784ec6cf8afd7918b9e736f07c74c2520fa8c4c72553ec69a294e5e559cd8fd995ee09ce33022a80fc6fdebff36e1ae2c4a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
- ### 0.1.0 (6/22/2013)
1
+ ### 0.2.0 (2026-10-20)
2
+
3
+ * [#15](https://github.com/dblock/canonical-emails/pull/15): Cache canonical model accessors by raw field value and remove `address=` from all canonical address types - [@dblock](https://github.com/dblock).
4
+ * [#14](https://github.com/dblock/canonical-emails/pull/14): Add `as:` for custom canonical email accessor names and optional original-reader overrides - [@dblock](https://github.com/dblock).
5
+ * [#13](https://github.com/dblock/canonical-emails/pull/13): Document and test independent canonical accessors for multiple email fields - [@dblock](https://github.com/dblock).
6
+ * [#12](https://github.com/dblock/canonical-emails/pull/12): Add explicit, case-insensitive provider registration, registry clearing, and an unregistered `TaggedGMail` type that retains plus-tags - [@dblock](https://github.com/dblock).
7
+ * [#11](https://github.com/dblock/canonical-emails/pull/11): Introduce breaking provider-aware `.parse` and `type:` APIs, replace nested provider address classes and transform pipelines, and enforce provider restrictions on address reassignment; see UPGRADING.md - [@dblock](https://github.com/dblock).
8
+ * [#10](https://github.com/dblock/canonical-emails/pull/10): Refactor provider transformations into a shared address subclass with opt-in behavior mixins and document migration in UPGRADING.md - [@dblock](https://github.com/dblock).
9
+ * [#6](https://github.com/dblock/canonical-emails/pull/6): Add release and agent guides and prepare version 0.2.0 - [@dblock](https://github.com/dblock).
10
+ * [#8](https://github.com/dblock/canonical-emails/pull/8): Add Danger checks with danger-changelog - [@dblock](https://github.com/dblock).
11
+ * [#7](https://github.com/dblock/canonical-emails/pull/7): Add line and branch coverage reporting with SimpleCov and Coveralls - [@dblock](https://github.com/dblock).
12
+ * [#5](https://github.com/dblock/canonical-emails/pull/5): Canonicalize Gmail plus-address tags and expose the original `username` and `tag` through `CanonicalEmails::GMail::Address`, adapted from [#3](https://github.com/dblock/canonical-emails/pull/3) - [@jdpopkin](https://github.com/jdpopkin).
13
+ * [#4](https://github.com/dblock/canonical-emails/pull/4): Replace Travis CI with GitHub Actions for Ruby 3.1–4.0 and ActiveSupport 6.1–8.1, require Ruby 3.1, and modernize development dependencies and lint configuration - [@dblock](https://github.com/dblock).
14
+ * Implemented RuboCop, Ruby style linter - [@dblock](https://github.com/dblock).
15
+ * Upgraded RSpec to 3.x syntax - [@dblock](https://github.com/dblock).
16
+
17
+ ### 0.1.0 (2013-06-22)
2
18
 
3
19
  * Initial public release, written on a plane from NYC to SF - [@dblock](https://github.com/dblock).
data/README.md CHANGED
@@ -1,100 +1,284 @@
1
1
  CanonicalEmails
2
2
  ==============
3
3
 
4
- [![Build Status](https://travis-ci.org/dblock/canonical-emails.png)](https://travis-ci.org/dblock/canonical-emails)
4
+ [![Gem Version](http://img.shields.io/gem/v/canonical-emails.svg)](http://badge.fury.io/rb/canonical-emails)
5
+ [![CI](https://github.com/dblock/canonical-emails/actions/workflows/ci.yml/badge.svg)](https://github.com/dblock/canonical-emails/actions/workflows/ci.yml)
6
+ [![Coverage Status](https://coveralls.io/repos/github/dblock/canonical-emails/badge.svg?branch=master)](https://coveralls.io/github/dblock/canonical-emails?branch=master)
5
7
 
6
- Combine email validation and transformations to produce canonical email addresses. For example, parse and transform `Donald Duck <donald.duck@gmail.com>` into `donaldduck@gmail.com`, for `@gmail.com` addresses only. Patches instances of `Mail::Address`.
8
+ Parse email addresses into provider-aware canonical forms. Gmail addresses become `CanonicalEmails::GMail` instances; unsupported domains become unchanged `CanonicalEmails::Address` instances. Both inherit from `Mail::Address`.
7
9
 
8
10
  ### Install
9
11
 
10
- Add `canonical-emails` to your Gemfile.
12
+ Add `canonical-emails` to your Gemfile. Requires Ruby 3.1 or newer and compatible Mail and ActiveSupport versions.
11
13
 
12
- ```
14
+ ```ruby
13
15
  gem 'canonical-emails'
14
16
  ```
15
17
 
16
- ### Use
18
+ See [UPGRADING.md](UPGRADING.md) for breaking API changes, Gmail plus-tag behavior, and migration considerations for stored canonical addresses.
19
+
20
+ ### Basic Usage
21
+
22
+ Add a canonical email accessor to your model:
17
23
 
18
- ``` ruby
24
+ ```ruby
19
25
  class User
20
26
  include CanonicalEmails::Extensions
21
27
 
22
28
  attr_accessor :email
23
- canonical_email :email, CanonicalEmails::GMail
29
+ canonical_email :email
30
+ end
31
+
32
+ user = User.new
33
+ user.email = "Alex Smith <Alex.Smith+Newsletter@gmail.com>"
34
+ user.canonical_email.class # CanonicalEmails::GMail
35
+ user.canonical_email.address # "alexsmith@gmail.com"
36
+ user.canonical_email.tag # "Newsletter"
37
+ ```
38
+
39
+ ### Multiple Email Fields
40
+
41
+ Declare `canonical_email` once for each field. Each declaration creates a separate `canonical_<field>` accessor and can select its own `type:`:
42
+
43
+ ```ruby
44
+ class User
45
+ include CanonicalEmails::Extensions
46
+
47
+ attr_accessor :email, :backup_email
48
+ canonical_email :email
49
+ canonical_email :backup_email, type: CanonicalEmails::TaggedGMail
50
+ end
51
+
52
+ user = User.new
53
+ user.email = "Alex.Smith+Newsletter@gmail.com"
54
+ user.backup_email = "Alex.Smith+Backup@gmail.com"
55
+ user.canonical_email.address # "alexsmith@gmail.com"
56
+ user.canonical_backup_email.address # "alexsmith+backup@gmail.com"
57
+ ```
58
+
59
+ Each accessor reads its own field without changing the original value. A nil field returns nil.
60
+
61
+ ### Custom Accessor Names
62
+
63
+ Use `as:` to choose the generated accessor's name instead of `canonical_<field>`. String and symbol names are supported, and each field can have its own name and type:
64
+
65
+ ```ruby
66
+ class User
67
+ include CanonicalEmails::Extensions
68
+
69
+ attr_accessor :email, :backup_email
70
+ canonical_email :email, as: :normalized_email
71
+ canonical_email :backup_email, as: :normalized_backup, type: CanonicalEmails::TaggedGMail
24
72
  end
25
73
 
26
74
  user = User.new
27
- user.email = "Donald Duck <Donald.Duck@gmail.com>"
28
- user.canonical_email.class # Mail::Address
29
- user.canonical_email.to_s # "Donald Duck <donaldduck@gmail.com>"
30
- user.canonical_email.address # "donaldduck@gmail.com"
31
- ```
75
+ user.email = "Alex.Smith+Newsletter@gmail.com"
76
+ user.backup_email = "Alex.Smith+Backup@gmail.com"
77
+ user.normalized_email.address # "alexsmith@gmail.com"
78
+ user.normalized_backup.address # "alexsmith+backup@gmail.com"
79
+ ```
32
80
 
33
- ### Transform
81
+ Custom names replace the default generated names. When the name differs from the source field, the original reader and writer remain unchanged.
34
82
 
35
- #### CanonicalEmails::Downcase
83
+ #### Override the Original Reader
36
84
 
37
- Replaces the address and domain portion of the email by its lowercase equivalent.
85
+ Use the source field's name with `as:` to return a parsed address directly from that reader:
38
86
 
39
- ``` ruby
40
- email = CanonicalEmails::Downcase.transform("Donald Duck <DoNaLD.DuCK@eXaMPLe.CoM>")
41
- email.to_s # "Donald Duck <donald.duck@example.com>"
42
- email.address # "donald.duck@example.com"
87
+ ```ruby
88
+ class User
89
+ include CanonicalEmails::Extensions
90
+
91
+ attr_accessor :email
92
+ canonical_email :email, as: :email
93
+ end
94
+
95
+ user = User.new
96
+ user.email = "Alex.Smith+Newsletter@gmail.com"
97
+ user.email.class # CanonicalEmails::GMail
98
+ user.email.address # "alexsmith@gmail.com"
99
+ user.email.tag # "Newsletter"
43
100
  ```
44
101
 
45
- #### CanonicalEmails::GMail
102
+ The reader now returns an address object (or `nil`) instead of a string. The original writer still stores the submitted value unchanged. The source reader must exist when accessed; inherited and generated readers are supported. `type:` also works with reader overrides.
46
103
 
47
- Gmail.com e-mail addresses ignore periods and aren't case-sensitive. The canonical version removes them and changes the address portion to lowercase.
104
+ Raw-value access depends on your model: for the `attr_accessor` example, `user.instance_variable_get(:@email)` returns the original string; in ActiveRecord, use `user[:email]`. No additional raw-value accessor is generated.
48
105
 
49
- ``` ruby
50
- email = CanonicalEmails::GMail.transform("Donald Duck <donald.duck@gmail.com>")
51
- email.to_s # "Donald Duck <donaldduck@gmail.com>"
52
- email.local # "donaldduck"
53
- email.address # "donaldduck@gmail.com"
106
+ ### Parse
107
+
108
+ To parse an address directly without a model, use `CanonicalEmails.parse`:
109
+
110
+ ```ruby
111
+ email = CanonicalEmails.parse("Alex Smith <Alex.Smith+Newsletter+Offers@GMAIL.COM>")
112
+ email.class # CanonicalEmails::GMail
113
+ email.to_s # "Alex Smith <alexsmith@gmail.com>"
114
+ email.address # "alexsmith@gmail.com"
115
+ email.local # "alexsmith"
116
+ email.domain # "gmail.com"
117
+ email.username # "Alex.Smith+Newsletter+Offers"
118
+ email.tag # "Newsletter+Offers"
119
+
120
+ email = CanonicalEmails.parse("Alex.Smith+Newsletter@EXAMPLE.COM")
121
+ email.class # CanonicalEmails::Address
122
+ email.address # "Alex.Smith+Newsletter@EXAMPLE.COM"
54
123
  ```
55
124
 
56
- ### Multiple Transformations
125
+ Provider detection matches the complete domain case-insensitively. Unregistered domains return generic addresses. See [Available Providers](#available-providers) for built-in behavior and [Register Providers](#register-providers) for adding your own types.
126
+
127
+ `parse(nil)` returns `nil`. Empty or whitespace-only strings return empty generic addresses. Domainless input remains a generic address. Non-string input raises `TypeError`; malformed input may raise Mail parsing errors.
128
+
129
+ The read-only `username` property preserves the original local part, including case, periods, and plus-tags. `tag` preserves the removed suffix, including case and additional `+` characters, when tag stripping is enabled. A missing tag returns `nil`; an explicitly empty tag returns `""`. Generic addresses have `tag == nil`.
57
130
 
58
- Combine multiple transformations, executed from left to right.
131
+ ### Available Providers
59
132
 
133
+ #### Gmail
134
+
135
+ `CanonicalEmails::GMail` is the default Gmail provider type. It registers `gmail.com` through the same registration API available to applications, matching the complete domain case-insensitively. Outlook, `googlemail.com`, and Google Workspace custom domains remain generic unless your application registers them.
136
+
137
+ Gmail normalization removes periods and the first `+` and everything after it from the local part, and lowercases the local part and domain. Display names are preserved. [Gmail period equivalence](https://support.google.com/mail/answer/7436150?hl=en) applies to `gmail.com`, not custom domains.
138
+
139
+ ```ruby
140
+ email = CanonicalEmails::GMail.parse("Alex Smith <Alex.Smith+Newsletter+Offers@GMAIL.COM>")
141
+ email.class # CanonicalEmails::GMail
142
+ email.to_s # "Alex Smith <alexsmith@gmail.com>"
143
+ email.address # "alexsmith@gmail.com"
144
+ email.username # "Alex.Smith+Newsletter+Offers"
145
+ email.tag # "Newsletter+Offers"
60
146
  ```
61
- canonical_email :email, CanonicalEmails::GMail, CanonicalEmails::...
147
+
148
+ #### Tagged Gmail
149
+
150
+ `CanonicalEmails::TaggedGMail` normalizes Gmail periods and case while retaining the complete plus suffix in the address, lowercased along with the rest of the local part. It is built in but not registered for automatic dispatch; select it explicitly:
151
+
152
+ ```ruby
153
+ email = CanonicalEmails::TaggedGMail.parse("Alex Smith <Alex.Smith+Newsletter+Offers@GMAIL.COM>")
154
+ email.class # CanonicalEmails::TaggedGMail
155
+ email.to_s # "Alex Smith <alexsmith+newsletter+offers@gmail.com>"
156
+ email.address # "alexsmith+newsletter+offers@gmail.com"
157
+ email.username # "Alex.Smith+Newsletter+Offers"
158
+ email.tag # nil
62
159
  ```
63
160
 
64
- ### Custom Transformations
65
-
66
- A transformation is a module that has a single `transform` method that returns a `Mail::Address` instance. This library patches internal methods of the `Mail::Address` class.
67
-
68
- ``` ruby
69
- module CanonicalEmails
70
- module ReverseName
71
- def self.transform(value)
72
- Mail::Address.new(value).tap do |email|
73
- email.instance_eval do
74
- def name
75
- super.reverse
76
- end
77
- end
78
- end
79
- end
161
+ It enforces the same Gmail domain restrictions as `GMail`. `tag` remains `nil` because the suffix is not stripped.
162
+
163
+ ### Register Providers
164
+
165
+ Register a custom class during application initialization to enable automatic selection in both `CanonicalEmails.parse` and model accessors. For example, an application can define an Outlook type that lowercases the domain while preserving the local part, periods, and plus-tags:
166
+
167
+ ```ruby
168
+ class Outlook < CanonicalEmails::Address
169
+ include CanonicalEmails::Behaviors::DowncaseDomain
170
+
171
+ def self.accepts?(domain)
172
+ domain&.casecmp?("outlook.com")
80
173
  end
81
174
  end
82
175
 
83
- email = CanonicalEmails::ReverseName.transform("Donald Duck <donald@example.org>")
84
- email.name # "kcuD dlanoD"
176
+ CanonicalEmails.register("outlook.com", type: Outlook)
177
+ email = CanonicalEmails.parse("Alex.Smith+Newsletter@OUTLOOK.COM")
178
+ email.class # Outlook
179
+ email.address # "Alex.Smith+Newsletter@outlook.com"
180
+ email.username # "Alex.Smith+Newsletter"
181
+ email.tag # nil
85
182
  ```
86
183
 
87
- ### Contribute
184
+ See [Custom Address Types](#custom-address-types) for additional normalization behaviors.
185
+
186
+ Registrations match complete domains case-insensitively, not subdomains or suffixes. The type must subclass `Address` and accept the registered domain. Repeating the same mapping is harmless; a conflicting mapping raises `ArgumentError` without replacing the existing type. Explicit `type:` and class-level `.parse` bypass the registry.
187
+
188
+ Call `CanonicalEmails.clear` during application initialization to remove all registrations, including the default Gmail mapping, before configuring your own provider set. For example, to use `TaggedGMail` for automatic Gmail detection:
189
+
190
+ ```ruby
191
+ CanonicalEmails.clear
192
+ CanonicalEmails.register("gmail.com", type: CanonicalEmails::TaggedGMail)
193
+
194
+ email = CanonicalEmails.parse("Alex.Smith+Newsletter@gmail.com")
195
+ email.class # CanonicalEmails::TaggedGMail
196
+ email.address # "alexsmith+newsletter@gmail.com"
197
+ ```
198
+
199
+ Defaults are not automatically restored: domains remain generic until registered again. Explicit `type:` and class-level `.parse` continue to work. Configure the full provider set before serving requests.
200
+
201
+ ### Explicit Types
202
+
203
+ Use `canonical_email :email, type: CanonicalEmails::Address` to disable provider detection in a model, or select another class with `type:`.
204
+
205
+ Specify `type:` to request an exact address class instead of provider detection:
206
+
207
+ ```ruby
208
+ email = CanonicalEmails.parse("Alex.Smith+Newsletter@GMAIL.COM", type: CanonicalEmails::Address)
209
+ email.address # "Alex.Smith+Newsletter@GMAIL.COM" (no normalization)
210
+
211
+ email = CanonicalEmails.parse("Alex.Smith+Newsletter@GMAIL.COM", type: CanonicalEmails::Downcase)
212
+ email.address # "alex.smith+newsletter@gmail.com" (lowercase both parts only)
213
+ ```
214
+
215
+ `type:` must be `CanonicalEmails::Address` or one of its subclasses. Classes also expose `.parse`, equivalent to specifying `type:`:
216
+
217
+ ```ruby
218
+ CanonicalEmails::GMail.parse("Alex.Smith+Newsletter@GMAIL.COM")
219
+ CanonicalEmails::Address.parse("Alex.Smith+Newsletter@GMAIL.COM")
220
+ CanonicalEmails::Downcase.parse("Alex.Smith+Newsletter@EXAMPLE.COM")
221
+ ```
88
222
 
89
- You're encouraged to contribute to this gem.
223
+ An explicit provider class rejects mismatched domains with `ArgumentError`, including empty and domainless input. All `.parse` entry points return `nil` for nil input.
224
+
225
+ Generic addresses preserve local-part and domain casing. Use `type: CanonicalEmails::Downcase` to lowercase both parts regardless of provider.
226
+
227
+ ### Assignment
228
+
229
+ #### Model Fields
230
+
231
+ Both separately named and overridden model readers cache their results per accessor and model instance. Each read compares the current raw input against a saved string copy, detecting writer assignments, changes that bypass the writer, and in-place string edits. Unchanged input returns the same address object (or cached nil); changed input is parsed again. Parsing errors propagate rather than returning a stale result. Configure provider registrations during initialization; registry changes alone do not invalidate existing model caches.
232
+
233
+ Canonical address objects do not support `address=`. Assign through the model's original writer instead:
234
+
235
+ ```ruby
236
+ user.email = "Alex.Smith+Newsletter@gmail.com"
237
+ user.canonical_email.equal?(user.canonical_email) # true
238
+ user.canonical_email.address = "Alex@example.com" # raises NoMethodError
239
+ user.email = "Alex@example.com"
240
+ user.canonical_email.address # "Alex@example.com"
241
+ ```
242
+
243
+ For `as: :email`, use `user.email = ...` to update the stored value. Removing `address=` does not make other inherited Mail APIs read-only.
244
+
245
+ #### Directly Parsed Addresses
246
+
247
+ Parse a new value instead of reassigning a directly parsed address:
248
+
249
+ ```ruby
250
+ email = CanonicalEmails.parse("Alex.Smith@gmail.com")
251
+ email = CanonicalEmails.parse("Alex Smith <Alex.Smith+Newsletter@GMAIL.COM>")
252
+ email.to_s # "Alex Smith <alexsmith@gmail.com>"
253
+ email.username # "Alex.Smith+Newsletter"
254
+ email.tag # "Newsletter"
255
+ ```
256
+
257
+ ### Custom Address Types
258
+
259
+ `CanonicalEmails::Address` performs no normalization by default. Include any combination of these mixins to opt in:
260
+
261
+ | Mixin under `CanonicalEmails::Behaviors` | Behavior |
262
+ |---|---|
263
+ | `DowncaseLocal` | Lowercase the local part. |
264
+ | `DowncaseDomain` | Lowercase the domain. |
265
+ | `RemovePeriods` | Remove periods from the local part. |
266
+ | `StripTags` | Remove the first `+` and its suffix, storing the original suffix in `tag`. |
267
+
268
+ `Downcase` includes the two downcasing mixins for all domains. `TaggedGMail` composes both downcasing mixins and `RemovePeriods`; `GMail` adds `StripTags`.
269
+
270
+ Custom types are not automatically registered for provider detection. Override `.accepts?(domain)` to restrict allowed domains; it receives the original parsed domain. Mixins otherwise apply to all domains. Only remove periods or tags where your application has established equivalence. A custom type works directly in a model: `canonical_email :email, type: CustomAddress`.
271
+
272
+ ### Validation
273
+
274
+ CanonicalEmails normalizes email addresses; it does not validate them. For Active Model email validations, consider [valid_email](https://github.com/hallelujah/valid_email), configured separately.
275
+
276
+ ### Contribute
90
277
 
91
- * Fork this project.
92
- * Make changes, write tests.
93
- * Updated CHANGELOG.
94
- * Make a pull request, bonus points for topic branches.
278
+ See [CONTRIBUTING](CONTRIBUTING.md).
95
279
 
96
280
  ### Copyright and License
97
281
 
98
- Copyright Daniel Doubrovkine and Contributors, Artsy Inc., 2013
282
+ Copyright Daniel Doubrovkine and Contributors, [Artsy](https://artsy.net), 2013-2014
99
283
 
100
284
  [MIT License](LICENSE.md)
data/UPGRADING.md ADDED
@@ -0,0 +1,92 @@
1
+ # Upgrading CanonicalEmails
2
+
3
+ ## From 0.1.x to 0.2.0
4
+
5
+ Requires Ruby 3.1 or newer. Both `require 'canonical-emails'` and `require 'canonical_emails'` remain supported.
6
+
7
+ ### Replace transform with parse
8
+
9
+ Names below are relative to `CanonicalEmails`.
10
+
11
+ | Before | After |
12
+ |---|---|
13
+ | `GMail.transform(value)` | `CanonicalEmails.parse(value)` |
14
+ | `Downcase.transform(value)` | `Downcase.parse(value)` |
15
+ | `canonical_email :email, GMail` | `canonical_email :email` |
16
+ | `canonical_email :email, Downcase` | `canonical_email :email, type: Downcase` |
17
+ | `email.class == Mail::Address` | `email.is_a?(Mail::Address)` |
18
+
19
+ `GMail` and `Downcase` are now subclasses of `CanonicalEmails::Address < Mail::Address`. Automatic parsing selects `GMail` only for `gmail.com`; other domains return an unchanged `Address`. Explicit `GMail.parse` rejects other domains with `ArgumentError`.
20
+
21
+ ### Gmail plus-tags are removed
22
+
23
+ ```ruby
24
+ # Before
25
+ CanonicalEmails::GMail.transform("Alex.Smith+Newsletter@gmail.com").address
26
+ # => "alexsmith+newsletter@gmail.com"
27
+
28
+ # After
29
+ email = CanonicalEmails.parse("Alex.Smith+Newsletter@gmail.com")
30
+ email.address # => "alexsmith@gmail.com"
31
+ email.username # => "Alex.Smith+Newsletter"
32
+ email.tag # => "Newsletter"
33
+
34
+ # To retain plus-tags
35
+ CanonicalEmails::TaggedGMail.parse("Alex.Smith+Newsletter@gmail.com").address
36
+ # => "alexsmith+newsletter@gmail.com"
37
+ ```
38
+
39
+ **Check stored canonical addresses and unique indexes for collisions before backfilling.** Previously distinct Gmail values may now canonicalize to the same address.
40
+
41
+ ### Handle nil and reject non-string input
42
+
43
+ | Before | After |
44
+ |---|---|
45
+ | `GMail.transform(nil)` returns an empty address | `CanonicalEmails.parse(nil)` returns `nil` |
46
+ | Model accessors stringify input before transformation | Input must be a String or nil; other types raise `TypeError` |
47
+
48
+ ```ruby
49
+ # Before
50
+ user.canonical_email.address
51
+
52
+ # After: the source field may be nil
53
+ user.canonical_email&.address
54
+ ```
55
+
56
+ Empty and domainless strings remain generic addresses with default parsing. Mail parsing errors propagate.
57
+
58
+ ### Replace address assignment
59
+
60
+ `address=` has been removed and raises `NoMethodError`.
61
+
62
+ ```ruby
63
+ # Before
64
+ email.address = "Alex@example.com"
65
+
66
+ # After: standalone address
67
+ email = CanonicalEmails.parse("Alex@example.com")
68
+
69
+ # After: model field
70
+ user.email = "Alex@example.com"
71
+ user.canonical_email.address # => "Alex@example.com"
72
+ ```
73
+
74
+ Model readers now return the same parsed object while the raw field value is unchanged, including nil results. Assignments and in-place string edits refresh the cache; provider registry changes alone do not.
75
+
76
+ ### Replace transformer pipelines with one address type
77
+
78
+ ```ruby
79
+ # Before: custom transformers applied in sequence
80
+ canonical_email :email, RemovePeriods, CanonicalEmails::Downcase
81
+
82
+ # After: select one class with the required behaviors
83
+ class CustomAddress < CanonicalEmails::Address
84
+ include CanonicalEmails::Behaviors::RemovePeriods
85
+ include CanonicalEmails::Behaviors::DowncaseLocal
86
+ include CanonicalEmails::Behaviors::DowncaseDomain
87
+ end
88
+
89
+ canonical_email :email, type: CustomAddress
90
+ ```
91
+
92
+ Move custom `.transform` logic into address subclasses or behavior mixins. Restrict provider-specific rules with `.accepts?(domain)` and register types during initialization for automatic dispatch. See [Custom Address Types](README.md#custom-address-types) and [Register Providers](README.md#register-providers).
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CanonicalEmails
4
+ # An unchanged Mail address with original local-part metadata.
5
+ class Address < Mail::Address
6
+ attr_reader :username, :tag
7
+
8
+ undef_method :address=
9
+
10
+ def self.parse(value)
11
+ CanonicalEmails.parse(value, type: self)
12
+ end
13
+
14
+ def self.accepts?(_domain)
15
+ true
16
+ end
17
+
18
+ def initialize(value = nil)
19
+ super
20
+ raise ArgumentError, "#{self.class} does not support domain #{get_domain.inspect}" unless self.class.accepts?(get_domain)
21
+
22
+ update_properties
23
+ end
24
+
25
+ private
26
+
27
+ def update_properties
28
+ @username = get_local
29
+ @tag = nil
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CanonicalEmails
4
+ # Opt-in transformations for Address subclasses.
5
+ module Behaviors
6
+ # Lowercases the local part.
7
+ module DowncaseLocal
8
+ def local(*)
9
+ super&.downcase
10
+ end
11
+ end
12
+
13
+ # Lowercases the domain.
14
+ module DowncaseDomain
15
+ def domain(*)
16
+ super&.downcase
17
+ end
18
+ end
19
+
20
+ # Removes periods from the local part.
21
+ module RemovePeriods
22
+ def local(*)
23
+ super&.delete('.')
24
+ end
25
+ end
26
+
27
+ # Removes the plus suffix while retaining it in the tag property.
28
+ module StripTags
29
+ def local(*)
30
+ value = super
31
+ return unless value
32
+
33
+ value.split('+', 2).first
34
+ end
35
+
36
+ private
37
+
38
+ def update_properties
39
+ super
40
+ @tag = username.split('+', 2).at(1) if username
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,20 +1,9 @@
1
- module CanonicalEmails
2
- module Downcase
3
-
4
- def self.transform(value)
5
- Mail::Address.new(value).tap do |email|
6
- email.instance_eval do
7
- def get_local
8
- value = super
9
- value.downcase if value
10
- end
11
- def domain
12
- value = super
13
- value.downcase if value
14
- end
15
- end
16
- end if value
17
- end
1
+ # frozen_string_literal: true
18
2
 
3
+ module CanonicalEmails
4
+ # Normalizes the local part and domain without changing the display name.
5
+ class Downcase < Address
6
+ include Behaviors::DowncaseLocal
7
+ include Behaviors::DowncaseDomain
19
8
  end
20
- end
9
+ end
@@ -1,17 +1,39 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanonicalEmails
4
+ # Adds lazily evaluated canonical address accessors to application models.
2
5
  module Extensions
3
6
  extend ActiveSupport::Concern
4
7
 
8
+ private
9
+
10
+ def cached_canonical_email(key, raw, type)
11
+ @canonical_email_cache ||= {}
12
+ cached = @canonical_email_cache[key]
13
+ return cached.last if cached && cached.first == raw
14
+
15
+ address = CanonicalEmails.parse(raw, type: type)
16
+ @canonical_email_cache[key] = [raw&.dup, address]
17
+ address
18
+ end
19
+
20
+ # Defines provider-aware canonical address accessors.
5
21
  module ClassMethods
6
- def canonical_email(field, *parsers)
7
- define_method "canonical_#{field}" do
8
- value = send(field)
9
- Array(parsers).each do |parser|
10
- value = parser.transform(value.to_s)
22
+ def canonical_email(field, type: nil, as: :"canonical_#{field}")
23
+ raise ArgumentError, 'as must be a nonempty String or Symbol' unless (as.is_a?(String) || as.is_a?(Symbol)) && !as.to_s.empty?
24
+
25
+ key = Object.new
26
+ if as.to_s == field.to_s
27
+ @canonical_email_readers ||= Module.new.tap { |readers| prepend readers }
28
+ @canonical_email_readers.define_method(as) do
29
+ cached_canonical_email(key, super(), type)
30
+ end
31
+ else
32
+ define_method as do
33
+ cached_canonical_email(key, send(field), type)
11
34
  end
12
- value
13
35
  end
14
36
  end
15
37
  end
16
38
  end
17
- end
39
+ end
@@ -1,22 +1,8 @@
1
- module CanonicalEmails
2
- module GMail
3
-
4
- def self.transform(value)
5
- Mail::Address.new(value).tap do |email|
6
- if email.domain && [ "gmail.com" ].include?(email.domain.downcase)
7
- email.instance_eval do
8
- def get_local
9
- value = super
10
- value.gsub(".", "").downcase if value
11
- end
12
- def domain
13
- value = super
14
- value.downcase if value
15
- end
16
- end
17
- end if value
18
- end
19
- end
1
+ # frozen_string_literal: true
20
2
 
3
+ module CanonicalEmails
4
+ # Removes periods and plus tags and normalizes case for gmail.com addresses only.
5
+ class GMail < TaggedGMail
6
+ include Behaviors::StripTags
21
7
  end
22
- end
8
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CanonicalEmails
4
+ # Normalizes Gmail periods and case while retaining plus-tags.
5
+ class TaggedGMail < Address
6
+ include Behaviors::DowncaseLocal
7
+ include Behaviors::DowncaseDomain
8
+ include Behaviors::RemovePeriods
9
+
10
+ def self.accepts?(domain)
11
+ domain&.casecmp?('gmail.com')
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CanonicalEmails
2
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
3
5
  end
@@ -1,7 +1,50 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'mail'
2
- require 'active_support'
4
+ require 'active_support/concern'
3
5
 
4
6
  require 'canonical-emails/version'
7
+ require 'canonical-emails/address'
8
+ require 'canonical-emails/behaviors'
5
9
  require 'canonical-emails/downcase'
10
+ require 'canonical-emails/tagged_gmail'
6
11
  require 'canonical-emails/gmail'
7
12
  require 'canonical-emails/extensions'
13
+
14
+ # Parses addresses into provider-aware canonical forms.
15
+ module CanonicalEmails
16
+ @types = {}
17
+ @types_mutex = Mutex.new
18
+
19
+ def self.clear
20
+ @types_mutex.synchronize { @types.clear }
21
+ end
22
+
23
+ def self.register(domain, type:)
24
+ raise ArgumentError, 'domain must be a nonempty domain String without whitespace or @' unless domain.is_a?(String) && !domain.empty? && !domain.match?(/[\s@]/)
25
+ raise ArgumentError, 'type must be a CanonicalEmails::Address subclass' unless type.is_a?(Class) && type <= Address
26
+
27
+ domain = domain.downcase
28
+ raise ArgumentError, "#{type} does not accept #{domain}" unless type.accepts?(domain)
29
+
30
+ @types_mutex.synchronize do
31
+ existing = @types[domain]
32
+ raise ArgumentError, "#{domain} is already registered to #{existing}" if existing && existing != type
33
+
34
+ @types[domain] = type
35
+ end
36
+ end
37
+
38
+ def self.parse(value, type: nil)
39
+ raise ArgumentError, 'type must be a CanonicalEmails::Address subclass' unless type.nil? || (type.is_a?(Class) && type <= Address)
40
+ return if value.nil?
41
+
42
+ raise TypeError, 'address must be a String or nil' unless value.is_a?(String)
43
+
44
+ data = Mail::Parsers::AddressListsParser.parse(value).addresses.first unless value.strip.empty?
45
+ type ||= @types_mutex.synchronize { @types.fetch(data&.domain&.downcase, Address) }
46
+ type.new(data)
47
+ end
48
+
49
+ register('gmail.com', type: GMail)
50
+ end
@@ -1 +1,3 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'canonical-emails'
metadata CHANGED
@@ -1,107 +1,84 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canonical-emails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
5
- prerelease:
4
+ version: 0.2.0
6
5
  platform: ruby
7
6
  authors:
8
7
  - Daniel Doubrovkine
9
- autorequire:
8
+ autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2013-06-22 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
- name: mail
14
+ name: activesupport
16
15
  requirement: !ruby/object:Gem::Requirement
17
- none: false
18
16
  requirements:
19
- - - ! '>='
17
+ - - ">="
20
18
  - !ruby/object:Gem::Version
21
19
  version: '0'
22
20
  type: :runtime
23
21
  prerelease: false
24
22
  version_requirements: !ruby/object:Gem::Requirement
25
- none: false
26
23
  requirements:
27
- - - ! '>='
24
+ - - ">="
28
25
  - !ruby/object:Gem::Version
29
26
  version: '0'
30
27
  - !ruby/object:Gem::Dependency
31
- name: activesupport
28
+ name: mail
32
29
  requirement: !ruby/object:Gem::Requirement
33
- none: false
34
30
  requirements:
35
- - - ! '>='
31
+ - - ">="
36
32
  - !ruby/object:Gem::Version
37
33
  version: '0'
38
34
  type: :runtime
39
35
  prerelease: false
40
36
  version_requirements: !ruby/object:Gem::Requirement
41
- none: false
42
37
  requirements:
43
- - - ! '>='
38
+ - - ">="
44
39
  - !ruby/object:Gem::Version
45
40
  version: '0'
46
- description:
41
+ description:
47
42
  email: dblock@dblock.org
48
43
  executables: []
49
44
  extensions: []
50
45
  extra_rdoc_files: []
51
46
  files:
52
- - .gitignore
53
- - .rspec
54
- - .travis.yml
55
47
  - CHANGELOG.md
56
- - Gemfile
57
48
  - LICENSE.md
58
49
  - README.md
59
- - Rakefile
60
- - canonical-emails.gemspec
50
+ - UPGRADING.md
61
51
  - lib/canonical-emails.rb
52
+ - lib/canonical-emails/address.rb
53
+ - lib/canonical-emails/behaviors.rb
62
54
  - lib/canonical-emails/downcase.rb
63
55
  - lib/canonical-emails/extensions.rb
64
56
  - lib/canonical-emails/gmail.rb
57
+ - lib/canonical-emails/tagged_gmail.rb
65
58
  - lib/canonical-emails/version.rb
66
59
  - lib/canonical_emails.rb
67
- - spec/canonical-emails/downcase_spec.rb
68
- - spec/canonical-emails/extensions_spec.rb
69
- - spec/canonical-emails/fixtures/downcase.yml
70
- - spec/canonical-emails/fixtures/extensions.yml
71
- - spec/canonical-emails/fixtures/gmail.yml
72
- - spec/canonical-emails/gmail_spec.rb
73
- - spec/canonical-emails/version_spec.rb
74
- - spec/shared/extension_examples.rb
75
- - spec/shared/transform_examples.rb
76
- - spec/spec_helper.rb
77
- - spec/support/reverse.rb
78
- - spec/support/yml_examples.rb
79
- homepage: http://github.com/dblock/canonical-emails
60
+ homepage: https://github.com/dblock/canonical-emails
80
61
  licenses:
81
62
  - MIT
82
- post_install_message:
63
+ metadata:
64
+ rubygems_mfa_required: 'true'
65
+ post_install_message:
83
66
  rdoc_options: []
84
67
  require_paths:
85
68
  - lib
86
69
  required_ruby_version: !ruby/object:Gem::Requirement
87
- none: false
88
70
  requirements:
89
- - - ! '>='
71
+ - - ">="
90
72
  - !ruby/object:Gem::Version
91
- version: '0'
92
- segments:
93
- - 0
94
- hash: 247467439
73
+ version: '3.1'
95
74
  required_rubygems_version: !ruby/object:Gem::Requirement
96
- none: false
97
75
  requirements:
98
- - - ! '>='
76
+ - - ">="
99
77
  - !ruby/object:Gem::Version
100
78
  version: 1.3.6
101
79
  requirements: []
102
- rubyforge_project:
103
- rubygems_version: 1.8.24
104
- signing_key:
105
- specification_version: 3
80
+ rubygems_version: 3.5.16
81
+ signing_key:
82
+ specification_version: 4
106
83
  summary: Combine email validation and transformations to produce canonical email addresses.
107
84
  test_files: []
data/.gitignore DELETED
@@ -1,2 +0,0 @@
1
- Gemfile.lock
2
- pkg
data/.rspec DELETED
@@ -1,2 +0,0 @@
1
- --format documentation
2
- --color
data/.travis.yml DELETED
@@ -1,6 +0,0 @@
1
- rvm:
2
- - 1.8.7
3
- - 1.9.3
4
- - 2.0.0
5
- - jruby-19mode
6
- - rbx-19mode
data/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- source "http://rubygems.org"
2
-
3
- gemspec
4
-
5
- gem "rspec"
6
- gem "rake"
data/Rakefile DELETED
@@ -1,13 +0,0 @@
1
- require 'rubygems'
2
- require 'bundler/gem_tasks'
3
-
4
- Bundler.setup :default, :development
5
-
6
- require 'rspec/core'
7
- require 'rspec/core/rake_task'
8
-
9
- RSpec::Core::RakeTask.new(:spec) do |spec|
10
- spec.pattern = FileList["spec/**/*_spec.rb"]
11
- end
12
-
13
- task :default => :spec
@@ -1,18 +0,0 @@
1
- $:.push File.expand_path("../lib", __FILE__)
2
- require 'canonical-emails/version'
3
-
4
- Gem::Specification.new do |s|
5
- s.name = "canonical-emails"
6
- s.version = CanonicalEmails::VERSION
7
- s.authors = [ "Daniel Doubrovkine" ]
8
- s.email = "dblock@dblock.org"
9
- s.platform = Gem::Platform::RUBY
10
- s.required_rubygems_version = '>= 1.3.6'
11
- s.files = `git ls-files`.split("\n")
12
- s.require_paths = [ "lib" ]
13
- s.homepage = "http://github.com/dblock/canonical-emails"
14
- s.licenses = [ "MIT" ]
15
- s.summary = "Combine email validation and transformations to produce canonical email addresses."
16
- s.add_dependency "mail"
17
- s.add_dependency "activesupport"
18
- end
@@ -1,5 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe CanonicalEmails::Downcase do
4
- it_behaves_like 'transform fixture_examples'
5
- end
@@ -1,5 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe CanonicalEmails::Extensions do
4
- it_behaves_like 'extension fixture_examples'
5
- end
@@ -1,30 +0,0 @@
1
- -
2
- source: Donald <donald@example.org>
3
- name: Donald
4
- local: donald
5
- domain: example.org
6
- -
7
- source: Donald < donald@example.org >
8
- name: Donald
9
- local: donald
10
- domain: example.org
11
- -
12
- source: donald@example.org
13
- local: donald
14
- domain: example.org
15
- -
16
- source: ! ' donald@example.org'
17
- local: donald
18
- domain: example.org
19
- -
20
- source: donald@eXaMPLe.org
21
- local: donald
22
- domain: example.org
23
- -
24
- source: DONALD@eXaMPLe.org
25
- local: donald
26
- domain: example.org
27
- -
28
- source: DoNaLD@example.org
29
- local: donald
30
- domain: example.org
@@ -1,16 +0,0 @@
1
- -
2
- email: Donald Duck <DoNaLD.DuCK@GMaiL.CoM>
3
- transforms:
4
- - CanonicalEmails::Downcase
5
- canonical_email: Donald Duck <donald.duck@gmail.com>
6
- -
7
- email: Donald Duck <DoNaLD.DuCK@GMaiL.CoM>
8
- transforms:
9
- - CanonicalEmails::GMail
10
- canonical_email: Donald Duck <donaldduck@gmail.com>
11
- -
12
- email: Donald Duck <DoNaLD.DuCK@GMaiL.CoM>
13
- transforms:
14
- - CanonicalEmails::GMail
15
- - CanonicalEmails::Reverse
16
- canonical_email: Donald Duck <kcuddlanod@moc.liamg>
@@ -1,29 +0,0 @@
1
- -
2
- source: Donald Duck <example@example.org>
3
- name: Donald Duck
4
- local: example
5
- domain: example.org
6
- -
7
- source: donald@example.org
8
- local: donald
9
- domain: example.org
10
- -
11
- source: donald@eXaMPLe.org
12
- local: donald
13
- domain: eXaMPLe.org
14
- -
15
- source: donald.duck@gmail.com
16
- local: donaldduck
17
- domain: gmail.com
18
- -
19
- source: donaldduck@gmail.com
20
- local: donaldduck
21
- domain: gmail.com
22
- -
23
- source: donald.duck@gMaiL.com
24
- local: donaldduck
25
- domain: gmail.com
26
- -
27
- source: donald.duck@google.com
28
- local: donald.duck
29
- domain: google.com
@@ -1,5 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe CanonicalEmails::GMail do
4
- it_behaves_like 'transform fixture_examples'
5
- end
@@ -1,7 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe CanonicalEmails do
4
- it "has a version" do
5
- CanonicalEmails::VERSION.should_not be_nil
6
- end
7
- end
@@ -1,14 +0,0 @@
1
- shared_examples 'extension fixture_examples' do
2
- yml_examples.each do |example|
3
- it "#{example['transforms'].join(', ')}" do
4
- klass = Class.new do
5
- include CanonicalEmails::Extensions
6
- attr_accessor :email
7
- canonical_email :email, * example['transforms'].map(&:constantize)
8
- end
9
- user = klass.new
10
- user.email = example['email']
11
- user.canonical_email.to_s.should == example['canonical_email']
12
- end
13
- end
14
- end
@@ -1,11 +0,0 @@
1
- shared_examples 'transform fixture_examples' do
2
- yml_examples.each do |example|
3
- it "transforms '#{example['source']}'" do
4
- subject.transform(example['source']).tap do |email|
5
- email.local.should == example['local']
6
- email.domain.should == example['domain']
7
- email.name.should == example['name']
8
- end
9
- end
10
- end
11
- end
data/spec/spec_helper.rb DELETED
@@ -1,12 +0,0 @@
1
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
-
3
- require 'rubygems'
4
- require 'rspec'
5
- require 'canonical-emails'
6
- require 'yaml'
7
-
8
- [ "support", "shared" ].each do |path|
9
- Dir["#{File.dirname(__FILE__)}/#{path}/**/*.rb"].each do |f|
10
- require f
11
- end
12
- end
@@ -1,18 +0,0 @@
1
- module CanonicalEmails
2
- module Reverse
3
-
4
- def self.transform(value)
5
- Mail::Address.new(value).tap do |email|
6
- email.instance_eval do
7
- def get_local
8
- super.reverse
9
- end
10
- def domain
11
- super.reverse
12
- end
13
- end
14
- end
15
- end
16
-
17
- end
18
- end
@@ -1,10 +0,0 @@
1
- def yml_examples
2
- caller.each do |c|
3
- c =~ /(.*):(\d+)/
4
- dir_name = File.dirname($1)
5
- file_name = File.basename($1)
6
- next unless file_name.end_with?("_spec.rb")
7
- fixture_file = "#{file_name.split('_spec').first}.yml"
8
- return YAML::load(File.open([ dir_name, 'fixtures', fixture_file ].join('/')))
9
- end
10
- end