imapcli 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,226 @@
1
+ module Imapcli
2
+ # Wrapper for Net::IMAP
3
+ class Client
4
+ require 'net/imap'
5
+ require 'filesize'
6
+ require 'descriptive_statistics'
7
+
8
+ attr_accessor :port, :user, :pass
9
+ attr_reader :responses
10
+
11
+ ## Initializs the Client class.
12
+ ##
13
+ ## +server_with_optional_port+ is the server's domain name; the port may be
14
+ ## added following a colon. Default port is 993.
15
+ ## +user+ is the user (account) name to log into the server.
16
+ ## +pass+ is the password to log into the server.
17
+ def initialize(server_with_optional_port, user, pass)
18
+ @port = 993 # default
19
+ self.server, @user, @pass = server_with_optional_port, user, pass
20
+ clear_log
21
+ end
22
+
23
+ # Attribute reader for the server domain name
24
+ def server
25
+ @server
26
+ end
27
+
28
+ # Attribute writer for the server domain name; a port may be appended with
29
+ # a colon.
30
+ #
31
+ # If no port is appended, the default port (993) will be used.
32
+ def server=(server_with_optional_port)
33
+ match = server_with_optional_port.match('^([^:]+):(\d+)$')
34
+ if match
35
+ @server = match[1]
36
+ @port = match[2].to_i
37
+ else
38
+ @server = server_with_optional_port
39
+ end
40
+ end
41
+
42
+ # Perform basic sanity check on server name
43
+ #
44
+ # Note that a propery regex for an FQDN is hard to achieve.
45
+ # See https://stackoverflow.com/a/106223/270712 and elsewhere.
46
+ def server_valid?
47
+ @server.match? '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
48
+ end
49
+
50
+ # Perform *very* basic sanity check on user name
51
+ #
52
+ def user_valid?
53
+ @user&.length > 0
54
+ end
55
+
56
+ # Returns true if both server and user name are valid.
57
+ def valid?
58
+ server_valid? && user_valid?
59
+ end
60
+
61
+ # Clears the server response log
62
+ def clear_log
63
+ @log = []
64
+ end
65
+
66
+ # Returns the last response from the server
67
+ def last_response
68
+ @log.last
69
+ end
70
+
71
+ # Returns a connection to the server.
72
+ #
73
+ # The value is cached.
74
+ def connection
75
+ @connection ||= Net::IMAP.new(@server, @port, true)
76
+ end
77
+
78
+ # Logs in to the server.
79
+ #
80
+ # Returns true if login was successful, false if not (e..g, invalid
81
+ # credentials).
82
+ def login
83
+ raise('no connection to a server') unless connection
84
+ begin
85
+ response_ok? connection.login(@user, @pass)
86
+ rescue Net::IMAP::NoResponseError => error
87
+ log_error error
88
+ end
89
+ end
90
+
91
+ # Logs out of the server.
92
+ def logout
93
+ # access instance variable to avoid creating a new connection
94
+ @connection.logout if @connection
95
+ end
96
+
97
+ # Returns the server's greeting (which may reveal the server software name
98
+ # such as 'Dovecot').
99
+ def greeting
100
+ query_server { connection.greeting.data.text.strip }
101
+ end
102
+
103
+ # Returns the server's capabilities.
104
+ def capability
105
+ @capability ||= query_server { connection.capability }
106
+ end
107
+
108
+ # Returns the character that is used to separate nested mailbox names.
109
+ def separator
110
+ @separator ||= query_server { connection.list('', '')[0].delim }
111
+ end
112
+
113
+ # Returns true if the server supports the IMAP QUOTA extension.
114
+ def supports_quota
115
+ capability.include? 'QUOTA'
116
+ end
117
+
118
+ # If the server +supports_quota+, returns an array containing the current
119
+ # usage (in kiB), the total quota (in kiB), and the percent usage.
120
+ def quota
121
+ if supports_quota
122
+ @quota ||= begin
123
+ info = query_server { @connection.getquotaroot('INBOX')[1] }
124
+ percent = info.quota.to_i > 0 ? info.usage.to_i.fdiv(info.quota.to_i) * 100 : nil
125
+ [ info.usage, info.quota, percent ]
126
+ end
127
+ end
128
+ end
129
+
130
+ # Returns an array of message indexes for a mailbox.
131
+ #
132
+ # The value is currently NOT cached.
133
+ def messages(mailbox)
134
+ query_server { connection.examine(mailbox) }
135
+ query_server { connection.search('ALL') }
136
+ end
137
+
138
+ # Examines a mailbox and returns statistics about the messages in it.
139
+ #
140
+ # Returns an array with the following keys:
141
+ # * :count: Total count of messages.
142
+ # * :size: Total size of all messages in bytes.
143
+ # * :min: Size of the smallest message.
144
+ # * :q1: First quartile of message sizes.
145
+ # * :median: Median of message sizes.
146
+ # * :q3: Third quartile of messages sizes.
147
+ # * :max: Size of largest message.
148
+ def examine(mailbox)
149
+ # Could use the EXAMINE command to get the number of messages in a mailbox,
150
+ # but we need to retrieve an array of message indexes anyway (to compute
151
+ # the total mailbox size), so we can save one roundtrip to the server.
152
+ # query_server { connection.examine(mailbox) }
153
+ # total = connection.responses['EXISTS'][0]
154
+ # unseen = query_server { connection.search('UNSEEN') }.length
155
+ messages = messages(mailbox)
156
+ count = messages.length
157
+ sizes = query_server { connection.fetch(messages, 'RFC822.SIZE').map { |f| f.attr['RFC822.SIZE'] }.sort }
158
+ {
159
+ count: count,
160
+ size: convert_bytes(sizes.sum),
161
+ min: convert_bytes(sizes.first),
162
+ q1: convert_bytes(sizes.percentile(25)),
163
+ median: convert_bytes(sizes.median),
164
+ q3: convert_bytes(sizes.percentile(75)),
165
+ max: convert_bytes(sizes.last)
166
+ }
167
+ end
168
+
169
+ # Collects stats for all mailboxes recursively.
170
+ def collect_stats
171
+ mailbox_tree.collect_stats(self)
172
+ end
173
+
174
+ # Gets a list of Net::IMAP::MailboxList items, one for each mailbox.
175
+ #
176
+ # The value is cached.
177
+ def mailboxes
178
+ @mailboxes ||= query_server { @connection.list('', '*') }
179
+ end
180
+
181
+ # Returns a tree of +Imapcli::Mailbox+ objects.
182
+ #
183
+ # The value is cached.
184
+ def mailbox_tree
185
+ @mailbox_tree ||= Mailbox.new(mailboxes)
186
+ end
187
+
188
+ # Attempts to locate a given +mailbox+ in the +mailbox_tree+.
189
+ #
190
+ # Returns nil if the mailbox is not found.
191
+ def find_mailbox(mailbox)
192
+ mailbox_tree.find_sub_mailbox(mailbox, separator)
193
+ end
194
+
195
+ private
196
+
197
+ def response_ok?(response)
198
+ @log << response
199
+ response.name == 'OK'
200
+ end
201
+
202
+ def log_error(error)
203
+ @log << error
204
+ false
205
+ end
206
+
207
+ # Wrapper function that can be used to execute code that queries the server.
208
+ #
209
+ # This function ensures that there is a valid +connection+ and raises an
210
+ # error if not. The code that queries the server must be contained in the
211
+ # +block+, and the +block+'s return value is returned by this function.
212
+ # The +connection+'s responses are logged.
213
+ def query_server(&block)
214
+ raise('no connection to a server') unless connection
215
+ result = yield
216
+ @log << connection.responses
217
+ result
218
+ end
219
+
220
+ # Converts a number of bytes to kiB.
221
+ def convert_bytes(bytes)
222
+ bytes.fdiv(1024).round
223
+ end
224
+
225
+ end # class Client
226
+ end # module Imapcli
@@ -0,0 +1,100 @@
1
+ module Imapcli
2
+ # Provides entry points for Imapcli.
3
+ #
4
+ # Most of the methods in this class return
5
+ class Command
6
+ def initialize(client)
7
+ raise 'Imapcli::Client is required' unless client
8
+ @client = client
9
+ end
10
+
11
+ # Checks if the server accepts the login with the given credentials.
12
+ #
13
+ # Returns true if successful, false if not.
14
+ def check
15
+ @client.login
16
+ end
17
+
18
+ # Collects basic information about the server.
19
+ #
20
+ # The block is called repeatedly with informative messages.
21
+ # If login is not successful, an error will be raised.
22
+ def info
23
+ perform do |output|
24
+ output << "greeting: #{@client.greeting}"
25
+ output << "capability: #{@client.capability.join(' ')}"
26
+ output << "hierarchy separator: #{@client.separator}"
27
+ if @client.supports_quota
28
+ usage = Filesize.from(@client.quota[0] + ' kB').pretty
29
+ available = Filesize.from(@client.quota[1] + ' kB').pretty
30
+ output << "quota: #{usage} used, #{available} available (#{@client.quota[2].round(1)}%)"
31
+ else
32
+ output << "quota: IMAP QUOTA extension not supported by this server"
33
+ end
34
+ end
35
+ end
36
+
37
+ def list
38
+ perform do |output|
39
+ @client.collect_stats
40
+ output << "mailboxes (folders) tree:"
41
+ output += traverse_mailbox_tree @client.mailbox_tree, 0
42
+ end
43
+ end
44
+
45
+ def stats(mailboxes)
46
+ perform do |output|
47
+ mailboxes.each do |name|
48
+ if mailbox = @client.find_mailbox(name)
49
+ mailbox.collect_stats(@client)
50
+ output << [
51
+ mailbox.full_name,
52
+ mailbox.stats[:count],
53
+ format_kib(mailbox.stats[:size]),
54
+ format_kib(mailbox.stats[:min]),
55
+ format_kib(mailbox.stats[:q1]),
56
+ format_kib(mailbox.stats[:median]),
57
+ format_kib(mailbox.stats[:q3]),
58
+ format_kib(mailbox.stats[:max])
59
+ ]
60
+ else
61
+ output << [ self.class.unknown_mailbox_prefix + name ] + Array.new(7, '---')
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def self.unknown_mailbox_prefix
68
+ '!!! '
69
+ end
70
+
71
+ private
72
+
73
+ def perform
74
+ output = []
75
+ if @client.login
76
+ yield output
77
+ else
78
+ raise 'unable to log into server'
79
+ end
80
+ output
81
+ end
82
+
83
+ def traverse_mailbox_tree(mailbox, depth = 0)
84
+ output = []
85
+ if mailbox.has_children?
86
+ indent = (' ' * depth) || ''
87
+ mailbox.children.each do |child|
88
+ output << indent + '- ' + child.name
89
+ output += traverse_mailbox_tree child, depth + 1
90
+ end
91
+ end
92
+ output
93
+ end
94
+
95
+ def format_kib(kib)
96
+ kib.to_s.reverse.gsub(/...(?=.)/,'\&,').reverse + ' kiB'.freeze
97
+ end
98
+
99
+ end
100
+ end
@@ -0,0 +1,108 @@
1
+ module Imapcli
2
+ # In IMAP speak, a mailbox is what one would commonly call a 'folder'
3
+ class Mailbox
4
+ attr_reader :level, :children, :imap_mailbox_list, :name, :stats
5
+
6
+ # Creates a new root Mailbox object and optionally adds sub mailboxes from
7
+ # an array of Net::IMAP::MailboxList items.
8
+ def initialize(mailbox_list_items = nil)
9
+ @level = 0
10
+ @children = {}
11
+ add_mailbox_list(mailbox_list_items) if mailbox_list_items
12
+ end
13
+
14
+ def [](mailbox)
15
+ @children[mailbox]
16
+ end
17
+
18
+ def full_name
19
+ imap_mailbox_list&.name
20
+ end
21
+
22
+ def has_children?
23
+ @children.length > 0
24
+ end
25
+
26
+ def children
27
+ @children.values
28
+ end
29
+
30
+ # Add a list of mailboxes as returned by Net::IMAP#list.
31
+ def add_mailbox_list(array_of_mailbox_list_items)
32
+ array_of_mailbox_list_items.sort_by { |m| m.name.downcase }.each { |i| add_mailbox i }
33
+ end
34
+
35
+ # Adds a sub mailbox designated by the +name+ of a Net::IMAP::MailboxList.
36
+ def add_mailbox(imap_mailbox_list, options = {})
37
+ return unless imap_mailbox_list&.name&.length > 0
38
+ recursive_add(0, imap_mailbox_list, imap_mailbox_list.name, options)
39
+ end
40
+
41
+ # Attempts to locate and retrieve a sub mailbox.
42
+ #
43
+ # Returns nil of none exists with the given name.
44
+ # Name must be relative to the current mailbox.
45
+ def find_sub_mailbox(relative_name, delimiter)
46
+ if relative_name
47
+ sub_mailbox_name, subs_subs = relative_name.split(delimiter, 2)
48
+ if sub_mailbox = @children[sub_mailbox_name]
49
+ sub_mailbox.find_sub_mailbox(subs_subs, delimiter)
50
+ else
51
+ nil # no matching sub mailbox found, stop searching the tree
52
+ end
53
+ else
54
+ self
55
+ end
56
+ end
57
+
58
+ # Collects statistics for this mailbox.
59
+ #
60
+ # +connection+ must be a Net::IMAP object
61
+ def collect_stats(client)
62
+ if full_name # proceed only if this is a mailbox of its own
63
+ @stats = client.examine(full_name)
64
+ end
65
+ end
66
+
67
+ # Collects statistics for this mailbox and all of its children.
68
+ #
69
+ # +connection+ must be a Net::IMAP object
70
+ def collect_stats_recursively(connection)
71
+ collect_stats(connection)
72
+ @children.values.each { |child| child.collect_stats_recursively(connection) }
73
+ end
74
+
75
+ protected
76
+
77
+ def level=(level)
78
+ @level = level
79
+ end
80
+
81
+ def name=(name)
82
+ @name = name
83
+ end
84
+
85
+ def recursive_add(level, imap_mailbox_list, relative_name = nil, options = {})
86
+ delimiter = options[:delimiter] || imap_mailbox_list.delim
87
+ if relative_name
88
+ sub_mailbox_name, subs_subs = relative_name.split(delimiter, 2)
89
+ if options[:case_insensitive] || (level == 0 && relative_name.upcase == 'INBOX')
90
+ key = sub_mailbox_name.upcase
91
+ else
92
+ key = sub_mailbox_name
93
+ end
94
+ # Create a new mailbox if there does not exist one by the name
95
+ unless sub_mailbox = @children[key]
96
+ sub_mailbox = Mailbox.new
97
+ sub_mailbox.level = level
98
+ sub_mailbox.name = sub_mailbox_name
99
+ @children[key] = sub_mailbox
100
+ end
101
+ sub_mailbox.recursive_add(level + 1, imap_mailbox_list, subs_subs, options)
102
+ else # no more sub mailboxes: we've reached the last of the children
103
+ @imap_mailbox_list = imap_mailbox_list
104
+ self
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,3 @@
1
+ module Imapcli
2
+ VERSION = '0.0.1'
3
+ end
data/lib/imapcli.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'imapcli/version.rb'
2
+ require 'imapcli/command.rb'
3
+ require 'imapcli/client.rb'
4
+ require 'imapcli/mailbox.rb'
@@ -0,0 +1,70 @@
1
+ require 'imapcli'
2
+ require 'dotenv'
3
+
4
+ RSpec.describe Imapcli::Client do
5
+
6
+ context 'with mock credentials for a nonexistent server' do
7
+ let(:client) { Imapcli::Client.new('imap.example.com', 'username', 'password') }
8
+ it 'knows when a server name is invalid' do
9
+ client.server = 'i n v a l i d'
10
+ expect(client.server_valid?).to eq false
11
+ end
12
+ it 'knows when a server name is valid' do
13
+ client.server = 'imap.gmail.com'
14
+ expect(client.server_valid?).to eq true
15
+ end
16
+ it 'knows when a user name is invalid' do
17
+ client.user = ''
18
+ expect(client.user_valid?).to eq false
19
+ end
20
+ it 'knows when a user name is valid' do
21
+ client.user = 'bovender@example.com'
22
+ expect(client.user_valid?).to eq true
23
+ end
24
+ it 'uses port 993 by default' do
25
+ expect(client.port).to eq 993
26
+ end
27
+ it 'extracts a port from the server info' do
28
+ client.server = 'imap.example.com:143'
29
+ expect(client.port).to eq 143
30
+ end
31
+ it 'extracts a server from the server string when a port is appended' do
32
+ client.server = 'imap.example.com:143'
33
+ expect(client.server).to eq 'imap.example.com'
34
+ end
35
+ end
36
+
37
+ context 'with valid credentials for an actual server' do
38
+ before :all do
39
+ Dotenv.load
40
+ end
41
+
42
+ let(:client) { Imapcli::Client.new(ENV['IMAP_SERVER'], ENV['IMAP_USER'], ENV['IMAP_PASS']) }
43
+
44
+ it 'the IMAP_SERVER variable must be set' do
45
+ expect(ENV['IMAP_SERVER']).to_not eq(nil)
46
+ end
47
+ it 'the IMAP_USER variable must be set' do
48
+ expect(ENV['IMAP_USER']).to_not eq(nil)
49
+ end
50
+ it 'the IMAP_PASS variable must be set' do
51
+ expect(ENV['IMAP_PASS']).to_not eq(nil)
52
+ end
53
+ it 'successfully logs in to the server' do
54
+ expect(client.login).to eq true
55
+ end
56
+ end
57
+
58
+ context 'with invalid credentials for an actual server' do
59
+ before :all do
60
+ Dotenv.load
61
+ end
62
+
63
+ let(:client) { Imapcli::Client.new(ENV['IMAP_SERVER'], ENV['IMAP_USER'], ENV['IMAP_PASS'] + 'invalid') }
64
+
65
+ it 'cannot log in to the server' do
66
+ expect(client.login).to eq false
67
+ end
68
+ end
69
+
70
+ end
@@ -0,0 +1,21 @@
1
+ require 'imapcli'
2
+ require 'net/imap'
3
+
4
+ RSpec.describe Imapcli::Mailbox do
5
+ # it 'parses a mailbox list' do
6
+ # mailbox_list = Net::IMAP::MailboxList.new(attr: nil, delim: '/', name: 'Root/Subfolder')
7
+ # mailbox_tree = Imapcli::MailboxTree.new(mailbox_list)
8
+ # expect(mailbox_tree.tree.length).to eq 1
9
+ # end
10
+ it 'returns nil if a given sub mailbox does not exist' do
11
+ mailbox = Imapcli::Mailbox.new
12
+ expect(mailbox.find_sub_mailbox('INBOX.does.not.exist', '.')).to eq nil
13
+ end
14
+ it 'adds and retrieves an existing sub mailbox' do
15
+ name = 'Root/Subfolder/Subsubfolder'
16
+ imap_mailbox_list = Net::IMAP::MailboxList.new(nil, '/', name)
17
+ mailbox = Imapcli::Mailbox.new
18
+ mailbox.add_mailbox(imap_mailbox_list)
19
+ expect(mailbox.find_sub_mailbox(name, '/').imap_mailbox_list).to eq imap_mailbox_list
20
+ end
21
+ end
@@ -0,0 +1,102 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
4
+ # this file to always be loaded, without a need to explicitly require it in any
5
+ # files.
6
+ #
7
+ # Given that it is always loaded, you are encouraged to keep this file as
8
+ # light-weight as possible. Requiring heavyweight dependencies from this file
9
+ # will add to the boot time of your test suite on EVERY test run, even for an
10
+ # individual file that may not need all of that loaded. Instead, consider making
11
+ # a separate helper file that requires the additional dependencies and performs
12
+ # the additional setup, and require it from the spec files that actually need
13
+ # it.
14
+ #
15
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
16
+ require 'pry'
17
+
18
+ RSpec.configure do |config|
19
+ # rspec-expectations config goes here. You can use an alternate
20
+ # assertion/expectation library such as wrong or the stdlib/minitest
21
+ # assertions if you prefer.
22
+ config.expect_with :rspec do |expectations|
23
+ # This option will default to `true` in RSpec 4. It makes the `description`
24
+ # and `failure_message` of custom matchers include text for helper methods
25
+ # defined using `chain`, e.g.:
26
+ # be_bigger_than(2).and_smaller_than(4).description
27
+ # # => "be bigger than 2 and smaller than 4"
28
+ # ...rather than:
29
+ # # => "be bigger than 2"
30
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
31
+ end
32
+
33
+ # rspec-mocks config goes here. You can use an alternate test double
34
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
35
+ config.mock_with :rspec do |mocks|
36
+ # Prevents you from mocking or stubbing a method that does not exist on
37
+ # a real object. This is generally recommended, and will default to
38
+ # `true` in RSpec 4.
39
+ mocks.verify_partial_doubles = true
40
+ end
41
+
42
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
43
+ # have no way to turn it off -- the option exists only for backwards
44
+ # compatibility in RSpec 3). It causes shared context metadata to be
45
+ # inherited by the metadata hash of host groups and examples, rather than
46
+ # triggering implicit auto-inclusion in groups with matching metadata.
47
+ config.shared_context_metadata_behavior = :apply_to_host_groups
48
+
49
+ # The settings below are suggested to provide a good initial experience
50
+ # with RSpec, but feel free to customize to your heart's content.
51
+ =begin
52
+ # This allows you to limit a spec run to individual examples or groups
53
+ # you care about by tagging them with `:focus` metadata. When nothing
54
+ # is tagged with `:focus`, all examples get run. RSpec also provides
55
+ # aliases for `it`, `describe`, and `context` that include `:focus`
56
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
57
+ config.filter_run_when_matching :focus
58
+
59
+ # Allows RSpec to persist some state between runs in order to support
60
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
61
+ # you configure your source control system to ignore this file.
62
+ config.example_status_persistence_file_path = "spec/examples.txt"
63
+
64
+ # Limits the available syntax to the non-monkey patched syntax that is
65
+ # recommended. For more details, see:
66
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
67
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
68
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
69
+ config.disable_monkey_patching!
70
+
71
+ # This setting enables warnings. It's recommended, but in some cases may
72
+ # be too noisy due to issues in dependencies.
73
+ config.warnings = true
74
+
75
+ # Many RSpec users commonly either run the entire suite or an individual
76
+ # file, and it's useful to allow more verbose output when running an
77
+ # individual spec file.
78
+ if config.files_to_run.one?
79
+ # Use the documentation formatter for detailed output,
80
+ # unless a formatter has already been configured
81
+ # (e.g. via a command-line flag).
82
+ config.default_formatter = "doc"
83
+ end
84
+
85
+ # Print the 10 slowest examples and example groups at the
86
+ # end of the spec run, to help surface which specs are running
87
+ # particularly slow.
88
+ config.profile_examples = 10
89
+
90
+ # Run specs in random order to surface order dependencies. If you find an
91
+ # order dependency and want to debug it, you can fix the order by providing
92
+ # the seed, which is printed after each run.
93
+ # --seed 1234
94
+ config.order = :random
95
+
96
+ # Seed global randomization in this process using the `--seed` CLI option.
97
+ # Setting this allows you to use `--seed` to deterministically reproduce
98
+ # test failures related to randomization by passing the same `--seed` value
99
+ # as the one that triggered the failure.
100
+ Kernel.srand config.seed
101
+ =end
102
+ end