mime 0.3.0 → 0.4.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.
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