libis-mapi 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,293 @@
1
+ require 'rubygems'
2
+ require 'tmail'
3
+
4
+ # these will be removed later
5
+ require 'time'
6
+ require 'mapi/mime'
7
+
8
+ # there is some Msg specific stuff in here.
9
+
10
+ class TMail::Mail
11
+ def quoted_body= str
12
+ body_port.wopen { |f| f.write str }
13
+ str
14
+ end
15
+ end
16
+
17
+ module Mapi
18
+ class Message
19
+ def mime
20
+ return @mime if @mime
21
+ # if these headers exist at all, they can be helpful. we may however get a
22
+ # application/ms-tnef mime root, which means there will be little other than
23
+ # headers. we may get nothing.
24
+ # and other times, when received from external, we get the full cigar, boundaries
25
+ # etc and all.
26
+ # sometimes its multipart, with no boundaries. that throws an error. so we'll be more
27
+ # forgiving here
28
+ @mime = Mime.new props.transport_message_headers.to_s, true
29
+ populate_headers
30
+ @mime
31
+ end
32
+
33
+ def headers
34
+ mime.headers
35
+ end
36
+
37
+ # copy data from msg properties storage to standard mime. headers
38
+ # i've now seen it where the existing headers had heaps on stuff, and the msg#props had
39
+ # practically nothing. think it was because it was a tnef - msg conversion done by exchange.
40
+ def populate_headers
41
+ # construct a From value
42
+ # should this kind of thing only be done when headers don't exist already? maybe not. if its
43
+ # sent, then modified and saved, the headers could be wrong?
44
+ # hmmm. i just had an example where a mail is sent, from an internal user, but it has transport
45
+ # headers, i think because one recipient was external. the only place the senders email address
46
+ # exists is in the transport headers. so its maybe not good to overwrite from.
47
+ # recipients however usually have smtp address available.
48
+ # maybe we'll do it for all addresses that are smtp? (is that equivalent to
49
+ # sender_email_address !~ /^\//
50
+ name, email = props.sender_name, props.sender_email_address
51
+ if props.sender_addrtype == 'SMTP'
52
+ headers['From'] = if name and email and name != email
53
+ [%{"#{name}" <#{email}>}]
54
+ else
55
+ [email || name]
56
+ end
57
+ elsif !headers.has_key?('From')
58
+ # some messages were never sent, so that sender stuff isn't filled out. need to find another
59
+ # way to get something
60
+ # what about marking whether we thing the email was sent or not? or draft?
61
+ # for partition into an eventual Inbox, Sent, Draft mbox set?
62
+ # i've now seen cases where this stuff is missing, but exists in transport message headers,
63
+ # so maybe i should inhibit this in that case.
64
+ if email
65
+ # disabling this warning for now
66
+ #Log.warn "* no smtp sender email address available (only X.400). creating fake one"
67
+ # this is crap. though i've specially picked the logic so that it generates the correct
68
+ # email addresses in my case (for my organisation).
69
+ # this user stuff will give valid email i think, based on alias.
70
+ user = name ? name.sub(/(.*), (.*)/, "\\2.\\1") : email[/\w+$/].downcase
71
+ domain = (email[%r{^/O=([^/]+)}i, 1].downcase + '.com' rescue email)
72
+ headers['From'] = [name ? %{"#{name}" <#{user}@#{domain}>} : "<#{user}@#{domain}>" ]
73
+ elsif name
74
+ # we only have a name? thats screwed up.
75
+ # disabling this warning for now
76
+ #Log.warn "* no smtp sender email address available (only name). creating fake one"
77
+ headers['From'] = [%{"#{name}"}]
78
+ else
79
+ # disabling this warning for now
80
+ #Log.warn "* no sender email address available at all. FIXME"
81
+ end
82
+ # else we leave the transport message header version
83
+ end
84
+
85
+ # for all of this stuff, i'm assigning in utf8 strings.
86
+ # thats ok i suppose, maybe i can say its the job of the mime class to handle that.
87
+ # but a lot of the headers are overloaded in different ways. plain string, many strings
88
+ # other stuff. what happens to a person who has a " in their name etc etc. encoded words
89
+ # i suppose. but that then happens before assignment. and can't be automatically undone
90
+ # until the header is decomposed into recipients.
91
+ recips_by_type = recipients.group_by { |r| r.type }
92
+ # i want to the the types in a specific order.
93
+ [:to, :cc, :bcc].each do |type|
94
+ # don't know why i bother, but if we can, we try to sort recipients by the numerical part
95
+ # of the ole name, or just leave it if we can't
96
+ recips = recips_by_type[type]
97
+ recips = (recips.sort_by { |r| r.obj.name[/\d{8}$/].hex } rescue recips)
98
+ # switched to using , for separation, not ;. see issue #4
99
+ # recips.empty? is strange. i wouldn't have thought it possible, but it was right?
100
+ headers[type.to_s.sub(/^(.)/) { $1.upcase }] = [recips.join(', ')] unless recips.empty?
101
+ end
102
+ headers['Subject'] = [props.subject] if props.subject
103
+
104
+ # fill in a date value. by default, we won't mess with existing value hear
105
+ if !headers.has_key?('Date')
106
+ # we want to get a received date, as i understand it.
107
+ # use this preference order, or pull the most recent?
108
+ keys = %w[message_delivery_time client_submit_time last_modification_time creation_time]
109
+ time = keys.each { |key| break time if time = props.send(key) }
110
+ time = nil unless Date === time
111
+
112
+ # now convert and store
113
+ # this is a little funky. not sure about time zone stuff either?
114
+ # actually seems ok. maybe its always UTC and interpreted anyway. or can be timezoneless.
115
+ # i have no timezone info anyway.
116
+ # in gmail, i see stuff like 15 Jan 2007 00:48:19 -0000, and it displays as 11:48.
117
+ # can also add .localtime here if desired. but that feels wrong.
118
+ headers['Date'] = [Time.iso8601(time.to_s).rfc2822] if time
119
+ # https://github.com/Scompler/ruby-msg/commit/86e2036f1a1b9b7eeb7a7abdea7a8054ef6ab6cf
120
+ # headers['Date'] = [time.rfc2822] if time
121
+ end
122
+
123
+ # some very simplistic mapping between internet message headers and the
124
+ # mapi properties
125
+ # any of these could be causing duplicates due to case issues. the hack in #to_mime
126
+ # just stops re-duplication at that point. need to move some smarts into the mime
127
+ # code to handle it.
128
+ mapi_header_map = [
129
+ [:internet_message_id, 'Message-ID'],
130
+ [:in_reply_to_id, 'In-Reply-To'],
131
+ # don't set these values if they're equal to the defaults anyway
132
+ [:importance, 'Importance', proc { |val| val.to_s == '1' ? nil : val }],
133
+ [:priority, 'Priority', proc { |val| val.to_s == '1' ? nil : val }],
134
+ [:sensitivity, 'Sensitivity', proc { |val| val.to_s == '0' ? nil : val }],
135
+ # yeah?
136
+ [:conversation_topic, 'Thread-Topic'],
137
+ # not sure of the distinction here
138
+ # :originator_delivery_report_requested ??
139
+ [:read_receipt_requested, 'Disposition-Notification-To', proc { |val| from }]
140
+ ]
141
+ mapi_header_map.each do |mapi, mime, *f|
142
+ next unless q = val = props.send(mapi) or headers.has_key?(mime)
143
+ next if f[0] and !(val = f[0].call(val))
144
+ headers[mime] = [val.to_s]
145
+ end
146
+ end
147
+
148
+ # redundant?
149
+ def type
150
+ props.message_class[/IPM\.(.*)/, 1].downcase rescue nil
151
+ end
152
+
153
+ # shortcuts to some things from the headers
154
+ %w[From To Cc Bcc Subject].each do |key|
155
+ define_method(key.downcase) { headers[key].join(' ') if headers.has_key?(key) }
156
+ end
157
+
158
+ def body_to_tmail
159
+ # to create the body
160
+ # should have some options about serializing rtf. and possibly options to check the rtf
161
+ # for rtf2html conversion, stripping those html tags or other similar stuff. maybe want to
162
+ # ignore it in the cases where it is generated from incoming html. but keep it if it was the
163
+ # source for html and plaintext.
164
+ if props.body_rtf or props.body_html
165
+ # should plain come first?
166
+ part = TMail::Mail.new
167
+ # its actually possible for plain body to be empty, but the others not.
168
+ # if i can get an html version, then maybe a callout to lynx can be made...
169
+ part.parts << TMail::Mail.parse("Content-Type: text/plain\r\n\r\n" + props.body) if props.body
170
+ # this may be automatically unwrapped from the rtf if the rtf includes the html
171
+ part.parts << TMail::Mail.parse("Content-Type: text/html\r\n\r\n" + props.body_html) if props.body_html
172
+ # temporarily disabled the rtf. its just showing up as an attachment anyway.
173
+ #mime.parts << Mime.new("Content-Type: text/rtf\r\n\r\n" + props.body_rtf) if props.body_rtf
174
+ # its thus currently possible to get no body at all if the only body is rtf. that is not
175
+ # really acceptable FIXME
176
+ part['Content-Type'] = 'multipart/alternative'
177
+ part
178
+ else
179
+ # check no header case. content type? etc?. not sure if my Mime class will accept
180
+ Log.debug "taking that other path"
181
+ # body can be nil, hence the to_s
182
+ TMail::Mail.parse "Content-Type: text/plain\r\n\r\n" + props.body.to_s
183
+ end
184
+ end
185
+
186
+ def to_tmail
187
+ # intended to be used for IPM.note, which is the email type. can use it for others if desired,
188
+ # YMMV
189
+ Log.warn "to_mime used on a #{props.message_class}" unless props.message_class == 'IPM.Note'
190
+ # we always have a body
191
+ mail = body = body_to_tmail
192
+
193
+ # If we have attachments, we take the current mime root (body), and make it the first child
194
+ # of a new tree that will contain body and attachments.
195
+ unless attachments.empty?
196
+ raise NotImplementedError
197
+ mime = Mime.new "Content-Type: multipart/mixed\r\n\r\n"
198
+ mime.parts << body
199
+ # i don't know any better way to do this. need multipart/related for inline images
200
+ # referenced by cid: urls to work, but don't want to use it otherwise...
201
+ related = false
202
+ attachments.each do |attach|
203
+ part = attach.to_mime
204
+ related = true if part.headers.has_key?('Content-ID') or part.headers.has_key?('Content-Location')
205
+ mime.parts << part
206
+ end
207
+ mime.headers['Content-Type'] = ['multipart/related'] if related
208
+ end
209
+
210
+ # at this point, mime is either
211
+ # - a single text/plain, consisting of the body ('taking that other path' above. rare)
212
+ # - a multipart/alternative, consiting of a few bodies (plain and html body. common)
213
+ # - a multipart/mixed, consisting of 1 of the above 2 types of bodies, and attachments.
214
+ # we add this standard preamble if its multipart
215
+ # FIXME preamble.replace, and body.replace both suck.
216
+ # preamble= is doable. body= wasn't being done because body will get rewritten from parts
217
+ # if multipart, and is only there readonly. can do that, or do a reparse...
218
+ # The way i do this means that only the first preamble will say it, not preambles of nested
219
+ # multipart chunks.
220
+ mail.quoted_body = "This is a multi-part message in MIME format.\r\n" if mail.multipart?
221
+
222
+ # now that we have a root, we can mix in all our headers
223
+ headers.each do |key, vals|
224
+ # don't overwrite the content-type, encoding style stuff
225
+ next if mail[key]
226
+ # some new temporary hacks
227
+ next if key =~ /content-type/i and vals[0] =~ /base64/
228
+ #next if mime.headers.keys.map(&:downcase).include? key.downcase
229
+ mail[key] = vals.first
230
+ end
231
+ # just a stupid hack to make the content-type header last
232
+ #mime.headers['Content-Type'] = mime.headers.delete 'Content-Type'
233
+
234
+ mail
235
+ end
236
+ end
237
+
238
+ class Attachment
239
+ def to_tmail
240
+ # TODO: smarter mime typing.
241
+ mimetype = props.attach_mime_tag || 'application/octet-stream'
242
+ part = TMail::Mail.parse "Content-Type: #{mimetype}\r\n\r\n"
243
+ part['Content-Disposition'] = %{attachment; filename="#{filename}"}
244
+ part['Content-Transfer-Encoding'] = 'base64'
245
+ part['Content-Location'] = props.attach_content_location if props.attach_content_location
246
+ part['Content-ID'] = props.attach_content_id if props.attach_content_id
247
+ # data.to_s for now. data was nil for some reason.
248
+ # perhaps it was a data object not correctly handled?
249
+ # hmmm, have to use read here. that assumes that the data isa stream.
250
+ # but if the attachment data is a string, then it won't work. possible?
251
+ data_str = if @embedded_msg
252
+ raise NotImplementedError
253
+ mime.headers['Content-Type'] = 'message/rfc822'
254
+ # https://github.com/Scompler/ruby-msg/commit/ea450e06878658abc3eab175c9b2abc71dd71a52
255
+ # mime.headers['Content-Type'] = ['message/rfc822']
256
+ # lets try making it not base64 for now
257
+ mime.headers.delete 'Content-Transfer-Encoding'
258
+ # not filename. rather name, or something else right?
259
+ # maybe it should be inline?? i forget attach_method / access meaning
260
+ mime.headers['Content-Disposition'] = [%{attachment; filename="#{@embedded_msg.subject}"}]
261
+ @embedded_msg.to_mime.to_s
262
+ elsif @embedded_ole
263
+ raise NotImplementedError
264
+ # kind of hacky
265
+ io = StringIO.new
266
+ Ole::Storage.new io do |ole|
267
+ ole.root.type = :dir
268
+ Ole::Storage::Dirent.copy @embedded_ole, ole.root
269
+ end
270
+ io.string
271
+ else
272
+ data.read.to_s
273
+ end
274
+ part.body = @embedded_msg ? data_str : Base64.encode64(data_str).gsub(/\n/, "\r\n")
275
+ part
276
+ end
277
+ end
278
+
279
+ class Msg < Message
280
+ def populate_headers
281
+ super
282
+ if !headers.has_key?('Date')
283
+ # can employ other methods for getting a time. heres one in a similar vein to msgconvert.pl,
284
+ # ie taking the time from an ole object
285
+ time = @root.ole.dirents.map { |dirent| dirent.modify_time || dirent.create_time }.compact.sort.last
286
+ headers['Date'] = [Time.iso8601(time.to_s).rfc2822] if time
287
+ # https://github.com/Scompler/ruby-msg/commit/86e2036f1a1b9b7eeb7a7abdea7a8054ef6ab6cf
288
+ # headers['Date'] = [time.rfc2822] if time
289
+ end
290
+ end
291
+ end
292
+ end
293
+
@@ -0,0 +1,69 @@
1
+ # we have two different "backends" for note conversion. we're sticking with
2
+ # the current (home grown) mime one until the tmail version is suitably
3
+ # polished.
4
+ require 'mapi/convert/note-mime'
5
+ require 'mapi/convert/contact'
6
+
7
+ module Mapi
8
+ class Message
9
+ CONVERSION_MAP = {
10
+ 'text/x-vcard' => [:to_vcard, 'vcf'],
11
+ 'message/rfc822' => [:to_mime, 'eml'],
12
+ 'text/plain' => [:to_post, 'txt']
13
+ # ...
14
+ }
15
+
16
+ # get the mime type of the message.
17
+ #
18
+ # @return [String] `message/rfc822` and so on
19
+ # @return [nil] Not known converion way
20
+ def mime_type
21
+ case props.message_class #.downcase <- have a feeling i saw other cased versions
22
+ when 'IPM.Contact'
23
+ # apparently "text/directory; profile=vcard" is what you're supposed to use
24
+ 'text/x-vcard'
25
+ when 'IPM.Note'
26
+ 'message/rfc822'
27
+ when 'IPM.Post'
28
+ 'text/plain'
29
+ when 'IPM.StickyNote'
30
+ 'text/plain' # hmmm....
31
+ else
32
+ Mapi::Log.warn 'unknown message_class - %p' % props.message_class
33
+ nil
34
+ end
35
+ end
36
+
37
+ # @return [Object] Use to_s
38
+ def convert
39
+ type = mime_type
40
+ unless pair = CONVERSION_MAP[type]
41
+ raise 'unable to convert message with mime type - %p' % type
42
+ end
43
+ send pair.first
44
+ end
45
+
46
+ # should probably be moved to mapi/convert/post
47
+ class Post
48
+ # not really sure what the pertinent properties are. we just do nothing for now...
49
+ #
50
+ # @param message [Message]
51
+ def initialize message
52
+ @message = message
53
+ end
54
+
55
+ # @return [String]
56
+ def to_s
57
+ # should maybe handle other types, like html body. need a better format for post
58
+ # probably anyway, cause a lot of meta data is getting chucked.
59
+ @message.props.body
60
+ end
61
+ end
62
+
63
+ # @return [Post]
64
+ def to_post
65
+ Post.new self
66
+ end
67
+ end
68
+ end
69
+
@@ -0,0 +1,46 @@
1
+ module Mapi
2
+ # This is a helper class for {Pst} and {Msg}.
3
+ class Helper
4
+ # @return [String, nil] Encoding name of ANSI string we assume
5
+ attr_reader :ansi_encoding
6
+
7
+ # @return [Boolean] Convert all ANSI string to UTF-8
8
+ attr_reader :to_unicode
9
+
10
+ # @param ansi_encoding [String]
11
+ # @param to_unicode [Boolean]
12
+ def initialize ansi_encoding=nil, to_unicode=false
13
+ @ansi_encoding = ansi_encoding || "BINARY"
14
+ @to_unicode = to_unicode
15
+ end
16
+
17
+ # Convert `ASCII_8BIT` string. Maybe produce UTF_8 string, or arbitrary object
18
+ #
19
+ # Use cases:
20
+ #
21
+ # - Decode PT_STRING8 in {Pst}
22
+ # - Decode `0x001e` in {Msg}
23
+ # - Decode body (rtf, text) in {PropertySet}
24
+ #
25
+ # @param str [String]
26
+ # @return [Object]
27
+ def convert_ansi_str str
28
+ return nil unless str
29
+ if @ansi_encoding
30
+ if @to_unicode
31
+ # assume we can convert this text to UTF-8
32
+ begin
33
+ str.force_encoding(@ansi_encoding).encode("UTF-8")
34
+ rescue Encoding::UndefinedConversionError => ex
35
+ # some text are already UTF-8 due to unknown reason
36
+ str.force_encoding("UTF-8").encode("UTF-8")
37
+ end
38
+ else
39
+ str.force_encoding(@ansi_encoding)
40
+ end
41
+ else
42
+ str
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/mapi/mime.rb ADDED
@@ -0,0 +1,227 @@
1
+ #
2
+ # = Introduction
3
+ #
4
+ # A *basic* mime class for _really_ _basic_ and probably non-standard parsing
5
+ # and construction of MIME messages.
6
+ #
7
+ # Intended for two main purposes in this project:
8
+ # 1. As the container that is used to build up the message for eventual
9
+ # serialization as an eml.
10
+ # 2. For assistance in parsing the +transport_message_headers+ provided in .msg files,
11
+ # which are then kept through to the final eml.
12
+ #
13
+ # = TODO
14
+ #
15
+ # * Better streaming support, rather than an all-in-string approach.
16
+ # * A fair bit remains to be done for this class, its fairly immature. But generally I'd like
17
+ # to see it be more generally useful.
18
+ # * All sorts of correctness issues, encoding particular.
19
+ # * Duplication of work in net/http.rb's +HTTPHeader+? Don't know if the overlap is sufficient.
20
+ # I don't want to lower case things, just for starters.
21
+ # * Mime was the original place I wrote #to_tree, intended as a quick debug hack.
22
+ #
23
+
24
+ require 'base64'
25
+
26
+ module Mapi
27
+ class Mime
28
+ # @return [Hash{String => Array}]
29
+ attr_reader :headers
30
+ # @return [String]
31
+ attr_reader :body
32
+ # @return [Mime]
33
+ attr_reader :parts
34
+ # @return [String]
35
+ attr_reader :content_type
36
+ # @return [String]
37
+ attr_reader :preamble
38
+ # @return [String]
39
+ attr_reader :epilogue
40
+
41
+ # Create a Mime object using +str+ as an initial serialization, which must contain headers
42
+ # and a body (even if empty). Needs work.
43
+ #
44
+ # @param str [String]
45
+ # @param ignore_body [Boolean]
46
+ def initialize str, ignore_body=false
47
+ headers, @body = $~[1..-1] if str[/(.*?\r?\n)(?:\r?\n(.*))?\Z/m]
48
+
49
+ @headers = Hash.new { |hash, key| hash[key] = [] }
50
+ @body ||= ''
51
+ headers.to_s.scan(/^\S+:\s*.*(?:\n\t.*)*/).each do |header|
52
+ @headers[header[/(\S+):/, 1]] << header[/\S+:\s*(.*)/m, 1].gsub(/\s+/m, ' ').strip # this is kind of wrong
53
+ end
54
+
55
+ # don't have to have content type i suppose
56
+ @content_type, attrs = nil, {}
57
+ if content_type = @headers['Content-Type'][0]
58
+ @content_type, attrs = Mime.split_header content_type
59
+ end
60
+
61
+ return if ignore_body
62
+
63
+ if multipart?
64
+ if body.empty?
65
+ @preamble = ''
66
+ @epilogue = ''
67
+ @parts = []
68
+ else
69
+ # we need to split the message at the boundary
70
+ boundary = attrs['boundary'] or raise "no boundary for multipart message"
71
+
72
+ # splitting the body:
73
+ parts = body.split(/--#{Regexp.quote boundary}/m)
74
+ unless parts[-1] =~ /^--/; warn "bad multipart boundary (missing trailing --)"
75
+ else parts[-1][0..1] = ''
76
+ end
77
+ parts.each_with_index do |part, i|
78
+ part =~ /^(\r?\n)?(.*?)(\r?\n)?\Z/m
79
+ part.replace $2
80
+ warn "bad multipart boundary" if (1...parts.length-1) === i and !($1 && $3)
81
+ end
82
+ @preamble = parts.shift
83
+ @epilogue = parts.pop
84
+ @parts = parts.map { |part| Mime.new part }
85
+ end
86
+ end
87
+ end
88
+
89
+ # @return [Boolean]
90
+ def multipart?
91
+ @content_type && @content_type =~ /^multipart/ ? true : false
92
+ end
93
+
94
+ # @return [String]
95
+ def inspect
96
+ # add some extra here.
97
+ "#<Mime content_type=#{@content_type.inspect}>"
98
+ end
99
+
100
+ # @return [String]
101
+ def to_tree
102
+ if multipart?
103
+ str = "- #{inspect}\n"
104
+ parts.each_with_index do |part, i|
105
+ last = i == parts.length - 1
106
+ part.to_tree.split(/\n/).each_with_index do |line, j|
107
+ str << " #{last ? (j == 0 ? "\\" : ' ') : '|'}" + line + "\n"
108
+ end
109
+ end
110
+ str
111
+ else
112
+ "- #{inspect}\n"
113
+ end
114
+ end
115
+
116
+ # Compose rfc822 eml file
117
+ #
118
+ # @param opts [Hash]
119
+ # @return [String]
120
+ def to_s opts={}
121
+ opts = {:boundary_counter => 0}.merge opts
122
+
123
+ body_encoder = proc { |body| body.bytes.pack("C*") }
124
+
125
+ if multipart?
126
+ boundary = Mime.make_boundary opts[:boundary_counter] += 1, self
127
+ @body = [preamble, parts.map { |part| "\r\n" + part.to_s(opts) + "\r\n" }, "--\r\n" + epilogue].
128
+ flatten.join("\r\n--" + boundary)
129
+ content_type, attrs = Mime.split_header @headers['Content-Type'][0]
130
+ attrs['boundary'] = boundary
131
+ @headers['Content-Type'] = [([content_type] + attrs.map { |key, val| %{#{key}="#{val}"} }).join('; ')]
132
+ else
133
+ raw_content_type = @headers['Content-Type'][0]
134
+ if raw_content_type
135
+ content_type, attrs = Mime.split_header raw_content_type
136
+ case content_type.split("/").first()
137
+ when "text"
138
+ attrs["charset"] = @body.encoding.name
139
+ @headers['Content-Type'] = [([content_type] + attrs.map { |key, val| %{#{key}="#{val}"} }).join('; ')]
140
+ end
141
+ end
142
+ end
143
+
144
+ # ensure non ASCII chars are well encoded (like rfc2047) by upper source
145
+ value_encoder = Proc.new { |val| val.encode("ASCII") }
146
+
147
+ str = ''
148
+ @headers.each do |key, vals|
149
+ case vals
150
+ when String
151
+ val = vals
152
+ str << "#{key}: #{value_encoder.call(val)}\r\n"
153
+ when Array
154
+ vals.each { |val| str << "#{key}: #{value_encoder.call(val)}\r\n" }
155
+ end
156
+ end
157
+ str << "\r\n"
158
+ str << body_encoder.call(@body)
159
+ end
160
+
161
+ # Compose encoded-word (rfc2047) for non-ASCII text
162
+ #
163
+ # @param str [String, nil]
164
+ # @return [String, nil]
165
+ # @private
166
+ def self.to_encoded_word str
167
+ # We can assume that str can produce valid byte array in regardless of encoding.
168
+
169
+ # Check if non-printable characters (including CR/LF) are inside.
170
+ if str && str.bytes.any? {|byte| byte <= 31 || 127 <= byte}
171
+ sprintf("=?%s?B?%s?=", str.encoding.name, Base64.strict_encode64(str))
172
+ else
173
+ str
174
+ end
175
+ end
176
+
177
+ # @param header [String]
178
+ # @return [Array(String, Hash{String => String})]
179
+ def self.split_header header
180
+ # FIXME: haven't read standard. not sure what its supposed to do with " in the name, or if other
181
+ # escapes are allowed. can't test on windows as " isn't allowed anyway. can be fixed with more
182
+ # accurate parser later.
183
+ # maybe move to some sort of Header class. but not all headers should be of it i suppose.
184
+ # at least add a join_header then, taking name and {}. for use in Mime#to_s (for boundary
185
+ # rewrite), and Attachment#to_mime, among others...
186
+ attrs = {}
187
+ header.scan(/;\s*([^\s=]+)\s*=\s*("[^"]*"|[^\s;]*)\s*/m).each do |key, value|
188
+ if attrs[key]; warn "ignoring duplicate header attribute #{key.inspect}"
189
+ else attrs[key] = value[/^"/] ? value[1..-2] : value
190
+ end
191
+ end
192
+
193
+ [header[/^[^;]+/].strip, attrs]
194
+ end
195
+
196
+ # +i+ is some value that should be unique for all multipart boundaries for a given message
197
+ #
198
+ # @return [String]
199
+ def self.make_boundary i, extra_obj = Mime
200
+ "----_=_NextPart_#{'%03d' % i}_#{'%08x' % extra_obj.object_id}.#{'%08x' % Time.now}"
201
+ end
202
+ end
203
+ end
204
+
205
+ =begin
206
+ things to consider for header work.
207
+ encoded words:
208
+ Subject: =?iso-8859-1?q?p=F6stal?=
209
+
210
+ and other mime funkyness:
211
+ Content-Disposition: attachment;
212
+ filename*0*=UTF-8''09%20%D7%90%D7%A5;
213
+ filename*1*=%20%D7%A1%D7%91-;
214
+ filename*2*=%D7%A7%95%A5.wma
215
+ Content-Transfer-Encoding: base64
216
+
217
+ and another, doing a test with an embedded newline in an attachment name, I
218
+ get this output from evolution. I get the feeling that this is probably a bug
219
+ with their implementation though, they weren't expecting new lines in filenames.
220
+ Content-Disposition: attachment; filename="asdf'b\"c
221
+ d efgh=i: ;\\j"
222
+ d efgh=i: ;\\j"; charset=us-ascii
223
+ Content-Type: text/plain; name="asdf'b\"c"; charset=us-ascii
224
+
225
+ =end
226
+
227
+