ruby-msg 1.3.1 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,109 @@
1
+ require 'mapi/types'
2
+ require 'mapi/property_set'
3
+
4
+ module Mapi
5
+ VERSION = '1.4.0'
6
+
7
+ #
8
+ # Mapi::Item is the base class used for all mapi objects, and is purely a
9
+ # property set container
10
+ #
11
+ class Item
12
+ attr_reader :properties
13
+ alias props properties
14
+
15
+ # +properties+ should be a PropertySet instance.
16
+ def initialize properties
17
+ @properties = properties
18
+ end
19
+ end
20
+
21
+ # a general attachment class. is subclassed by Msg and Pst attachment classes
22
+ class Attachment < Item
23
+ def filename
24
+ props.attach_long_filename || props.attach_filename
25
+ end
26
+
27
+ def data
28
+ @embedded_msg || @embedded_ole || props.attach_data
29
+ end
30
+
31
+ # with new stream work, its possible to not have the whole thing in memory at one time,
32
+ # just to save an attachment
33
+ #
34
+ # a = msg.attachments.first
35
+ # a.save open(File.basename(a.filename || 'attachment'), 'wb')
36
+ def save io
37
+ raise "can only save binary data blobs, not ole dirs" if @embedded_ole
38
+ data.each_read { |chunk| io << chunk }
39
+ end
40
+
41
+ def inspect
42
+ "#<#{self.class.to_s[/\w+$/]}" +
43
+ (filename ? " filename=#{filename.inspect}" : '') +
44
+ (@embedded_ole ? " embedded_type=#{@embedded_ole.embedded_type.inspect}" : '') + ">"
45
+ end
46
+ end
47
+
48
+ class Recipient < Item
49
+ # some kind of best effort guess for converting to standard mime style format.
50
+ # there are some rules for encoding non 7bit stuff in mail headers. should obey
51
+ # that here, as these strings could be unicode
52
+ # email_address will be an EX:/ address (X.400?), unless external recipient. the
53
+ # other two we try first.
54
+ # consider using entry id for this too.
55
+ def name
56
+ name = props.transmittable_display_name || props.display_name
57
+ # dequote
58
+ name[/^'(.*)'/, 1] or name rescue nil
59
+ end
60
+
61
+ def email
62
+ props.smtp_address || props.org_email_addr || props.email_address
63
+ end
64
+
65
+ RECIPIENT_TYPES = { 0 => :orig, 1 => :to, 2 => :cc, 3 => :bcc }
66
+ def type
67
+ RECIPIENT_TYPES[props.recipient_type]
68
+ end
69
+
70
+ def to_s
71
+ if name = self.name and !name.empty? and email && name != email
72
+ %{"#{name}" <#{email}>}
73
+ else
74
+ email || name
75
+ end
76
+ end
77
+
78
+ def inspect
79
+ "#<#{self.class.to_s[/\w+$/]}:#{self.to_s.inspect}>"
80
+ end
81
+ end
82
+
83
+ # i refer to it as a message (as does mapi), although perhaps Item is better, as its a more general
84
+ # concept than a message, as used in Pst files. though maybe i'll switch to using
85
+ # Mapi::Object as the base class there.
86
+ #
87
+ # IMessage essentially, but there's also stuff like IMAPIFolder etc. so, for this to form
88
+ # basis for PST Item, it'd need to be more general.
89
+ class Message < Item
90
+ # these 2 collections should be provided by our subclasses
91
+ def attachments
92
+ raise NotImplementedError
93
+ end
94
+
95
+ def recipients
96
+ raise NotImplementedError
97
+ end
98
+
99
+ def inspect
100
+ str = %w[message_class from to subject].map do |key|
101
+ " #{key}=#{props.send(key).inspect}"
102
+ end.compact.join
103
+ str << " recipients=#{recipients.inspect}"
104
+ str << " attachments=#{attachments.inspect}"
105
+ "#<#{self.class.to_s[/\w+$/]}#{str}>"
106
+ end
107
+ end
108
+ end
109
+
@@ -0,0 +1,61 @@
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
+ def mime_type
18
+ case props.message_class #.downcase <- have a feeling i saw other cased versions
19
+ when 'IPM.Contact'
20
+ # apparently "text/directory; profile=vcard" is what you're supposed to use
21
+ 'text/x-vcard'
22
+ when 'IPM.Note'
23
+ 'message/rfc822'
24
+ when 'IPM.Post'
25
+ 'text/plain'
26
+ when 'IPM.StickyNote'
27
+ 'text/plain' # hmmm....
28
+ else
29
+ Mapi::Log.warn 'unknown message_class - %p' % props.message_class
30
+ nil
31
+ end
32
+ end
33
+
34
+ def convert
35
+ type = mime_type
36
+ unless pair = CONVERSION_MAP[type]
37
+ raise 'unable to convert message with mime type - %p' % type
38
+ end
39
+ send pair.first
40
+ end
41
+
42
+ # should probably be moved to mapi/convert/post
43
+ class Post
44
+ # not really sure what the pertinent properties are. we just do nothing for now...
45
+ def initialize message
46
+ @message = message
47
+ end
48
+
49
+ def to_s
50
+ # should maybe handle other types, like html body. need a better format for post
51
+ # probably anyway, cause a lot of meta data is getting chucked.
52
+ @message.props.body
53
+ end
54
+ end
55
+
56
+ def to_post
57
+ Post.new self
58
+ end
59
+ end
60
+ end
61
+
@@ -0,0 +1,142 @@
1
+ require 'rubygems'
2
+ require 'vpim/vcard'
3
+
4
+ # patch Vpim. TODO - fix upstream, or verify old behaviour was ok
5
+ def Vpim.encode_text v
6
+ # think the regexp was wrong
7
+ v.to_str.gsub(/(.)/m) do
8
+ case $1
9
+ when "\n"
10
+ "\\n"
11
+ when "\\", ",", ";"
12
+ "\\#{$1}"
13
+ else
14
+ $1
15
+ end
16
+ end
17
+ end
18
+
19
+ module Mapi
20
+ class Message
21
+ class VcardConverter
22
+ include Vpim
23
+
24
+ # a very incomplete mapping, but its a start...
25
+ # can't find where to set a lot of stuff, like zipcode, jobtitle etc
26
+ VCARD_MAP = {
27
+ # these are all standard mapi properties
28
+ :name => [
29
+ {
30
+ :given => :given_name,
31
+ :family => :surname,
32
+ :fullname => :subject
33
+ }
34
+ ],
35
+ # outlook seems to eschew the mapi properties this time,
36
+ # like postal_address, street_address, home_address_city
37
+ # so we use the named properties
38
+ :addr => [
39
+ {
40
+ :location => 'work',
41
+ :street => :business_address_street,
42
+ :locality => proc do |props|
43
+ [props.business_address_city, props.business_address_state].compact * ', '
44
+ end
45
+ }
46
+ ],
47
+
48
+ # right type? maybe date
49
+ :birthday => :birthday,
50
+ :nickname => :nickname
51
+
52
+ # photo available?
53
+ # FIXME finish, emails, telephones etc
54
+ }
55
+
56
+ attr_reader :msg
57
+ def initialize msg
58
+ @msg = msg
59
+ end
60
+
61
+ def field name, *args
62
+ DirectoryInfo::Field.create name, Vpim.encode_text_list(args)
63
+ end
64
+
65
+ def get_property key
66
+ if String === key
67
+ return key
68
+ elsif key.respond_to? :call
69
+ value = key.call msg.props
70
+ else
71
+ value = msg.props[key]
72
+ end
73
+ if String === value and value.empty?
74
+ nil
75
+ else
76
+ value
77
+ end
78
+ end
79
+
80
+ def get_properties hash
81
+ constants = {}
82
+ others = {}
83
+ hash.each do |to, from|
84
+ if String === from
85
+ constants[to] = from
86
+ else
87
+ value = get_property from
88
+ others[to] = value if value
89
+ end
90
+ end
91
+ return nil if others.empty?
92
+ others.merge constants
93
+ end
94
+
95
+ def convert
96
+ Vpim::Vcard::Maker.make2 do |m|
97
+ # handle name
98
+ [:name, :addr].each do |type|
99
+ VCARD_MAP[type].each do |hash|
100
+ next unless props = get_properties(hash)
101
+ m.send "add_#{type}" do |n|
102
+ props.each { |key, value| n.send "#{key}=", value }
103
+ end
104
+ end
105
+ end
106
+
107
+ (VCARD_MAP.keys - [:name, :addr]).each do |key|
108
+ value = get_property VCARD_MAP[key]
109
+ m.send "#{key}=", value if value
110
+ end
111
+
112
+ # the rest of the stuff is custom
113
+
114
+ url = get_property(:webpage) || get_property(:business_home_page)
115
+ m.add_field field('URL', url) if url
116
+ m.add_field field('X-EVOLUTION-FILE-AS', get_property(:file_under)) if get_property(:file_under)
117
+
118
+ addr = get_property(:email_email_address) || get_property(:email_original_display_name)
119
+ if addr
120
+ m.add_email addr do |e|
121
+ e.format ='x400' unless msg.props.email_addr_type == 'SMTP'
122
+ end
123
+ end
124
+
125
+ if org = get_property(:company_name)
126
+ m.add_field field('ORG', get_property(:company_name))
127
+ end
128
+
129
+ # TODO: imaddress
130
+ end
131
+ end
132
+ end
133
+
134
+ def to_vcard
135
+ #p props.raw.reject { |key, value| key.guid.inspect !~ /00062004-0000-0000-c000-000000000046/ }.
136
+ # map { |key, value| [key.to_sym, value] }.reject { |a, b| b.respond_to? :read }
137
+ #y props.to_h.reject { |a, b| b.respond_to? :read }
138
+ VcardConverter.new(self).convert
139
+ end
140
+ end
141
+ end
142
+
@@ -0,0 +1,274 @@
1
+ require 'base64'
2
+ require 'mime'
3
+ require 'time'
4
+
5
+ # there is still some Msg specific stuff in here.
6
+
7
+ module Mapi
8
+ class Message
9
+ def mime
10
+ return @mime if @mime
11
+ # if these headers exist at all, they can be helpful. we may however get a
12
+ # application/ms-tnef mime root, which means there will be little other than
13
+ # headers. we may get nothing.
14
+ # and other times, when received from external, we get the full cigar, boundaries
15
+ # etc and all.
16
+ # sometimes its multipart, with no boundaries. that throws an error. so we'll be more
17
+ # forgiving here
18
+ @mime = Mime.new props.transport_message_headers.to_s, true
19
+ populate_headers
20
+ @mime
21
+ end
22
+
23
+ def headers
24
+ mime.headers
25
+ end
26
+
27
+ # copy data from msg properties storage to standard mime. headers
28
+ # i've now seen it where the existing headers had heaps on stuff, and the msg#props had
29
+ # practically nothing. think it was because it was a tnef - msg conversion done by exchange.
30
+ def populate_headers
31
+ # construct a From value
32
+ # should this kind of thing only be done when headers don't exist already? maybe not. if its
33
+ # sent, then modified and saved, the headers could be wrong?
34
+ # hmmm. i just had an example where a mail is sent, from an internal user, but it has transport
35
+ # headers, i think because one recipient was external. the only place the senders email address
36
+ # exists is in the transport headers. so its maybe not good to overwrite from.
37
+ # recipients however usually have smtp address available.
38
+ # maybe we'll do it for all addresses that are smtp? (is that equivalent to
39
+ # sender_email_address !~ /^\//
40
+ name, email = props.sender_name, props.sender_email_address
41
+ if props.sender_addrtype == 'SMTP'
42
+ headers['From'] = if name and email and name != email
43
+ [%{"#{name}" <#{email}>}]
44
+ else
45
+ [email || name]
46
+ end
47
+ elsif !headers.has_key?('From')
48
+ # some messages were never sent, so that sender stuff isn't filled out. need to find another
49
+ # way to get something
50
+ # what about marking whether we thing the email was sent or not? or draft?
51
+ # for partition into an eventual Inbox, Sent, Draft mbox set?
52
+ # i've now seen cases where this stuff is missing, but exists in transport message headers,
53
+ # so maybe i should inhibit this in that case.
54
+ if email
55
+ # disabling this warning for now
56
+ #Log.warn "* no smtp sender email address available (only X.400). creating fake one"
57
+ # this is crap. though i've specially picked the logic so that it generates the correct
58
+ # email addresses in my case (for my organisation).
59
+ # this user stuff will give valid email i think, based on alias.
60
+ user = name ? name.sub(/(.*), (.*)/, "\\2.\\1") : email[/\w+$/].downcase
61
+ domain = (email[%r{^/O=([^/]+)}i, 1].downcase + '.com' rescue email)
62
+ headers['From'] = [name ? %{"#{name}" <#{user}@#{domain}>} : "<#{user}@#{domain}>" ]
63
+ elsif name
64
+ # we only have a name? thats screwed up.
65
+ # disabling this warning for now
66
+ #Log.warn "* no smtp sender email address available (only name). creating fake one"
67
+ headers['From'] = [%{"#{name}"}]
68
+ else
69
+ # disabling this warning for now
70
+ #Log.warn "* no sender email address available at all. FIXME"
71
+ end
72
+ # else we leave the transport message header version
73
+ end
74
+
75
+ # for all of this stuff, i'm assigning in utf8 strings.
76
+ # thats ok i suppose, maybe i can say its the job of the mime class to handle that.
77
+ # but a lot of the headers are overloaded in different ways. plain string, many strings
78
+ # other stuff. what happens to a person who has a " in their name etc etc. encoded words
79
+ # i suppose. but that then happens before assignment. and can't be automatically undone
80
+ # until the header is decomposed into recipients.
81
+ recips_by_type = recipients.group_by { |r| r.type }
82
+ # i want to the the types in a specific order.
83
+ [:to, :cc, :bcc].each do |type|
84
+ # don't know why i bother, but if we can, we try to sort recipients by the numerical part
85
+ # of the ole name, or just leave it if we can't
86
+ recips = recips_by_type[type]
87
+ recips = (recips.sort_by { |r| r.obj.name[/\d{8}$/].hex } rescue recips)
88
+ # switched to using , for separation, not ;. see issue #4
89
+ # recips.empty? is strange. i wouldn't have thought it possible, but it was right?
90
+ headers[type.to_s.sub(/^(.)/) { $1.upcase }] = [recips.join(', ')] unless recips.empty?
91
+ end
92
+ headers['Subject'] = [props.subject] if props.subject
93
+
94
+ # fill in a date value. by default, we won't mess with existing value hear
95
+ if !headers.has_key?('Date')
96
+ # we want to get a received date, as i understand it.
97
+ # use this preference order, or pull the most recent?
98
+ keys = %w[message_delivery_time client_submit_time last_modification_time creation_time]
99
+ time = keys.each { |key| break time if time = props.send(key) }
100
+ time = nil unless Date === time
101
+
102
+ # now convert and store
103
+ # this is a little funky. not sure about time zone stuff either?
104
+ # actually seems ok. maybe its always UTC and interpreted anyway. or can be timezoneless.
105
+ # i have no timezone info anyway.
106
+ # in gmail, i see stuff like 15 Jan 2007 00:48:19 -0000, and it displays as 11:48.
107
+ # can also add .localtime here if desired. but that feels wrong.
108
+ headers['Date'] = [Time.iso8601(time.to_s).rfc2822] if time
109
+ end
110
+
111
+ # some very simplistic mapping between internet message headers and the
112
+ # mapi properties
113
+ # any of these could be causing duplicates due to case issues. the hack in #to_mime
114
+ # just stops re-duplication at that point. need to move some smarts into the mime
115
+ # code to handle it.
116
+ mapi_header_map = [
117
+ [:internet_message_id, 'Message-ID'],
118
+ [:in_reply_to_id, 'In-Reply-To'],
119
+ # don't set these values if they're equal to the defaults anyway
120
+ [:importance, 'Importance', proc { |val| val.to_s == '1' ? nil : val }],
121
+ [:priority, 'Priority', proc { |val| val.to_s == '1' ? nil : val }],
122
+ [:sensitivity, 'Sensitivity', proc { |val| val.to_s == '0' ? nil : val }],
123
+ # yeah?
124
+ [:conversation_topic, 'Thread-Topic'],
125
+ # not sure of the distinction here
126
+ # :originator_delivery_report_requested ??
127
+ [:read_receipt_requested, 'Disposition-Notification-To', proc { |val| from }]
128
+ ]
129
+ mapi_header_map.each do |mapi, mime, *f|
130
+ next unless q = val = props.send(mapi) or headers.has_key?(mime)
131
+ next if f[0] and !(val = f[0].call(val))
132
+ headers[mime] = [val.to_s]
133
+ end
134
+ end
135
+
136
+ # redundant?
137
+ def type
138
+ props.message_class[/IPM\.(.*)/, 1].downcase rescue nil
139
+ end
140
+
141
+ # shortcuts to some things from the headers
142
+ %w[From To Cc Bcc Subject].each do |key|
143
+ define_method(key.downcase) { headers[key].join(' ') if headers.has_key?(key) }
144
+ end
145
+
146
+ def body_to_mime
147
+ # to create the body
148
+ # should have some options about serializing rtf. and possibly options to check the rtf
149
+ # for rtf2html conversion, stripping those html tags or other similar stuff. maybe want to
150
+ # ignore it in the cases where it is generated from incoming html. but keep it if it was the
151
+ # source for html and plaintext.
152
+ if props.body_rtf or props.body_html
153
+ # should plain come first?
154
+ mime = Mime.new "Content-Type: multipart/alternative\r\n\r\n"
155
+ # its actually possible for plain body to be empty, but the others not.
156
+ # if i can get an html version, then maybe a callout to lynx can be made...
157
+ mime.parts << Mime.new("Content-Type: text/plain\r\n\r\n" + props.body) if props.body
158
+ # this may be automatically unwrapped from the rtf if the rtf includes the html
159
+ mime.parts << Mime.new("Content-Type: text/html\r\n\r\n" + props.body_html) if props.body_html
160
+ # temporarily disabled the rtf. its just showing up as an attachment anyway.
161
+ #mime.parts << Mime.new("Content-Type: text/rtf\r\n\r\n" + props.body_rtf) if props.body_rtf
162
+ # its thus currently possible to get no body at all if the only body is rtf. that is not
163
+ # really acceptable FIXME
164
+ mime
165
+ else
166
+ # check no header case. content type? etc?. not sure if my Mime class will accept
167
+ Log.debug "taking that other path"
168
+ # body can be nil, hence the to_s
169
+ Mime.new "Content-Type: text/plain\r\n\r\n" + props.body.to_s
170
+ end
171
+ end
172
+
173
+ def to_mime
174
+ # intended to be used for IPM.note, which is the email type. can use it for others if desired,
175
+ # YMMV
176
+ Log.warn "to_mime used on a #{props.message_class}" unless props.message_class == 'IPM.Note'
177
+ # we always have a body
178
+ mime = body = body_to_mime
179
+
180
+ # If we have attachments, we take the current mime root (body), and make it the first child
181
+ # of a new tree that will contain body and attachments.
182
+ unless attachments.empty?
183
+ mime = Mime.new "Content-Type: multipart/mixed\r\n\r\n"
184
+ mime.parts << body
185
+ # i don't know any better way to do this. need multipart/related for inline images
186
+ # referenced by cid: urls to work, but don't want to use it otherwise...
187
+ related = false
188
+ attachments.each do |attach|
189
+ part = attach.to_mime
190
+ related = true if part.headers.has_key?('Content-ID') or part.headers.has_key?('Content-Location')
191
+ mime.parts << part
192
+ end
193
+ mime.headers['Content-Type'] = ['multipart/related'] if related
194
+ end
195
+
196
+ # at this point, mime is either
197
+ # - a single text/plain, consisting of the body ('taking that other path' above. rare)
198
+ # - a multipart/alternative, consiting of a few bodies (plain and html body. common)
199
+ # - a multipart/mixed, consisting of 1 of the above 2 types of bodies, and attachments.
200
+ # we add this standard preamble if its multipart
201
+ # FIXME preamble.replace, and body.replace both suck.
202
+ # preamble= is doable. body= wasn't being done because body will get rewritten from parts
203
+ # if multipart, and is only there readonly. can do that, or do a reparse...
204
+ # The way i do this means that only the first preamble will say it, not preambles of nested
205
+ # multipart chunks.
206
+ mime.preamble.replace "This is a multi-part message in MIME format.\r\n" if mime.multipart?
207
+
208
+ # now that we have a root, we can mix in all our headers
209
+ headers.each do |key, vals|
210
+ # don't overwrite the content-type, encoding style stuff
211
+ next if mime.headers.has_key? key
212
+ # some new temporary hacks
213
+ next if key =~ /content-type/i and vals[0] =~ /base64/
214
+ next if mime.headers.keys.map(&:downcase).include? key.downcase
215
+ mime.headers[key] += vals
216
+ end
217
+ # just a stupid hack to make the content-type header last, when using OrderedHash
218
+ mime.headers['Content-Type'] = mime.headers.delete 'Content-Type'
219
+
220
+ mime
221
+ end
222
+ end
223
+
224
+ class Attachment
225
+ def to_mime
226
+ # TODO: smarter mime typing.
227
+ mimetype = props.attach_mime_tag || 'application/octet-stream'
228
+ mime = Mime.new "Content-Type: #{mimetype}\r\n\r\n"
229
+ mime.headers['Content-Disposition'] = [%{attachment; filename="#{filename}"}]
230
+ mime.headers['Content-Transfer-Encoding'] = ['base64']
231
+ mime.headers['Content-Location'] = [props.attach_content_location] if props.attach_content_location
232
+ mime.headers['Content-ID'] = [props.attach_content_id] if props.attach_content_id
233
+ # data.to_s for now. data was nil for some reason.
234
+ # perhaps it was a data object not correctly handled?
235
+ # hmmm, have to use read here. that assumes that the data isa stream.
236
+ # but if the attachment data is a string, then it won't work. possible?
237
+ data_str = if @embedded_msg
238
+ mime.headers['Content-Type'] = 'message/rfc822'
239
+ # lets try making it not base64 for now
240
+ mime.headers.delete 'Content-Transfer-Encoding'
241
+ # not filename. rather name, or something else right?
242
+ # maybe it should be inline?? i forget attach_method / access meaning
243
+ mime.headers['Content-Disposition'] = [%{attachment; filename="#{@embedded_msg.subject}"}]
244
+ @embedded_msg.to_mime.to_s
245
+ elsif @embedded_ole
246
+ # kind of hacky
247
+ io = StringIO.new
248
+ Ole::Storage.new io do |ole|
249
+ ole.root.type = :dir
250
+ Ole::Storage::Dirent.copy @embedded_ole, ole.root
251
+ end
252
+ io.string
253
+ else
254
+ # FIXME: shouldn't be required
255
+ data.read.to_s rescue ''
256
+ end
257
+ mime.body.replace @embedded_msg ? data_str : Base64.encode64(data_str).gsub(/\n/, "\r\n")
258
+ mime
259
+ end
260
+ end
261
+
262
+ class Msg < Message
263
+ def populate_headers
264
+ super
265
+ if !headers.has_key?('Date')
266
+ # can employ other methods for getting a time. heres one in a similar vein to msgconvert.pl,
267
+ # ie taking the time from an ole object
268
+ time = @root.ole.dirents.map { |dirent| dirent.modify_time || dirent.create_time }.compact.sort.last
269
+ headers['Date'] = [Time.iso8601(time.to_s).rfc2822] if time
270
+ end
271
+ end
272
+ end
273
+ end
274
+