imapcli 0.0.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/imapcli.gemspec
CHANGED
@@ -6,8 +6,9 @@ spec = Gem::Specification.new do |s|
|
|
6
6
|
s.author = 'Daniel Kraus (bovender)'
|
7
7
|
s.email = 'bovender@bovender.de'
|
8
8
|
s.homepage = 'https://github.com/bovender/imapcli'
|
9
|
+
s.license = 'Apache-2.0'
|
9
10
|
s.platform = Gem::Platform::RUBY
|
10
|
-
s.summary = '
|
11
|
+
s.summary = 'Command-line tool to query IMAP servers'
|
11
12
|
s.files = `git ls-files`.split("\n")
|
12
13
|
s.require_paths << 'lib'
|
13
14
|
s.has_rdoc = true
|
data/lib/imapcli.rb
CHANGED
data/lib/imapcli/client.rb
CHANGED
@@ -145,7 +145,7 @@ module Imapcli
|
|
145
145
|
# * :median: Median of message sizes.
|
146
146
|
# * :q3: Third quartile of messages sizes.
|
147
147
|
# * :max: Size of largest message.
|
148
|
-
def
|
148
|
+
def message_sizes(mailbox)
|
149
149
|
# Could use the EXAMINE command to get the number of messages in a mailbox,
|
150
150
|
# but we need to retrieve an array of message indexes anyway (to compute
|
151
151
|
# the total mailbox size), so we can save one roundtrip to the server.
|
@@ -153,22 +153,16 @@ module Imapcli
|
|
153
153
|
# total = connection.responses['EXISTS'][0]
|
154
154
|
# unseen = query_server { connection.search('UNSEEN') }.length
|
155
155
|
messages = messages(mailbox)
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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
|
-
}
|
156
|
+
if messages.length > 0
|
157
|
+
query_server { connection.fetch(messages, 'RFC822.SIZE').map { |f| f.attr['RFC822.SIZE'] } }
|
158
|
+
else
|
159
|
+
[]
|
160
|
+
end
|
167
161
|
end
|
168
162
|
|
169
163
|
# Collects stats for all mailboxes recursively.
|
170
164
|
def collect_stats
|
171
|
-
|
165
|
+
mailbox_root.collect_stats(self)
|
172
166
|
end
|
173
167
|
|
174
168
|
# Gets a list of Net::IMAP::MailboxList items, one for each mailbox.
|
@@ -181,15 +175,15 @@ module Imapcli
|
|
181
175
|
# Returns a tree of +Imapcli::Mailbox+ objects.
|
182
176
|
#
|
183
177
|
# The value is cached.
|
184
|
-
def
|
185
|
-
@
|
178
|
+
def mailbox_root
|
179
|
+
@mailbox_root ||= Mailbox.new(mailboxes)
|
186
180
|
end
|
187
181
|
|
188
|
-
# Attempts to locate a given +mailbox+ in the +
|
182
|
+
# Attempts to locate a given +mailbox+ in the +mailbox_root+.
|
189
183
|
#
|
190
184
|
# Returns nil if the mailbox is not found.
|
191
185
|
def find_mailbox(mailbox)
|
192
|
-
|
186
|
+
mailbox_root.find_sub_mailbox(mailbox, separator)
|
193
187
|
end
|
194
188
|
|
195
189
|
private
|
@@ -217,10 +211,5 @@ module Imapcli
|
|
217
211
|
result
|
218
212
|
end
|
219
213
|
|
220
|
-
# Converts a number of bytes to kiB.
|
221
|
-
def convert_bytes(bytes)
|
222
|
-
bytes.fdiv(1024).round
|
223
|
-
end
|
224
|
-
|
225
214
|
end # class Client
|
226
215
|
end # module Imapcli
|
data/lib/imapcli/command.rb
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
module Imapcli
|
2
|
+
require 'pp'
|
2
3
|
# Provides entry points for Imapcli.
|
3
4
|
#
|
4
5
|
# Most of the methods in this class return
|
5
6
|
class Command
|
6
7
|
def initialize(client)
|
7
|
-
raise 'Imapcli::Client is required' unless client
|
8
|
+
raise ArgumentError, 'Imapcli::Client is required' unless client && client.is_a?(Imapcli::Client)
|
8
9
|
@client = client
|
9
10
|
end
|
10
11
|
|
@@ -16,11 +17,9 @@ module Imapcli
|
|
16
17
|
end
|
17
18
|
|
18
19
|
# 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
20
|
def info
|
23
|
-
perform do
|
21
|
+
perform do
|
22
|
+
output = []
|
24
23
|
output << "greeting: #{@client.greeting}"
|
25
24
|
output << "capability: #{@client.capability.join(' ')}"
|
26
25
|
output << "hierarchy separator: #{@client.separator}"
|
@@ -34,33 +33,45 @@ module Imapcli
|
|
34
33
|
end
|
35
34
|
end
|
36
35
|
|
36
|
+
# Lists all mailboxes
|
37
37
|
def list
|
38
|
-
perform do
|
39
|
-
@client.
|
40
|
-
output << "mailboxes (folders) tree:"
|
41
|
-
output += traverse_mailbox_tree @client.mailbox_tree, 0
|
38
|
+
perform do
|
39
|
+
traverse_mailbox_tree(@client.mailbox_root)
|
42
40
|
end
|
43
41
|
end
|
44
42
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
43
|
+
# Collects statistics about mailboxes.
|
44
|
+
#
|
45
|
+
# If a block is given, it is called with the current mailbox count and the
|
46
|
+
# total mailbox count so that current progress can be computed.
|
47
|
+
def stats(mailbox_names = [], options = {})
|
48
|
+
mailbox_names = [mailbox_names] unless mailbox_names.is_a? Array
|
49
|
+
perform do
|
50
|
+
output = []
|
51
|
+
# Map the command line arguments to Imapcli::Mailbox objects
|
52
|
+
mailboxes = find_mailboxes(mailbox_names)
|
53
|
+
list = mailboxes.inject([]) do |ary, mailbox|
|
54
|
+
ary + mailbox.to_list(determine_max_level(mailbox, options))
|
55
|
+
end
|
56
|
+
raise 'mailbox not found' unless list.count > 0
|
57
|
+
current_count = 0
|
58
|
+
yield list.length if block_given?
|
59
|
+
total_stats = Stats.new
|
60
|
+
list.each do |mailbox|
|
61
|
+
# Since we are working on a flat list of mailboxes, set the maximum
|
62
|
+
# level to 0 when collecting stats.
|
63
|
+
mailbox.collect_stats(@client, 0) do |stats|
|
64
|
+
total_stats.add(stats)
|
65
|
+
current_count += 1
|
66
|
+
yield current_count if block_given?
|
62
67
|
end
|
63
68
|
end
|
69
|
+
sorted_list(list, options).each do |mailbox|
|
70
|
+
output << stats_to_table(mailbox.full_name, mailbox.stats)
|
71
|
+
end
|
72
|
+
# output << Array.new(8, '======')
|
73
|
+
output << stats_to_table('Total', total_stats) if list.length > 1
|
74
|
+
output
|
64
75
|
end
|
65
76
|
end
|
66
77
|
|
@@ -71,29 +82,85 @@ module Imapcli
|
|
71
82
|
private
|
72
83
|
|
73
84
|
def perform
|
74
|
-
output = []
|
75
85
|
if @client.login
|
76
|
-
yield
|
86
|
+
yield
|
77
87
|
else
|
78
88
|
raise 'unable to log into server'
|
79
89
|
end
|
80
|
-
output
|
81
90
|
end
|
82
91
|
|
83
92
|
def traverse_mailbox_tree(mailbox, depth = 0)
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
93
|
+
this = mailbox.is_imap_mailbox? ? ["#{' ' * [depth - 1, 0].max}- #{mailbox.name}"] : []
|
94
|
+
mailbox.children.inject(this) do |ary, child|
|
95
|
+
ary + traverse_mailbox_tree(child, depth + 1)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def stats_to_table(first_cell, stats)
|
100
|
+
[
|
101
|
+
first_cell,
|
102
|
+
stats.count,
|
103
|
+
stats.total_size,
|
104
|
+
stats.min_size,
|
105
|
+
stats.quartile_1_size,
|
106
|
+
stats.median_size,
|
107
|
+
stats.quartile_3_size,
|
108
|
+
stats.max_size
|
109
|
+
]
|
110
|
+
end
|
111
|
+
|
112
|
+
# Finds and returns mailboxes based on mailbox names.
|
113
|
+
def find_mailboxes(names)
|
114
|
+
if names && names.length > 0
|
115
|
+
Imapcli::Mailbox.consolidate(
|
116
|
+
names.map { |name| @client.find_mailbox(name) }.compact
|
117
|
+
)
|
118
|
+
else
|
119
|
+
[@client.mailbox_root]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Determines the maximum level for mailbox statistics.
|
124
|
+
#
|
125
|
+
# If the mailbox is the root mailbox, the entire mailbox tree is traversed
|
126
|
+
# by default, unless a :depth option limits the maximum depth.
|
127
|
+
#
|
128
|
+
# If the mailbox is not the root mailbox, by default no recursion will be
|
129
|
+
# performed, unless a :depth option requests a particular depth.
|
130
|
+
#
|
131
|
+
# Options:
|
132
|
+
# * +:depth+ maximum depth of recursion
|
133
|
+
def determine_max_level(mailbox, options = {})
|
134
|
+
if mailbox.is_root?
|
135
|
+
options[:depth]
|
136
|
+
else
|
137
|
+
depth = options[:depth] || 0
|
138
|
+
depth >= 0 ? mailbox.level + depth : nil
|
91
139
|
end
|
92
|
-
output
|
93
140
|
end
|
94
141
|
|
95
|
-
def
|
96
|
-
|
142
|
+
def sorted_list(list, options = {})
|
143
|
+
sorted = case options[:sort]
|
144
|
+
when :count
|
145
|
+
list.sort_by { |mailbox| mailbox.stats.count }
|
146
|
+
when :total_size
|
147
|
+
list.sort_by { |mailbox| mailbox.stats.total_size }
|
148
|
+
when :median_size
|
149
|
+
list.sort_by { |mailbox| mailbox.stats.median_size }
|
150
|
+
when :min_size
|
151
|
+
list.sort_by { |mailbox| mailbox.stats.min_size }
|
152
|
+
when :q1
|
153
|
+
list.sort_by { |mailbox| mailbox.stats.q1 }
|
154
|
+
when :q3
|
155
|
+
list.sort_by { |mailbox| mailbox.stats.q3 }
|
156
|
+
when :max_size
|
157
|
+
list.sort_by { |mailbox| mailbox.stats.max_size }
|
158
|
+
when nil
|
159
|
+
list
|
160
|
+
else
|
161
|
+
raise "invalid sort option: #{options[:sort]}"
|
162
|
+
end
|
163
|
+
options[:sort_order] == :desc ? sorted.reverse : sorted
|
97
164
|
end
|
98
165
|
|
99
166
|
end
|
data/lib/imapcli/mailbox.rb
CHANGED
@@ -1,13 +1,15 @@
|
|
1
1
|
module Imapcli
|
2
2
|
# In IMAP speak, a mailbox is what one would commonly call a 'folder'
|
3
3
|
class Mailbox
|
4
|
+
attr_accessor :options
|
4
5
|
attr_reader :level, :children, :imap_mailbox_list, :name, :stats
|
5
6
|
|
6
7
|
# Creates a new root Mailbox object and optionally adds sub mailboxes from
|
7
8
|
# an array of Net::IMAP::MailboxList items.
|
8
|
-
def initialize(mailbox_list_items = nil)
|
9
|
+
def initialize(mailbox_list_items = nil, options = {})
|
9
10
|
@level = 0
|
10
11
|
@children = {}
|
12
|
+
@options = options
|
11
13
|
add_mailbox_list(mailbox_list_items) if mailbox_list_items
|
12
14
|
end
|
13
15
|
|
@@ -15,6 +17,37 @@ module Imapcli
|
|
15
17
|
@children[mailbox]
|
16
18
|
end
|
17
19
|
|
20
|
+
# Determines if this mailbox represents a dedicated IMAP mailbox with an
|
21
|
+
# associated Net::IMAP::MailboxList structure.
|
22
|
+
def is_imap_mailbox?
|
23
|
+
not imap_mailbox_list.nil?
|
24
|
+
end
|
25
|
+
|
26
|
+
def is_root?
|
27
|
+
name.respond_to?(:empty?) ? !!name.empty? : !name
|
28
|
+
end
|
29
|
+
|
30
|
+
# Counts all sub mailboxes recursively.
|
31
|
+
#
|
32
|
+
# The result includes the current mailbox.
|
33
|
+
def count(max_level = nil)
|
34
|
+
sum = 1
|
35
|
+
if max_level.nil? || level < max_level
|
36
|
+
@children.values.inject(sum) do |count, child|
|
37
|
+
count + child.count(max_level)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Determines the maximum level in the mailbox tree
|
43
|
+
def get_max_level
|
44
|
+
if has_children?
|
45
|
+
@children.values.map { |child| child.get_max_level }.max
|
46
|
+
else
|
47
|
+
level
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
18
51
|
def full_name
|
19
52
|
imap_mailbox_list&.name
|
20
53
|
end
|
@@ -33,9 +66,16 @@ module Imapcli
|
|
33
66
|
end
|
34
67
|
|
35
68
|
# Adds a sub mailbox designated by the +name+ of a Net::IMAP::MailboxList.
|
36
|
-
def add_mailbox(imap_mailbox_list
|
69
|
+
def add_mailbox(imap_mailbox_list)
|
37
70
|
return unless imap_mailbox_list&.name&.length > 0
|
38
|
-
recursive_add(0, imap_mailbox_list, imap_mailbox_list.name
|
71
|
+
recursive_add(0, imap_mailbox_list, imap_mailbox_list.name)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Returns true if this mailbox contains a given other mailbox.
|
75
|
+
def contains?(other_mailbox)
|
76
|
+
@children.values.any? do |child|
|
77
|
+
child == other_mailbox || child.contains?(other_mailbox)
|
78
|
+
end
|
39
79
|
end
|
40
80
|
|
41
81
|
# Attempts to locate and retrieve a sub mailbox.
|
@@ -45,7 +85,8 @@ module Imapcli
|
|
45
85
|
def find_sub_mailbox(relative_name, delimiter)
|
46
86
|
if relative_name
|
47
87
|
sub_mailbox_name, subs_subs = relative_name.split(delimiter, 2)
|
48
|
-
|
88
|
+
key = normalize_key(sub_mailbox_name, level)
|
89
|
+
if sub_mailbox = @children[key]
|
49
90
|
sub_mailbox.find_sub_mailbox(subs_subs, delimiter)
|
50
91
|
else
|
51
92
|
nil # no matching sub mailbox found, stop searching the tree
|
@@ -55,21 +96,49 @@ module Imapcli
|
|
55
96
|
end
|
56
97
|
end
|
57
98
|
|
58
|
-
# Collects statistics for this mailbox
|
99
|
+
# Collects statistics for this mailbox and the subordinate mailboxes up to
|
100
|
+
# a given level.
|
59
101
|
#
|
60
|
-
#
|
61
|
-
|
102
|
+
# If level is nil, all sub mailboxes are analyzed as well.
|
103
|
+
#
|
104
|
+
# If a block is given, it is called with the Imapcli::Stats object for this
|
105
|
+
# mailbox.
|
106
|
+
def collect_stats(client, max_level = nil)
|
107
|
+
return if @stats
|
62
108
|
if full_name # proceed only if this is a mailbox of its own
|
63
|
-
@stats = client.
|
109
|
+
@stats = Stats.new(client.message_sizes(full_name))
|
110
|
+
end
|
111
|
+
yield @stats if block_given?
|
112
|
+
if max_level.nil? || level < max_level
|
113
|
+
@children.values.each do |child|
|
114
|
+
child.collect_stats(client, max_level) { |child_stats| yield child_stats}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Converts the mailbox tree to a flat list.
|
120
|
+
#
|
121
|
+
# Mailbox objects that do not represent IMAP mailboxes (such as the root
|
122
|
+
# mailbox) are not included.
|
123
|
+
def to_list(max_level = nil)
|
124
|
+
me = is_imap_mailbox? ? [self] : []
|
125
|
+
if max_level.nil? || level < max_level
|
126
|
+
@children.values.inject(me) do |ary, child|
|
127
|
+
ary + child.to_list(max_level)
|
128
|
+
end.sort_by { |e| e.full_name }
|
129
|
+
else
|
130
|
+
me
|
64
131
|
end
|
65
132
|
end
|
66
133
|
|
67
|
-
#
|
134
|
+
# Consolidates a list of mailboxes: If a mailbox is a sub-mailbox of another
|
135
|
+
# one, the mailbox is removed from the list.
|
68
136
|
#
|
69
|
-
#
|
70
|
-
def
|
71
|
-
|
72
|
-
|
137
|
+
# @param [Array] list of mailboxes
|
138
|
+
def self.consolidate(list)
|
139
|
+
list.reject do |mailbox|
|
140
|
+
list.any? { |parent| parent.contains? mailbox }
|
141
|
+
end.uniq
|
73
142
|
end
|
74
143
|
|
75
144
|
protected
|
@@ -82,15 +151,11 @@ module Imapcli
|
|
82
151
|
@name = name
|
83
152
|
end
|
84
153
|
|
85
|
-
def recursive_add(level, imap_mailbox_list, relative_name = nil
|
154
|
+
def recursive_add(level, imap_mailbox_list, relative_name = nil)
|
86
155
|
delimiter = options[:delimiter] || imap_mailbox_list.delim
|
87
156
|
if relative_name
|
88
157
|
sub_mailbox_name, subs_subs = relative_name.split(delimiter, 2)
|
89
|
-
|
90
|
-
key = sub_mailbox_name.upcase
|
91
|
-
else
|
92
|
-
key = sub_mailbox_name
|
93
|
-
end
|
158
|
+
key = normalize_key(sub_mailbox_name, level)
|
94
159
|
# Create a new mailbox if there does not exist one by the name
|
95
160
|
unless sub_mailbox = @children[key]
|
96
161
|
sub_mailbox = Mailbox.new
|
@@ -98,11 +163,21 @@ module Imapcli
|
|
98
163
|
sub_mailbox.name = sub_mailbox_name
|
99
164
|
@children[key] = sub_mailbox
|
100
165
|
end
|
101
|
-
sub_mailbox.recursive_add(level + 1, imap_mailbox_list, subs_subs
|
166
|
+
sub_mailbox.recursive_add(level + 1, imap_mailbox_list, subs_subs)
|
102
167
|
else # no more sub mailboxes: we've reached the last of the children
|
103
168
|
@imap_mailbox_list = imap_mailbox_list
|
104
169
|
self
|
105
170
|
end
|
106
171
|
end
|
172
|
+
|
173
|
+
# Normalizes a mailbox name for use as the key in the children hash.
|
174
|
+
def normalize_key(key, level)
|
175
|
+
if options[:case_insensitive] || (level == 0 && key.upcase == 'INBOX')
|
176
|
+
key.upcase
|
177
|
+
else
|
178
|
+
key
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
107
182
|
end
|
108
183
|
end
|