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 +4 -4
- data/CHANGELOG +24 -0
- data/lib/Imap/Message.rb +162 -9
- data/lib/Imap/VERSION.rb +1 -1
- data/lib/imap.rb +5 -0
- data/test/Imap/Message_test.rb +129 -0
- data/test/Imap_test.rb +10 -0
- data/test/test_helper.rb +70 -9
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9f5b0c607f3b3e66d7284613916d45c077de9a0e97f50f960fa8e63fd7b5586
|
|
4
|
+
data.tar.gz: 4529940cbd85a56bfbd4ef9d472536234852cd4cabf6349f90763644905ab953
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
32
|
-
|
|
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 ||=
|
|
100
|
+
@subject ||= Message.decode(envelope && envelope.subject).to_s.tr("\r\n", ' ').strip
|
|
37
101
|
end
|
|
38
102
|
|
|
39
103
|
def from
|
|
40
|
-
@from ||=
|
|
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 ||= (
|
|
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(*
|
|
64
|
-
@fetch_data = imap_client.imap.fetch(message_id, [*
|
|
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
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
|
data/test/Imap/Message_test.rb
CHANGED
|
@@ -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(
|
|
38
|
+
def fetch(message_ids, attrs)
|
|
37
39
|
@fetched_attrs = attrs
|
|
38
|
-
|
|
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[
|
|
67
|
-
result['BODY[
|
|
68
|
-
when '
|
|
69
|
-
result['
|
|
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'] =
|
|
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
|