imap.rb 0.4.9 → 0.6.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: b062efdc696b59573e564f135485c3e987a8a8078b36e87783537cbcd040712a
4
- data.tar.gz: 150f927c8023ed6cbbb1b58fa5b863620ba2043ec71058c52f36a75c817e9e0a
3
+ metadata.gz: db42d933da16aca65af3c854ea0f26da5ec173da7df4260e0bb2879476da0bbd
4
+ data.tar.gz: 1c751f2b12f3dcaa5cfa6035ead2c6b24ba81c3c866cd7bf454e56f67ada69ab
5
5
  SHA512:
6
- metadata.gz: c979d4b09d86154e500454440396443d9db9ee8134603ea5d432ed093e46bc3a54fa33aaa24667302dc540566e8863c6d50c22c112c87ee91a1b224c7a4d567c
7
- data.tar.gz: 4e91af7317d63811a9ec9dff17ff14130926bf101d4631e6fbfa5069501c12e90b1af0fa28efa315a6a1dc52b823b012e89f14e6d7dbdec3b8b7fe1fa44e3bff
6
+ metadata.gz: fea3b0f91b2813bc83be27ee944d2d34c4e7b7740b406349e85e5d24fbccc5b199a0b272e144bf8e3ea096a5a9dfa68760aef3c537cd6ddaf92f9533a9ee209c
7
+ data.tar.gz: 6e8c310a8660730d3c268f17f236ea92c7d33bd3de96edfb189acb4c0f34dfd6a7d9bc7037f8cb38486ac34836ba04c99df1960649f755a93c097cfb8ced3c00
data/CHANGELOG CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## 20260917
4
4
 
5
+ 0.6.0: One fetch for a slice of messages rather than one per attribute per message.
6
+
7
+ 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.
8
+ 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.
9
+ 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.
10
+ 4. ~ Imap::Message#body: unchanged and still lazy, a body being the one thing worth not fetching until it is wanted.
11
+ 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.
12
+ 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.
13
+ 7. ~ Imap::VERSION: /0.5.0/0.6.0/
14
+
15
+ 0.5.0: + OR and NOT to the criteria, and HEADER given its field
16
+
17
+ 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.
18
+ 2. + Imap::Search#none_of: not: {subject: 'Payday'}. NOT takes one key, so several are negated apiece, NOT a NOT b.
19
+ 3. ~ Imap::Search#to_imap_search_keys: OR and NOT read before the operator tests, being neither general nor boolean.
20
+ 4. HEADER wanted no change: an array value already flattens to ['HEADER', field, value], which nothing said and nothing tested. It is tested now.
21
+ 5. + tests for each, and for OR mixed with the plain keys.
22
+ 6. ~ Imap::VERSION: /0.4.9/0.5.0/
23
+
24
+
5
25
  0.4.9: + imap.rb.gemspec, so that the gem resolves under both names.
6
26
 
7
27
  1. + imap.rb.gemspec: the same code published as imap.rb, which is how gems are named here: 21 carry the suffix and imap is one of the few which do not. rubygems has no notion of an alias, so the two are separate gems which must be kept at the same version by hand.
data/lib/Imap/Message.rb CHANGED
@@ -15,11 +15,20 @@ require_relative './Search'
15
15
 
16
16
  class Imap
17
17
  class Message
18
+
19
+ SLICE = 200
18
20
  class << self
19
21
 
22
+ # One fetch for a slice of messages rather than one per attribute per
23
+ # message. A hundred messages cost a round trip apiece for the subject and
24
+ # another apiece for the from; they cost one for the hundred now.
20
25
  def search(imap_client, **search_criteria)
21
26
  message_ids = Imap::Search.new(imap_client, search_criteria).message_ids
22
- message_ids.collect{|message_id| Imap::Message.new(message_id, imap_client)}
27
+ 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)
30
+ end
31
+ end
23
32
  end
24
33
  alias_method :find, :search
25
34
 
@@ -33,15 +42,15 @@ class Imap
33
42
  end
34
43
 
35
44
  def subject
36
- @subject ||= fetch_data('BODY[HEADER.FIELDS (SUBJECT)]').first.attr['BODY[HEADER.FIELDS (SUBJECT)]'].sub(/^Subject: /, '').strip
45
+ @subject ||= envelope.subject.to_s.strip
37
46
  end
38
47
 
39
48
  def from
40
- @from ||= fetch_data('BODY[HEADER.FIELDS (FROM)]').first.attr['BODY[HEADER.FIELDS (FROM)]'].sub(/^From: /, '').strip
49
+ @from ||= address_to_s(envelope.from && envelope.from.first)
41
50
  end
42
51
 
43
52
  def to
44
- @to ||= (fetch_data('ENVELOPE').first.attr['ENVELOPE'].to || []).map{|addr| addr.mailbox + '@' + addr.host}
53
+ @to ||= (envelope.to || []).collect{|address| address_to_s(address)}
45
54
  end
46
55
 
47
56
  def urls
@@ -55,9 +64,21 @@ class Imap
55
64
 
56
65
  private
57
66
 
58
- def initialize(message_id = nil, imap_client = nil)
67
+ def initialize(message_id = nil, imap_client = nil, attrs = nil)
59
68
  @message_id = message_id
60
69
  @imap_client = imap_client
70
+ @attrs = attrs
71
+ end
72
+
73
+ # Prefetched where search() built this, fetched where the caller built it.
74
+ def envelope
75
+ @envelope ||= (@attrs && @attrs['ENVELOPE']) || fetch_data('ENVELOPE').first.attr['ENVELOPE']
76
+ end
77
+
78
+ def address_to_s(address)
79
+ return nil unless address
80
+ email = [address.mailbox, address.host].compact.join('@')
81
+ address.name.to_s.empty? ? email : "#{address.name} <#{email}>"
61
82
  end
62
83
 
63
84
  def fetch_data(*attrs)
data/lib/Imap/Search.rb CHANGED
@@ -123,6 +123,10 @@ class Imap
123
123
  criteria.inject([]) do |m, kv|
124
124
  key, value = kv.first.to_s.upcase, kv.last
125
125
  case
126
+ when key == 'OR'
127
+ m << any_of(value)
128
+ when key == 'NOT'
129
+ m << none_of(value)
126
130
  when general_operator?(key)
127
131
  m << general_to_imap_search_key(key, value)
128
132
  when boolean_operator?(key)
@@ -133,6 +137,17 @@ class Imap
133
137
  end.flatten
134
138
  end
135
139
 
140
+ # OR takes two keys and no more, so three criteria nest: OR OR a b c. Each
141
+ # is a criteria hash of its own, so either side may nest further.
142
+ def any_of(criteria_hashes)
143
+ criteria_hashes.collect{|criteria_hash| Search.new(nil, criteria_hash).to_imap_search_keys}.inject{|m, keys| ['OR'] + m + keys}
144
+ end
145
+
146
+ # NOT takes one key, so each criterion is negated on its own: NOT a NOT b.
147
+ def none_of(criteria_hash)
148
+ criteria_hash.inject([]){|m, kv| m + ['NOT'] + Search.new(nil, Hash[*kv]).to_imap_search_keys}
149
+ end
150
+
136
151
  def general_to_imap_search_key(key, value)
137
152
  value.is_a?(Negated) ? ['NOT', key, value.value] : [key, value]
138
153
  end
data/lib/Imap/VERSION.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Imap
2
- VERSION = '0.4.9'
2
+ VERSION = '0.6.0'
3
3
  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/)
@@ -44,6 +44,34 @@ describe Imap::Search do
44
44
  end
45
45
  end
46
46
 
47
+ describe 'OR, NOT and HEADER' do
48
+ def keys(criteria) = Imap::Search.new(client, criteria).to_imap_search_keys
49
+
50
+ it 'takes two criteria hashes for OR' do
51
+ _(keys(or: [{from: 'a@x'}, {to: 'b@y'}])).must_equal ['OR', 'FROM', 'a@x', 'TO', 'b@y']
52
+ end
53
+
54
+ it 'nests OR where there are more than two, it taking only two' do
55
+ _(keys(or: [{from: 'a@x'}, {to: 'b@y'}, {cc: 'c@z'}])).must_equal ['OR', 'OR', 'FROM', 'a@x', 'TO', 'b@y', 'CC', 'c@z']
56
+ end
57
+
58
+ it 'negates a criterion for NOT' do
59
+ _(keys(not: {subject: 'Payday'})).must_equal ['NOT', 'SUBJECT', 'Payday']
60
+ end
61
+
62
+ it 'negates each on its own where NOT carries more than one, it taking only one' do
63
+ _(keys(not: {subject: 'Payday', seen: true})).must_equal ['NOT', 'SUBJECT', 'Payday', 'NOT', 'SEEN']
64
+ end
65
+
66
+ it 'takes a field and a value for HEADER' do
67
+ _(keys(header: ['List-Id', 'announce'])).must_equal ['HEADER', 'List-Id', 'announce']
68
+ end
69
+
70
+ it 'mixes with the plain keys' do
71
+ _(keys(since: '1-Sep-2026', or: [{from: 'a@x'}, {to: 'b@y'}])).must_equal ['SINCE', '1-Sep-2026', 'OR', 'FROM', 'a@x', 'TO', 'b@y']
72
+ end
73
+ end
74
+
47
75
  describe 'the chaining interface' do
48
76
  it 'names the keys in lower case' do
49
77
  _(Imap::Search.new(client).from('x@y.com').criteria).must_equal({FROM: 'x@y.com'})
data/test/test_helper.rb CHANGED
@@ -32,10 +32,14 @@ class MockIMAP
32
32
  end
33
33
 
34
34
  attr_reader :fetched_attrs
35
+ attr_reader :fetch_count
35
36
 
36
- def fetch(message_id, attrs)
37
+ def fetch(message_ids, attrs)
37
38
  @fetched_attrs = attrs
38
- [OpenStruct.new(attr: mock_fetch_attrs(message_id, attrs))]
39
+ @fetch_count = (@fetch_count || 0) + 1
40
+ Array(message_ids).collect do |message_id|
41
+ OpenStruct.new(seqno: message_id, attr: mock_fetch_attrs(message_id, attrs))
42
+ end
39
43
  end
40
44
 
41
45
  def store(message_id, flags, values); end
@@ -63,12 +67,10 @@ class MockIMAP
63
67
  case attr
64
68
  when 'BODY[TEXT]', 'BODY.PEEK[TEXT]'
65
69
  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"
70
70
  when 'ENVELOPE'
71
71
  result['ENVELOPE'] = OpenStruct.new(
72
+ subject: "Mock Subject #{message_id}",
73
+ from: [OpenStruct.new(mailbox: 'sender', host: 'example.com')],
72
74
  to: message_id == 99 ? nil : [OpenStruct.new(mailbox: 'user', host: 'example.com')]
73
75
  )
74
76
  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.4.9
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran