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,169 @@
1
+ require 'stringio'
2
+ require 'strscan'
3
+ require 'rtf'
4
+
5
+ module Mapi
6
+ #
7
+ # = Introduction
8
+ #
9
+ # The +RTF+ module contains a few helper functions for dealing with rtf
10
+ # in mapi messages: +rtfdecompr+, and <tt>rtf2html</tt>.
11
+ #
12
+ # Both were ported from their original C versions for simplicity's sake.
13
+ #
14
+ module RTF
15
+ RTF_PREBUF =
16
+ "{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}" \
17
+ "{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript " \
18
+ "\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier" \
19
+ "{\\colortbl\\red0\\green0\\blue0\n\r\\par " \
20
+ "\\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx"
21
+
22
+ # Decompresses compressed rtf +data+, as found in the mapi property
23
+ # +PR_RTF_COMPRESSED+. Code converted from my C version, which in turn
24
+ # I wrote from a Java source, in JTNEF I believe.
25
+ #
26
+ # C version was modified to use circular buffer for back references,
27
+ # instead of the optimization of the Java version to index directly into
28
+ # output buffer. This was in preparation to support streaming in a
29
+ # read/write neutral fashion.
30
+ def rtfdecompr data
31
+ io = StringIO.new data
32
+ buf = RTF_PREBUF + "\x00" * (4096 - RTF_PREBUF.length)
33
+ wp = RTF_PREBUF.length
34
+ rtf = ''
35
+
36
+ # get header fields (as defined in RTFLIB.H)
37
+ compr_size, uncompr_size, magic, crc32 = io.read(16).unpack 'V*'
38
+ #warn "compressed-RTF data size mismatch" unless io.size == data.compr_size + 4
39
+
40
+ # process the data
41
+ case magic
42
+ when 0x414c454d # "MELA" magic number that identifies the stream as a uncompressed stream
43
+ rtf = io.read uncompr_size
44
+ when 0x75465a4c # "LZFu" magic number that identifies the stream as a compressed stream
45
+ flag_count = -1
46
+ flags = nil
47
+ while rtf.length < uncompr_size and !io.eof?
48
+ # each flag byte flags 8 literals/references, 1 per bit
49
+ flags = ((flag_count += 1) % 8 == 0) ? io.getc : flags >> 1
50
+ if 1 == (flags & 1) # each flag bit is 1 for reference, 0 for literal
51
+ rp, l = io.getc, io.getc
52
+ # offset is a 12 byte number. 2^12 is 4096, so thats fine
53
+ rp = (rp << 4) | (l >> 4) # the offset relative to block start
54
+ l = (l & 0xf) + 2 # the number of bytes to copy
55
+ l.times do
56
+ rtf << buf[wp] = buf[rp]
57
+ wp = (wp + 1) % 4096
58
+ rp = (rp + 1) % 4096
59
+ end
60
+ else
61
+ rtf << buf[wp] = io.getc
62
+ wp = (wp + 1) % 4096
63
+ end
64
+ end
65
+ else # unknown magic number
66
+ raise "Unknown compression type (magic number 0x%08x)" % magic
67
+ end
68
+
69
+ # not sure if its due to a bug in the above code. doesn't seem to be
70
+ # in my tests, but sometimes there's a trailing null. we chomp it here,
71
+ # which actually makes the resultant rtf smaller than its advertised
72
+ # size (+uncompr_size+).
73
+ rtf.chomp! 0.chr
74
+ rtf
75
+ end
76
+
77
+ # Note, this is a conversion of the original C code. Not great - needs tests and
78
+ # some refactoring, and an attempt to correct some inaccuracies. Hacky but works.
79
+ #
80
+ # Returns +nil+ if it doesn't look like an rtf encapsulated rtf.
81
+ #
82
+ # Some cases that the original didn't deal with have been patched up, eg from
83
+ # this chunk, where there are tags outside of the htmlrtf ignore block.
84
+ #
85
+ # "{\\*\\htmltag116 <br />}\\htmlrtf \\line \\htmlrtf0 \\line {\\*\\htmltag84 <a href..."
86
+ #
87
+ # We take the approach of ignoring all rtf tags not explicitly handled. A proper
88
+ # parse tree would be nicer to work with. will need to look for ruby rtf library
89
+ #
90
+ # Some of the original comment to the c code is excerpted here:
91
+ #
92
+ # Sometimes in MAPI, the PR_BODY_HTML property contains the HTML of a message.
93
+ # But more usually, the HTML is encoded inside the RTF body (which you get in the
94
+ # PR_RTF_COMPRESSED property). These routines concern the decoding of the HTML
95
+ # from this RTF body.
96
+ #
97
+ # An encoded htmlrtf file is a valid RTF document, but which contains additional
98
+ # html markup information in its comments, and sometimes contains the equivalent
99
+ # rtf markup outside the comments. Therefore, when it is displayed by a plain
100
+ # simple RTF reader, the html comments are ignored and only the rtf markup has
101
+ # effect. Typically, this rtf markup is not as rich as the html markup would have been.
102
+ # But for an html-aware reader (such as the code below), we can ignore all the
103
+ # rtf markup, and extract the html markup out of the comments, and get a valid
104
+ # html document.
105
+ #
106
+ # There are actually two kinds of html markup in comments. Most of them are
107
+ # prefixed by "\*\htmltagNNN", for some number NNN. But sometimes there's one
108
+ # prefixed by "\*\mhtmltagNNN" followed by "\*\htmltagNNN". In this case,
109
+ # the two are equivalent, but the m-tag is for a MIME Multipart/Mixed Message
110
+ # and contains tags that refer to content-ids (e.g. img src="cid:072344a7")
111
+ # while the normal tag just refers to a name (e.g. img src="fred.jpg")
112
+ # The code below keeps the m-tag and discards the normal tag.
113
+ # If there are any m-tags like this, then the message also contains an
114
+ # attachment with a PR_CONTENT_ID property e.g. "072344a7". Actually,
115
+ # sometimes the m-tag is e.g. img src="http://outlook/welcome.html" and the
116
+ # attachment has a PR_CONTENT_LOCATION "http://outlook/welcome.html" instead
117
+ # of a PR_CONTENT_ID.
118
+ #
119
+ def rtf2html rtf
120
+ scan = StringScanner.new rtf
121
+ # require \fromhtml. is this worth keeping? apparently you see \\fromtext if it
122
+ # was converted from plain text.
123
+ return nil unless rtf["\\fromhtml"]
124
+ html = ''
125
+ ignore_tag = nil
126
+ # skip up to the first htmltag. return nil if we don't ever find one
127
+ return nil unless scan.scan_until /(?=\{\\\*\\htmltag)/
128
+ until scan.empty?
129
+ if scan.scan /\{/
130
+ elsif scan.scan /\}/
131
+ elsif scan.scan /\\\*\\htmltag(\d+) ?/
132
+ #p scan[1]
133
+ if ignore_tag == scan[1]
134
+ scan.scan_until /\}/
135
+ ignore_tag = nil
136
+ end
137
+ elsif scan.scan /\\\*\\mhtmltag(\d+) ?/
138
+ ignore_tag = scan[1]
139
+ elsif scan.scan /\\par ?/
140
+ html << "\r\n"
141
+ elsif scan.scan /\\tab ?/
142
+ html << "\t"
143
+ elsif scan.scan /\\'([0-9A-Za-z]{2})/
144
+ html << scan[1].hex.chr
145
+ elsif scan.scan /\\pntext/
146
+ scan.scan_until /\}/
147
+ elsif scan.scan /\\htmlrtf/
148
+ scan.scan_until /\\htmlrtf0 ?/
149
+ # a generic throw away unknown tags thing.
150
+ # the above 2 however, are handled specially
151
+ elsif scan.scan /\\[a-z-]+(\d+)? ?/
152
+ #elsif scan.scan /\\li(\d+) ?/
153
+ #elsif scan.scan /\\fi-(\d+) ?/
154
+ elsif scan.scan /[\r\n]/
155
+ elsif scan.scan /\\([{}\\])/
156
+ html << scan[1]
157
+ elsif scan.scan /(.)/
158
+ html << scan[1]
159
+ else
160
+ p :wtf
161
+ end
162
+ end
163
+ html.strip.empty? ? nil : html
164
+ end
165
+
166
+ module_function :rtf2html, :rtfdecompr
167
+ end
168
+ end
169
+
@@ -0,0 +1,51 @@
1
+ require 'rubygems'
2
+ require 'ole/types'
3
+
4
+ module Mapi
5
+ Log = Logger.new_with_callstack
6
+
7
+ module Types
8
+ #
9
+ # Mapi property types, taken from http://msdn2.microsoft.com/en-us/library/bb147591.aspx.
10
+ #
11
+ # The fields are [mapi name, variant name, description]. Maybe I should just make it a
12
+ # struct.
13
+ #
14
+ # seen some synonyms here, like PT_I8 vs PT_LONG. seen stuff like PT_SRESTRICTION, not
15
+ # sure what that is. look at `grep ' PT_' data/mapitags.yaml | sort -u`
16
+ # also, it has stuff like PT_MV_BINARY, where _MV_ probably means multi value, and is
17
+ # likely just defined to | in 0x1000.
18
+ #
19
+ # Note that the last 2 are the only ones where the Mapi value differs from the Variant value
20
+ # for the corresponding variant type. Odd. Also, the last 2 are currently commented out here
21
+ # because of the clash.
22
+ #
23
+ # Note 2 - the strings here say VT_BSTR, but I don't have that defined in Ole::Types. Should
24
+ # maybe change them to match. I've also seen reference to PT_TSTRING, which is defined as some
25
+ # sort of get unicode first, and fallback to ansii or something.
26
+ #
27
+ DATA = {
28
+ 0x0001 => ['PT_NULL', 'VT_NULL', 'Null (no valid data)'],
29
+ 0x0002 => ['PT_SHORT', 'VT_I2', '2-byte integer (signed)'],
30
+ 0x0003 => ['PT_LONG', 'VT_I4', '4-byte integer (signed)'],
31
+ 0x0004 => ['PT_FLOAT', 'VT_R4', '4-byte real (floating point)'],
32
+ 0x0005 => ['PT_DOUBLE', 'VT_R8', '8-byte real (floating point)'],
33
+ 0x0006 => ['PT_CURRENCY', 'VT_CY', '8-byte integer (scaled by 10,000)'],
34
+ 0x000a => ['PT_ERROR', 'VT_ERROR', 'SCODE value; 32-bit unsigned integer'],
35
+ 0x000b => ['PT_BOOLEAN', 'VT_BOOL', 'Boolean'],
36
+ 0x000d => ['PT_OBJECT', 'VT_UNKNOWN', 'Data object'],
37
+ 0x001e => ['PT_STRING8', 'VT_BSTR', 'String'],
38
+ 0x001f => ['PT_UNICODE', 'VT_BSTR', 'String'],
39
+ 0x0040 => ['PT_SYSTIME', 'VT_DATE', '8-byte real (date in integer, time in fraction)'],
40
+ #0x0102 => ['PT_BINARY', 'VT_BLOB', 'Binary (unknown format)'],
41
+ #0x0102 => ['PT_CLSID', 'VT_CLSID', 'OLE GUID']
42
+ }
43
+
44
+ module Constants
45
+ DATA.each { |num, (mapi_name, variant_name, desc)| const_set mapi_name, num }
46
+ end
47
+
48
+ include Constants
49
+ end
50
+ end
51
+
data/lib/rtf.rb CHANGED
@@ -1,5 +1,3 @@
1
- #! /usr/bin/ruby -w
2
-
3
1
  require 'stringio'
4
2
 
5
3
  # this file is pretty crap, its just to ensure there is always something readable if
@@ -109,10 +107,3 @@ module RTF
109
107
  end
110
108
  end
111
109
 
112
- if $0 == __FILE__
113
- #str = File.read('test.rtf')
114
- str = YAML.load(open('rtfs.yaml'))[2]
115
- #puts str
116
- puts text
117
- end
118
-
@@ -0,0 +1,60 @@
1
+ require 'test/unit'
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'mapi'
5
+ require 'mapi/convert'
6
+
7
+ class TestMapiPropertySet < Test::Unit::TestCase
8
+ include Mapi
9
+
10
+ def test_contact_from_property_hash
11
+ make_key1 = proc { |id| PropertySet::Key.new id }
12
+ make_key2 = proc { |id| PropertySet::Key.new id, PropertySet::PSETID_Address }
13
+ store = {
14
+ make_key1[0x001a] => 'IPM.Contact',
15
+ make_key1[0x0037] => 'full name',
16
+ make_key1[0x3a06] => 'given name',
17
+ make_key1[0x3a08] => 'business telephone number',
18
+ make_key1[0x3a11] => 'surname',
19
+ make_key1[0x3a15] => 'postal address',
20
+ make_key1[0x3a16] => 'company name',
21
+ make_key1[0x3a17] => 'title',
22
+ make_key1[0x3a18] => 'department name',
23
+ make_key1[0x3a19] => 'office location',
24
+ make_key2[0x8005] => 'file under',
25
+ make_key2[0x801b] => 'business address',
26
+ make_key2[0x802b] => 'web page',
27
+ make_key2[0x8045] => 'business address street',
28
+ make_key2[0x8046] => 'business address city',
29
+ make_key2[0x8047] => 'business address state',
30
+ make_key2[0x8048] => 'business address postal code',
31
+ make_key2[0x8049] => 'business address country',
32
+ make_key2[0x804a] => 'business address post office box',
33
+ make_key2[0x8062] => 'im address',
34
+ make_key2[0x8082] => 'SMTP',
35
+ make_key2[0x8083] => 'email@address.com'
36
+ }
37
+ props = PropertySet.new store
38
+ message = Message.new props
39
+ assert_equal 'text/x-vcard', message.mime_type
40
+ vcard = message.to_vcard
41
+ assert_equal Vpim::Vcard, vcard.class
42
+ assert_equal <<-'end', vcard.to_s
43
+ BEGIN:VCARD
44
+ VERSION:3.0
45
+ N:surname;given name;;;
46
+ FN:full name
47
+ ADR;TYPE=work:;;business address street;business address city\, business ad
48
+ dress state;;;
49
+ X-EVOLUTION-FILE-AS:file under
50
+ EMAIL:email@address.com
51
+ ORG:company name
52
+ END:VCARD
53
+ end
54
+ end
55
+
56
+ def test_contact_from_msg
57
+ # load some msg contacts and convert them...
58
+ end
59
+ end
60
+
@@ -0,0 +1,66 @@
1
+ require 'test/unit'
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'mapi'
5
+ require 'mapi/convert'
6
+
7
+ class TestMapiPropertySet < Test::Unit::TestCase
8
+ include Mapi
9
+
10
+ def test_using_pseudo_properties
11
+ # load some compressed rtf data
12
+ data = File.read File.dirname(__FILE__) + '/test_rtf.data'
13
+ store = {
14
+ PropertySet::Key.new(0x0037) => 'Subject',
15
+ PropertySet::Key.new(0x0c1e) => 'SMTP',
16
+ PropertySet::Key.new(0x0c1f) => 'sender@email.com',
17
+ PropertySet::Key.new(0x1009) => StringIO.new(data)
18
+ }
19
+ props = PropertySet.new store
20
+ msg = Message.new props
21
+ def msg.attachments
22
+ []
23
+ end
24
+ def msg.recipients
25
+ []
26
+ end
27
+ # the ignoring of \r here should change. its actually not output consistently currently.
28
+ assert_equal((<<-end), msg.to_mime.to_s.gsub(/NextPart[_0-9a-z\.]+/, 'NextPart_XXX').delete("\r"))
29
+ From: sender@email.com
30
+ Subject: Subject
31
+ Content-Type: multipart/alternative; boundary="----_=_NextPart_XXX"
32
+
33
+ This is a multi-part message in MIME format.
34
+
35
+ ------_=_NextPart_XXX
36
+ Content-Type: text/plain
37
+
38
+
39
+ I will be out of the office starting 15.02.2007 and will not return until
40
+ 27.02.2007.
41
+
42
+ I will respond to your message when I return. For urgent enquiries please
43
+ contact Motherine Jacson.
44
+
45
+
46
+
47
+ ------_=_NextPart_XXX
48
+ Content-Type: text/html
49
+
50
+ <html>
51
+ <body>
52
+ <br>I will be out of the office starting 15.02.2007 and will not return until
53
+ <br>27.02.2007.
54
+ <br>
55
+ <br>I will respond to your message when I return. For urgent enquiries please
56
+ <br>contact Motherine Jacson.
57
+ <br>
58
+ <br></body>
59
+ </html>
60
+
61
+
62
+ ------_=_NextPart_XXX--
63
+ end
64
+ end
65
+ end
66
+
@@ -14,9 +14,11 @@ class TestMime < Test::Unit::TestCase
14
14
  assert_equal 'Body text.', mime.body
15
15
  assert_equal false, mime.multipart?
16
16
  assert_equal nil, mime.parts
17
- # we get round trip conversion. this is mostly fluke, as orderedhash hasn't been
18
- # added yet
19
17
  assert_equal "Header1: Value1\r\nHeader2: Value2\r\n\r\nBody text.", mime.to_s
20
18
  end
19
+
20
+ def test_boundaries
21
+ assert_match(/^----_=_NextPart_001_/, Mime.make_boundary(1))
22
+ end
21
23
  end
22
24
 
@@ -0,0 +1,29 @@
1
+ #! /usr/bin/ruby
2
+
3
+ TEST_DIR = File.dirname __FILE__
4
+ $: << "#{TEST_DIR}/../lib"
5
+
6
+ require 'test/unit'
7
+ require 'mapi/msg'
8
+
9
+ class TestMsg < Test::Unit::TestCase
10
+ def test_blammo
11
+ Mapi::Msg.open "#{TEST_DIR}/test_Blammo.msg" do |msg|
12
+ assert_equal '"TripleNickel" <TripleNickel@mapi32.net>', msg.from
13
+ assert_equal 'BlammoBlammo', msg.subject
14
+ assert_equal 0, msg.recipients.length
15
+ assert_equal 0, msg.attachments.length
16
+ # this is all properties
17
+ assert_equal 66, msg.properties.raw.length
18
+ # this is unique named properties
19
+ assert_equal 48, msg.properties.to_h.length
20
+ # get the named property keys
21
+ keys = msg.properties.raw.keys.select { |key| String === key.code }
22
+ assert_equal '55555555-5555-5555-c000-000000000046', keys[0].guid.format
23
+ assert_equal 'Yippee555', msg.properties[keys[0]]
24
+ assert_equal '66666666-6666-6666-c000-000000000046', keys[1].guid.format
25
+ assert_equal 'Yippee666', msg.properties[keys[1]]
26
+ end
27
+ end
28
+ end
29
+
@@ -0,0 +1,116 @@
1
+ require 'test/unit'
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'mapi/property_set'
5
+
6
+ class TestMapiPropertySet < Test::Unit::TestCase
7
+ include Mapi
8
+
9
+ def test_constants
10
+ assert_equal '00020328-0000-0000-c000-000000000046', PropertySet::PS_MAPI.format
11
+ end
12
+
13
+ def test_lookup
14
+ guid = Ole::Types::Clsid.parse '00020328-0000-0000-c000-000000000046'
15
+ assert_equal 'PS_MAPI', PropertySet::NAMES[guid]
16
+ end
17
+
18
+ def test_simple_key
19
+ key = PropertySet::Key.new 0x0037
20
+ assert_equal PropertySet::PS_MAPI, key.guid
21
+ hash = {key => 'hash lookup'}
22
+ assert_equal 'hash lookup', hash[PropertySet::Key.new(0x0037)]
23
+ assert_equal '0x0037', key.inspect
24
+ assert_equal :subject, key.to_sym
25
+ end
26
+
27
+ def test_complex_keys
28
+ key = PropertySet::Key.new 'Keywords', PropertySet::PS_PUBLIC_STRINGS
29
+ # note that the inspect string now uses symbolic guids
30
+ assert_equal '#<Key PS_PUBLIC_STRINGS/"Keywords">', key.inspect
31
+ # note that this isn't categories
32
+ assert_equal :keywords, key.to_sym
33
+ custom_guid = '00020328-0000-0000-c000-deadbeefcafe'
34
+ key = PropertySet::Key.new 0x8000, Ole::Types::Clsid.parse(custom_guid)
35
+ assert_equal "#<Key {#{custom_guid}}/0x8000>", key.inspect
36
+ key = PropertySet::Key.new 0x8005, PropertySet::PSETID_Address
37
+ assert_equal 'file_under', key.to_s
38
+ end
39
+
40
+ def test_property_set_basics
41
+ # the propertystore can be mocked with a hash:
42
+ store = {
43
+ PropertySet::Key.new(0x0037) => 'the subject',
44
+ PropertySet::Key.new('Keywords', PropertySet::PS_PUBLIC_STRINGS) => ['some keywords'],
45
+ PropertySet::Key.new(0x8888) => 'un-mapped value'
46
+ }
47
+ props = PropertySet.new store
48
+ # can resolve subject
49
+ assert_equal PropertySet::Key.new(0x0037), props.resolve('subject')
50
+ # note that the way things are set up, you can't resolve body though. ie, only
51
+ # existent (not all-known) properties resolve. maybe this should be changed. it'll
52
+ # need to be, for <tt>props.body=</tt> to work as it should.
53
+ assert_equal nil, props.resolve('body')
54
+ assert_equal 'the subject', props.subject
55
+ assert_equal ['some keywords'], props.keywords
56
+ # other access methods
57
+ assert_equal 'the subject', props['subject']
58
+ assert_equal 'the subject', props[0x0037]
59
+ assert_equal 'the subject', props[0x0037, PropertySet::PS_MAPI]
60
+ # note that the store is accessible directly, as #raw currently (maybe i should rename)
61
+ assert_equal store, props.raw
62
+ # note that currently, props.each / props.to_h works with the symbolically
63
+ # mapped properties, so the above un-mapped value won't be in the list:
64
+ assert_equal({:subject => 'the subject', :keywords => ['some keywords']}, props.to_h)
65
+ assert_equal [:keywords, :subject], props.keys.sort_by(&:to_s)
66
+ assert_equal [['some keywords'], 'the subject'], props.values.sort_by(&:to_s)
67
+ end
68
+
69
+ # other things we could test - write support. duplicate key handling
70
+
71
+ def test_pseudo_properties
72
+ # load some compressed rtf data
73
+ data = File.read File.dirname(__FILE__) + '/test_rtf.data'
74
+ props = PropertySet.new PropertySet::Key.new(0x1009) => StringIO.new(data)
75
+ # all these get generated from the rtf. still need tests for the way the priorities work
76
+ # here, and also the html embedded in rtf stuff....
77
+ assert_equal((<<-'end').chomp.gsub(/\n/, "\n\r"), props.body_rtf)
78
+ {\rtf1\ansi\ansicpg1252\fromtext \deff0{\fonttbl
79
+ {\f0\fswiss Arial;}
80
+ {\f1\fmodern Courier New;}
81
+ {\f2\fnil\fcharset2 Symbol;}
82
+ {\f3\fmodern\fcharset0 Courier New;}}
83
+ {\colortbl\red0\green0\blue0;\red0\green0\blue255;}
84
+ \uc1\pard\plain\deftab360 \f0\fs20 \par
85
+ I will be out of the office starting 15.02.2007 and will not return until\par
86
+ 27.02.2007.\par
87
+ \par
88
+ I will respond to your message when I return. For urgent enquiries please\par
89
+ contact Motherine Jacson.\par
90
+ \par
91
+ }
92
+ end
93
+ assert_equal <<-'end', props.body_html
94
+ <html>
95
+ <body>
96
+ <br>I will be out of the office starting 15.02.2007 and will not return until
97
+ <br>27.02.2007.
98
+ <br>
99
+ <br>I will respond to your message when I return. For urgent enquiries please
100
+ <br>contact Motherine Jacson.
101
+ <br>
102
+ <br></body>
103
+ </html>
104
+ end
105
+ assert_equal <<-'end', props.body
106
+
107
+ I will be out of the office starting 15.02.2007 and will not return until
108
+ 27.02.2007.
109
+
110
+ I will respond to your message when I return. For urgent enquiries please
111
+ contact Motherine Jacson.
112
+
113
+ end
114
+ end
115
+ end
116
+