imap.rb 0.4.9

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b062efdc696b59573e564f135485c3e987a8a8078b36e87783537cbcd040712a
4
+ data.tar.gz: 150f927c8023ed6cbbb1b58fa5b863620ba2043ec71058c52f36a75c817e9e0a
5
+ SHA512:
6
+ metadata.gz: c979d4b09d86154e500454440396443d9db9ee8134603ea5d432ed093e46bc3a54fa33aaa24667302dc540566e8863c6d50c22c112c87ee91a1b224c7a4d567c
7
+ data.tar.gz: 4e91af7317d63811a9ec9dff17ff14130926bf101d4631e6fbfa5069501c12e90b1af0fa28efa315a6a1dc52b823b012e89f14e6d7dbdec3b8b7fe1fa44e3bff
data/CHANGELOG ADDED
@@ -0,0 +1,93 @@
1
+ # imap/CHANGELOG
2
+
3
+ ## 20260917
4
+
5
+ 0.4.9: + imap.rb.gemspec, so that the gem resolves under both names.
6
+
7
+ 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.
8
+ 2. It differs from imap.gemspec in the three lines measurand and namo differ in, the heading comment naming the file, the name, and the gemspec each lists among its own files. Neither ships the other.
9
+ 3. imap.rb is unregistered, so this is its first version. bitget, eodhd, measurand and webdav each had their second name join at whatever the first stood at rather than backfilling, and each did it as a patch of its own carrying nothing else.
10
+ 4. gem-publish builds and pushes each in turn, gem build and gem push taking one file apiece and gem build *.gemspec building only whichever the shell lists first.
11
+ 5. ~ Gemfile: gemspec name: 'imap', bundler refusing to choose between two gemspecs and stopping with "There are multiple gemspecs at ...". Every pair here which uses gemspec names one: webdav, measurand and eodhd each name the elder of their two, and namo, which carried both from its first release, names the bare one. imap is the elder here.
12
+ 6. ~ Imap::VERSION: /0.4.8/0.4.9/
13
+
14
+
15
+ 0.4.8: Rewrite the CHANGELOG in the standard form.
16
+
17
+ 1. ~ CHANGELOG: ## before each date rather than ## [version] - date, the version and its summary beneath, a blank line between that and the first item, and the items numbered rather than dashed. No item is reworded, dropped or reordered.
18
+ 2. ~ CHANGELOG: the dates in basic form, 20260910 for 2026-09-10, as every other gem here writes them.
19
+ 3. ~ CHANGELOG: + the title, # imap/CHANGELOG, as coinmarketcap.rb and release-audit have it.
20
+ 4. ~ CHANGELOG: the five releases of 20260917 under one date heading, which is how the form carries more than one in a day.
21
+ 5. 9 of the 12 gems here were already in this form; imap and gate.rb were not, which leaves gate.rb.
22
+ 6. ~ Imap::VERSION: /0.4.7/0.4.8/
23
+
24
+ 0.4.7: ~ Imap::Search: the chaining interface, which the header has documented and which has never run
25
+
26
+ 1. ~ Imap::Search: the generated methods named in lower case, so that from() answers where FROM() did. All 32 were upper case, being defined straight off the key constants, and none was reachable as the header wrote it.
27
+ 2. ~ Imap::Search: the generated methods return self, having returned the criteria hash, so that they chain.
28
+ 3. + Imap::Search#not: applies to what follows immediately and to nothing after it. + Negated, which carries a general key's value through the hash; a boolean needs none, false already reading as NOT.
29
+ 4. ~ Imap::Search#general_to_imap_search_key: ['NOT', key, value] where the value is Negated.
30
+ 5. ~ Imap::Search: the Usage header takes an imap_client, without which all() has nothing to search.
31
+ 6. + tests: the lower case names, that they chain, both negations, and that not applies once only.
32
+ 7. ~ Imap::VERSION: /0.4.6/0.4.7/
33
+
34
+ 0.4.6: ~ Imap::Message#body: BODY.PEEK[TEXT]; ~ Imap::Search#to_imap_search_keys: raise upon a key which is neither
35
+
36
+ 1. ~ Imap::Message#body: BODY.PEEK[TEXT] for BODY[TEXT]. Reading a body set \Seen, so anything which looked at one marked it read; PEEK is the whole of the difference and the response still answers to BODY[TEXT].
37
+ 2. ~ Imap::Search#to_imap_search_keys: + UnknownSearchKey where the case matched neither operator. An unrecognised key was dropped and the search ran wider than it was asked for, saying nothing.
38
+ 3. ~ Imap::Message#to: [] where the envelope carries no to, which raised NoMethodError upon nil.
39
+ 4. ~ test/test_helper.rb: MockIMAP answers BODY.PEEK[TEXT] with BODY[TEXT] as a server does, records the attrs fetched, and gives message 99 an envelope with no to.
40
+ 5. + a test apiece: that #body peeks, that #to is empty where there is no to, and that an unknown key raises.
41
+ 6. ~ Imap::VERSION: /0.4.5/0.4.6/
42
+
43
+ 0.4.5: + Gemfile; ~ imap.gemspec: dependencies=, - spec.date, + metadata
44
+
45
+ 1. + Gemfile: source and gemspec, as switches, eodhd and moby have it, so that the dependencies are declared once in the gemspec rather than restated here to drift. bitget and coinmarketcap restate theirs and are the minority. This was the only gem here with no Gemfile at all.
46
+ 2. ~ imap.gemspec: + 'Gemfile' to spec.files, which every other gem here ships.
47
+ 3. ~ imap.gemspec: dependencies= and development_dependencies= through the Gem::Specification extension, as switches, eodhd and moby have it and as bitget, coinmarketcap and this did not. The constraints survive: the splat takes a bare string or a [name, constraint] pair alike.
48
+ 4. ~ imap.gemspec: - spec.date = '2025-12-20', nine months and two versions stale, and the last one left in the estate. RubyGems answers 1980-01-02 in its place, the reproducible-build epoch, which is what coinmarketcap has carried since dropping its own at 0.6.0.
49
+ 5. ~ imap.gemspec: + metadata, the four uris switches and moby declare, so that the RubyGems page links to the issues, the changelog, the source and the README. master here where moby says main, each by the branch it is on.
50
+ 6. ~ Imap::VERSION: /0.4.4/0.4.5/
51
+
52
+ 0.4.4: + Rakefile, - test/test_all.rb
53
+
54
+ 1. + Rakefile: Rake::TestTask over FileList['test/**/*_test.rb'], with libs for lib and test, and warning = true. The minimal form nine of the gems here use rather than the fuller one five use: the :spec alias and the :version task belong to the minority, and a :version task reading VERSION.rb would not have caught 0.4.1, where require 'imap' left Imap::VERSION undefined while VERSION.rb sat there defining it. rake has been a development dependency since 0.4.0 with nothing here to run.
55
+ 2. - test/test_all.rb: a second entry point which the Rakefile's FileList makes redundant, and which had drifted from the files on disk in two ways: it never loaded test/Imap/VERSION_test.rb, added in 0.4.2, so it ran 42 tests where there are 44; and it required './Imap_test' for a file named imap_test.rb, which resolved only because the filesystem is case insensitive and would not have on Linux. A glob cannot drift. 14 of the 16 gems here with a Rakefile carry no such file; switches keeps spec/all.rb because its Rakefile targets it, and bitget carries the same redundancy this removes.
56
+ 3. ~ test/Imap/Message_test.rb: must_match(/Mock body/) for must_match /Mock body/, the ambiguous / which warning = true reports and which is the only warning the suite raises.
57
+ 4. ~ imap.gemspec: + 'Rakefile' to spec.files, as eodhd, switches and moby have.
58
+ 5. ~ Imap::VERSION: /0.4.3/0.4.4/
59
+
60
+ 0.4.3: ~ test/test_helper.rb: + require 'minitest/mock', which minitest 6 no longer brings
61
+
62
+ 1. ~ test/test_helper.rb: + `require 'minitest/mock'`. 35 of the 42 tests call stub and not one of them could: the suite answered "42 runs, 7 assertions, 0 failures, 35 errors". minitest 5's autorun required minitest/mock and shipped it, so the suite ran until minitest 6 dropped both the require and the file; the same thing coinmarketcap 0.6.1 found.
63
+ 2. ~ imap.gemspec: + minitest-mock as a development dependency, which minitest ~> 5.0 carried and minitest 6 does not.
64
+ 3. ~ Imap::VERSION: /0.4.2/0.4.3/
65
+
66
+
67
+ ## 20260910
68
+
69
+ 0.4.2: ~ lib/imap.rb: + require_relative './Imap/VERSION'; + test/Imap/VERSION_test.rb
70
+
71
+ 1. ~ lib/imap.rb: + `require_relative './Imap/VERSION'`, last among the requires. The file had been present and unreached, so `require 'imap'` left Imap::VERSION undefined and the gemspec was the only thing loading it.
72
+ 2. + test/Imap/VERSION_test.rb: that Imap::VERSION is a string and that it is three numbers separated by dots. Modelled on moby's, less the example comparing against the changelog, which was unreachable here while the file was misspelt.
73
+ 3. The test loads the library rather than VERSION.rb, which is the whole of its value: run against v0.4.1 it fails with "uninitialized constant Imap::VERSION". Nine libraries were in that state and not one of them carried this test.
74
+ 4. ~ Imap::VERSION: /0.4.1/0.4.2/
75
+
76
+
77
+ ## 20260822
78
+
79
+ 0.4.1: + LICENSE; ~ imap.gemspec: + CHANGELOG
80
+
81
+ 1. + LICENSE: the MIT text, copyright 2025-2026 thoran. The gemspec had declared MIT while the repository carried no licence text at all.
82
+ 2. ~ imap.gemspec: + 'LICENSE' and 'CHANGELOG' to spec.files, neither of which the gem had been shipping.
83
+ 3. ~ Imap::VERSION: /0.4.0/0.4.1/
84
+
85
+
86
+ ## 20251220
87
+
88
+ 0.4.0: Cleanup for publishing.
89
+
90
+ 1. /ImapClient/Imap/
91
+ 2. + imap.gemspec
92
+ 3. + tests
93
+ 4. + Imap::Message#to.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec name: 'imap'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 thoran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # imap
2
+
3
+ A Ruby wrapper around Net::IMAP with an elegant DSL for searching, reading, and managing email.
4
+
5
+ ## Features
6
+
7
+ - Clean, hash-based search interface
8
+ - Intuitive message accessors
9
+ - No Rails dependency - works anywhere
10
+ - Zero external dependencies beyond net-imap
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ ```ruby
17
+ gem 'imap'
18
+ ```
19
+
20
+ And then execute:
21
+
22
+ ```bash
23
+ $ bundle install
24
+ ```
25
+
26
+ Or install it yourself as:
27
+
28
+ ```bash
29
+ $ gem install imap
30
+ ```
31
+
32
+ ## Usage
33
+ ```ruby
34
+ require 'imap'
35
+
36
+ # Connect and authenticate
37
+ imap_client = Imap.setup(
38
+ server: 'imap.thoran.com',
39
+ username: 'code@thoran.com',
40
+ password: 'bigsecret',
41
+ mailbox: 'INBOX' # optional/default
42
+ )
43
+
44
+ messages = imap_client.search(
45
+ from: 'noreply@example.com',
46
+ subject: 'Payday Loans',
47
+ since: '18-DEC-2025',
48
+ seen: false
49
+ )
50
+
51
+ # Access message details
52
+ messages.each do |message|
53
+ puts message.subject
54
+ puts message.from
55
+ puts message.to
56
+ puts message.body
57
+ puts message.urls # Extracted URLs from body
58
+ end
59
+
60
+ # Mark messages as read
61
+ messages.first.mark_as_read
62
+
63
+ # Clean up
64
+ imap_client.bye
65
+ ```
66
+
67
+ ## Contributing
68
+
69
+ 1. Fork it (https://github.com/thoran/imap/fork)
70
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
71
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
72
+ 4. Push to the branch (`git push origin my-new-feature`)
73
+ 5. Create a new pull request
74
+
75
+ ## License
76
+
77
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # Rakefile
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << 'lib'
7
+ t.libs << 'test'
8
+ t.test_files = FileList['test/**/*_test.rb']
9
+ t.warning = true
10
+ end
11
+
12
+ task default: :test
data/imap.rb.gemspec ADDED
@@ -0,0 +1,58 @@
1
+ # imap.rb.gemspec
2
+
3
+ require_relative './lib/Imap/VERSION'
4
+
5
+ class Gem::Specification
6
+ def dependencies=(gems)
7
+ gems.each{|gem| add_dependency(*gem)}
8
+ end
9
+
10
+ def development_dependencies=(gems)
11
+ gems.each{|gem| add_development_dependency(*gem)}
12
+ end
13
+ end
14
+
15
+ Gem::Specification.new do |spec|
16
+ spec.name = 'imap.rb'
17
+
18
+ spec.version = Imap::VERSION
19
+
20
+ spec.summary = "IMAP for Ruby made easy."
21
+ spec.description = "A Ruby wrapper around Net::IMAP with an elegant DSL for searching, reading, and managing email."
22
+
23
+ spec.author = 'thoran'
24
+ spec.email = 'code@thoran.com'
25
+ spec.homepage = 'https://github.com/thoran/imap'
26
+ spec.license = 'MIT'
27
+
28
+ spec.required_ruby_version = '>= 2.7'
29
+
30
+ spec.dependencies = [
31
+ ['net-imap', '~> 0.4']
32
+ ]
33
+
34
+ spec.development_dependencies = [
35
+ ['minitest', '~> 5.0'],
36
+ 'minitest-mock',
37
+ ['rake', '~> 13.0']
38
+ ]
39
+
40
+ spec.files = [
41
+ 'imap.rb.gemspec',
42
+ Dir['lib/**/*.rb'],
43
+ Dir['test/**/*.rb'],
44
+ 'CHANGELOG',
45
+ 'Gemfile',
46
+ 'Rakefile',
47
+ 'LICENSE',
48
+ 'README.md',
49
+ ].flatten
50
+ spec.require_paths = ['lib']
51
+
52
+ spec.metadata = {
53
+ "bug_tracker_uri" => "https://github.com/thoran/imap/issues",
54
+ "changelog_uri" => "https://github.com/thoran/imap/blob/master/CHANGELOG",
55
+ "source_code_uri" => "https://github.com/thoran/imap",
56
+ "documentation_uri" => "https://github.com/thoran/imap/blob/master/README.md"
57
+ }
58
+ end
@@ -0,0 +1,67 @@
1
+ # Imap/Message.rb
2
+ # Imap::Message
3
+
4
+ # Usage:
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
13
+
14
+ require_relative './Search'
15
+
16
+ class Imap
17
+ class Message
18
+ class << self
19
+
20
+ def search(imap_client, **search_criteria)
21
+ message_ids = Imap::Search.new(imap_client, search_criteria).message_ids
22
+ message_ids.collect{|message_id| Imap::Message.new(message_id, imap_client)}
23
+ end
24
+ alias_method :find, :search
25
+
26
+ end # class << self
27
+
28
+ attr_accessor :imap_client
29
+ attr_accessor :message_id
30
+
31
+ def body
32
+ @body ||= fetch_data('BODY.PEEK[TEXT]').first.attr['BODY[TEXT]']
33
+ end
34
+
35
+ def subject
36
+ @subject ||= fetch_data('BODY[HEADER.FIELDS (SUBJECT)]').first.attr['BODY[HEADER.FIELDS (SUBJECT)]'].sub(/^Subject: /, '').strip
37
+ end
38
+
39
+ def from
40
+ @from ||= fetch_data('BODY[HEADER.FIELDS (FROM)]').first.attr['BODY[HEADER.FIELDS (FROM)]'].sub(/^From: /, '').strip
41
+ end
42
+
43
+ def to
44
+ @to ||= (fetch_data('ENVELOPE').first.attr['ENVELOPE'].to || []).map{|addr| addr.mailbox + '@' + addr.host}
45
+ end
46
+
47
+ def urls
48
+ body.scan(/https?:\/\/[\S]+/)
49
+ end
50
+
51
+ def mark_as_seen
52
+ imap_client.imap.store(@message_id, '+FLAGS', [:Seen])
53
+ end
54
+ alias_method :mark_as_read, :mark_as_seen
55
+
56
+ private
57
+
58
+ def initialize(message_id = nil, imap_client = nil)
59
+ @message_id = message_id
60
+ @imap_client = imap_client
61
+ end
62
+
63
+ def fetch_data(*attrs)
64
+ @fetch_data = imap_client.imap.fetch(message_id, [*attrs])
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,166 @@
1
+ # Imap/Search.rb
2
+ # Imap::Search
3
+
4
+ # Usage:
5
+ # 1. Imap::Search.new(imap_client).not.subject('Payday Loans').answered.from('noreply@example.com').all
6
+ # 2. Imap::Search.new(imap_client, {from: 'noreply@example.com', answered: true})
7
+
8
+ # Notes:
9
+ # 1. List of search keys taken from RFC-3501 (INTERNET MESSAGE ACCESS PROTOCOL - VERSION 4rev1), http://tools.ietf.org/html/rfc3501.
10
+
11
+ class Imap
12
+ class Search
13
+
14
+ class UnknownSearchKey < ArgumentError; end
15
+
16
+ # A general key carries its value; negating one has to carry both, the hash
17
+ # having nowhere else to put it. A boolean key needs none of this: false
18
+ # already reads as NOT.
19
+ Negated = Struct.new(:value)
20
+
21
+ ALL_SEARCH_KEYS = %w{
22
+ ALL
23
+ ANSWERED
24
+ BCC
25
+ BEFORE
26
+ BODY
27
+ CC
28
+ DELETED
29
+ DRAFT
30
+ FLAGGED
31
+ FROM
32
+ HEADER
33
+ KEYWORD
34
+ LARGER
35
+ NEW
36
+ NOT
37
+ OLD
38
+ ON
39
+ OR
40
+ RECENT
41
+ SEEN
42
+ SENTBEFORE
43
+ SENTON
44
+ SENTSINCE
45
+ SINCE
46
+ SMALLER
47
+ SUBJECT
48
+ TEXT
49
+ TO
50
+ UID
51
+ UNANSWERED
52
+ UNDELETED
53
+ UNDRAFT
54
+ UNFLAGGED
55
+ UNKEYWORD
56
+ UNSEEN
57
+ }
58
+
59
+ BOOLEAN_SEARCH_KEYS = %w{
60
+ ANSWERED UNANSWERED
61
+ DELETED UNDELETED
62
+ DRAFT UNDRAFT
63
+ FLAGGED UNFLAGGED
64
+ NEW
65
+ OLD
66
+ RECENT
67
+ SEEN UNSEEN
68
+ }
69
+
70
+ GENERAL_SEARCH_KEYS = %w{
71
+ BCC
72
+ BEFORE
73
+ BODY
74
+ CC
75
+ FROM
76
+ HEADER
77
+ KEYWORD UNKEYWORD
78
+ LARGER
79
+ ON
80
+ SENTBEFORE
81
+ SENTON
82
+ SENTSINCE
83
+ SINCE
84
+ SMALLER
85
+ SUBJECT
86
+ TEXT
87
+ TO
88
+ UID
89
+ }
90
+
91
+ BOOLEAN_SEARCH_KEYS.each do |boolean_search_key|
92
+ define_method boolean_search_key.downcase do |value = true|
93
+ criteria.merge!(boolean_search_key.to_sym => negating? ? !value : value)
94
+ self
95
+ end
96
+ end
97
+
98
+ GENERAL_SEARCH_KEYS.each do |general_search_key|
99
+ define_method general_search_key.downcase do |value|
100
+ criteria.merge!(general_search_key.to_sym => negating? ? Negated.new(value) : value)
101
+ self
102
+ end
103
+ end
104
+
105
+ # Applies to what follows immediately, and to nothing after that.
106
+ def not
107
+ @negate = true
108
+ self
109
+ end
110
+
111
+ attr_accessor :criteria
112
+ attr_accessor :imap_client
113
+
114
+ def general_operator?(search_key)
115
+ GENERAL_SEARCH_KEYS.include?(search_key)
116
+ end
117
+
118
+ def boolean_operator?(search_key)
119
+ BOOLEAN_SEARCH_KEYS.include?(search_key)
120
+ end
121
+
122
+ def to_imap_search_keys
123
+ criteria.inject([]) do |m, kv|
124
+ key, value = kv.first.to_s.upcase, kv.last
125
+ case
126
+ when general_operator?(key)
127
+ m << general_to_imap_search_key(key, value)
128
+ when boolean_operator?(key)
129
+ m << boolean_to_imap_search_key(key, value)
130
+ else
131
+ raise UnknownSearchKey, "no such search key: #{key}"
132
+ end
133
+ end.flatten
134
+ end
135
+
136
+ def general_to_imap_search_key(key, value)
137
+ value.is_a?(Negated) ? ['NOT', key, value.value] : [key, value]
138
+ end
139
+
140
+ def boolean_to_imap_search_key(key, value)
141
+ if value
142
+ [key]
143
+ else
144
+ ['NOT', key]
145
+ end
146
+ end
147
+
148
+ def negating?
149
+ negate = @negate
150
+ @negate = false
151
+ negate
152
+ end
153
+
154
+ def message_ids
155
+ imap_client.imap.search(to_imap_search_keys)
156
+ end
157
+ alias_method :all, :message_ids
158
+
159
+ private
160
+
161
+ def initialize(imap_client = nil, criteria = nil)
162
+ @criteria = criteria || {}
163
+ @imap_client = imap_client
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,3 @@
1
+ class Imap
2
+ VERSION = '0.4.9'
3
+ end
data/lib/imap.rb ADDED
@@ -0,0 +1,83 @@
1
+ # imap.rb
2
+ # Imap
3
+
4
+ require 'net/imap'
5
+ require_relative './Imap/Message'
6
+ require_relative './Imap/VERSION'
7
+
8
+ class Imap
9
+ class << self
10
+
11
+ def setup(config)
12
+ raise ArgumentError, "Server must be specified" unless config[:server]
13
+ imap_client = Imap.new(**config)
14
+ if config[:username] && config[:password]
15
+ imap_client.login(username: config[:username], password: config[:password])
16
+ end
17
+ if config[:mailbox]
18
+ imap_client.mailbox = config[:mailbox]
19
+ end
20
+ imap_client
21
+ end
22
+
23
+ end # class << self
24
+
25
+ attr_accessor :server
26
+ attr_accessor :ssl
27
+ attr_accessor :username
28
+ attr_accessor :password
29
+ attr_reader :mailbox
30
+
31
+ def login(username: nil, password: nil)
32
+ username ||= @username
33
+ password ||= @password
34
+ begin
35
+ imap.login(username, password)
36
+ true
37
+ rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError
38
+ false
39
+ end
40
+ end
41
+
42
+ def mailbox=(mailbox)
43
+ @mailbox = mailbox
44
+ imap.select(mailbox)
45
+ end
46
+
47
+ def search(criteria = {})
48
+ Imap::Message.search(self, **criteria)
49
+ end
50
+ alias_method :messages, :search
51
+ alias_method :find, :search
52
+
53
+ def bye
54
+ logout
55
+ disconnect
56
+ end
57
+
58
+ def logout
59
+ imap.logout
60
+ end
61
+
62
+ def disconnect
63
+ imap.disconnect
64
+ end
65
+
66
+ def imap
67
+ @imap ||= Net::IMAP.new(server, ssl: @ssl)
68
+ end
69
+
70
+ def ssl?
71
+ @ssl
72
+ end
73
+
74
+ private
75
+
76
+ def initialize(server:, ssl: true, username: nil, password: nil, mailbox: 'INBOX')
77
+ @server = server
78
+ @ssl = ssl
79
+ @username = username
80
+ @password = password
81
+ @mailbox = mailbox
82
+ end
83
+ end
@@ -0,0 +1,121 @@
1
+ # test/Imap/Message_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe Imap::Message do
6
+ let(:client) do
7
+ mock_imap = MockIMAP.new('imap.example.com', ssl: true)
8
+ Net::IMAP.stub(:new, mock_imap) do
9
+ Imap.setup(
10
+ server: 'imap.example.com',
11
+ username: 'user@example.com',
12
+ password: 'secret'
13
+ )
14
+ end
15
+ end
16
+
17
+ let(:imap_message) do
18
+ Imap::Message.new(1, client)
19
+ end
20
+
21
+ describe '.search' do
22
+ it 'returns an array of messages' do
23
+ messages = Imap::Message.search(client, from: 'test@example.com')
24
+ _(messages).must_be_instance_of Array
25
+ _(messages.first).must_be_instance_of Imap::Message
26
+ end
27
+
28
+ it 'has find alias' do
29
+ _((Imap::Message.method(:find) == Imap::Message.method(:search))).must_equal true
30
+ end
31
+ end
32
+
33
+ describe '#body' do
34
+ it 'returns the message body' do
35
+ _(imap_message.body).must_match(/Mock body/)
36
+ end
37
+
38
+ it 'caches the result' do
39
+ first_call = imap_message.body
40
+ second_call = imap_message.body
41
+ _(first_call.object_id).must_equal second_call.object_id
42
+ end
43
+
44
+ it 'peeks, so that reading a body does not mark it seen' do
45
+ imap_message.body
46
+ _(client.imap.fetched_attrs).must_include 'BODY.PEEK[TEXT]'
47
+ end
48
+ end
49
+
50
+ describe '#subject' do
51
+ it 'returns the subject without prefix' do
52
+ _(imap_message.subject).must_match(/Mock Subject/)
53
+ _(imap_message.subject).wont_match(/^Subject:/)
54
+ end
55
+
56
+ it 'strips whitespace' do
57
+ _(imap_message.subject).must_equal(imap_message.subject.strip)
58
+ end
59
+ end
60
+
61
+ describe '#from' do
62
+ it 'returns the from address' do
63
+ _(imap_message.from).must_match(/sender@example.com/)
64
+ end
65
+
66
+ it 'removes From: prefix' do
67
+ _(imap_message.from).wont_match(/^From:/)
68
+ end
69
+ end
70
+
71
+ describe '#to' do
72
+ it 'returns an array of email addresses' do
73
+ addresses = imap_message.to
74
+ _(addresses).must_be_instance_of(Array)
75
+ _(addresses.first).must_match(/@/)
76
+ end
77
+
78
+ it 'formats addresses correctly' do
79
+ addresses = imap_message.to
80
+ _(addresses.first).must_equal('user@example.com')
81
+ end
82
+
83
+ it 'is empty where the envelope carries no to' do
84
+ _(Imap::Message.new(99, client).to).must_equal []
85
+ end
86
+ end
87
+
88
+ describe '#urls' do
89
+ it 'extracts http URLs from body' do
90
+ imap_message.stub(:body, 'Check out http://example.com and https://test.com') do
91
+ urls = imap_message.urls
92
+ _(urls).must_include('http://example.com')
93
+ _(urls).must_include('https://test.com')
94
+ end
95
+ end
96
+
97
+ it 'returns empty array when no URLs' do
98
+ imap_message.stub(:body, 'No URLs here') do
99
+ _(urls = imap_message.urls).must_be_empty
100
+ end
101
+ end
102
+ end
103
+
104
+ describe '#mark_as_seen' do
105
+ it 'marks the message as seen' do
106
+ _(imap_message.mark_as_seen).must_be_nil
107
+ end
108
+
109
+ it 'has mark_as_read alias' do
110
+ _((imap_message.method(:mark_as_read) == imap_message.method(:mark_as_seen))).must_equal true
111
+ end
112
+ end
113
+
114
+ describe '#initialize' do
115
+ it 'accepts message_id and imap_client' do
116
+ test_message = Imap::Message.new(42, client)
117
+ _(test_message.message_id).must_equal(42)
118
+ _(test_message.imap_client).must_equal(client)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,120 @@
1
+ # test/Imap/Search_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe Imap::Search do
6
+ let(:client) do
7
+ mock_imap = MockIMAP.new('imap.example.com', ssl: true)
8
+ Net::IMAP.stub(:new, mock_imap) do
9
+ Imap.setup(
10
+ server: 'imap.example.com',
11
+ username: 'user@example.com',
12
+ password: 'secret'
13
+ )
14
+ end
15
+ end
16
+
17
+ let(:search) do
18
+ Imap::Search.new(client, {})
19
+ end
20
+
21
+ describe '#to_imap_search_keys' do
22
+ it 'converts general search keys' do
23
+ test_search = Imap::Search.new(client, from: 'test@example.com')
24
+ keys = test_search.to_imap_search_keys
25
+ _(keys).must_include 'FROM'
26
+ _(keys).must_include 'test@example.com'
27
+ end
28
+
29
+ it 'converts boolean search keys' do
30
+ test_search = Imap::Search.new(client, seen: false)
31
+ keys = test_search.to_imap_search_keys
32
+ _(keys).must_include 'NOT'
33
+ _(keys).must_include 'SEEN'
34
+ end
35
+
36
+ it 'raises upon a key which is neither' do
37
+ _{Imap::Search.new(client, bogus: 'x').to_imap_search_keys}.must_raise Imap::Search::UnknownSearchKey
38
+ end
39
+
40
+ it 'handles multiple criteria' do
41
+ test_search = Imap::Search.new(client, from: 'test@example.com', seen: true)
42
+ keys = test_search.to_imap_search_keys
43
+ _(keys.length).must_be :>, 2
44
+ end
45
+ end
46
+
47
+ describe 'the chaining interface' do
48
+ it 'names the keys in lower case' do
49
+ _(Imap::Search.new(client).from('x@y.com').criteria).must_equal({FROM: 'x@y.com'})
50
+ end
51
+
52
+ it 'returns itself, so that it chains' do
53
+ search = Imap::Search.new(client)
54
+ _(search.from('x@y.com')).must_be_same_as search
55
+ end
56
+
57
+ it 'negates a general key' do
58
+ _(Imap::Search.new(client).not.subject('Payday Loans').to_imap_search_keys).must_equal ['NOT', 'SUBJECT', 'Payday Loans']
59
+ end
60
+
61
+ it 'negates a boolean key' do
62
+ _(Imap::Search.new(client).not.seen.to_imap_search_keys).must_equal ['NOT', 'SEEN']
63
+ end
64
+
65
+ it 'negates what follows immediately and nothing after it' do
66
+ keys = Imap::Search.new(client).not.subject('Payday Loans').answered.from('noreply@example.com').to_imap_search_keys
67
+ _(keys).must_equal ['NOT', 'SUBJECT', 'Payday Loans', 'ANSWERED', 'FROM', 'noreply@example.com']
68
+ end
69
+ end
70
+
71
+ describe '#message_ids' do
72
+ it 'returns an array of message IDs' do
73
+ ids = search.message_ids
74
+ _(ids).must_be_instance_of Array
75
+ end
76
+
77
+ it 'has all alias' do
78
+ _((search.method(:all) == search.method(:message_ids))).must_equal true
79
+ end
80
+ end
81
+
82
+ describe '#boolean_operator?' do
83
+ it 'returns true for boolean operators' do
84
+ _(search.boolean_operator?('SEEN')).must_equal true
85
+ _(search.boolean_operator?('ANSWERED')).must_equal true
86
+ end
87
+
88
+ it 'returns false for general operators' do
89
+ _(search.boolean_operator?('FROM')).must_equal false
90
+ end
91
+ end
92
+
93
+ describe '#general_operator?' do
94
+ it 'returns true for general operators' do
95
+ _(search.general_operator?('FROM')).must_equal true
96
+ _(search.general_operator?('SUBJECT')).must_equal true
97
+ end
98
+
99
+ it 'returns false for boolean operators' do
100
+ _(search.general_operator?('SEEN')).must_equal false
101
+ end
102
+ end
103
+
104
+ describe '#initialize' do
105
+ it 'accepts imap_client parameter' do
106
+ test_search = Imap::Search.new(client)
107
+ _(test_search.imap_client).must_equal client
108
+ end
109
+
110
+ it 'accepts criteria parameter' do
111
+ test_search = Imap::Search.new(client, from: 'test@example.com')
112
+ _(test_search.criteria).must_equal({ from: 'test@example.com' })
113
+ end
114
+
115
+ it 'defaults criteria to empty hash' do
116
+ test_search = Imap::Search.new(client)
117
+ _(test_search.criteria).must_equal({})
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,15 @@
1
+ # test/Imap/VERSION_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe Imap do
6
+ describe "VERSION" do
7
+ it "is a string" do
8
+ _(Imap::VERSION).must_be_instance_of String
9
+ end
10
+
11
+ it "is three numbers separated by dots" do
12
+ _(Imap::VERSION).must_match(/\A\d+\.\d+\.\d+\z/)
13
+ end
14
+ end
15
+ end
data/test/Imap_test.rb ADDED
@@ -0,0 +1,119 @@
1
+ # test/imap_test.rb
2
+
3
+ require_relative './test_helper'
4
+
5
+ describe Imap do
6
+ let(:client) do
7
+ Net::IMAP.stub(:new, MockIMAP.new('imap.example.com', ssl: true)) do
8
+ Imap.setup(
9
+ server: 'imap.example.com',
10
+ username: 'user@example.com',
11
+ password: 'secret',
12
+ mailbox: 'INBOX'
13
+ )
14
+ end
15
+ end
16
+
17
+ describe ".setup" do
18
+ it 'creates a new client instance' do
19
+ _(client).must_be_instance_of(Imap)
20
+ end
21
+
22
+ it 'requires a server parameter' do
23
+ _(proc{Imap.setup({})}).must_raise(ArgumentError)
24
+ end
25
+
26
+ it 'sets the server' do
27
+ _(client.server).must_equal 'imap.example.com'
28
+ end
29
+
30
+ it 'defaults ssl to true' do
31
+ _(client.ssl?).must_equal true
32
+ end
33
+
34
+ it 'defaults mailbox to INBOX' do
35
+ _(client.mailbox).must_equal 'INBOX'
36
+ end
37
+ end
38
+
39
+ describe "#login" do
40
+ it 'returns true on successful login' do
41
+ Net::IMAP.stub(:new, MockIMAP.new('imap.example.com', ssl: true)) do
42
+ test_client = Imap.new(server: 'imap.example.com')
43
+ result = test_client.login(username: 'user', password: 'pass')
44
+ _(result).must_equal true
45
+ end
46
+ end
47
+
48
+ it 'returns false on failed login' do
49
+ mock = MockIMAP.new('imap.example.com', ssl: true)
50
+ def mock.login(username, password)
51
+ mock_response = OpenStruct.new(data: OpenStruct.new(text: 'Authentication failed'))
52
+ raise Net::IMAP::BadResponseError, mock_response
53
+ end
54
+
55
+ Net::IMAP.stub(:new, mock) do
56
+ test_client = Imap.new(server: 'imap.example.com')
57
+ result = test_client.login(username: 'user', password: 'wrong')
58
+ _(result).must_equal false
59
+ end
60
+ end
61
+ end
62
+
63
+ describe "#mailbox=" do
64
+ it 'sets the mailbox' do
65
+ Net::IMAP.stub(:new, MockIMAP.new('imap.example.com', ssl: true)) do
66
+ test_client = Imap.new(server: 'imap.example.com')
67
+ test_client.mailbox = 'Sent'
68
+ _(test_client.mailbox).must_equal 'Sent'
69
+ end
70
+ end
71
+ end
72
+
73
+ describe "#search" do
74
+ it 'returns an array of Message objects' do
75
+ Net::IMAP.stub(:new, MockIMAP.new('imap.example.com', ssl: true)) do
76
+ test_client = Imap.setup(
77
+ server: 'imap.example.com',
78
+ username: 'user',
79
+ password: 'pass'
80
+ )
81
+ messages = test_client.search(from: 'test@example.com')
82
+ _(messages).must_be_instance_of Array
83
+ _(messages.first).must_be_instance_of Imap::Message
84
+ end
85
+ end
86
+
87
+ it 'has messages and find aliases' do
88
+ _((client.method(:messages) == client.method(:search))).must_equal true
89
+ _((client.method(:find) == client.method(:search))).must_equal true
90
+ end
91
+ end
92
+
93
+ describe "#initialize" do
94
+ it 'accepts server parameter' do
95
+ test_client = Imap.new(server: 'mail.example.com')
96
+ _(test_client.server).must_equal 'mail.example.com'
97
+ end
98
+
99
+ it 'accepts ssl parameter' do
100
+ test_client = Imap.new(server: 'mail.example.com', ssl: false)
101
+ _(test_client.ssl?).must_equal false
102
+ end
103
+
104
+ it 'accepts username parameter' do
105
+ test_client = Imap.new(server: 'mail.example.com', username: 'test@example.com')
106
+ _(test_client.username).must_equal 'test@example.com'
107
+ end
108
+
109
+ it 'accepts password parameter' do
110
+ test_client = Imap.new(server: 'mail.example.com', password: 'secret')
111
+ _(test_client.password).must_equal 'secret'
112
+ end
113
+
114
+ it 'accepts mailbox parameter' do
115
+ test_client = Imap.new(server: 'mail.example.com', mailbox: 'Sent')
116
+ _(test_client.mailbox).must_equal 'Sent'
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,78 @@
1
+ # test/test_helper.rb
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest/mock'
5
+ require 'minitest/spec'
6
+ require 'ostruct'
7
+ require_relative '../lib/imap'
8
+
9
+ class MockIMAP
10
+ attr_reader :login_called
11
+ attr_reader :logout_called
12
+ attr_reader :search_called
13
+ attr_reader :search_criteria
14
+ attr_reader :select_called
15
+ attr_reader :selected_mailbox
16
+
17
+ def login(username, password)
18
+ @login_called = true
19
+ @username = username
20
+ @password = password
21
+ end
22
+
23
+ def select(mailbox)
24
+ @select_called = true
25
+ @selected_mailbox = mailbox
26
+ end
27
+
28
+ def search(criteria)
29
+ @search_called = true
30
+ @search_criteria = criteria
31
+ [1, 2, 3]
32
+ end
33
+
34
+ attr_reader :fetched_attrs
35
+
36
+ def fetch(message_id, attrs)
37
+ @fetched_attrs = attrs
38
+ [OpenStruct.new(attr: mock_fetch_attrs(message_id, attrs))]
39
+ end
40
+
41
+ def store(message_id, flags, values); end
42
+
43
+ def logout
44
+ @logout_called = true
45
+ end
46
+
47
+ def disconnect; end
48
+
49
+ private
50
+
51
+ def initialize(server, ssl:)
52
+ @server = server
53
+ @ssl = ssl
54
+ @login_called = false
55
+ @select_called = false
56
+ @search_called = false
57
+ @logout_called = false
58
+ end
59
+
60
+ def mock_fetch_attrs(message_id, attrs)
61
+ result = {}
62
+ attrs.each do |attr|
63
+ case attr
64
+ when 'BODY[TEXT]', 'BODY.PEEK[TEXT]'
65
+ 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
+ when 'ENVELOPE'
71
+ result['ENVELOPE'] = OpenStruct.new(
72
+ to: message_id == 99 ? nil : [OpenStruct.new(mailbox: 'user', host: 'example.com')]
73
+ )
74
+ end
75
+ end
76
+ result
77
+ end
78
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imap.rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.9
5
+ platform: ruby
6
+ authors:
7
+ - thoran
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: net-imap
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest-mock
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: A Ruby wrapper around Net::IMAP with an elegant DSL for searching, reading,
69
+ and managing email.
70
+ email: code@thoran.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - CHANGELOG
76
+ - Gemfile
77
+ - LICENSE
78
+ - README.md
79
+ - Rakefile
80
+ - imap.rb.gemspec
81
+ - lib/Imap/Message.rb
82
+ - lib/Imap/Search.rb
83
+ - lib/Imap/VERSION.rb
84
+ - lib/imap.rb
85
+ - test/Imap/Message_test.rb
86
+ - test/Imap/Search_test.rb
87
+ - test/Imap/VERSION_test.rb
88
+ - test/Imap_test.rb
89
+ - test/test_helper.rb
90
+ homepage: https://github.com/thoran/imap
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ bug_tracker_uri: https://github.com/thoran/imap/issues
95
+ changelog_uri: https://github.com/thoran/imap/blob/master/CHANGELOG
96
+ source_code_uri: https://github.com/thoran/imap
97
+ documentation_uri: https://github.com/thoran/imap/blob/master/README.md
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '2.7'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 4.0.20
113
+ specification_version: 4
114
+ summary: IMAP for Ruby made easy.
115
+ test_files: []