imap.rb 0.6.0 → 0.8.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: 45cf03dcb336e9795a5ef5339e6ac0c6e3b2f5e79014f3462684f39f79aae98f
4
+ data.tar.gz: e204d83b456b966f0f9a9c3a55acfb492564da65a2b2be22d8c27326104ba755
5
5
  SHA512:
6
- metadata.gz: fea3b0f91b2813bc83be27ee944d2d34c4e7b7740b406349e85e5d24fbccc5b199a0b272e144bf8e3ea096a5a9dfa68760aef3c537cd6ddaf92f9533a9ee209c
7
- data.tar.gz: 6e8c310a8660730d3c268f17f236ea92c7d33bd3de96edfb189acb4c0f34dfd6a7d9bc7037f8cb38486ac34836ba04c99df1960649f755a93c097cfb8ced3c00
6
+ metadata.gz: 20938befdd15cc9da7f5679b0043facbdbc0d3a69b04f93a0100c43b4314b6832ecfed375484d1429221ee60703dfc94ac3abdd1f4ff40060ed528828187fcf4
7
+ data.tar.gz: e467e3aed0b2b09cc86e175a1b3f940e37922232e4efb97998c5dc7a959b5a3f9b8f012bb1a8ff6505d7dff83f488e418c5e7d8670fe5b4db7333991ff683d77
data/CHANGELOG CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## 20260917
4
4
 
5
+ 0.8.0: + Imap::Message.for, the fetch without the search, and a class a program can subclass.
6
+
7
+ 1. + Imap::Message.for(imap_client, message_ids): the batched fetch on its own, .search now being the search and then this. A program which builds its own search keys had no way in: .search couples the two, so search-mail had 159 lines of its own Hit reimplementing the fetch beside it.
8
+ 2. ~ Imap::Message.for: builds with new rather than Imap::Message.new, so that a subclass is handed back its own kind. A program which wants a message of its own, with a mailbox or a decoded body upon it, subclasses rather than starts again.
9
+ 3. + 4 tests: that .for builds for ids searched elsewhere, that it fetches once, that it is empty where nothing was found, and that a subclass gets itself back.
10
+ 4. ~ Imap::VERSION: /0.7.0/0.8.0/
11
+
12
+ 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
13
+
14
+ 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.
15
+ 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.
16
+ 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.
17
+ 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.
18
+ 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.
19
+ 6. ~ Imap::Message#subject: decoded, and the folding newlines made spaces. #from and #to likewise decode the display name.
20
+ 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.
21
+ 8. + Imap#mailboxes: LIST '' '*', the \Noselect containers left out, since there is no searching everywhere without first knowing where everywhere is.
22
+ 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.
23
+ 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.
24
+ 11. ~ Imap::VERSION: /0.6.0/0.7.0/
25
+
5
26
  0.6.0: One fetch for a slice of messages rather than one per attribute per message.
6
27
 
7
28
  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
@@ -3,54 +3,146 @@
3
3
 
4
4
  # Usage:
5
5
  # 1. Imap::Message.search(imap_client, {from: 'noreply@example.com'})
6
- # 2. Imap::Message.new(message_id, imap_client)
7
- # 3. Imap::Message.new(message_id, imap_client).body
8
- # 4. Imap::Message.new(message_id, imap_client).urls
9
- # 5. Imap::Message.new(message_id, imap_client).mark_as_read
10
- # 6. Imap::Message.new(message_id, imap_client).subject
11
- # 7. Imap::Message.new(message_id, imap_client).from
12
- # 8. Imap::Message.new(message_id, imap_client).to
6
+ # 2. Imap::Message.for(imap_client, imap_client.imap.search(['ALL']))
7
+ # 3. Imap::Message.new(message_id, imap_client)
8
+ # 4. Imap::Message.new(message_id, imap_client).body
9
+ # 5. Imap::Message.new(message_id, imap_client).urls
10
+ # 6. Imap::Message.new(message_id, imap_client).mark_as_read
11
+ # 7. Imap::Message.new(message_id, imap_client).subject
12
+ # 8. Imap::Message.new(message_id, imap_client).from
13
+ # 9. Imap::Message.new(message_id, imap_client).to
14
+ # 10. Imap::Message.new(message_id, imap_client).received_at
15
+ # 11. Imap::Message.new(message_id, imap_client).attachment_filenames
16
+ # 12. Imap::Message.new(message_id, imap_client).source
17
+
18
+ require 'time'
13
19
 
14
20
  require_relative './Search'
15
21
 
16
22
  class Imap
17
23
  class Message
18
24
 
25
+ # What one fetch brings back, since the round trip costs the same whether it
26
+ # asks for one of these or for all of them.
27
+ FETCH_ATTRIBUTES = %w{UID FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODYSTRUCTURE}
19
28
  SLICE = 200
29
+ ENCODED_WORD = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/
30
+
20
31
  class << self
21
32
 
33
+ def search(imap_client, **search_criteria)
34
+ self.for(imap_client, Imap::Search.new(imap_client, search_criteria).message_ids)
35
+ end
36
+ alias_method :find, :search
37
+
22
38
  # One fetch for a slice of messages rather than one per attribute per
23
39
  # message. A hundred messages cost a round trip apiece for the subject and
24
40
  # another apiece for the from; they cost one for the hundred now.
25
- def search(imap_client, **search_criteria)
26
- message_ids = Imap::Search.new(imap_client, search_criteria).message_ids
41
+ #
42
+ # new and not Imap::Message.new, so that a subclass gets its own kind back.
43
+ def for(imap_client, message_ids)
27
44
  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)
45
+ imap_client.imap.fetch(slice, FETCH_ATTRIBUTES).collect do |data|
46
+ new(data.seqno, imap_client, data)
30
47
  end
31
48
  end
32
49
  end
33
- alias_method :find, :search
50
+
51
+ # RFC 2047: =?charset?B?base64?= and =?charset?Q?quoted-printable?=, which
52
+ # headers carry wherever the subject or a name is not ASCII.
53
+ def decode(value)
54
+ return nil if value.nil?
55
+ value.to_s.gsub(/(?<=\?=)\s+(?==\?)/, '').gsub(ENCODED_WORD){decode_word($1, $2, $3)}
56
+ end
57
+
58
+ private
59
+
60
+ def decode_word(charset, encoding, text)
61
+ bytes =
62
+ if encoding.upcase == 'B'
63
+ text.unpack1('m')
64
+ else
65
+ text.tr('_', ' ').gsub(/=([0-9A-Fa-f]{2})/){$1.hex.chr}
66
+ end
67
+ bytes.force_encoding(charset.split('*').first).encode('UTF-8', invalid: :replace, undef: :replace)
68
+ rescue StandardError
69
+ text
70
+ end
34
71
 
35
72
  end # class << self
36
73
 
37
74
  attr_accessor :imap_client
38
75
  attr_accessor :message_id
39
76
 
40
- def body
41
- @body ||= fetch_data('BODY.PEEK[TEXT]').first.attr['BODY[TEXT]']
77
+ def uid
78
+ data.uid
79
+ end
80
+
81
+ def flags
82
+ data.flags || []
83
+ end
84
+
85
+ def seen?
86
+ flags.include?(:Seen)
87
+ end
88
+
89
+ def size
90
+ data.size.to_i
91
+ end
92
+
93
+ # When the server took delivery, which is what a mailbox is ordered by.
94
+ def received_at
95
+ @received_at ||= data.internaldate
96
+ end
97
+
98
+ # When the sender says they sent it, which is the Date header and is theirs
99
+ # to get wrong. Time.parse and not Net::IMAP.decode_time: the envelope
100
+ # carries the RFC 5322 header, not IMAP's own date-time.
101
+ def sent_at
102
+ @sent_at ||= to_time(envelope && envelope.date)
42
103
  end
43
104
 
44
105
  def subject
45
- @subject ||= envelope.subject.to_s.strip
106
+ @subject ||= Message.decode(envelope && envelope.subject).to_s.tr("\r\n", ' ').strip
46
107
  end
47
108
 
48
109
  def from
49
110
  @from ||= address_to_s(envelope.from && envelope.from.first)
50
111
  end
51
112
 
113
+ def from_address
114
+ @from_address ||= address_only(envelope.from && envelope.from.first)
115
+ end
116
+
117
+ def from_domain
118
+ @from_domain ||= from_address && from_address.split('@').last.to_s.downcase
119
+ end
120
+
52
121
  def to
53
- @to ||= (envelope.to || []).collect{|address| address_to_s(address)}
122
+ @to ||= addresses(envelope.to)
123
+ end
124
+
125
+ def cc
126
+ @cc ||= addresses(envelope.cc)
127
+ end
128
+
129
+ # IMAP cannot search upon an attachment, but the bodystructure names them and
130
+ # arrives with the rest.
131
+ def attachment?
132
+ !attachment_filenames.empty?
133
+ end
134
+
135
+ def attachment_filenames
136
+ @attachment_filenames ||= filenames_in(data.bodystructure)
137
+ end
138
+
139
+ def body
140
+ @body ||= fetch_data('BODY.PEEK[TEXT]').first.text
141
+ end
142
+
143
+ # The whole message, headers and all, for whatever wants to parse MIME.
144
+ def source
145
+ @source ||= fetch_data('BODY.PEEK[]').first.message
54
146
  end
55
147
 
56
148
  def urls
@@ -64,25 +156,71 @@ class Imap
64
156
 
65
157
  private
66
158
 
67
- def initialize(message_id = nil, imap_client = nil, attrs = nil)
159
+ def initialize(message_id = nil, imap_client = nil, data = nil)
68
160
  @message_id = message_id
69
161
  @imap_client = imap_client
70
- @attrs = attrs
162
+ @data = data
163
+ end
164
+
165
+ # The Net::IMAP::FetchData itself rather than its attr hash, since it reads
166
+ # every one of these attributes already and decodes the internaldate to a
167
+ # Time along the way. Prefetched where search() built this, fetched where
168
+ # the caller built it.
169
+ def data
170
+ @data ||= fetch_data(*FETCH_ATTRIBUTES).first
71
171
  end
72
172
 
73
- # Prefetched where search() built this, fetched where the caller built it.
74
173
  def envelope
75
- @envelope ||= (@attrs && @attrs['ENVELOPE']) || fetch_data('ENVELOPE').first.attr['ENVELOPE']
174
+ data.envelope
175
+ end
176
+
177
+ def to_time(value)
178
+ return value if value.is_a?(Time)
179
+ value.nil? || value.to_s.empty? ? nil : Time.parse(value.to_s)
180
+ rescue ArgumentError
181
+ nil
182
+ end
183
+
184
+ def addresses(list)
185
+ (list || []).collect{|address| address_to_s(address)}.compact
186
+ end
187
+
188
+ def address_only(address)
189
+ address && [address.mailbox, address.host].compact.join('@')
76
190
  end
77
191
 
78
192
  def address_to_s(address)
79
193
  return nil unless address
80
- email = [address.mailbox, address.host].compact.join('@')
81
- address.name.to_s.empty? ? email : "#{address.name} <#{email}>"
194
+ email = address_only(address)
195
+ name = Message.decode(address.name).to_s.strip
196
+ name.empty? ? email : "#{name} <#{email}>"
197
+ end
198
+
199
+ def filenames_in(structure)
200
+ return [] unless structure
201
+ return structure.parts.flat_map{|part| filenames_in(part)} if structure.respond_to?(:parts) && structure.parts
202
+ return filenames_in(structure.body) if structure.respond_to?(:body) && structure.body
203
+ [filename_of(structure)].compact
204
+ end
205
+
206
+ # The disposition names it where the sender set one, the content type's NAME
207
+ # where they did not.
208
+ def filename_of(part)
209
+ name = disposition_filename(part) || parameter_filename(part)
210
+ name.nil? || name.to_s.strip.empty? ? nil : Message.decode(name).strip
211
+ end
212
+
213
+ def disposition_filename(part)
214
+ disposition = part.respond_to?(:disposition) ? part.disposition : nil
215
+ disposition && disposition.param ? disposition.param['FILENAME'] : nil
216
+ end
217
+
218
+ def parameter_filename(part)
219
+ part.respond_to?(:param) && part.param ? part.param['NAME'] : nil
82
220
  end
83
221
 
84
- def fetch_data(*attrs)
85
- @fetch_data = imap_client.imap.fetch(message_id, [*attrs])
222
+ def fetch_data(*fetch_attributes)
223
+ @fetch_data = imap_client.imap.fetch(message_id, [*fetch_attributes])
86
224
  end
87
225
  end
88
226
  end
data/lib/Imap/VERSION.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Imap
2
- VERSION = '0.6.0'
2
+ VERSION = '0.8.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
@@ -30,6 +30,29 @@ describe Imap::Message do
30
30
  end
31
31
  end
32
32
 
33
+ describe '.for' do
34
+ it 'builds messages for ids the caller searched for itself' do
35
+ messages = Imap::Message.for(client, [1, 2, 3])
36
+ _(messages.size).must_equal 3
37
+ _(messages.first).must_be_instance_of Imap::Message
38
+ _(messages.first.subject).must_match(/Mock Subject/)
39
+ end
40
+
41
+ it 'fetches once for the whole slice' do
42
+ Imap::Message.for(client, [1, 2, 3])
43
+ _(client.imap.fetch_count).must_equal 1
44
+ end
45
+
46
+ it 'is empty where nothing was found' do
47
+ _(Imap::Message.for(client, [])).must_equal []
48
+ end
49
+
50
+ it 'gives a subclass back its own kind' do
51
+ hit = Class.new(Imap::Message)
52
+ _(hit.for(client, [1]).first).must_be_instance_of hit
53
+ end
54
+ end
55
+
33
56
  describe 'the batched fetch' do
34
57
  it 'fetches once for the whole slice rather than once per message' do
35
58
  messages = Imap::Message.search(client, from: 'test@example.com')
@@ -109,6 +132,111 @@ describe Imap::Message do
109
132
  end
110
133
  end
111
134
 
135
+ describe '#uid, #flags, #seen? and #size' do
136
+ it 'reads what the one fetch brought back' do
137
+ _(imap_message.uid).must_equal 1001
138
+ _(imap_message.flags).must_equal []
139
+ _(imap_message.seen?).must_equal false
140
+ _(imap_message.size).must_equal 2048
141
+ end
142
+
143
+ it 'is seen where the flags say so' do
144
+ _(Imap::Message.new(2, client).seen?).must_equal true
145
+ end
146
+
147
+ it 'asks for them in the same fetch as the envelope' do
148
+ imap_message.uid
149
+ imap_message.subject
150
+ _(client.imap.fetch_count).must_equal 1
151
+ _(client.imap.fetched_attrs).must_equal Imap::Message::FETCH_ATTRIBUTES
152
+ end
153
+ end
154
+
155
+ describe '#received_at and #sent_at' do
156
+ it 'returns the internaldate as a Time, zone and all' do
157
+ _(imap_message.received_at).must_be_instance_of Time
158
+ _(imap_message.received_at.year).must_equal 2026
159
+ _(imap_message.received_at.utc_offset).must_equal 10 * 3600
160
+ end
161
+
162
+ it 'returns the envelope date as a Time' do
163
+ _(imap_message.sent_at).must_be_instance_of Time
164
+ _(imap_message.sent_at.min).must_equal 5
165
+ end
166
+ end
167
+
168
+ describe '#cc' do
169
+ it 'returns an array of addresses' do
170
+ _(imap_message.cc).must_equal ['copied@example.com']
171
+ end
172
+
173
+ it 'is empty where the envelope carries no cc' do
174
+ _(Imap::Message.new(99, client).cc).must_equal []
175
+ end
176
+ end
177
+
178
+ describe '#from_address and #from_domain' do
179
+ it 'drops the display name and downcases the domain' do
180
+ message = Imap::Message.new(2, client)
181
+ _(message.from).must_equal 'Café <sender@Example.COM>'
182
+ _(message.from_address).must_equal 'sender@Example.COM'
183
+ _(message.from_domain).must_equal 'example.com'
184
+ end
185
+ end
186
+
187
+ describe '.decode' do
188
+ it 'decodes a base64 encoded word' do
189
+ _(Imap::Message.decode('=?UTF-8?B?UsOpc3Vtw6k=?=')).must_equal 'Résumé'
190
+ end
191
+
192
+ it 'decodes a quoted-printable encoded word, underscore for space' do
193
+ _(Imap::Message.decode('=?UTF-8?Q?Caf=C3=A9_au_lait?=')).must_equal 'Café au lait'
194
+ end
195
+
196
+ it 'drops the whitespace between adjacent encoded words' do
197
+ _(Imap::Message.decode('=?UTF-8?Q?Caf=C3=A9?= =?UTF-8?Q?_au_lait?=')).must_equal 'Café au lait'
198
+ end
199
+
200
+ it 'leaves plain text alone and nil nil' do
201
+ _(Imap::Message.decode('Plain subject')).must_equal 'Plain subject'
202
+ _(Imap::Message.decode(nil)).must_be_nil
203
+ end
204
+
205
+ it 'returns the text where the charset is not one Ruby knows' do
206
+ _(Imap::Message.decode('=?NOSUCHSET?B?UsOpc3Vtw6k=?=')).must_equal 'UsOpc3Vtw6k='
207
+ end
208
+ end
209
+
210
+ describe '#subject' do
211
+ it 'decodes the encoded word' do
212
+ _(Imap::Message.new(2, client).subject).must_equal 'Résumé for review'
213
+ end
214
+ end
215
+
216
+ describe '#attachment? and #attachment_filenames' do
217
+ it 'names the attachment from the disposition' do
218
+ _(imap_message.attachment?).must_equal true
219
+ _(imap_message.attachment_filenames).must_equal ['invoice.pdf']
220
+ end
221
+
222
+ it 'falls back to the content type NAME and decodes it' do
223
+ _(Imap::Message.new(2, client).attachment_filenames).must_equal ['résumé.pdf']
224
+ end
225
+
226
+ it 'has none where the bodystructure carries none' do
227
+ message = Imap::Message.new(3, client)
228
+ _(message.attachment_filenames).must_equal []
229
+ _(message.attachment?).must_equal false
230
+ end
231
+ end
232
+
233
+ describe '#source' do
234
+ it 'peeks at the whole message, headers and all' do
235
+ _(imap_message.source).must_match(/\ASubject: /)
236
+ _(client.imap.fetched_attrs).must_include 'BODY.PEEK[]'
237
+ end
238
+ end
239
+
112
240
  describe '#urls' do
113
241
  it 'extracts http URLs from body' do
114
242
  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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran