ruby-msg-nx 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/COPYING +20 -0
- data/ChangeLog +108 -0
- data/Home.md +133 -0
- data/Rakefile +52 -0
- data/bin/mapitool +204 -0
- data/data/mapitags.yaml +4168 -0
- data/data/named_map.yaml +114 -0
- data/data/types.yaml +15 -0
- data/lib/mapi/base.rb +104 -0
- data/lib/mapi/convert/contact.rb +142 -0
- data/lib/mapi/convert/note-mime.rb +282 -0
- data/lib/mapi/convert/note-tmail.rb +287 -0
- data/lib/mapi/convert.rb +69 -0
- data/lib/mapi/helper.rb +45 -0
- data/lib/mapi/mime.rb +227 -0
- data/lib/mapi/msg.rb +516 -0
- data/lib/mapi/property_set.rb +323 -0
- data/lib/mapi/pst.rb +1995 -0
- data/lib/mapi/rtf.rb +288 -0
- data/lib/mapi/types.rb +51 -0
- data/lib/mapi/version.rb +3 -0
- data/lib/mapi.rb +5 -0
- data/ruby-msg.gemspec +26 -0
- metadata +102 -0
@@ -0,0 +1,287 @@
|
|
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
|
+
end
|
120
|
+
|
121
|
+
# some very simplistic mapping between internet message headers and the
|
122
|
+
# mapi properties
|
123
|
+
# any of these could be causing duplicates due to case issues. the hack in #to_mime
|
124
|
+
# just stops re-duplication at that point. need to move some smarts into the mime
|
125
|
+
# code to handle it.
|
126
|
+
mapi_header_map = [
|
127
|
+
[:internet_message_id, 'Message-ID'],
|
128
|
+
[:in_reply_to_id, 'In-Reply-To'],
|
129
|
+
# don't set these values if they're equal to the defaults anyway
|
130
|
+
[:importance, 'Importance', proc { |val| val.to_s == '1' ? nil : val }],
|
131
|
+
[:priority, 'Priority', proc { |val| val.to_s == '1' ? nil : val }],
|
132
|
+
[:sensitivity, 'Sensitivity', proc { |val| val.to_s == '0' ? nil : val }],
|
133
|
+
# yeah?
|
134
|
+
[:conversation_topic, 'Thread-Topic'],
|
135
|
+
# not sure of the distinction here
|
136
|
+
# :originator_delivery_report_requested ??
|
137
|
+
[:read_receipt_requested, 'Disposition-Notification-To', proc { |val| from }]
|
138
|
+
]
|
139
|
+
mapi_header_map.each do |mapi, mime, *f|
|
140
|
+
next unless q = val = props.send(mapi) or headers.has_key?(mime)
|
141
|
+
next if f[0] and !(val = f[0].call(val))
|
142
|
+
headers[mime] = [val.to_s]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# redundant?
|
147
|
+
def type
|
148
|
+
props.message_class[/IPM\.(.*)/, 1].downcase rescue nil
|
149
|
+
end
|
150
|
+
|
151
|
+
# shortcuts to some things from the headers
|
152
|
+
%w[From To Cc Bcc Subject].each do |key|
|
153
|
+
define_method(key.downcase) { headers[key].join(' ') if headers.has_key?(key) }
|
154
|
+
end
|
155
|
+
|
156
|
+
def body_to_tmail
|
157
|
+
# to create the body
|
158
|
+
# should have some options about serializing rtf. and possibly options to check the rtf
|
159
|
+
# for rtf2html conversion, stripping those html tags or other similar stuff. maybe want to
|
160
|
+
# ignore it in the cases where it is generated from incoming html. but keep it if it was the
|
161
|
+
# source for html and plaintext.
|
162
|
+
if props.body_rtf or props.body_html
|
163
|
+
# should plain come first?
|
164
|
+
part = TMail::Mail.new
|
165
|
+
# its actually possible for plain body to be empty, but the others not.
|
166
|
+
# if i can get an html version, then maybe a callout to lynx can be made...
|
167
|
+
part.parts << TMail::Mail.parse("Content-Type: text/plain\r\n\r\n" + props.body) if props.body
|
168
|
+
# this may be automatically unwrapped from the rtf if the rtf includes the html
|
169
|
+
part.parts << TMail::Mail.parse("Content-Type: text/html\r\n\r\n" + props.body_html) if props.body_html
|
170
|
+
# temporarily disabled the rtf. its just showing up as an attachment anyway.
|
171
|
+
#mime.parts << Mime.new("Content-Type: text/rtf\r\n\r\n" + props.body_rtf) if props.body_rtf
|
172
|
+
# its thus currently possible to get no body at all if the only body is rtf. that is not
|
173
|
+
# really acceptable FIXME
|
174
|
+
part['Content-Type'] = 'multipart/alternative'
|
175
|
+
part
|
176
|
+
else
|
177
|
+
# check no header case. content type? etc?. not sure if my Mime class will accept
|
178
|
+
Log.debug "taking that other path"
|
179
|
+
# body can be nil, hence the to_s
|
180
|
+
TMail::Mail.parse "Content-Type: text/plain\r\n\r\n" + props.body.to_s
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def to_tmail
|
185
|
+
# intended to be used for IPM.note, which is the email type. can use it for others if desired,
|
186
|
+
# YMMV
|
187
|
+
Log.warn "to_mime used on a #{props.message_class}" unless props.message_class == 'IPM.Note'
|
188
|
+
# we always have a body
|
189
|
+
mail = body = body_to_tmail
|
190
|
+
|
191
|
+
# If we have attachments, we take the current mime root (body), and make it the first child
|
192
|
+
# of a new tree that will contain body and attachments.
|
193
|
+
unless attachments.empty?
|
194
|
+
raise NotImplementedError
|
195
|
+
mime = Mime.new "Content-Type: multipart/mixed\r\n\r\n"
|
196
|
+
mime.parts << body
|
197
|
+
# i don't know any better way to do this. need multipart/related for inline images
|
198
|
+
# referenced by cid: urls to work, but don't want to use it otherwise...
|
199
|
+
related = false
|
200
|
+
attachments.each do |attach|
|
201
|
+
part = attach.to_mime
|
202
|
+
related = true if part.headers.has_key?('Content-ID') or part.headers.has_key?('Content-Location')
|
203
|
+
mime.parts << part
|
204
|
+
end
|
205
|
+
mime.headers['Content-Type'] = ['multipart/related'] if related
|
206
|
+
end
|
207
|
+
|
208
|
+
# at this point, mime is either
|
209
|
+
# - a single text/plain, consisting of the body ('taking that other path' above. rare)
|
210
|
+
# - a multipart/alternative, consiting of a few bodies (plain and html body. common)
|
211
|
+
# - a multipart/mixed, consisting of 1 of the above 2 types of bodies, and attachments.
|
212
|
+
# we add this standard preamble if its multipart
|
213
|
+
# FIXME preamble.replace, and body.replace both suck.
|
214
|
+
# preamble= is doable. body= wasn't being done because body will get rewritten from parts
|
215
|
+
# if multipart, and is only there readonly. can do that, or do a reparse...
|
216
|
+
# The way i do this means that only the first preamble will say it, not preambles of nested
|
217
|
+
# multipart chunks.
|
218
|
+
mail.quoted_body = "This is a multi-part message in MIME format.\r\n" if mail.multipart?
|
219
|
+
|
220
|
+
# now that we have a root, we can mix in all our headers
|
221
|
+
headers.each do |key, vals|
|
222
|
+
# don't overwrite the content-type, encoding style stuff
|
223
|
+
next if mail[key]
|
224
|
+
# some new temporary hacks
|
225
|
+
next if key =~ /content-type/i and vals[0] =~ /base64/
|
226
|
+
#next if mime.headers.keys.map(&:downcase).include? key.downcase
|
227
|
+
mail[key] = vals.first
|
228
|
+
end
|
229
|
+
# just a stupid hack to make the content-type header last
|
230
|
+
#mime.headers['Content-Type'] = mime.headers.delete 'Content-Type'
|
231
|
+
|
232
|
+
mail
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
class Attachment
|
237
|
+
def to_tmail
|
238
|
+
# TODO: smarter mime typing.
|
239
|
+
mimetype = props.attach_mime_tag || 'application/octet-stream'
|
240
|
+
part = TMail::Mail.parse "Content-Type: #{mimetype}\r\n\r\n"
|
241
|
+
part['Content-Disposition'] = %{attachment; filename="#{filename}"}
|
242
|
+
part['Content-Transfer-Encoding'] = 'base64'
|
243
|
+
part['Content-Location'] = props.attach_content_location if props.attach_content_location
|
244
|
+
part['Content-ID'] = props.attach_content_id if props.attach_content_id
|
245
|
+
# data.to_s for now. data was nil for some reason.
|
246
|
+
# perhaps it was a data object not correctly handled?
|
247
|
+
# hmmm, have to use read here. that assumes that the data isa stream.
|
248
|
+
# but if the attachment data is a string, then it won't work. possible?
|
249
|
+
data_str = if @embedded_msg
|
250
|
+
raise NotImplementedError
|
251
|
+
mime.headers['Content-Type'] = 'message/rfc822'
|
252
|
+
# lets try making it not base64 for now
|
253
|
+
mime.headers.delete 'Content-Transfer-Encoding'
|
254
|
+
# not filename. rather name, or something else right?
|
255
|
+
# maybe it should be inline?? i forget attach_method / access meaning
|
256
|
+
mime.headers['Content-Disposition'] = [%{attachment; filename="#{@embedded_msg.subject}"}]
|
257
|
+
@embedded_msg.to_mime.to_s
|
258
|
+
elsif @embedded_ole
|
259
|
+
raise NotImplementedError
|
260
|
+
# kind of hacky
|
261
|
+
io = StringIO.new
|
262
|
+
Ole::Storage.new io do |ole|
|
263
|
+
ole.root.type = :dir
|
264
|
+
Ole::Storage::Dirent.copy @embedded_ole, ole.root
|
265
|
+
end
|
266
|
+
io.string
|
267
|
+
else
|
268
|
+
data.read.to_s
|
269
|
+
end
|
270
|
+
part.body = @embedded_msg ? data_str : Base64.encode64(data_str).gsub(/\n/, "\r\n")
|
271
|
+
part
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
class Msg < Message
|
276
|
+
def populate_headers
|
277
|
+
super
|
278
|
+
if !headers.has_key?('Date')
|
279
|
+
# can employ other methods for getting a time. heres one in a similar vein to msgconvert.pl,
|
280
|
+
# ie taking the time from an ole object
|
281
|
+
time = @root.ole.dirents.map { |dirent| dirent.modify_time || dirent.create_time }.compact.sort.last
|
282
|
+
headers['Date'] = [Time.iso8601(time.to_s).rfc2822] if time
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
data/lib/mapi/convert.rb
ADDED
@@ -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
|
+
|
data/lib/mapi/helper.rb
ADDED
@@ -0,0 +1,45 @@
|
|
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
|
+
if @ansi_encoding
|
29
|
+
if @to_unicode
|
30
|
+
# assume we can convert this text to UTF-8
|
31
|
+
begin
|
32
|
+
str.force_encoding(@ansi_encoding).encode("UTF-8")
|
33
|
+
rescue Encoding::UndefinedConversionError => ex
|
34
|
+
# some text are already UTF-8 due to unknown reason
|
35
|
+
str.force_encoding("UTF-8").encode("UTF-8")
|
36
|
+
end
|
37
|
+
else
|
38
|
+
str.force_encoding(@ansi_encoding)
|
39
|
+
end
|
40
|
+
else
|
41
|
+
str
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
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
|
+
|