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
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
|