imap.rb 0.6.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db42d933da16aca65af3c854ea0f26da5ec173da7df4260e0bb2879476da0bbd
4
- data.tar.gz: 1c751f2b12f3dcaa5cfa6035ead2c6b24ba81c3c866cd7bf454e56f67ada69ab
3
+ metadata.gz: f9f5b0c607f3b3e66d7284613916d45c077de9a0e97f50f960fa8e63fd7b5586
4
+ data.tar.gz: 4529940cbd85a56bfbd4ef9d472536234852cd4cabf6349f90763644905ab953
5
5
  SHA512:
6
- metadata.gz: fea3b0f91b2813bc83be27ee944d2d34c4e7b7740b406349e85e5d24fbccc5b199a0b272e144bf8e3ea096a5a9dfa68760aef3c537cd6ddaf92f9533a9ee209c
7
- data.tar.gz: 6e8c310a8660730d3c268f17f236ea92c7d33bd3de96edfb189acb4c0f34dfd6a7d9bc7037f8cb38486ac34836ba04c99df1960649f755a93c097cfb8ced3c00
6
+ metadata.gz: 7279ae1bb4fd2456fa2acf57cad565a0af28d2be5795e1e6bf9924b2b9f6583acf2e4a71735944c8bf3f3dc56d403d3abba5943eb87ea15054f12c718665bab9
7
+ data.tar.gz: 060c6a1b974e983979130cf62d03e0630ef548b64b9e80a522c6064c031126385de84f762cb1a158fd50b03711902f1430038b8aa0b3f63864c7dc4e85366548
data/CHANGELOG CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## 20260917
4
4
 
5
+ 0.7.0: ~ Imap::Message: + #uid, #flags, #seen?, #size, #received_at, #sent_at, #cc, #from_address, #from_domain, #attachment?, #attachment_filenames, #source, .decode; + Imap#mailboxes
6
+
7
+ 1. + Imap::Message#uid, #flags, #seen?, #size, #received_at, #sent_at, #cc, #from_address, #from_domain, #attachment?, #attachment_filenames, #source. Everything a search had to go around the gem to Net::IMAP for.
8
+ 2. ~ Imap::Message: + FETCH_ATTRIBUTES, UID FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODYSTRUCTURE, which .search asks for in the one fetch it already made. The round trip costs the same whether it asks for one of these or for all six, so it asks for all six.
9
+ 3. ~ Imap::Message: keeps the Net::IMAP::FetchData rather than its attr hash, + Imap::Message#data for the old #attrs. FetchData reads every one of these attributes itself, so #uid, #flags, #size, #envelope and #bodystructure delegate rather than index, #body and #source become #text and #message, and #received_at is Net::IMAP.decode_time's work rather than Time.parse's, the INTERNALDATE being IMAP's own date-time and not RFC 5322's. #sent_at keeps Time.parse, the envelope carrying the header as the sender wrote it. Half a dozen accessors of net-imap's were being reimplemented here a little worse.
10
+ 4. ~ Imap::Message#envelope: reads FETCH_ATTRIBUTES where a caller built the message, rather than the ENVELOPE alone, so that a message built by hand answers the same questions as a searched one for the same one round trip.
11
+ 5. + Imap::Message.decode: RFC 2047, =?charset?B?...?= and =?charset?Q?...?=, the whitespace between adjacent encoded words dropped as the RFC says. A subject or a display name in anything but ASCII arrived as its encoding and was unreadable and unsearchable both. The text as it stands where the charset is not one Ruby knows.
12
+ 6. ~ Imap::Message#subject: decoded, and the folding newlines made spaces. #from and #to likewise decode the display name.
13
+ 7. + Imap::Message#source: BODY.PEEK[] for the whole message, headers and all, for whatever wants to parse the MIME. The gem does not: that is the mail gem's work and not IMAP's, and this is the one line it needs.
14
+ 8. + Imap#mailboxes: LIST '' '*', the \Noselect containers left out, since there is no searching everywhere without first knowing where everywhere is.
15
+ 9. ~ test/test_helper.rb: MockIMAP answers with a Net::IMAP::FetchData as a server's response parses to, rather than an OpenStruct wearing seqno and attr, and answers the six attributes and LIST as a server does. Message 2 carries the encoded words and names its attachment in the content type, message 1 in the disposition, message 99 an envelope with neither to nor cc.
16
+ 10. + 20 tests: the attributes off the one fetch, both dates as Times, the decoder upon each encoding and upon a charset it cannot know, both ways an attachment is named, and the selectable mailboxes.
17
+ 11. ~ Imap::VERSION: /0.6.0/0.7.0/
18
+
5
19
  0.6.0: One fetch for a slice of messages rather than one per attribute per message.
6
20
 
7
21
  1. ~ Imap::Message.search: fetches the ENVELOPE for a slice of message ids and hands each Imap::Message its own, + SLICE = 200. A hundred messages cost a round trip apiece for the subject and another apiece for the from; they cost one for the hundred now, which at any real latency is the whole cost of a search.
data/lib/Imap/Message.rb CHANGED
@@ -10,13 +10,23 @@
10
10
  # 6. Imap::Message.new(message_id, imap_client).subject
11
11
  # 7. Imap::Message.new(message_id, imap_client).from
12
12
  # 8. Imap::Message.new(message_id, imap_client).to
13
+ # 9. Imap::Message.new(message_id, imap_client).received_at
14
+ # 10. Imap::Message.new(message_id, imap_client).attachment_filenames
15
+ # 11. Imap::Message.new(message_id, imap_client).source
16
+
17
+ require 'time'
13
18
 
14
19
  require_relative './Search'
15
20
 
16
21
  class Imap
17
22
  class Message
18
23
 
24
+ # What one fetch brings back, since the round trip costs the same whether it
25
+ # asks for one of these or for all of them.
26
+ FETCH_ATTRIBUTES = %w{UID FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODYSTRUCTURE}
19
27
  SLICE = 200
28
+ ENCODED_WORD = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/
29
+
20
30
  class << self
21
31
 
22
32
  # One fetch for a slice of messages rather than one per attribute per
@@ -25,32 +35,108 @@ class Imap
25
35
  def search(imap_client, **search_criteria)
26
36
  message_ids = Imap::Search.new(imap_client, search_criteria).message_ids
27
37
  message_ids.each_slice(SLICE).flat_map do |slice|
28
- imap_client.imap.fetch(slice, ['ENVELOPE']).collect do |data|
29
- Imap::Message.new(data.seqno, imap_client, data.attr)
38
+ imap_client.imap.fetch(slice, FETCH_ATTRIBUTES).collect do |data|
39
+ Imap::Message.new(data.seqno, imap_client, data)
30
40
  end
31
41
  end
32
42
  end
33
43
  alias_method :find, :search
34
44
 
45
+ # RFC 2047: =?charset?B?base64?= and =?charset?Q?quoted-printable?=, which
46
+ # headers carry wherever the subject or a name is not ASCII.
47
+ def decode(value)
48
+ return nil if value.nil?
49
+ value.to_s.gsub(/(?<=\?=)\s+(?==\?)/, '').gsub(ENCODED_WORD){decode_word($1, $2, $3)}
50
+ end
51
+
52
+ private
53
+
54
+ def decode_word(charset, encoding, text)
55
+ bytes =
56
+ if encoding.upcase == 'B'
57
+ text.unpack1('m')
58
+ else
59
+ text.tr('_', ' ').gsub(/=([0-9A-Fa-f]{2})/){$1.hex.chr}
60
+ end
61
+ bytes.force_encoding(charset.split('*').first).encode('UTF-8', invalid: :replace, undef: :replace)
62
+ rescue StandardError
63
+ text
64
+ end
65
+
35
66
  end # class << self
36
67
 
37
68
  attr_accessor :imap_client
38
69
  attr_accessor :message_id
39
70
 
40
- def body
41
- @body ||= fetch_data('BODY.PEEK[TEXT]').first.attr['BODY[TEXT]']
71
+ def uid
72
+ data.uid
73
+ end
74
+
75
+ def flags
76
+ data.flags || []
77
+ end
78
+
79
+ def seen?
80
+ flags.include?(:Seen)
81
+ end
82
+
83
+ def size
84
+ data.size.to_i
85
+ end
86
+
87
+ # When the server took delivery, which is what a mailbox is ordered by.
88
+ def received_at
89
+ @received_at ||= data.internaldate
90
+ end
91
+
92
+ # When the sender says they sent it, which is the Date header and is theirs
93
+ # to get wrong. Time.parse and not Net::IMAP.decode_time: the envelope
94
+ # carries the RFC 5322 header, not IMAP's own date-time.
95
+ def sent_at
96
+ @sent_at ||= to_time(envelope && envelope.date)
42
97
  end
43
98
 
44
99
  def subject
45
- @subject ||= envelope.subject.to_s.strip
100
+ @subject ||= Message.decode(envelope && envelope.subject).to_s.tr("\r\n", ' ').strip
46
101
  end
47
102
 
48
103
  def from
49
104
  @from ||= address_to_s(envelope.from && envelope.from.first)
50
105
  end
51
106
 
107
+ def from_address
108
+ @from_address ||= address_only(envelope.from && envelope.from.first)
109
+ end
110
+
111
+ def from_domain
112
+ @from_domain ||= from_address && from_address.split('@').last.to_s.downcase
113
+ end
114
+
52
115
  def to
53
- @to ||= (envelope.to || []).collect{|address| address_to_s(address)}
116
+ @to ||= addresses(envelope.to)
117
+ end
118
+
119
+ def cc
120
+ @cc ||= addresses(envelope.cc)
121
+ end
122
+
123
+ # IMAP cannot search upon an attachment, but the bodystructure names them and
124
+ # arrives with the rest.
125
+ def attachment?
126
+ !attachment_filenames.empty?
127
+ end
128
+
129
+ def attachment_filenames
130
+ @attachment_filenames ||= filenames_in(data.bodystructure)
131
+ end
132
+
133
+ def body
134
+ @body ||= fetch_data('BODY.PEEK[TEXT]').first.text
135
+ end
136
+
137
+ # The whole message, headers and all, for whatever wants to parse MIME.
138
+ def source
139
+ @source ||= fetch_data('BODY.PEEK[]').first.message
54
140
  end
55
141
 
56
142
  def urls
@@ -64,25 +150,71 @@ class Imap
64
150
 
65
151
  private
66
152
 
67
- def initialize(message_id = nil, imap_client = nil, attrs = nil)
153
+ def initialize(message_id = nil, imap_client = nil, data = nil)
68
154
  @message_id = message_id
69
155
  @imap_client = imap_client
70
- @attrs = attrs
156
+ @data = data
157
+ end
158
+
159
+ # The Net::IMAP::FetchData itself rather than its attr hash, since it reads
160
+ # every one of these attributes already and decodes the internaldate to a
161
+ # Time along the way. Prefetched where search() built this, fetched where
162
+ # the caller built it.
163
+ def data
164
+ @data ||= fetch_data(*FETCH_ATTRIBUTES).first
71
165
  end
72
166
 
73
- # Prefetched where search() built this, fetched where the caller built it.
74
167
  def envelope
75
- @envelope ||= (@attrs && @attrs['ENVELOPE']) || fetch_data('ENVELOPE').first.attr['ENVELOPE']
168
+ data.envelope
169
+ end
170
+
171
+ def to_time(value)
172
+ return value if value.is_a?(Time)
173
+ value.nil? || value.to_s.empty? ? nil : Time.parse(value.to_s)
174
+ rescue ArgumentError
175
+ nil
176
+ end
177
+
178
+ def addresses(list)
179
+ (list || []).collect{|address| address_to_s(address)}.compact
180
+ end
181
+
182
+ def address_only(address)
183
+ address && [address.mailbox, address.host].compact.join('@')
76
184
  end
77
185
 
78
186
  def address_to_s(address)
79
187
  return nil unless address
80
- email = [address.mailbox, address.host].compact.join('@')
81
- address.name.to_s.empty? ? email : "#{address.name} <#{email}>"
188
+ email = address_only(address)
189
+ name = Message.decode(address.name).to_s.strip
190
+ name.empty? ? email : "#{name} <#{email}>"
191
+ end
192
+
193
+ def filenames_in(structure)
194
+ return [] unless structure
195
+ return structure.parts.flat_map{|part| filenames_in(part)} if structure.respond_to?(:parts) && structure.parts
196
+ return filenames_in(structure.body) if structure.respond_to?(:body) && structure.body
197
+ [filename_of(structure)].compact
198
+ end
199
+
200
+ # The disposition names it where the sender set one, the content type's NAME
201
+ # where they did not.
202
+ def filename_of(part)
203
+ name = disposition_filename(part) || parameter_filename(part)
204
+ name.nil? || name.to_s.strip.empty? ? nil : Message.decode(name).strip
205
+ end
206
+
207
+ def disposition_filename(part)
208
+ disposition = part.respond_to?(:disposition) ? part.disposition : nil
209
+ disposition && disposition.param ? disposition.param['FILENAME'] : nil
210
+ end
211
+
212
+ def parameter_filename(part)
213
+ part.respond_to?(:param) && part.param ? part.param['NAME'] : nil
82
214
  end
83
215
 
84
- def fetch_data(*attrs)
85
- @fetch_data = imap_client.imap.fetch(message_id, [*attrs])
216
+ def fetch_data(*fetch_attributes)
217
+ @fetch_data = imap_client.imap.fetch(message_id, [*fetch_attributes])
86
218
  end
87
219
  end
88
220
  end
data/lib/Imap/VERSION.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Imap
2
- VERSION = '0.6.0'
2
+ VERSION = '0.7.0'
3
3
  end
data/lib/imap.rb CHANGED
@@ -44,6 +44,11 @@ class Imap
44
44
  imap.select(mailbox)
45
45
  end
46
46
 
47
+ # Every mailbox which can be selected, the \Noselect containers left out.
48
+ def mailboxes
49
+ (imap.list('', '*') || []).reject{|mailbox| mailbox.attr.include?(:Noselect)}.collect(&:name)
50
+ end
51
+
47
52
  def search(criteria = {})
48
53
  Imap::Message.search(self, **criteria)
49
54
  end
@@ -109,6 +109,111 @@ describe Imap::Message do
109
109
  end
110
110
  end
111
111
 
112
+ describe '#uid, #flags, #seen? and #size' do
113
+ it 'reads what the one fetch brought back' do
114
+ _(imap_message.uid).must_equal 1001
115
+ _(imap_message.flags).must_equal []
116
+ _(imap_message.seen?).must_equal false
117
+ _(imap_message.size).must_equal 2048
118
+ end
119
+
120
+ it 'is seen where the flags say so' do
121
+ _(Imap::Message.new(2, client).seen?).must_equal true
122
+ end
123
+
124
+ it 'asks for them in the same fetch as the envelope' do
125
+ imap_message.uid
126
+ imap_message.subject
127
+ _(client.imap.fetch_count).must_equal 1
128
+ _(client.imap.fetched_attrs).must_equal Imap::Message::FETCH_ATTRIBUTES
129
+ end
130
+ end
131
+
132
+ describe '#received_at and #sent_at' do
133
+ it 'returns the internaldate as a Time, zone and all' do
134
+ _(imap_message.received_at).must_be_instance_of Time
135
+ _(imap_message.received_at.year).must_equal 2026
136
+ _(imap_message.received_at.utc_offset).must_equal 10 * 3600
137
+ end
138
+
139
+ it 'returns the envelope date as a Time' do
140
+ _(imap_message.sent_at).must_be_instance_of Time
141
+ _(imap_message.sent_at.min).must_equal 5
142
+ end
143
+ end
144
+
145
+ describe '#cc' do
146
+ it 'returns an array of addresses' do
147
+ _(imap_message.cc).must_equal ['copied@example.com']
148
+ end
149
+
150
+ it 'is empty where the envelope carries no cc' do
151
+ _(Imap::Message.new(99, client).cc).must_equal []
152
+ end
153
+ end
154
+
155
+ describe '#from_address and #from_domain' do
156
+ it 'drops the display name and downcases the domain' do
157
+ message = Imap::Message.new(2, client)
158
+ _(message.from).must_equal 'Café <sender@Example.COM>'
159
+ _(message.from_address).must_equal 'sender@Example.COM'
160
+ _(message.from_domain).must_equal 'example.com'
161
+ end
162
+ end
163
+
164
+ describe '.decode' do
165
+ it 'decodes a base64 encoded word' do
166
+ _(Imap::Message.decode('=?UTF-8?B?UsOpc3Vtw6k=?=')).must_equal 'Résumé'
167
+ end
168
+
169
+ it 'decodes a quoted-printable encoded word, underscore for space' do
170
+ _(Imap::Message.decode('=?UTF-8?Q?Caf=C3=A9_au_lait?=')).must_equal 'Café au lait'
171
+ end
172
+
173
+ it 'drops the whitespace between adjacent encoded words' do
174
+ _(Imap::Message.decode('=?UTF-8?Q?Caf=C3=A9?= =?UTF-8?Q?_au_lait?=')).must_equal 'Café au lait'
175
+ end
176
+
177
+ it 'leaves plain text alone and nil nil' do
178
+ _(Imap::Message.decode('Plain subject')).must_equal 'Plain subject'
179
+ _(Imap::Message.decode(nil)).must_be_nil
180
+ end
181
+
182
+ it 'returns the text where the charset is not one Ruby knows' do
183
+ _(Imap::Message.decode('=?NOSUCHSET?B?UsOpc3Vtw6k=?=')).must_equal 'UsOpc3Vtw6k='
184
+ end
185
+ end
186
+
187
+ describe '#subject' do
188
+ it 'decodes the encoded word' do
189
+ _(Imap::Message.new(2, client).subject).must_equal 'Résumé for review'
190
+ end
191
+ end
192
+
193
+ describe '#attachment? and #attachment_filenames' do
194
+ it 'names the attachment from the disposition' do
195
+ _(imap_message.attachment?).must_equal true
196
+ _(imap_message.attachment_filenames).must_equal ['invoice.pdf']
197
+ end
198
+
199
+ it 'falls back to the content type NAME and decodes it' do
200
+ _(Imap::Message.new(2, client).attachment_filenames).must_equal ['résumé.pdf']
201
+ end
202
+
203
+ it 'has none where the bodystructure carries none' do
204
+ message = Imap::Message.new(3, client)
205
+ _(message.attachment_filenames).must_equal []
206
+ _(message.attachment?).must_equal false
207
+ end
208
+ end
209
+
210
+ describe '#source' do
211
+ it 'peeks at the whole message, headers and all' do
212
+ _(imap_message.source).must_match(/\ASubject: /)
213
+ _(client.imap.fetched_attrs).must_include 'BODY.PEEK[]'
214
+ end
215
+ end
216
+
112
217
  describe '#urls' do
113
218
  it 'extracts http URLs from body' do
114
219
  imap_message.stub(:body, 'Check out http://example.com and https://test.com') do
data/test/Imap_test.rb CHANGED
@@ -70,6 +70,16 @@ describe Imap do
70
70
  end
71
71
  end
72
72
 
73
+ describe "#mailboxes" do
74
+ it 'names every mailbox which can be selected' do
75
+ _(client.mailboxes).must_equal ['INBOX', 'Archive/2026']
76
+ end
77
+
78
+ it 'leaves out the \\Noselect containers' do
79
+ _(client.mailboxes).wont_include 'Archive'
80
+ end
81
+ end
82
+
73
83
  describe "#search" do
74
84
  it 'returns an array of Message objects' do
75
85
  Net::IMAP.stub(:new, MockIMAP.new('imap.example.com', ssl: true)) do
data/test/test_helper.rb CHANGED
@@ -31,6 +31,7 @@ class MockIMAP
31
31
  [1, 2, 3]
32
32
  end
33
33
 
34
+ attr_reader :list_called
34
35
  attr_reader :fetched_attrs
35
36
  attr_reader :fetch_count
36
37
 
@@ -38,10 +39,19 @@ class MockIMAP
38
39
  @fetched_attrs = attrs
39
40
  @fetch_count = (@fetch_count || 0) + 1
40
41
  Array(message_ids).collect do |message_id|
41
- OpenStruct.new(seqno: message_id, attr: mock_fetch_attrs(message_id, attrs))
42
+ Net::IMAP::FetchData.new(message_id, mock_fetch_attrs(message_id, attrs))
42
43
  end
43
44
  end
44
45
 
46
+ def list(reference, pattern)
47
+ @list_called = true
48
+ [
49
+ OpenStruct.new(attr: [:Hasnochildren], name: 'INBOX'),
50
+ OpenStruct.new(attr: [:Noselect, :Haschildren], name: 'Archive'),
51
+ OpenStruct.new(attr: [:Hasnochildren], name: 'Archive/2026')
52
+ ]
53
+ end
54
+
45
55
  def store(message_id, flags, values); end
46
56
 
47
57
  def logout
@@ -67,14 +77,63 @@ class MockIMAP
67
77
  case attr
68
78
  when 'BODY[TEXT]', 'BODY.PEEK[TEXT]'
69
79
  result['BODY[TEXT]'] = "Mock body for message #{message_id}"
80
+ when 'BODY[]', 'BODY.PEEK[]'
81
+ result['BODY[]'] = "Subject: Mock Subject #{message_id}\r\n\r\nMock body for message #{message_id}"
82
+ when 'UID'
83
+ result['UID'] = 1000 + message_id
84
+ when 'FLAGS'
85
+ result['FLAGS'] = message_id == 2 ? [:Seen] : []
86
+ when 'INTERNALDATE'
87
+ result['INTERNALDATE'] = '17-Sep-2026 09:15:00 +1000'
88
+ when 'RFC822.SIZE'
89
+ result['RFC822.SIZE'] = 2048
90
+ when 'BODYSTRUCTURE'
91
+ result['BODYSTRUCTURE'] = mock_bodystructure(message_id)
70
92
  when 'ENVELOPE'
71
- result['ENVELOPE'] = OpenStruct.new(
72
- subject: "Mock Subject #{message_id}",
73
- from: [OpenStruct.new(mailbox: 'sender', host: 'example.com')],
74
- to: message_id == 99 ? nil : [OpenStruct.new(mailbox: 'user', host: 'example.com')]
75
- )
93
+ result['ENVELOPE'] = mock_envelope(message_id)
76
94
  end
77
95
  end
78
96
  result
79
97
  end
98
+
99
+ # Message 2 carries the encoded words, message 99 the empty envelope.
100
+ def mock_envelope(message_id)
101
+ OpenStruct.new(
102
+ date: 'Thu, 17 Sep 2026 09:05:00 +1000',
103
+ subject: message_id == 2 ? '=?UTF-8?B?UsOpc3Vtw6kgZm9yIHJldmlldw==?=' : "Mock Subject #{message_id}",
104
+ from: [
105
+ message_id == 2 ?
106
+ OpenStruct.new(name: '=?UTF-8?Q?Caf=C3=A9?=', mailbox: 'sender', host: 'Example.COM') :
107
+ OpenStruct.new(mailbox: 'sender', host: 'example.com')
108
+ ],
109
+ to: message_id == 99 ? nil : [OpenStruct.new(mailbox: 'user', host: 'example.com')],
110
+ cc: message_id == 99 ? nil : [OpenStruct.new(mailbox: 'copied', host: 'example.com')]
111
+ )
112
+ end
113
+
114
+ # Message 1 names its attachment in the disposition, message 2 in the content
115
+ # type's NAME and in an encoded word, the rest carry none.
116
+ def mock_bodystructure(message_id)
117
+ text = OpenStruct.new(media_type: 'TEXT', subtype: 'PLAIN', param: nil, disposition: nil)
118
+ case message_id
119
+ when 1
120
+ attachment = OpenStruct.new(
121
+ media_type: 'APPLICATION',
122
+ subtype: 'PDF',
123
+ param: {'NAME' => 'ignored.pdf'},
124
+ disposition: OpenStruct.new(dsp_type: 'ATTACHMENT', param: {'FILENAME' => 'invoice.pdf'})
125
+ )
126
+ OpenStruct.new(media_type: 'MULTIPART', subtype: 'MIXED', parts: [text, attachment])
127
+ when 2
128
+ attachment = OpenStruct.new(
129
+ media_type: 'APPLICATION',
130
+ subtype: 'PDF',
131
+ param: {'NAME' => '=?UTF-8?B?csOpc3Vtw6kucGRm?='},
132
+ disposition: nil
133
+ )
134
+ OpenStruct.new(media_type: 'MULTIPART', subtype: 'MIXED', parts: [text, attachment])
135
+ else
136
+ text
137
+ end
138
+ end
80
139
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imap.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran