imapcli 0.0.1 → 1.0.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/Gemfile +1 -0
- data/Gemfile.lock +4 -0
- data/NEWS +9 -0
- data/README.md +181 -53
- data/bin/imapcli +58 -18
- data/imapcli.gemspec +2 -1
- data/lib/imapcli.rb +2 -0
- data/lib/imapcli/client.rb +11 -22
- data/lib/imapcli/command.rb +106 -39
- data/lib/imapcli/mailbox.rb +95 -20
- data/lib/imapcli/option_validator.rb +70 -0
- data/lib/imapcli/stats.rb +62 -0
- data/lib/imapcli/version.rb +1 -1
- data/spec/lib/imapcli/client_spec.rb +3 -3
- data/spec/lib/imapcli/command_spec.rb +123 -0
- data/spec/lib/imapcli/mailbox_spec.rb +61 -3
- data/spec/lib/imapcli/option_validator_spec.rb +4 -0
- data/spec/lib/imapcli/stats_spec.rb +68 -0
- data/spec/spec_helper.rb +2 -0
- metadata +11 -4
@@ -0,0 +1,70 @@
|
|
1
|
+
module Imapcli
|
2
|
+
# Utility class to validate user options
|
3
|
+
#
|
4
|
+
# Invalid options will trigger a runtime exception (which is handled by GLI).
|
5
|
+
class OptionValidator
|
6
|
+
attr_reader :errors, :warnings, :options
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@errors = []
|
10
|
+
@warnings = []
|
11
|
+
end
|
12
|
+
|
13
|
+
def global_options_valid?(global_options)
|
14
|
+
if global_options[:s].nil? || global_options[:s].empty?
|
15
|
+
@errors << 'missing server name (use -s option or set IMAP_SERVER environment variable)'
|
16
|
+
end
|
17
|
+
if global_options[:s].nil? || global_options[:s].empty?
|
18
|
+
@errors << 'missing server name (use -s option or set IMAP_SERVER environment variable)'
|
19
|
+
end
|
20
|
+
if global_options[:P] && global_options[:p]
|
21
|
+
@errors << '-p and -P options do not agree'
|
22
|
+
end
|
23
|
+
|
24
|
+
pass?
|
25
|
+
end
|
26
|
+
|
27
|
+
# Validates options for the stats command.
|
28
|
+
#
|
29
|
+
# @return [true false] indicating success or failure; warnings can be accessed as attribute
|
30
|
+
def stats_options_valid?(options, args)
|
31
|
+
@options = {}
|
32
|
+
raise 'incompatible options -r/--recurse and -R/--no_recurse' if options[:r] && options[:R]
|
33
|
+
|
34
|
+
if options[:recurse]
|
35
|
+
if args.empty?
|
36
|
+
@warnings << 'warning: superfluous -r/--recurse option; will recurse from root by default'
|
37
|
+
else
|
38
|
+
@options[:depth] = -1
|
39
|
+
end
|
40
|
+
elsif options[:no_recurse]
|
41
|
+
if args.empty?
|
42
|
+
@options[:depth] = 0
|
43
|
+
else
|
44
|
+
@warnings << 'warning: superfluous -R/--no_recurse option; will not recurse from non-root mailbox by default'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
if options[:sort]
|
49
|
+
available_sort_options = %w(count total_size min_size q1 median_size q3 max_size)
|
50
|
+
if available_sort_options.include? options[:sort].downcase
|
51
|
+
@options[:sort] = options[:sort].to_sym
|
52
|
+
else
|
53
|
+
# GLI did not print the available options even with the :must_match option
|
54
|
+
@errors << "sort option must be one of: #{available_sort_options.join(', ')}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
pass?
|
59
|
+
end
|
60
|
+
|
61
|
+
def pass?
|
62
|
+
@errors.empty?
|
63
|
+
end
|
64
|
+
|
65
|
+
def warnings?
|
66
|
+
@warnings.count > 0
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Imapcli
|
2
|
+
# Handles mailbox statistics.
|
3
|
+
#
|
4
|
+
# +message_sizes+ is an array of message sizes in bytes.
|
5
|
+
class Stats
|
6
|
+
|
7
|
+
def initialize(message_sizes = [])
|
8
|
+
@message_sizes = message_sizes
|
9
|
+
end
|
10
|
+
|
11
|
+
# Adds other statistics.
|
12
|
+
def add(other_stats)
|
13
|
+
return unless other_stats
|
14
|
+
@message_sizes += other_stats.message_sizes
|
15
|
+
invalidate
|
16
|
+
end
|
17
|
+
|
18
|
+
def count
|
19
|
+
@count ||= @message_sizes&.length
|
20
|
+
end
|
21
|
+
|
22
|
+
def total_size
|
23
|
+
@total_size ||= convert_bytes(@message_sizes.sum)
|
24
|
+
end
|
25
|
+
|
26
|
+
def min_size
|
27
|
+
@min ||= convert_bytes(@message_sizes.min)
|
28
|
+
end
|
29
|
+
|
30
|
+
def quartile_1_size
|
31
|
+
@q1 ||= convert_bytes(@message_sizes.percentile(25))
|
32
|
+
end
|
33
|
+
|
34
|
+
def median_size
|
35
|
+
@median ||= convert_bytes(@message_sizes.median)
|
36
|
+
end
|
37
|
+
|
38
|
+
def quartile_3_size
|
39
|
+
@q3 ||= convert_bytes(@message_sizes.percentile(75))
|
40
|
+
end
|
41
|
+
|
42
|
+
def max_size
|
43
|
+
@max ||= convert_bytes(@message_sizes.max)
|
44
|
+
end
|
45
|
+
|
46
|
+
protected
|
47
|
+
|
48
|
+
attr_accessor :message_sizes
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
# Converts a number of bytes to kiB.
|
53
|
+
def convert_bytes(bytes)
|
54
|
+
bytes.fdiv(1024).round if bytes
|
55
|
+
end
|
56
|
+
|
57
|
+
def invalidate
|
58
|
+
@count, @total_size, @min, @max, @q1, @q3, @median = nil # sets others to nil too
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
data/lib/imapcli/version.rb
CHANGED
@@ -34,7 +34,7 @@ RSpec.describe Imapcli::Client do
|
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
|
-
context 'with valid credentials for an actual server' do
|
37
|
+
context 'with valid credentials for an actual server', network: true do
|
38
38
|
before :all do
|
39
39
|
Dotenv.load
|
40
40
|
end
|
@@ -55,12 +55,12 @@ RSpec.describe Imapcli::Client do
|
|
55
55
|
end
|
56
56
|
end
|
57
57
|
|
58
|
-
context 'with invalid credentials for an actual server' do
|
58
|
+
context 'with invalid credentials for an actual server', network: true do
|
59
59
|
before :all do
|
60
60
|
Dotenv.load
|
61
61
|
end
|
62
62
|
|
63
|
-
let(:client) { Imapcli::Client.new(ENV['IMAP_SERVER'], ENV['IMAP_USER'], ENV['IMAP_PASS']
|
63
|
+
let(:client) { Imapcli::Client.new(ENV['IMAP_SERVER'], ENV['IMAP_USER'], "#{ENV['IMAP_PASS']}invalid") }
|
64
64
|
|
65
65
|
it 'cannot log in to the server' do
|
66
66
|
expect(client.login).to eq false
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'imapcli'
|
2
|
+
|
3
|
+
RSpec.describe Imapcli::Command do
|
4
|
+
|
5
|
+
context 'without a client' do
|
6
|
+
it 'cannot be instantiated' do
|
7
|
+
expect { Imapcli::Command.new('foobar') }.to raise_error(ArgumentError)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
context 'with a mock client' do
|
12
|
+
let(:client) do
|
13
|
+
client = Imapcli::Client.new('server', 'user', 'pass')
|
14
|
+
allow(client).to receive(:login).and_return(true)
|
15
|
+
client
|
16
|
+
end
|
17
|
+
let(:command) { Imapcli::Command.new(client) }
|
18
|
+
let(:mailbox_root) do
|
19
|
+
Imapcli::Mailbox.new([
|
20
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox'),
|
21
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Foo'),
|
22
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Foo/Sub'),
|
23
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Bar'),
|
24
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Bar/Sub'),
|
25
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Bar/Sub/Subsub'),
|
26
|
+
])
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'can be instantiated' do
|
30
|
+
expect { Imapcli::Command.new(client) }.to_not raise_error
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'logs in' do
|
34
|
+
expect(command.check).to eq true
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'collects information about the server' do
|
38
|
+
allow(client).to receive(:greeting).and_return 'hello'
|
39
|
+
allow(client).to receive(:capability).and_return ['lots', 'of', 'capabilities']
|
40
|
+
allow(client).to receive(:separator).and_return '/'
|
41
|
+
allow(client).to receive(:supports_quota).and_return true
|
42
|
+
allow(client).to receive(:quota).and_return [ '1024', '2048', 50.00 ]
|
43
|
+
output = command.info
|
44
|
+
expect(output).to be_a Array
|
45
|
+
expect(output[0]).to eq 'greeting: hello'
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'lists mailboxes' do
|
49
|
+
allow(client).to receive(:mailbox_root).and_return mailbox_root
|
50
|
+
output = command.list
|
51
|
+
expect(output).to be_a Array
|
52
|
+
expect(output[0]).to eq '- Inbox'
|
53
|
+
end
|
54
|
+
|
55
|
+
context 'collecting statistics' do
|
56
|
+
before :each do
|
57
|
+
allow(client).to receive(:mailbox_root).and_return mailbox_root
|
58
|
+
allow(client).to receive(:separator).and_return '/'
|
59
|
+
allow(client).to receive(:message_sizes) do |mailbox|
|
60
|
+
case mailbox
|
61
|
+
when 'Inbox'
|
62
|
+
(1..4).map { |i| 1024 * i }
|
63
|
+
when 'Inbox/Foo'
|
64
|
+
(3..10).map { |i| 1024 * i }
|
65
|
+
when 'Inbox/Foo/Sub'
|
66
|
+
Array.new(10, 1024)
|
67
|
+
when 'Inbox/Bar'
|
68
|
+
(1..2).map { |i| 1024 * i }
|
69
|
+
when 'Inbox/Bar/Sub'
|
70
|
+
[ 1, 1024 * 20 ]
|
71
|
+
when 'Inbox/Bar/Sub/Subsub'
|
72
|
+
(1..4).map { |i| 1024 * i }
|
73
|
+
else
|
74
|
+
[1024, 2048, 4096, 8192]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'for all folders' do
|
80
|
+
output = command.stats()
|
81
|
+
expect(output).to be_a Array
|
82
|
+
expect(output.length).to eq 8 # including divider and summary line
|
83
|
+
expect(output[0][0]).to eq 'Inbox'
|
84
|
+
expect(output[0][1]).to eq 4
|
85
|
+
end
|
86
|
+
it 'for a given folder' do
|
87
|
+
output = command.stats('Inbox/Foo')
|
88
|
+
expect(output).to be_a Array
|
89
|
+
expect(output.length).to eq 3 # including divider and summary line
|
90
|
+
expect(output[0][0]).to eq 'Inbox/Foo'
|
91
|
+
expect(output[0][1]).to eq 8 # depends on message_sizes stub (see above)
|
92
|
+
end
|
93
|
+
it 'for a given folder and subfolders' do
|
94
|
+
output = command.stats('Inbox/Foo', depth: -1)
|
95
|
+
expect(output).to be_a Array
|
96
|
+
expect(output.length).to eq 4 # including divider and summary line
|
97
|
+
expect(output[1][0]).to eq 'Inbox/Foo/Sub'
|
98
|
+
end
|
99
|
+
it 'sorts by number of messages' do
|
100
|
+
output = command.stats('Inbox', depth: -1, sort: :count, sort_order: :desc)
|
101
|
+
expect(output).to be_a Array
|
102
|
+
expect(output[0][0]).to eq 'Inbox/Foo/Sub'
|
103
|
+
end
|
104
|
+
it 'sorts by total message size' do
|
105
|
+
output = command.stats('Inbox', depth: -1, sort: :total_size, sort_order: :desc)
|
106
|
+
expect(output).to be_a Array
|
107
|
+
expect(output[0][0]).to eq 'Inbox/Foo'
|
108
|
+
end
|
109
|
+
it 'sorts by largest message' do
|
110
|
+
output = command.stats('Inbox', depth: -1, sort: :max_size, sort_order: :desc)
|
111
|
+
expect(output).to be_a Array
|
112
|
+
expect(output[0][0]).to eq 'Inbox/Bar/Sub'
|
113
|
+
end
|
114
|
+
it 'sorts by smallest message' do
|
115
|
+
output = command.stats('Inbox', depth: -1, sort: :min_size, sort_order: :desc)
|
116
|
+
expect(output).to be_a Array
|
117
|
+
# binding.pry
|
118
|
+
expect(output[0][0]).to eq 'Inbox/Foo'
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
end
|
123
|
+
end
|
@@ -2,13 +2,22 @@ require 'imapcli'
|
|
2
2
|
require 'net/imap'
|
3
3
|
|
4
4
|
RSpec.describe Imapcli::Mailbox do
|
5
|
+
let(:mailbox) do
|
6
|
+
Imapcli::Mailbox.new([
|
7
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox'),
|
8
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Foo'),
|
9
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Foo/Sub'),
|
10
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Bar'),
|
11
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Bar/Sub'),
|
12
|
+
Net::IMAP::MailboxList.new(nil, '/', 'Inbox/Bar/Sub/Subsub'),
|
13
|
+
])
|
14
|
+
end
|
5
15
|
# it 'parses a mailbox list' do
|
6
16
|
# mailbox_list = Net::IMAP::MailboxList.new(attr: nil, delim: '/', name: 'Root/Subfolder')
|
7
|
-
#
|
8
|
-
# expect(
|
17
|
+
# mailbox_root = Imapcli::MailboxTree.new(mailbox_list)
|
18
|
+
# expect(mailbox_root.tree.length).to eq 1
|
9
19
|
# end
|
10
20
|
it 'returns nil if a given sub mailbox does not exist' do
|
11
|
-
mailbox = Imapcli::Mailbox.new
|
12
21
|
expect(mailbox.find_sub_mailbox('INBOX.does.not.exist', '.')).to eq nil
|
13
22
|
end
|
14
23
|
it 'adds and retrieves an existing sub mailbox' do
|
@@ -18,4 +27,53 @@ RSpec.describe Imapcli::Mailbox do
|
|
18
27
|
mailbox.add_mailbox(imap_mailbox_list)
|
19
28
|
expect(mailbox.find_sub_mailbox(name, '/').imap_mailbox_list).to eq imap_mailbox_list
|
20
29
|
end
|
30
|
+
it 'counts the number of mailboxes' do
|
31
|
+
expect(mailbox.count).to eq 7 # includes virtual root mailbox
|
32
|
+
end
|
33
|
+
it 'determines the maximum level in the subtree' do
|
34
|
+
expect(mailbox.get_max_level).to eq 3
|
35
|
+
end
|
36
|
+
it 'converts a tree to a list' do
|
37
|
+
list = mailbox.to_list
|
38
|
+
list_names = list.map { |m| m.full_name }
|
39
|
+
expect(list_names).to eq [
|
40
|
+
'Inbox',
|
41
|
+
'Inbox/Bar',
|
42
|
+
'Inbox/Bar/Sub',
|
43
|
+
'Inbox/Bar/Sub/Subsub',
|
44
|
+
'Inbox/Foo',
|
45
|
+
'Inbox/Foo/Sub',
|
46
|
+
]
|
47
|
+
end
|
48
|
+
it 'converts a tree to a list up to a certain level' do
|
49
|
+
list = mailbox.to_list(1)
|
50
|
+
list_names = list.map { |m| m.full_name }
|
51
|
+
expect(list_names).to eq [
|
52
|
+
'Inbox',
|
53
|
+
'Inbox/Bar',
|
54
|
+
'Inbox/Foo'
|
55
|
+
]
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'identification and consolidation' do
|
59
|
+
let(:parent) { mailbox.find_sub_mailbox('Inbox', '/') }
|
60
|
+
let(:child) { mailbox.find_sub_mailbox('Inbox/Bar/Sub/Subsub', '/') }
|
61
|
+
|
62
|
+
it 'knows if it is contains another mailbox' do
|
63
|
+
expect(parent.contains? child).to eq true
|
64
|
+
end
|
65
|
+
it 'knows if it is does not contain another mailbox' do
|
66
|
+
expect(child.contains? mailbox).to eq false
|
67
|
+
end
|
68
|
+
# it 'consolidates several of the same mailboxes' do
|
69
|
+
# expect(Imapcli::Mailbox.consolidate([parent, parent])).to eq [ parent ]
|
70
|
+
# end
|
71
|
+
it 'consolidates several of the same mailbox' do
|
72
|
+
expect(Imapcli::Mailbox.consolidate([child, child])).to eq [ child ]
|
73
|
+
end
|
74
|
+
it 'consolidates several different mailboxes' do
|
75
|
+
expect(Imapcli::Mailbox.consolidate([child, parent])).to eq [ parent ]
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
21
79
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'imapcli'
|
2
|
+
|
3
|
+
RSpec.describe Imapcli::Stats do
|
4
|
+
let(:array) { (1..12).map { |i| i * 1024 } }
|
5
|
+
let(:other_array) { (13..24).map { |i| i * 1024 } }
|
6
|
+
let(:stats) { Imapcli::Stats.new(array) }
|
7
|
+
let(:other_stats) { Imapcli::Stats.new(other_array) }
|
8
|
+
|
9
|
+
it 'knows the number of items' do
|
10
|
+
expect(stats.count).to eq 12
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'computes the minimum' do
|
14
|
+
expect(stats.min_size).to eq 1
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'computes the maximum' do
|
18
|
+
expect(stats.max_size).to eq 12
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'computes the median' do
|
22
|
+
expect(stats.median_size).to eq 7 # response is rounded
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'computes the first quartile' do
|
26
|
+
expect(stats.quartile_1_size).to eq 4 # response is rounded
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'computes the third quartile' do
|
30
|
+
expect(stats.quartile_3_size).to eq 9 # response is rounded
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'adds nothing if other stats are nil' do
|
34
|
+
expect { stats.add nil }.to_not raise_error
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'adding other stats' do
|
38
|
+
before :each do
|
39
|
+
stats.add other_stats
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'can add other stats' do
|
43
|
+
expect(stats.count).to eq 24
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'computes the minimum' do
|
47
|
+
expect(stats.min_size).to eq 1
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'computes the maximum' do
|
51
|
+
expect(stats.max_size).to eq 24
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'computes the median' do
|
55
|
+
expect(stats.median_size).to eq 13 # response is rounded
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'computes the first quartile' do
|
59
|
+
expect(stats.quartile_1_size).to eq 7 # response is rounded
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'computes the third quartile' do
|
63
|
+
expect(stats.quartile_3_size).to eq 18 # response is rounded
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -46,6 +46,8 @@ RSpec.configure do |config|
|
|
46
46
|
# triggering implicit auto-inclusion in groups with matching metadata.
|
47
47
|
config.shared_context_metadata_behavior = :apply_to_host_groups
|
48
48
|
|
49
|
+
config.filter_run_excluding network: true
|
50
|
+
|
49
51
|
# The settings below are suggested to provide a good initial experience
|
50
52
|
# with RSpec, but feel free to customize to your heart's content.
|
51
53
|
=begin
|