imapcli 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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 = 'A description of your project'
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
@@ -2,3 +2,5 @@ require 'imapcli/version.rb'
2
2
  require 'imapcli/command.rb'
3
3
  require 'imapcli/client.rb'
4
4
  require 'imapcli/mailbox.rb'
5
+ require 'imapcli/stats.rb'
6
+ require 'imapcli/option_validator.rb'
@@ -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 examine(mailbox)
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
- 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
- }
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
- mailbox_tree.collect_stats(self)
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 mailbox_tree
185
- @mailbox_tree ||= Mailbox.new(mailboxes)
178
+ def mailbox_root
179
+ @mailbox_root ||= Mailbox.new(mailboxes)
186
180
  end
187
181
 
188
- # Attempts to locate a given +mailbox+ in the +mailbox_tree+.
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
- mailbox_tree.find_sub_mailbox(mailbox, separator)
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
@@ -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 |output|
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 |output|
39
- @client.collect_stats
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
- 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, '---')
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 output
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
- 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
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 format_kib(kib)
96
- kib.to_s.reverse.gsub(/...(?=.)/,'\&,').reverse + ' kiB'.freeze
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
@@ -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, options = {})
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, options)
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
- if sub_mailbox = @children[sub_mailbox_name]
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
- # +connection+ must be a Net::IMAP object
61
- def collect_stats(client)
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.examine(full_name)
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
- # Collects statistics for this mailbox and all of its children.
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
- # +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) }
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, options = {})
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
- 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
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, options)
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