groat-smtpd 0.1.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.
@@ -0,0 +1,277 @@
1
+ # vim: set sw=2 sts=2 ts=2 et syntax=ruby: #
2
+ =begin license
3
+ Copyright 2011 Novell, Inc.
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Author(s):
18
+ Peter Bowen <pzbowen@gmail.com> Ottawa, Ontario, Canada
19
+ =end
20
+
21
+ require 'groat/smtpd/smtpsyntax'
22
+
23
+ module Groat
24
+ module SMTPD
25
+ class SMTP < SMTPSyntax
26
+ ehlo_keyword "x-groat"
27
+ verb :mail, :smtp_verb_mail
28
+ verb :rcpt, :smtp_verb_rcpt
29
+ verb :data, :smtp_verb_data
30
+ verb :helo, :smtp_verb_helo
31
+ verb :ehlo, :smtp_verb_ehlo
32
+ verb :quit, :smtp_verb_quit
33
+ verb :rset, :smtp_verb_rset
34
+ verb :noop, :smtp_verb_noop
35
+
36
+ def initialize(*args)
37
+ @hostname = "groat.example" if @hostname.nil?
38
+ super(*args)
39
+ end
40
+
41
+ def deliver!
42
+ end
43
+
44
+ # Reply methods
45
+
46
+ def response_ok(args = {})
47
+ defaults = {:code => 250, :message => "OK"}
48
+ reply defaults.merge(args)
49
+ end
50
+
51
+ def response_bad_command(args = {})
52
+ defaults = {:code => 500, :message => "Bad command"}
53
+ reply defaults.merge(args)
54
+ end
55
+
56
+ def response_syntax_error(args = {})
57
+ defaults = {:code => 501, :message => "Syntax error"}
58
+ reply defaults.merge(args)
59
+ end
60
+
61
+ def response_bad_sequence(args = {})
62
+ defaults = {:code => 503, :message => "Bad sequence of commands",
63
+ :terminate => true }
64
+ reply defaults.merge(args)
65
+ end
66
+
67
+ def response_bad_command_parameter(args = {})
68
+ defaults = {:code => 504, :message => "Invalid parameter"}
69
+ reply defaults.merge(args)
70
+ end
71
+
72
+ def response_no_valid_rcpt(args = {})
73
+ defaults = {:code => 554, :message => "No valid recipients"}
74
+ reply defaults.merge(args)
75
+ end
76
+
77
+ def response_bad_parameter(args = {})
78
+ defaults = {:code => 555, :message => "Parameter not recognized"}
79
+ reply defaults.merge(args)
80
+ end
81
+
82
+ def response_service_shutdown(args = {})
83
+ defaults = {:code => 421, :message => "Server closing connection",
84
+ :terminate => true}
85
+ reply defaults.merge(args)
86
+ end
87
+
88
+ # Groat framework methods
89
+
90
+ def send_greeting
91
+ reply :code => 220, :message => "#{@hostname} ESMTP Ready"
92
+ end
93
+
94
+ def service_shutdown
95
+ response_service_shutdown
96
+ end
97
+
98
+ def verb_missing(verb, parameters)
99
+ response_bad_command :message => "Unknown command #{verb}"
100
+ end
101
+
102
+ # Utility functions
103
+
104
+ # No pipelining allowed (not in RFC5321)
105
+ def check_command_group
106
+ if clientdata?
107
+ response_bad_sequence
108
+ end
109
+ end
110
+
111
+ def reset_connection
112
+ @hello = nil
113
+ @hello_extra = nil
114
+ @esmtp = false
115
+ reset_buffers
116
+ super
117
+ end
118
+
119
+ def reset_buffers
120
+ @mailfrom = nil
121
+ @rcptto = []
122
+ @message = ""
123
+ end
124
+
125
+ def in_mail_transaction?
126
+ not @mailfrom.nil?
127
+ end
128
+
129
+ def esmtp?
130
+ @esmtp
131
+ end
132
+
133
+ # Generic handler for hello action
134
+ # Keyword determines Mail Service Type
135
+ # See: http://www.iana.org/assignments/mail-parameters
136
+ def handle_hello(args, keyword)
137
+ keyword = keyword.to_s.upcase.intern
138
+ check_command_group
139
+ response_syntax_error if args.empty?
140
+ hello, hello_extra = args.split(" ", 2)
141
+ hello =~ DOMAIN_OR_LITERAL
142
+ if $~.nil?
143
+ respond_syntax_error :message=>"Syntax Error: expected hostname or IP literal"
144
+ elsif hello.start_with? '[' and not valid_address_literal(hello)
145
+ respond_syntax_error :message=>"Syntax Error: invalid IP literal"
146
+ else
147
+ @hello = hello
148
+ @hello_extra = hello_extra
149
+ end
150
+ reset_buffers
151
+ response_text = ["#{@hostname} at your service"]
152
+ if (keyword == :EHLO)
153
+ @esmtp = true
154
+ ehlo_keywords.each do |kw, params|
155
+ param_str = params.to_a.join(' ')
156
+ if param_str.empty?
157
+ response_text << "#{kw}"
158
+ else
159
+ response_text << "#{kw} #{param_str}"
160
+ end
161
+ end
162
+ end
163
+ reply :code => 250, :message => response_text
164
+ end
165
+
166
+ # Verb handlers
167
+ def smtp_verb_helo(args)
168
+ handle_hello(args, :HELO)
169
+ end
170
+
171
+ def smtp_verb_ehlo(args)
172
+ handle_hello(args, :EHLO)
173
+ end
174
+
175
+ define_hook :validate_mailfrom
176
+ def smtp_verb_mail(args)
177
+ check_command_group
178
+ response_bad_sequence if @hello.nil?
179
+ # This should be start_with? 'FROM:<', but Outlook puts a space
180
+ # between the ':' and the '<'
181
+ response_syntax_error unless args.upcase.start_with? 'FROM:'
182
+ # Remove leading "FROM:" and following spaces
183
+ args = args[5..-1].lstrip
184
+ if args[0..2].rstrip.eql? '<>'
185
+ path = '<>'
186
+ param_str = args[3..-1].to_s
187
+ else
188
+ path, param_str = split_path(args)
189
+ response_syntax_error :message => 'Path error' if path.nil?
190
+ end
191
+ unless param_str.strip.empty?
192
+ response_syntax_error unless esmtp?
193
+ end
194
+ params = parse_params(param_str)
195
+ response_bad_parameter unless mail_params_valid(params)
196
+ # Validation complete
197
+ # RFC5321 § 4.1.1.2
198
+ # "This command clears the reverse-path buffer, the forward-path
199
+ # buffer, and the mail data buffer, and it inserts the reverse-path
200
+ # information from its argument clause into the reverse-path buffer."
201
+ reset_buffers
202
+ process_mail_params(params)
203
+ mailfrom = normalize_path(path)
204
+ run_hook :validate_mailfrom, mailfrom
205
+ @mailfrom = mailfrom
206
+ response_ok
207
+ end
208
+
209
+ define_hook :validate_rcptto
210
+ def smtp_verb_rcpt(args)
211
+ check_command_group
212
+ response_bad_sequence if @mailfrom.nil?
213
+ # This should be start_with? 'TO:<', but Outlook puts a space
214
+ # between the ':' and the '<'
215
+ response_syntax_error unless args.upcase.start_with? 'TO:'
216
+ # Remove leading "TO:" and the following spaces
217
+ args = args[3..-1].lstrip
218
+ path, param_str = split_path(args)
219
+ response_syntax error :message => 'Path error' if path.nil?
220
+ unless param_str.strip.empty?
221
+ response_syntax_error unless esmtp?
222
+ end
223
+ params = parse_params(param_str)
224
+ rcptto = normalize_path(path)
225
+ run_hook :validate_rcptto, rcptto
226
+ @rcptto << rcptto
227
+ response_ok
228
+ end
229
+
230
+ def smtp_verb_data(args)
231
+ check_command_group
232
+ response_syntax_error unless args.empty?
233
+ return response_no_valid_rcpt if @rcptto.count < 1
234
+ toclient "354 Enter message, ending with \".\" on a line by itself.\r\n"
235
+ loop do
236
+ line = fromclient
237
+ # RFC 5321 § 4.1.1.4
238
+ # "The custom of accepting lines ending only in <LF>, as a concession to
239
+ # non-conforming behavior on the part of some UNIX systems, has proven
240
+ # to cause more interoperability problems than it solves, and SMTP
241
+ # server systems MUST NOT do this, even in the name of improved
242
+ # robustness."
243
+ break if line.chomp("\r\n").eql?('.')
244
+ # RFC5321 sect 4.5.2, remove leading '.' if found
245
+ line.slice!(0) if line.start_with? '.'
246
+ @message << line
247
+ end
248
+ message = deliver!
249
+ reset_buffers
250
+ response_ok :message => message
251
+ end
252
+
253
+ def smtp_verb_rset(args)
254
+ check_command_group
255
+ response_syntax_error unless args.empty?
256
+ reset_buffers
257
+ response_ok
258
+ end
259
+
260
+ def smtp_verb_quit(args)
261
+ check_command_group
262
+ response_syntax_error unless args.empty?
263
+ reset_buffers
264
+ reply :code=>221,
265
+ :message=>"#{@hostname} Service closing transmission channel",
266
+ :terminate=>true
267
+ end
268
+
269
+ # RFC 5321 § 4.1.1.9
270
+ # "If a parameter string is specified, servers SHOULD ignore it."
271
+ def smtp_verb_noop(args)
272
+ check_command_group
273
+ response_ok
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,377 @@
1
+ # vim: set sw=2 sts=2 ts=2 et syntax=ruby: #
2
+ =begin license
3
+ Copyright 2011 Novell, Inc.
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Author(s):
18
+ Peter Bowen <pzbowen@gmail.com> Ottawa, Ontario, Canada
19
+ =end
20
+
21
+ require 'groat/smtpd/base'
22
+ require 'ipaddr'
23
+
24
+ ## SMTP Syntax implements the basic functions
25
+ ## that allow implementing RFC 5321 and RFC 3848
26
+ ## It does not define any verbs
27
+
28
+ module Groat
29
+ module SMTPD
30
+ class SMTPExtensionError < Exception
31
+ end
32
+
33
+ class SMTPResponse < Response
34
+ def initialize(args = {})
35
+ @code = args[:code] || 500
36
+ super(args)
37
+ end
38
+
39
+ def reply_text
40
+ text = ""
41
+ if @message.is_a? Array
42
+ last = @message.pop
43
+ if @message.count > 0
44
+ @message.each do |line|
45
+ text << @code.to_s + "-#{line}\r\n"
46
+ end
47
+ end
48
+ text << @code.to_s + " #{last}\r\n"
49
+ else
50
+ text << @code.to_s + " #{@message}\r\n"
51
+ end
52
+ end
53
+ end
54
+
55
+ class SMTPSyntax < Base
56
+ def initialize(*args)
57
+ super(*args)
58
+ @response_class = SMTPResponse
59
+ end
60
+
61
+
62
+ inheritable_attr(:ehlo_keywords)
63
+ self.ehlo_keywords = {}
64
+ inheritable_attr(:smtp_verbs)
65
+ self.smtp_verbs = {}
66
+ inheritable_attr(:mail_parameters)
67
+ self.mail_parameters = {}
68
+ inheritable_attr(:rcpt_parameters)
69
+ self.rcpt_parameters = {}
70
+ inheritable_attr(:mail_maxlen)
71
+ self.mail_maxlen = 512
72
+ inheritable_attr(:rcpt_maxlen)
73
+ self.rcpt_maxlen = 512
74
+
75
+ define_hook :before_all_verbs
76
+ define_hook :after_all_verbs
77
+
78
+ def self.ehlo_keyword(keyword, params = [], condition = nil)
79
+ sym = keyword.to_s.upcase.intern
80
+ ehlo_keywords[sym] = {:params => params, :condition => condition}
81
+ end
82
+
83
+ def self.ehlo_keyword_known?(kw)
84
+ sym = kw.to_s.upcase.intern
85
+ ehlo_keywords.has_key? sym
86
+ end
87
+
88
+ def ehlo_keywords
89
+ list = {}
90
+ self.class.ehlo_keywords.each do |k, v|
91
+ valid = false
92
+
93
+ if v[:condition].nil?
94
+ valid = true
95
+ else
96
+ valid = send v[:condition]
97
+ end
98
+
99
+ if valid
100
+ if v[:params].kind_of? Symbol
101
+ params = send v[:params]
102
+ else
103
+ params = v[:params]
104
+ end
105
+ list[k] = list[k].to_a|params.to_a
106
+ end
107
+ end
108
+ list
109
+ end
110
+
111
+ def self.run_verb_hook_for(hook, verb, scope, *args)
112
+ if not smtp_verbs[verb].nil? and not smtp_verbs[verb][hook].nil?
113
+ smtp_verbs[verb][hook].each do |callback|
114
+ if callback.kind_of? Symbol
115
+ scope.send(callback, *args)
116
+ else
117
+ callback.call(*args)
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ def self.before_verb(name, method = nil, &block)
124
+ sym = name.to_s.upcase.intern
125
+ smtp_verbs[sym] = {} unless smtp_verbs.has_key? sym
126
+ smtp_verbs[sym][:before] = [] unless smtp_verbs[sym].has_key? :before
127
+ callback = block_given? ? block : method
128
+ smtp_verbs[sym][:before] << callback
129
+ end
130
+
131
+ def self.after_verb(name, method = nil, &block)
132
+ sym = name.to_s.upcase.intern
133
+ smtp_verbs[sym] = {} unless smtp_verbs.has_key? sym
134
+ smtp_verbs[sym][:after] = [] unless smtp_verbs[sym].has_key? :after
135
+ callback = block_given? ? block : method
136
+ smtp_verbs[sym][:after] << callback
137
+ end
138
+
139
+ def self.validate_verb(name, method = nil, &block)
140
+ sym = name.to_s.upcase.intern
141
+ smtp_verbs[sym] = {} unless smtp_verbs.has_key? sym
142
+ smtp_verbs[sym][:valid] = [] unless smtp_verbs[sym].has_key? :valid
143
+ callback = block_given? ? block : method
144
+ smtp_verbs[sym][:valid] << callback
145
+ end
146
+
147
+ def self.verb(name, method)
148
+ sym = name.to_s.upcase.intern
149
+ smtp_verbs[sym] = {} unless smtp_verbs.has_key? sym
150
+ smtp_verbs[sym][:method] = method
151
+ end
152
+
153
+ def self.mail_param(name, method)
154
+ sym = name.to_s.upcase.intern
155
+ mail_parameters[sym] = method
156
+ end
157
+
158
+ def run_verb_hook(hook, verb, *args)
159
+ self.class.run_verb_hook_for(hook, verb, self, *args)
160
+ end
161
+
162
+ def known_verbs
163
+ self.class.smtp_verbs.map{|k, v| k if v.has_key?(:method)}.compact
164
+ end
165
+
166
+ def smtp_verb(verb)
167
+ hooks = self.class.smtp_verbs[verb]
168
+ unless hooks.nil?
169
+ hooks[:method]
170
+ end
171
+ end
172
+
173
+ def do_verb(verb, args)
174
+ args = args.to_s.strip
175
+ run_verb_hook :validate, verb, args
176
+ if smtp_verb(verb).nil?
177
+ verb_missing verb, args
178
+ else
179
+ send smtp_verb(verb), args
180
+ end
181
+ end
182
+
183
+ # Lines which do not have a valid verb
184
+ def do_garbage(garbage)
185
+ response_syntax_error :message=>"syntax error - invalid character"
186
+ end
187
+
188
+ def verb_missing(verb, parameters)
189
+ end
190
+
191
+ def mail_params_valid(params)
192
+ params.each do |name, value|
193
+ return false unless self.class.mail_parameters.has_key? name
194
+ end
195
+ true
196
+ end
197
+
198
+ def process_mail_params(params)
199
+ params.each do |name, value|
200
+ send self.class.mail_parameters[name], value
201
+ end
202
+ end
203
+
204
+ # Does the client support SMTP extensions?
205
+ def esmtp?
206
+ false
207
+ end
208
+
209
+ # Did the client successfully authenticate?
210
+ def authenticated?
211
+ false
212
+ end
213
+
214
+ # Return the protocol name for use with "WITH" in the Received: header
215
+ def protocol
216
+ # This could return "SMTPS" which is non-standard is two cases:
217
+ # - Client sends EHLO -> STARTTLS -> HELO sequence
218
+ # - If using implicit TLS (i.e. non-standard port 465)
219
+ (esmtp? ? "E" : "") + "SMTP" + (secure? ? "S" : "") + (authenticated? ? "A" : "")
220
+ end
221
+
222
+
223
+ # RFC 5321 § 2.2.2: "verbs [...] are bound by the same rules as EHLO i
224
+ # keywords"; § 4.1.1.1 defines it as /\A[A-Za-z0-9]([A-Za-z0-9-]*)\Z/
225
+ # This splits the verb off and then finds the correct method to call
226
+ VERB = /\A[A-Za-z0-9]([A-Za-z0-9-]*)\Z/
227
+ def process_line(line)
228
+ k, v = line.chomp.split(' ', 2)
229
+ if k.to_s !~ VERB
230
+ run :do_garbage, line
231
+ end
232
+ k = k.to_s.upcase.tr('-', '_').intern
233
+ run_hook :before_all_verbs, k
234
+ run_verb_hook :before, k
235
+ res = run :do_verb, k, v.to_s.strip
236
+ run_verb_hook :after, k
237
+ run_hook :after_all_verbs, k
238
+ res
239
+ end
240
+
241
+ def parse_params(param_str)
242
+ params = {}
243
+ param_str.split(' ').each do |p|
244
+ k, v = p.split('=', 2)
245
+ k = k.intern
246
+ params[k] = v
247
+ end
248
+ params
249
+ end
250
+
251
+ ## Path handling functions
252
+ # From RFC5321 section 4.1.2
253
+ R_Let_dig = '[0-9a-z]'
254
+ R_Ldh_str = "[0-9a-z-]*#{R_Let_dig}"
255
+ R_sub_domain = "#{R_Let_dig}(#{R_Ldh_str})?"
256
+ R_Domain = "#{R_sub_domain}(\\.#{R_sub_domain})*"
257
+ # The RHS domain syntax is explicitly from RFC2821; see
258
+ # http://www.imc.org/ietf-smtp/mail-archive/msg05431.html
259
+ R_RHS_Domain = "#{R_sub_domain}(\\.#{R_sub_domain})+"
260
+ R_At_domain = "@#{R_Domain}"
261
+ R_A_d_l = "#{R_At_domain}(,#{R_At_domain})*"
262
+
263
+ R_atext = "[a-z0-9!\#$%&'*+\\/=?^_`{|}~-]"
264
+ R_Atom = "#{R_atext}+"
265
+ R_Dot_string = "#{R_Atom}(\\.#{R_Atom})*"
266
+ R_qtextSMTP = "[\\040-\\041\\043-\\133\\135-\\176]"
267
+ R_quoted_pairSMTP = "\\134[\\040-\\176]"
268
+ R_Quoted_string = "\"(#{R_qtextSMTP}|#{R_quoted_pairSMTP})*\""
269
+
270
+ R_Local_part = "(#{R_Dot_string}|#{R_Quoted_string})"
271
+
272
+ # This should really be 0-255 with no leading zeros
273
+ R_Snum = "(0|[1-9][0-9]{0,2})"
274
+ R_IPv4_address_literal = "#{R_Snum}(\.#{R_Snum}){3}"
275
+ R_IPv6_hex = "[0-9a-f]{1,4}"
276
+ R_IPv6_full = "#{R_IPv6_hex}(:#{R_IPv6_hex}){7}"
277
+ R_IPv6_comp = "(#{R_IPv6_hex}(:#{R_IPv6_hex}){0,5})?::(#{R_IPv6_hex}(:#{R_IPv6_hex}){0,5})?"
278
+ R_IPv6v4_full = "#{R_IPv6_hex}(:#{R_IPv6_hex}){3}:#{R_IPv4_address_literal}"
279
+ R_IPv6v4_comp = "(#{R_IPv6_hex}(:#{R_IPv6_hex}){0,3})?::(#{R_IPv6_hex}(:#{R_IPv6_hex}){0,3})?:#{R_IPv4_address_literal}"
280
+ R_IPv6_address_literal = "IPv6:(#{R_IPv6_full}|#{R_IPv6_comp}|#{R_IPv6v4_full}|#{R_IPv6v4_comp})"
281
+ # RFC 5321 § 4.1.3 "Standardized-tag MUST be specified in a i
282
+ # Standards-Track RFC and registered with IANA
283
+ # At this point, only "IPv6" has been register, which
284
+ # already handled. Therefore we are using a slightly simpler regex
285
+ #R_dcontent = "[\\041-\\132\\136-\\176]"
286
+ #R_General_address_literal = "#{R_Ldh_str}:(#{R_dcontent}+)"
287
+ #R_address_literal = "\\[(#{R_IPv4_address_literal}|#{R_IPv6_address_literal}|#{R_General_address_literal})\\]"
288
+ R_address_literal = "\\[(#{R_IPv4_address_literal}|#{R_IPv6_address_literal})\\]"
289
+
290
+ R_Mailbox = "#{R_Local_part}@(#{R_RHS_Domain}|#{R_address_literal})"
291
+
292
+ # For example, the EHLO/HELO parameter
293
+ DOMAIN_OR_LITERAL = /\A(#{R_Domain}|#{R_address_literal})\Z/i
294
+
295
+ R_Path = "<(#{R_A_d_l}:)?#{R_Mailbox}>"
296
+
297
+ # For example, an unquoted local part of a mailbox
298
+ DOT_STRING = /\A#{R_Dot_string}\Z/i
299
+
300
+ # MatchData[1] is the local part and [4] is the domain or address literal
301
+ MAILBOX = /\A#{R_Mailbox}\Z/i
302
+
303
+ # If a string begins with a path (allows for characters after the path)
304
+ # MatchData[1] is the Source Route, [9] is the local part, and
305
+ # [12] is the domain or address literal
306
+ PATH_PART = /\A#{R_Path}/i
307
+
308
+ # Only has path (vs. starts with path)
309
+ # Same MatchData as PATH_PART
310
+ PATH= /\A#{R_Path}\Z/i
311
+
312
+ EXCESSIVE_QUOTE = /\134([^\041\134])/
313
+
314
+ def valid_address_literal(literal)
315
+ return false unless literal.start_with? '['
316
+ return false unless literal.end_with? ']'
317
+ begin
318
+ IPAddr.new(literal[1..-2])
319
+ rescue ::ArgumentError
320
+ return false
321
+ end
322
+ true
323
+ end
324
+
325
+ def split_path(args)
326
+ m = args =~ PATH_PART
327
+ if m.nil?
328
+ [nil, args]
329
+ else
330
+ response = [$~.to_s, $'.strip]
331
+ if $~[12].start_with? '['
332
+ return [nil, args] unless valid_address_literal $~[12]
333
+ end
334
+ response
335
+ end
336
+ end
337
+
338
+ def normalize_local_part(local)
339
+ if local.start_with? '"'
340
+ local.gsub!(EXCESSIVE_QUOTE, '\1')
341
+ local = local[1..-2] if local[1..-2] =~ DOT_STRING
342
+ end
343
+ local
344
+ end
345
+
346
+ # Remove the leading '<', trailing '>', switch domains lower case and
347
+ # remove unnecessary quoting in the localpart
348
+ def normalize_path(path)
349
+ return '' if path.eql? '<>'
350
+ path =~ PATH
351
+ $~[1].to_s.downcase + normalize_local_part($~[9]) + "@" + $~[12].downcase
352
+ end
353
+
354
+ def normalize_mailbox(addr)
355
+ addr =~ MAILBOX
356
+ normalize_local_part($~[1]) + "@" + $~[4].downcase
357
+ end
358
+
359
+ # Defined in RFC 3461 § 4, referenced in RFC 5321 § 4.1.2
360
+ R_xchar_list = "\\041-\\052\\054\\074\\076-\\176"
361
+ R_xtext_hexchar = "\\053[0-9A-F]{2}"
362
+ XTEXT = /\A([#{R_xchar_list}]|#{R_xtext_hexchar})*\Z/
363
+ XTEXT_HEXSEQ = /#{R_xtext_hexchar}/
364
+ XTEXT_NOT_XCHAR = /[^#{R_xchar_list}]/
365
+
366
+ def from_xtext(str)
367
+ if str =~ XTEXT
368
+ str.gsub!(XTEXT_HEXSEQ) {|s| s[1..2].hex.chr }
369
+ end
370
+ end
371
+
372
+ def to_xtext(str)
373
+ str.gsub!(XTEXT_NOT_XCHAR) {|s| '+' + s[0].to_s(16).upcase }
374
+ end
375
+ end
376
+ end
377
+ end