ruby-msg 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+