libis-mapi 0.3.1

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,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
+