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 +4 -4
- data/CHANGELOG +21 -0
- data/lib/Imap/Message.rb +162 -24
- data/lib/Imap/VERSION.rb +1 -1
- data/lib/imap.rb +5 -0
- data/test/Imap/Message_test.rb +128 -0
- data/test/Imap_test.rb +10 -0
- data/test/test_helper.rb +65 -6
- 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: 45cf03dcb336e9795a5ef5339e6ac0c6e3b2f5e79014f3462684f39f79aae98f
|
|
4
|
+
data.tar.gz: e204d83b456b966f0f9a9c3a55acfb492564da65a2b2be22d8c27326104ba755
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
7
|
-
# 3. Imap::Message.new(message_id, imap_client)
|
|
8
|
-
# 4. Imap::Message.new(message_id, imap_client).
|
|
9
|
-
# 5. Imap::Message.new(message_id, imap_client).
|
|
10
|
-
# 6. Imap::Message.new(message_id, imap_client).
|
|
11
|
-
# 7. Imap::Message.new(message_id, imap_client).
|
|
12
|
-
# 8. Imap::Message.new(message_id, imap_client).
|
|
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
|
-
|
|
26
|
-
|
|
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,
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
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,
|
|
159
|
+
def initialize(message_id = nil, imap_client = nil, data = nil)
|
|
68
160
|
@message_id = message_id
|
|
69
161
|
@imap_client = imap_client
|
|
70
|
-
@
|
|
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
|
-
|
|
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 =
|
|
81
|
-
address.name.to_s.
|
|
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(*
|
|
85
|
-
@fetch_data = imap_client.imap.fetch(message_id, [*
|
|
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
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,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
|
-
|
|
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'] =
|
|
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
|