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