imap.rb 0.5.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: 8be67e0de4db977ee6ba474e7355fadae929bd267132fd867d64f96caa3c7a19
4
- data.tar.gz: e930fe80e9f8cffac1837670688e22f8826ce50391a36b5f5cc8474f0c89e9fb
3
+ metadata.gz: f9f5b0c607f3b3e66d7284613916d45c077de9a0e97f50f960fa8e63fd7b5586
4
+ data.tar.gz: 4529940cbd85a56bfbd4ef9d472536234852cd4cabf6349f90763644905ab953
5
5
  SHA512:
6
- metadata.gz: 3b8f36bc1285a4f379624038e387cd70e825c07b617f07a60ebd5ba11ee62a775126eb1a0263107739bea073b44b2d2cc86c4c3f05a5533eb8ea848f5f11b0f0
7
- data.tar.gz: 36f1969380582aef53570369140fdffddd920fabc04a157faca0accd49f38286f8563934b3db3c049e0769074d355339cc234ccfe65fa63f53d8a61eebc096fa
6
+ metadata.gz: 7279ae1bb4fd2456fa2acf57cad565a0af28d2be5795e1e6bf9924b2b9f6583acf2e4a71735944c8bf3f3dc56d403d3abba5943eb87ea15054f12c718665bab9
7
+ data.tar.gz: 060c6a1b974e983979130cf62d03e0630ef548b64b9e80a522c6064c031126385de84f762cb1a158fd50b03711902f1430038b8aa0b3f63864c7dc4e85366548
data/CHANGELOG CHANGED
@@ -2,6 +2,30 @@
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
+
19
+ 0.6.0: One fetch for a slice of messages rather than one per attribute per message.
20
+
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.
22
+ 2. ~ Imap::Message#initialize: + attrs, which search() supplies and a caller need not. + Imap::Message#envelope, which reads what was prefetched or fetches where there is none, so a message built by hand behaves as before.
23
+ 3. ~ Imap::Message#subject, #from, #to: read from the envelope rather than fetching a header apiece. #from answers 'name <mailbox@host>' where the envelope names one, and the bare address where it does not, having answered the raw From header.
24
+ 4. ~ Imap::Message#body: unchanged and still lazy, a body being the one thing worth not fetching until it is wanted.
25
+ 5. ~ test/test_helper.rb: MockIMAP#fetch takes a list as a server does, answers with a seqno apiece, and counts its calls. Its envelope carries a subject and a from; the two BODY[HEADER.FIELDS] cases went with the code that asked for them.
26
+ 6. + tests: that a search of three fetches once, that the prefetched envelope is not fetched again, and that a message built by hand still fetches.
27
+ 7. ~ Imap::VERSION: /0.5.0/0.6.0/
28
+
5
29
  0.5.0: + OR and NOT to the criteria, and HEADER given its field
6
30
 
7
31
  1. + Imap::Search#any_of: or: [{from: 'a@x'}, {to: 'b@y'}]. OR takes two keys and no more, so three or more nest, OR OR a b c, as the server reads them. Each side is a criteria hash of its own and may nest further.
data/lib/Imap/Message.rb CHANGED
@@ -10,38 +10,133 @@
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
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}
27
+ SLICE = 200
28
+ ENCODED_WORD = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/
29
+
18
30
  class << self
19
31
 
32
+ # One fetch for a slice of messages rather than one per attribute per
33
+ # message. A hundred messages cost a round trip apiece for the subject and
34
+ # another apiece for the from; they cost one for the hundred now.
20
35
  def search(imap_client, **search_criteria)
21
36
  message_ids = Imap::Search.new(imap_client, search_criteria).message_ids
22
- message_ids.collect{|message_id| Imap::Message.new(message_id, imap_client)}
37
+ message_ids.each_slice(SLICE).flat_map do |slice|
38
+ imap_client.imap.fetch(slice, FETCH_ATTRIBUTES).collect do |data|
39
+ Imap::Message.new(data.seqno, imap_client, data)
40
+ end
41
+ end
23
42
  end
24
43
  alias_method :find, :search
25
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
+
26
66
  end # class << self
27
67
 
28
68
  attr_accessor :imap_client
29
69
  attr_accessor :message_id
30
70
 
31
- def body
32
- @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)
33
97
  end
34
98
 
35
99
  def subject
36
- @subject ||= fetch_data('BODY[HEADER.FIELDS (SUBJECT)]').first.attr['BODY[HEADER.FIELDS (SUBJECT)]'].sub(/^Subject: /, '').strip
100
+ @subject ||= Message.decode(envelope && envelope.subject).to_s.tr("\r\n", ' ').strip
37
101
  end
38
102
 
39
103
  def from
40
- @from ||= fetch_data('BODY[HEADER.FIELDS (FROM)]').first.attr['BODY[HEADER.FIELDS (FROM)]'].sub(/^From: /, '').strip
104
+ @from ||= address_to_s(envelope.from && envelope.from.first)
105
+ end
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
41
113
  end
42
114
 
43
115
  def to
44
- @to ||= (fetch_data('ENVELOPE').first.attr['ENVELOPE'].to || []).map{|addr| addr.mailbox + '@' + addr.host}
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
45
140
  end
46
141
 
47
142
  def urls
@@ -55,13 +150,71 @@ class Imap
55
150
 
56
151
  private
57
152
 
58
- def initialize(message_id = nil, imap_client = nil)
153
+ def initialize(message_id = nil, imap_client = nil, data = nil)
59
154
  @message_id = message_id
60
155
  @imap_client = imap_client
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
165
+ end
166
+
167
+ def 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('@')
184
+ end
185
+
186
+ def address_to_s(address)
187
+ return nil unless address
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
61
214
  end
62
215
 
63
- def fetch_data(*attrs)
64
- @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])
65
218
  end
66
219
  end
67
220
  end
data/lib/Imap/VERSION.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Imap
2
- VERSION = '0.5.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
@@ -30,6 +30,30 @@ describe Imap::Message do
30
30
  end
31
31
  end
32
32
 
33
+ describe 'the batched fetch' do
34
+ it 'fetches once for the whole slice rather than once per message' do
35
+ messages = Imap::Message.search(client, from: 'test@example.com')
36
+ _(messages.size).must_equal 3
37
+ _(client.imap.fetch_count).must_equal 1
38
+ end
39
+
40
+ it 'reads the prefetched envelope rather than fetching again' do
41
+ message = Imap::Message.search(client, from: 'test@example.com').first
42
+ before = client.imap.fetch_count
43
+ message.subject
44
+ message.from
45
+ message.to
46
+ _(client.imap.fetch_count).must_equal before
47
+ end
48
+
49
+ it 'still fetches for a message the caller built' do
50
+ message = Imap::Message.new(1, client)
51
+ before = client.imap.fetch_count.to_i
52
+ message.subject
53
+ _(client.imap.fetch_count).must_equal before + 1
54
+ end
55
+ end
56
+
33
57
  describe '#body' do
34
58
  it 'returns the message body' do
35
59
  _(imap_message.body).must_match(/Mock body/)
@@ -85,6 +109,111 @@ describe Imap::Message do
85
109
  end
86
110
  end
87
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
+
88
217
  describe '#urls' do
89
218
  it 'extracts http URLs from body' do
90
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,11 +31,25 @@ class MockIMAP
31
31
  [1, 2, 3]
32
32
  end
33
33
 
34
+ attr_reader :list_called
34
35
  attr_reader :fetched_attrs
36
+ attr_reader :fetch_count
35
37
 
36
- def fetch(message_id, attrs)
38
+ def fetch(message_ids, attrs)
37
39
  @fetched_attrs = attrs
38
- [OpenStruct.new(attr: mock_fetch_attrs(message_id, attrs))]
40
+ @fetch_count = (@fetch_count || 0) + 1
41
+ Array(message_ids).collect do |message_id|
42
+ Net::IMAP::FetchData.new(message_id, mock_fetch_attrs(message_id, attrs))
43
+ end
44
+ end
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
+ ]
39
53
  end
40
54
 
41
55
  def store(message_id, flags, values); end
@@ -63,16 +77,63 @@ class MockIMAP
63
77
  case attr
64
78
  when 'BODY[TEXT]', 'BODY.PEEK[TEXT]'
65
79
  result['BODY[TEXT]'] = "Mock body for message #{message_id}"
66
- when 'BODY[HEADER.FIELDS (SUBJECT)]'
67
- result['BODY[HEADER.FIELDS (SUBJECT)]'] = "Subject: Mock Subject #{message_id}\r\n"
68
- when 'BODY[HEADER.FIELDS (FROM)]'
69
- result['BODY[HEADER.FIELDS (FROM)]'] = "From: sender@example.com\r\n"
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
- to: message_id == 99 ? nil : [OpenStruct.new(mailbox: 'user', host: 'example.com')]
73
- )
93
+ result['ENVELOPE'] = mock_envelope(message_id)
74
94
  end
75
95
  end
76
96
  result
77
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
78
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran