mime 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. data/README.rdoc +192 -140
  2. data/lib/mime/composite_media.rb +230 -0
  3. data/lib/mime/content_formats/text_flowed.rb +6 -6
  4. data/lib/mime/discrete_media.rb +73 -0
  5. data/lib/mime/discrete_media_factory.rb +19 -13
  6. data/lib/mime/header.rb +48 -0
  7. data/lib/mime/headers/internet.rb +124 -27
  8. data/lib/mime/headers/mime.rb +38 -27
  9. data/lib/mime/mail.rb +51 -0
  10. data/lib/mime/media.rb +30 -0
  11. data/lib/mime/parser.rb +1 -1
  12. data/lib/mime.rb +27 -9
  13. data/test/scaffold/application.msg +2 -5
  14. data/test/scaffold/audio.msg +2 -5
  15. data/test/scaffold/image.msg +0 -0
  16. data/test/scaffold/multipart_alternative.msg +7 -7
  17. data/test/scaffold/multipart_alternative_related.msg +0 -0
  18. data/test/scaffold/multipart_form_data_file_and_text.msg +0 -0
  19. data/test/scaffold/multipart_form_data_mixed.msg +0 -0
  20. data/test/scaffold/multipart_form_data_text.msg +12 -12
  21. data/test/scaffold/multipart_mixed_inline_and_attachment.msg +0 -0
  22. data/test/scaffold/multipart_mixed_inline_and_attachment2.msg +0 -0
  23. data/test/scaffold/multipart_related.msg +0 -0
  24. data/test/scaffold/rfc822_composite.msg +0 -0
  25. data/test/scaffold/{plain_text_email.msg → rfc822_discrete.msg} +5 -5
  26. data/test/scaffold/text.msg +2 -5
  27. data/test/scaffold/video.msg +2 -5
  28. data/test/test_mime.rb +323 -150
  29. data/test/test_text_flowed.rb +1 -1
  30. metadata +13 -12
  31. data/lib/mime/composite_media_type.rb +0 -169
  32. data/lib/mime/discrete_media_type.rb +0 -78
  33. data/lib/mime/header_container.rb +0 -32
  34. data/lib/mime/media_type.rb +0 -51
  35. data/lib/mime/message.rb +0 -61
@@ -0,0 +1,230 @@
1
+ module MIME
2
+
3
+ #
4
+ # Composite media types allow encapsulating, mixing, and hierarchical
5
+ # structuring of entities of different types within a single message.
6
+ # Therefore, a CompositeMedia body is composed of one or more CompositeMedia
7
+ # and/or DiscreteMedia objects.
8
+ #
9
+ # CompositeMedia implements Content-Disposition for dictating presentation
10
+ # style of body entities via #add, #attach, and #inline. For more information
11
+ # on disposition parameters, such as filename, size, and modification-date,
12
+ # see https://tools.ietf.org/html/rfc2183.
13
+ #
14
+ # This class is abstract.
15
+ #
16
+ class CompositeMedia < Media
17
+
18
+ class Body
19
+ #
20
+ # Create new composite body.
21
+ #
22
+ def initialize boundary
23
+ @boundary = boundary
24
+ @body = Array.new
25
+ end
26
+
27
+ #
28
+ # Format the CompositeMedia object as a MIME message.
29
+ #
30
+ def to_s
31
+ all_entities = @body.join("\r\n--#{@boundary}\r\n")
32
+ "--#{@boundary}\r\n#{all_entities}\r\n--#{@boundary}--\r\n"
33
+ end
34
+
35
+ #
36
+ # Add +entity+ to the composite body.
37
+ #
38
+ def add entity
39
+ @body.push(entity)
40
+ end
41
+ end
42
+
43
+
44
+ attr_reader :boundary
45
+
46
+ def initialize content_type
47
+ AbstractClassError.no_instantiation(self, CompositeMedia)
48
+ @boundary = "Boundary_#{ID.generate_id}" # delimits body entities
49
+ super(Body.new(boundary), content_type, 'boundary' => boundary)
50
+ end
51
+
52
+ #
53
+ # Add a Media +entity+ to the message.
54
+ #
55
+ # The entity will be added to the main body of the message with no
56
+ # disposition specified. Presentation of the entity will be dictated by
57
+ # the display user agent.
58
+ #
59
+ # === Text and HTML Multipart/Alternative message
60
+ #
61
+ # A display user agent may only be capable of displaying plain text. If so,
62
+ # it will choose to display the Text/Plain entity. However, if it is capable
63
+ # of displaying HTML, it may choose to display the Text/HTML version.
64
+ #
65
+ # msg = MIME::Multipart::Alternative.new
66
+ # msg.add(MIME::Text.new('plain text'))
67
+ # msg.add(MIME::Text.new('<html>html text</html>', 'html'))
68
+ #
69
+ # The order in which the entities are added is significant. Add the simplest
70
+ # representations first.
71
+ #
72
+ def add entity
73
+ raise Error.new('can only add Media objects') unless entity.is_a? Media
74
+ @body.add(entity)
75
+ end
76
+
77
+ #
78
+ # Attach a Media +entity+ to the message.
79
+ #
80
+ # The entity will be presented as separate from the main body of the
81
+ # message. Thus, display of the entity will not be automatic, but contingent
82
+ # upon some further action of the user. For example, the display user agent
83
+ # may present an icon representation of the entity, which the user can
84
+ # select to view or save the entity.
85
+ #
86
+ # === Attachment with filename and size parameters:
87
+ #
88
+ # f = File.open('file.txt')
89
+ # file = MIME::Text.new(f.read)
90
+ # text = MIME::Text.new('See the attached file.')
91
+ #
92
+ # msg = MIME::Multipart::Mixed.new
93
+ # msg.inline(text)
94
+ # msg.attach(file, 'filename' => f.path, 'size' => f.size)
95
+ #
96
+ def attach entity, params = {}
97
+ entity.set_disposition('attachment', params)
98
+ add(entity)
99
+ end
100
+
101
+ #
102
+ # Inline a Media +entity+ in the message.
103
+ #
104
+ # The entity will be embedded within the main body of the message. Thus,
105
+ # display of the entity will be automatic upon display of the message.
106
+ # Inline entities should be added in the order in which they occur within
107
+ # the message.
108
+ #
109
+ # === Message with two embedded images:
110
+ #
111
+ # msg = MIME::Multipart::Mixed.new
112
+ # msg.inline(MIME::Image.new(File.read('screenshot1.png'), 'png'))
113
+ # msg.inline(MIME::Image.new(File.read('screenshot2.png'), 'png'))
114
+ # msg.description = 'My screenshots'
115
+ #
116
+ def inline entity, params = {}
117
+ entity.set_disposition('inline', params)
118
+ add(entity)
119
+ end
120
+
121
+ end
122
+
123
+ #
124
+ # Message is intended to encapsulate another message. In particular, the
125
+ # <em>message/rfc822</em> content type is used to encapsulate RFC 822
126
+ # messages.
127
+ #
128
+ # TODO Implement
129
+ #
130
+ class Message < CompositeMedia
131
+ end
132
+
133
+ #
134
+ # The abstract base class for all multipart message subtypes. The entities of
135
+ # a multipart message are delimited by a unique boundary.
136
+ #
137
+ class Multipart < CompositeMedia
138
+ def initialize media_subtype
139
+ AbstractClassError.no_instantiation(self, Multipart)
140
+ super("multipart/#{media_subtype}")
141
+ end
142
+ end
143
+
144
+ #
145
+ # The Alternative subtype indicates that each contained entity is an
146
+ # alternatively formatted version of the same content. The most complex
147
+ # version should be added to the message first, i.e. it will be sequentially
148
+ # last in the message.
149
+ #
150
+ class Multipart::Alternative < Multipart
151
+
152
+ #
153
+ # Returns a Multipart::Alternative object with a content type of
154
+ # multipart/alternative.
155
+ #
156
+ def initialize
157
+ super('alternative')
158
+ end
159
+
160
+ end
161
+
162
+ #
163
+ # The FormData subtype expresses values for HTML form data submissions.
164
+ # ---
165
+ # RFCs consulted during implementation:
166
+ #
167
+ # * RFC-1867 Form-based File Upload in HTML
168
+ # * RFC-2388 Returning Values from Forms: multipart/form-data
169
+ #
170
+ class Multipart::FormData < Multipart
171
+
172
+ #
173
+ # Returns a Multipart::FormData object with a content type of
174
+ # multipart/form-data.
175
+ #
176
+ def initialize
177
+ super('form-data')
178
+ end
179
+
180
+ #
181
+ # Add the Media object, +entity+, to the FormData object. +name+ is
182
+ # typically an HTML input tag variable name. If the input tag is of type
183
+ # _file_, then +filename+ must be specified to indicate a file upload.
184
+ #
185
+ def add entity, name, filename = nil
186
+ entity.set_disposition('form-data', 'name' => name, 'filename' => filename)
187
+ super(entity)
188
+ end
189
+
190
+ end
191
+
192
+ #
193
+ # The Mixed subtype aggregates contextually independent entities.
194
+ #
195
+ class Multipart::Mixed < Multipart
196
+
197
+ #
198
+ # Returns a Multipart::Mixed object with a content type of
199
+ # multipart/mixed.
200
+ #
201
+ def initialize
202
+ super('mixed')
203
+ end
204
+
205
+ end
206
+
207
+ #
208
+ # The Related subtype aggregates multiple related entities. The message
209
+ # consists of a root (the first entity) which references subsequent inline
210
+ # entities. Message entities should be referenced by their Content-ID header.
211
+ # The syntax of a reference is unspecified and is instead dictated by the
212
+ # encoding or protocol used in the entity.
213
+ # ---
214
+ # RFC consulted during implementation:
215
+ #
216
+ # * RFC-2387 The MIME Multipart/Related Content-type
217
+ #
218
+ class Multipart::Related < Multipart
219
+
220
+ #
221
+ # Returns a Multipart::Related object with a content type of
222
+ # multipart/related.
223
+ #
224
+ def initialize
225
+ super('related')
226
+ end
227
+
228
+ end
229
+
230
+ end
@@ -41,15 +41,15 @@ module MIME::ContentFormats
41
41
  # Encode plain +text+ into flowed format, reducing long lines to +max+
42
42
  # characters or less using soft line breaks (i.e., SPACE+CRLF).
43
43
  #
44
- # The default +max+ line length is 79 characters. According to the RFC, 66
45
- # and 72 characters is also common.
44
+ # According to the RFC, the +max+ flowed line length is 79 characters. Line
45
+ # lengths of 66 and 72 characters are common.
46
46
  #
47
47
  # The features of RFC 2646, such as line quoting and space-stuffing,
48
48
  # are not implemented.
49
49
  #
50
50
  def self.encode(text, max = MAX_FLOWED_LINE)
51
- if max > 79
52
- raise ArgumentError, 'flowed lines must be 79 characters or less'
51
+ if max > MAX_FLOWED_LINE
52
+ raise ArgumentError, "flowed lines must be #{MAX_FLOWED_LINE} characters or less"
53
53
  end
54
54
 
55
55
  out = []
@@ -83,7 +83,7 @@ module MIME::ContentFormats
83
83
  if word.size < max
84
84
  line << word + char
85
85
  else
86
- word.scan(/.{1,#{MAX_SMTP_LINE}}/) {|s| out << s }
86
+ word.scan(/.{1,#{MIME::MAX_LINE_LENGTH}}/) {|s| out << s }
87
87
  end
88
88
  end
89
89
  word.clear
@@ -98,7 +98,7 @@ module MIME::ContentFormats
98
98
  out << line + word
99
99
  else
100
100
  out << line unless line.empty?
101
- word.scan(/.{1,#{MAX_SMTP_LINE}}/) {|s| out << s }
101
+ word.scan(/.{1,#{MIME::MAX_LINE_LENGTH}}/) {|s| out << s }
102
102
  end
103
103
  elsif ! line.empty?
104
104
  out << line
@@ -0,0 +1,73 @@
1
+ module MIME
2
+
3
+ #
4
+ # Discrete media must be handled by non-MIME mechanisms; they are opaque to
5
+ # MIME processors. Therefore, the body of a DiscreteMedia object does not need
6
+ # further MIME processing.
7
+ #
8
+ # This class is abstract.
9
+ #
10
+ class DiscreteMedia < Media
11
+ def initialize(content, content_type, content_params)
12
+ AbstractClassError.no_instantiation(self, DiscreteMedia)
13
+ super
14
+ end
15
+ end
16
+
17
+ #
18
+ # Application is intended for discrete data that is to be processed by some
19
+ # type of application program. The body contains information which must be
20
+ # processed by an application before it is viewable or usable by a user.
21
+ #
22
+ # Application is the catch all class. If your content cannot be identified as
23
+ # another DiscreteMedia, then it is application media.
24
+ #
25
+ class Application < DiscreteMedia
26
+ def initialize(body, subtype = 'octet-stream', params = {})
27
+ super(body, "application/#{subtype}", params)
28
+ end
29
+ end
30
+
31
+ #
32
+ # Audio is intended for discrete audio content. The +subtype+ indicates the
33
+ # specific audio format, such as *mpeg* or *midi*.
34
+ #
35
+ class Audio < DiscreteMedia
36
+ def initialize(body, subtype = 'basic', params = {})
37
+ super(body, "audio/#{subtype}", params)
38
+ end
39
+ end
40
+
41
+ #
42
+ # Image is intented for discrete image content. The +subtype+ indicates the
43
+ # specific image format, such as *jpeg* or *gif*.
44
+ #
45
+ class Image < DiscreteMedia
46
+ def initialize(body, subtype = 'jpeg', params = {})
47
+ super(body, "image/#{subtype}", params)
48
+ end
49
+ end
50
+
51
+ #
52
+ # Text is intended for content which is principally textual in form. The
53
+ # +subtype+ indicates the specific text type, such as *plain* or *html*.
54
+ #
55
+ class Text < DiscreteMedia
56
+ def initialize(body, subtype = 'plain', params = {})
57
+ super(body, "text/#{subtype}", params)
58
+ end
59
+ end
60
+
61
+ #
62
+ # Video is intended for discrete video content. The content +subtype+
63
+ # indicates the specific video format. The RFC describes video media as
64
+ # content that contains a time-varying-picture image, possibly with color and
65
+ # coordinated sound.
66
+ #
67
+ class Video < DiscreteMedia
68
+ def initialize(body, subtype = 'mpeg', params = {})
69
+ super(body, "video/#{subtype}", params)
70
+ end
71
+ end
72
+
73
+ end
@@ -1,16 +1,20 @@
1
1
  module MIME
2
2
 
3
3
  #
4
- # Module used only for initializing derived DiscreteMediaType objects.
4
+ # Module used only for initializing derived DiscreteMedia objects.
5
5
  #
6
6
  module DiscreteMediaFactory
7
7
 
8
+ module DispositionParameters
9
+ attr_accessor :path, :size
10
+ end
11
+
8
12
  class << self
9
13
 
10
14
  include ContentTypes
11
15
 
12
16
  #
13
- # Creates a corresponding DiscreteMediaType subclass object for the given
17
+ # Creates a corresponding DiscreteMedia subclass object for the given
14
18
  # +file+ based on +file+'s filename extension. +file+ can be a file path
15
19
  # or File object.
16
20
  #
@@ -22,14 +26,16 @@ module MIME
22
26
  # +path+ method is utilized by other methods in the MIME library,
23
27
  # therefore, eliminating redundant and explicit filename assignments.
24
28
  #
25
- # ==== Comparison Example
29
+ # === Comparison Example
26
30
  #
27
- # entity1 = open('/tmp/file1.txt')
28
- # entity2 = DiscreteMediaFactory.create('/tmp/file2.txt')
31
+ # file1 = '/tmp/file1.txt'
32
+ # file2 = '/tmp/file2.txt'
33
+ # entity1 = Text.new(File.read(file1))
34
+ # entity2 = DiscreteMediaFactory.create(file2)
29
35
  #
30
36
  # mixed_msg = Multipart::Mixed.new
31
- # mixed_msg.attach_entity(entity1.read, entity.path)
32
- # mixed_msg.attach_entity(entity2) # no path needed
37
+ # mixed_msg.attach(entity1, 'filename' => file1)
38
+ # mixed_msg.attach(entity2) # filename automatically added
33
39
  #
34
40
  def create file, content_type = nil
35
41
  if file.is_a? File
@@ -49,15 +55,15 @@ module MIME
49
55
 
50
56
  media_obj =
51
57
  case type
52
- when 'application'; ApplicationMedia.new(cntnt, subtype)
53
- when 'audio' ; AudioMedia.new(cntnt, subtype)
54
- when 'image' ; ImageMedia.new(cntnt, subtype)
55
- when 'text' ; TextMedia.new(cntnt, subtype)
56
- when 'video' ; VideoMedia.new(cntnt, subtype)
58
+ when 'application'; Application.new(cntnt, subtype)
59
+ when 'audio' ; Audio.new(cntnt, subtype)
60
+ when 'image' ; Image.new(cntnt, subtype)
61
+ when 'text' ; Text.new(cntnt, subtype)
62
+ when 'video' ; Video.new(cntnt, subtype)
57
63
  else raise UnknownContentError, "invalid content type: #{ctype}"
58
64
  end
59
65
 
60
- class << media_obj; attr_accessor :path end
66
+ media_obj.extend(DispositionParameters)
61
67
  media_obj.path = fname
62
68
  media_obj
63
69
  end
@@ -0,0 +1,48 @@
1
+ module MIME
2
+
3
+ #
4
+ # Header section for Internet and MIME messages.
5
+ #
6
+ class Header
7
+
8
+ def initialize
9
+ @headers = Hash.new
10
+ end
11
+
12
+ #
13
+ # Convert all headers to their string equivalents and join them using the
14
+ # RFC 2822 CRLF line separator.
15
+ #--
16
+ # TODO fold lines to 78 chars.
17
+ # word.scan(/(.,?){1,78}/) OR word.split
18
+ #
19
+ def to_s
20
+ @headers.to_a.map {|kv| kv.join(": ")}.join("\r\n")
21
+ end
22
+
23
+ #
24
+ # Get header value associated with +name+.
25
+ #
26
+ def get name
27
+ _, value = @headers.find {|k,v| name.downcase == k.downcase }
28
+ value
29
+ end
30
+
31
+ #
32
+ # Set header +name+ to +value+. If a header of the same name exists it will
33
+ # be overwritten. Header names are _case-insensitive_.
34
+ #
35
+ def set name, value
36
+ delete(name)
37
+ @headers.store(name, value)
38
+ end
39
+
40
+ #
41
+ # Delete header associated with +name+.
42
+ #
43
+ def delete name
44
+ @headers.delete_if {|k| name.downcase == k.downcase }
45
+ end
46
+
47
+ end
48
+ end
@@ -4,8 +4,18 @@ module MIME
4
4
  #
5
5
  # The RFC 2822 Internet message header fields.
6
6
  #
7
+ # Mailbox fields #to, #from, #cc, #bcc, and #reply_to may be a single email
8
+ # address, an array of email addresses, or a hash of _email_ => _name_
9
+ # pairs. When using a hash, set _name_ to +nil+ to omit email display name.
10
+ # The #sender field is a special case and contain only a single mailbox.
11
+ #
7
12
  module Internet
8
13
 
14
+ # Internet message character specifications (RFC 5322)
15
+ ATOM = /[[:alnum:]!#\$%&'*+\/=?^_`{|}~-]/
16
+ DOT_ATOM = /^#{ATOM}+(#{ATOM}|\.)*$/
17
+ SPECIALS = /[()<>\[\]:;@\,."]/
18
+
9
19
  attr_reader(
10
20
  # Required Headers
11
21
  :to,
@@ -15,76 +25,163 @@ module MIME
15
25
  # Optional Headers
16
26
  :cc,
17
27
  :bcc,
28
+ :sender,
18
29
  :reply_to,
19
30
  :message_id,
31
+ :in_reply_to,
32
+ :references,
20
33
  :comments,
21
34
  :keywords,
22
35
  :subject
23
36
  )
24
37
 
38
+ #
39
+ # Origination date at which the creator of the message indicated that the
40
+ # message was complete and ready to enter the mail delivery system.
41
+ #
25
42
  def date= date
26
43
  @date = date
27
- headers.add('Date', @date)
44
+ headers.set('Date', date.rfc2822)
28
45
  end
29
46
 
30
- def from= list
31
- @from = stringify_email_list(list)
32
- headers.add('From', @from)
47
+ #
48
+ # Person(s) or system(s) responsible for writing the message.
49
+ #
50
+ def from= mailbox
51
+ @from = mailbox
52
+ headers.set('From', stringify_mailbox(mailbox))
53
+ end
54
+
55
+ #
56
+ # Mailbox of the agent responsible for actual transmission of the message.
57
+ # Sender field is required if the From field contains multiple mailboxes.
58
+ #
59
+ # === Example scenario
60
+ # If a secretary were to send a message for another person, the mailbox of
61
+ # the secretary would appear in the Sender field and the mailbox of the
62
+ # actual author would appear in the From field.
63
+ #
64
+ def sender= mailbox
65
+ if (mailbox.is_a?(Hash) || mailbox.is_a?(Array)) && mailbox.size != 1
66
+ raise ArgumentError, '"Sender" must be a single mailbox specification'
67
+ end
68
+ @sender = mailbox
69
+ headers.set('Sender', stringify_mailbox(mailbox))
33
70
  end
34
71
 
35
- def to= list
36
- @to = stringify_email_list(list)
37
- headers.add('To', @to)
72
+ #
73
+ # Mailbox(es) of the primary recipient(s).
74
+ #
75
+ def to= mailbox
76
+ @to = mailbox
77
+ headers.set('To', stringify_mailbox(mailbox))
38
78
  end
39
79
 
40
- def cc= list
41
- @cc = stringify_email_list(list)
42
- headers.add('Cc', @cc)
80
+ #
81
+ # Mailbox(es) of others who are to receive the message, though the content
82
+ # of the message may not be directed at them; "Carbon Copy."
83
+ #
84
+ def cc= mailbox
85
+ @cc = mailbox
86
+ headers.set('Cc', stringify_mailbox(mailbox))
43
87
  end
44
88
 
45
- def bcc= list
46
- @bcc = stringify_email_list(list)
47
- headers.add('Bcc', @bcc)
89
+ #
90
+ # Mailbox(es) of recipients of the message whose addresses are not to be
91
+ # revealed to other recipients of the message; "Blind Carbon Copy."
92
+ #
93
+ def bcc= mailbox
94
+ @bcc = mailbox
95
+ headers.set('Bcc', stringify_mailbox(mailbox))
48
96
  end
49
97
 
50
- def reply_to= list
51
- @reply_to = stringify_email_list(list)
52
- headers.add('Reply-To', @reply_to)
98
+ #
99
+ # Mailbox(es) to which the author suggests that replies be sent.
100
+ #
101
+ def reply_to= mailbox
102
+ @reply_to = mailbox
103
+ headers.set('Reply-To', stringify_mailbox(mailbox))
53
104
  end
54
105
 
106
+ #
107
+ # Globally unique identifier of the message.
55
108
  #
56
109
  # The message +id+ must contain an embedded "@" symbol. An example +id+
57
110
  # might be <em>some-unique-id@domain.com</em>.
58
111
  #
59
112
  def message_id= id
60
- @message_id = "<#{id}>"
61
- headers.add('Message-ID', @message_id)
113
+ @message_id = id
114
+ headers.set('Message-ID', "<#{id}>")
115
+ end
116
+
117
+ #
118
+ # The +id+ of the message to which this message is a reply.
119
+ #--
120
+ # TODO fully implement and test
121
+ #
122
+ def in_reply_to= id
123
+ @in_reply_to = id
124
+ headers.set('In-Reply-To', "<#{id}>")
125
+ end
126
+
127
+ #
128
+ # The +id+ used to identify a "thread" of conversation.
129
+ #--
130
+ # TODO fully implement and test
131
+ #
132
+ def references= id
133
+ @references = id
134
+ headers.set('References', "<#{id}>")
62
135
  end
63
136
 
137
+ #
138
+ # Additional comments about the message content.
139
+ #
64
140
  def comments= comments
65
141
  @comments = comments
66
- headers.add('Comments', @comments)
142
+ headers.set('Comments', comments)
67
143
  end
68
144
 
145
+ #
146
+ # Comma-separated list of important words and phrases that might be useful
147
+ # for the recipient.
148
+ #
69
149
  def keywords= keywords
70
150
  @keywords = keywords
71
- headers.add('Keywords', @keywords)
151
+ headers.set('Keywords', keywords)
72
152
  end
73
153
 
154
+ #
155
+ # The message topic.
156
+ #
74
157
  def subject= subject
75
158
  @subject = subject
76
- headers.add('Subject', @subject)
159
+ headers.set('Subject', subject)
77
160
  end
78
161
 
79
162
 
80
163
  private
81
164
 
82
- #
83
- # +list+ may be a single email address or a Hash of _email_ => _name_
84
- # pairs. Set _name_ to nil when it is unknown.
85
- #
86
- def stringify_email_list list
87
- list.map {|email, name| name ? "#{name} <#{email}>" : email}.join(', ')
165
+ def stringify_mailbox mailbox
166
+ case mailbox
167
+ when Hash
168
+ mailbox.map do |email, name|
169
+ if name
170
+ if name =~ SPECIALS
171
+ name.gsub!('"', '\"')
172
+ %["#{name}" <#{email}>]
173
+ else
174
+ %[#{name} <#{email}>]
175
+ end
176
+ else
177
+ email
178
+ end
179
+ end.join(', ')
180
+ when Array
181
+ mailbox.join(', ')
182
+ else
183
+ return mailbox
184
+ end
88
185
  end
89
186
 
90
187
  end