mailshears 0.0.1

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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +32 -0
  3. data/bin/install-fixtures.sh +27 -0
  4. data/bin/mailshears +124 -0
  5. data/doc/LICENSE +661 -0
  6. data/doc/TODO +16 -0
  7. data/doc/mailshears.example.conf.yml +37 -0
  8. data/doc/man1/mailshears.1 +184 -0
  9. data/lib/common/agendav_plugin.rb +54 -0
  10. data/lib/common/configuration.rb +116 -0
  11. data/lib/common/davical_plugin.rb +104 -0
  12. data/lib/common/domain.rb +64 -0
  13. data/lib/common/dovecot_plugin.rb +130 -0
  14. data/lib/common/errors.rb +15 -0
  15. data/lib/common/exit_codes.rb +9 -0
  16. data/lib/common/filesystem.rb +43 -0
  17. data/lib/common/plugin.rb +238 -0
  18. data/lib/common/postfixadmin_plugin.rb +180 -0
  19. data/lib/common/roundcube_plugin.rb +96 -0
  20. data/lib/common/runner.rb +73 -0
  21. data/lib/common/user.rb +120 -0
  22. data/lib/common/user_interface.rb +53 -0
  23. data/lib/mailshears.rb +7 -0
  24. data/lib/mv/mv_dummy_runner.rb +45 -0
  25. data/lib/mv/mv_plugin.rb +40 -0
  26. data/lib/mv/mv_runner.rb +56 -0
  27. data/lib/mv/plugins/agendav.rb +46 -0
  28. data/lib/mv/plugins/davical.rb +43 -0
  29. data/lib/mv/plugins/dovecot.rb +64 -0
  30. data/lib/mv/plugins/postfixadmin.rb +70 -0
  31. data/lib/mv/plugins/roundcube.rb +44 -0
  32. data/lib/prune/plugins/agendav.rb +13 -0
  33. data/lib/prune/plugins/davical.rb +13 -0
  34. data/lib/prune/plugins/dovecot.rb +11 -0
  35. data/lib/prune/plugins/postfixadmin.rb +13 -0
  36. data/lib/prune/plugins/roundcube.rb +14 -0
  37. data/lib/prune/prune_dummy_runner.rb +44 -0
  38. data/lib/prune/prune_plugin.rb +66 -0
  39. data/lib/prune/prune_runner.rb +34 -0
  40. data/lib/rm/plugins/agendav.rb +38 -0
  41. data/lib/rm/plugins/davical.rb +38 -0
  42. data/lib/rm/plugins/dovecot.rb +48 -0
  43. data/lib/rm/plugins/postfixadmin.rb +114 -0
  44. data/lib/rm/plugins/roundcube.rb +42 -0
  45. data/lib/rm/rm_dummy_runner.rb +39 -0
  46. data/lib/rm/rm_plugin.rb +77 -0
  47. data/lib/rm/rm_runner.rb +51 -0
  48. data/mailshears.gemspec +39 -0
  49. data/test/mailshears.test.conf.yml +36 -0
  50. data/test/mailshears_test.rb +250 -0
  51. data/test/sql/agendav-fixtures.sql +9 -0
  52. data/test/sql/agendav.sql +157 -0
  53. data/test/sql/davical-fixtures.sql +23 -0
  54. data/test/sql/davical.sql +4371 -0
  55. data/test/sql/postfixadmin-fixtures.sql +48 -0
  56. data/test/sql/postfixadmin.sql +737 -0
  57. data/test/sql/roundcube-fixtures.sql +4 -0
  58. data/test/sql/roundcube.sql +608 -0
  59. data/test/test_mv.rb +174 -0
  60. data/test/test_prune.rb +121 -0
  61. data/test/test_rm.rb +154 -0
  62. metadata +133 -0
@@ -0,0 +1,64 @@
1
+ require 'common/errors'
2
+
3
+ # A class representing a syntactically valid domain. Essentially, the
4
+ # part after the "@" in an email address. Once constructed, you can be
5
+ # sure that it's valid.
6
+ #
7
+ class Domain
8
+
9
+ # @domain contains the String representation of this domain.
10
+ @domain = nil
11
+
12
+
13
+ # Initialize this Domain object. If the domain is invalid, then an
14
+ # {InvalidDomainError} will be raised containing the reason
15
+ # why the domain is invalid.
16
+ #
17
+ # @param domain [String] the string representation of this domain.
18
+ #
19
+ def initialize(domain)
20
+ if domain.empty? then
21
+ msg = "domain cannot be empty"
22
+ raise InvalidDomainError.new(msg)
23
+ end
24
+
25
+ @domain = domain
26
+ end
27
+
28
+
29
+ # Convert this domain to a String.
30
+ #
31
+ # @return [String] the string representation of this Domain.
32
+ #
33
+ def to_s()
34
+ return @domain
35
+ end
36
+
37
+
38
+ # Check if this Domain is equal to some other Domain. The comparison
39
+ # is based on their String representations.
40
+ #
41
+ # @param other [Domain] the Domain object to compare me to.
42
+ #
43
+ # @return [Boolean] If *self* and *other* have equal String
44
+ # representations, then true is returned. Otherwise, false is
45
+ # returned.
46
+ #
47
+ def ==(other)
48
+ return self.to_s() == other.to_s()
49
+ end
50
+
51
+
52
+ # Compare two Domain objects for sorting purposes. The comparison
53
+ # is based on their String representations.
54
+ #
55
+ # @param other [Domain] the Domain object to compare me to.
56
+ #
57
+ # @return [0,1,-1] a trinary indicator of how *self* relates to *other*,
58
+ # obtained by performing the same comparison on the String
59
+ # representations of *self* and *other*.
60
+ #
61
+ def <=>(other)
62
+ return self.to_s() <=> other.to_s()
63
+ end
64
+ end
@@ -0,0 +1,130 @@
1
+ require 'common/domain'
2
+ require 'common/filesystem'
3
+ require 'common/plugin'
4
+ require 'common/user'
5
+
6
+ # Code that all Dovecot plugins ({DovecotPrune}, {DovecotRm}, and
7
+ # {DovecotMv}) will share.
8
+ #
9
+ module DovecotPlugin
10
+
11
+ # We implement the Plugin "interface."
12
+ include Plugin
13
+
14
+
15
+ # Initialize this Dovecot {Plugin} with values in *cfg*.
16
+ #
17
+ # @param cfg [Configuration] the configuration for this plugin.
18
+ #
19
+ def initialize(cfg)
20
+ @domain_root = cfg.dovecot_mail_root
21
+ end
22
+
23
+ # Describe the given Dovecot domain by its filesystem path. The
24
+ # domain need not exist to obtain its path.
25
+ #
26
+ # @param domain [Domain] the {Domain} object whose description we want.
27
+ #
28
+ # @return [String] a String giving the path under which this domain's
29
+ # mailboxes would reside on the filesystem.
30
+ #
31
+ def describe_domain(domain)
32
+ return get_domain_path(domain)
33
+ end
34
+
35
+
36
+ # Describe the given Dovecot user by its filesystem mailbox
37
+ # path. The user need not exist to obtain its mailbox path.
38
+ #
39
+ # @param user [User] the {User} object whose description we want.
40
+ #
41
+ # @return [String] a String giving the path where this user's
42
+ # mailbox would reside on the filesystem.
43
+ #
44
+ def describe_user(user)
45
+ return get_user_path(user)
46
+ end
47
+
48
+
49
+ protected;
50
+
51
+ # Return the filesystem path for the given {Domain} object.
52
+ #
53
+ # @param domain [Domain] the {Domain} whose path we want.
54
+ #
55
+ # @return [String] the filesystem path where this domain's mail
56
+ # would be located.
57
+ #
58
+ def get_domain_path(domain)
59
+ return File.join(@domain_root, domain.to_s())
60
+ end
61
+
62
+
63
+ # Return the filesystem path of this {User}'s mailbox.
64
+ #
65
+ # @param user [User] the {User} whose mailbox path we want.
66
+ #
67
+ # @return [String] the filesystem path where this user's mail
68
+ # would be located.
69
+ #
70
+ def get_user_path(user)
71
+ domain_path = get_domain_path(user.domain())
72
+ return File.join(domain_path, user.localpart())
73
+ end
74
+
75
+
76
+ # Produce a list of domains that exist in the Dovecot mailstore.
77
+ #
78
+ # @return [Array<Domain>] an array of {Domain} objects that have
79
+ # corresponding directories within the Dovecot mailstore.
80
+ #
81
+ def list_domains()
82
+ return Filesystem.get_subdirs(@domain_root).map{ |d| Domain.new(d) }
83
+ end
84
+
85
+
86
+ # Produce a list of users belonging to the given *domains* in the
87
+ # Dovecot mailstore.
88
+ #
89
+ # @param domains [Array<Domain>] an array of {Domain} objects whose
90
+ # users we'd like to find.
91
+ #
92
+ # @return [Array<User>] an array of {User} objects that have
93
+ # corresponding directories within the Dovecot mailstore belonging
94
+ # to the specified *domains*.
95
+ #
96
+ def list_domains_users(domains)
97
+ users = []
98
+
99
+ domains.each do |domain|
100
+ begin
101
+ # Throws a NonexistentDomainError if the domain's path
102
+ # doesn't exist on the filesystem. In this case, we want
103
+ # to report zero users.
104
+ domain_path = get_domain_path(domain)
105
+ usernames = Filesystem.get_subdirs(domain_path)
106
+
107
+ usernames.each do |username|
108
+ users << User.new("#{username}@#{domain}")
109
+ end
110
+ rescue NonexistentDomainError
111
+ # Party hard.
112
+ end
113
+ end
114
+
115
+ return users
116
+ end
117
+
118
+
119
+ # Produce a list of all users in the Dovecot mailstore.
120
+ #
121
+ # @return [Array<User>] a list of users who have mailbox directories
122
+ # within the Dovecot mailstore.
123
+ #
124
+ def list_users()
125
+ domains = list_domains()
126
+ users = list_domains_users(domains)
127
+ return users
128
+ end
129
+
130
+ end
@@ -0,0 +1,15 @@
1
+ # An error indicating that a username is syntactically invalid.
2
+ class InvalidUserError < StandardError; end
3
+
4
+ # An error indicating that a domain is syntactically invalid.
5
+ class InvalidDomainError < StandardError; end
6
+
7
+ # An error indicating that a user does not exist.
8
+ class NonexistentUserError < StandardError; end
9
+
10
+ # An error indicating that a domain does not exist.
11
+ class NonexistentDomainError < StandardError; end
12
+
13
+ # An error indicating that some user already exists. For example, if
14
+ # one tries to rename a user and the destination user already exists.
15
+ class UserAlreadyExistsError < StandardError; end
@@ -0,0 +1,9 @@
1
+ # Command-line exit codes. In other words, what you'll see if you
2
+ # <tt>echo $?</tt> after running the executable on the command-line.
3
+ module ExitCodes
4
+ # Everything went better than expected.
5
+ SUCCESS = 0
6
+
7
+ # The command-line arguments were not what we expected.
8
+ BAD_COMMAND_LINE = 1
9
+ end
@@ -0,0 +1,43 @@
1
+ # Convenience methods for working with the filesystem. This class
2
+ # only provides static methods, to be used analogously to the File
3
+ # class (for example, <tt>File.directory?</tt>).
4
+ #
5
+ class Filesystem
6
+
7
+ # Return whether or not the given path begins with a dot (ASCII
8
+ # period).
9
+ #
10
+ # @param path [String] the path whose first character you want to check.
11
+ #
12
+ # @return [Boolean] whether or not *path* begins with an ASCII period.
13
+ #
14
+ def self.begins_with_dot?(path)
15
+ return (path[0..0] == '.')
16
+ end
17
+
18
+
19
+ # Get a list of all real subdirectories of the given directory.
20
+ #
21
+ # @param dir [String] the directory whose subdirectories you want.
22
+ #
23
+ # @return [Array<String>] a list of subdirectories of *dir*, not
24
+ # including the pseudo-directories "." and ".." (the current/parent
25
+ # directories).
26
+ #
27
+ def self.get_subdirs(dir)
28
+ subdirs = []
29
+ return subdirs if not File.directory?(dir)
30
+
31
+ Dir.open(dir) do |d|
32
+ d.each do |entry|
33
+ relative_path = File.join(dir, entry)
34
+ if File.directory?(relative_path) and not self.begins_with_dot?(entry)
35
+ subdirs << entry
36
+ end
37
+ end
38
+ end
39
+
40
+ return subdirs
41
+ end
42
+
43
+ end
@@ -0,0 +1,238 @@
1
+ require 'common/domain'
2
+ require 'common/user'
3
+
4
+ # Methods that all plugins must provide. Certain operations -- for
5
+ # example, user listing -- must be supported by all plugins. These
6
+ # operations are defined here, often with naive default
7
+ # implementations, but it is up to each individual plugin to ensure
8
+ # that they are in fact implemented (well).
9
+ #
10
+ module Plugin
11
+
12
+ # These are class methods for runnable plugins, meant to be
13
+ # _extended_. Those runnable plugins get a magic *run* method but
14
+ # need to define their own *runner* and *dummy_runner* to make it
15
+ # work.
16
+ #
17
+ module Run
18
+
19
+ # A callback function, called whenever another class or module
20
+ # includes this one. This is used to build a list of all things
21
+ # that inherited this class. Having such a list lets us run a
22
+ # collection of plugins without knowing in advance what they are.
23
+ #
24
+ # @param c [Class,Module] the name of the class or module that
25
+ # included us.
26
+ #
27
+ def included(c)
28
+ @includers ||= []
29
+ @includers << c
30
+ end
31
+
32
+
33
+ # Obtain the list of classes and modules that have included this one.
34
+ #
35
+ # @return [Array<Class,Module>] the list of classes and modules
36
+ # that have included this one.
37
+ #
38
+ def includers()
39
+ @includers ||= []
40
+ return @includers
41
+ end
42
+
43
+
44
+ # The runner class associated with this plugin. This method must
45
+ # be supplied by the child class, since they will all have
46
+ # different runners.
47
+ #
48
+ # @return [Class] the runner class associated with this plugin.
49
+ #
50
+ def runner()
51
+ raise NotImplementedError
52
+ end
53
+
54
+
55
+ # The "dummy" runner class associated with this plugin. This method
56
+ # must be supplied by the child class, since they will all have
57
+ # different dummy runners.
58
+ #
59
+ # @return [Class] the dummy runner class associated with this
60
+ # plugin.
61
+ #
62
+ def dummy_runner()
63
+ raise NotImplementedError
64
+ end
65
+
66
+
67
+ # Run all of the plugins that have included this module.
68
+ #
69
+ # @param cfg [Configuration] the configuration options to pass to
70
+ # each of the plugins.
71
+ #
72
+ # @param args [Array<Object>] a variable number of additional
73
+ # arguments to be passed to the plugins we're running.
74
+ #
75
+ def run(cfg, *args)
76
+ includers().each do |includer|
77
+ plugin = includer.new(cfg)
78
+
79
+ if cfg.i_mean_business then
80
+ runner = runner().new()
81
+ else
82
+ runner = dummy_runner().new()
83
+ end
84
+
85
+ # The splat passes the correct (we hope) number of arguments to the
86
+ # appropriate runner. The Rm(Dummy)Runner have splats on their
87
+ # *target arguments as well, to turn ARGV back into an array.
88
+ runner.run(cfg, plugin, *args)
89
+ end
90
+ end
91
+ end
92
+
93
+
94
+ # A generic version of {#describe_user}/{#describe_domain} that
95
+ # dispatches base on the class of the target.
96
+ #
97
+ # @param target [User,Domain] either a user or a domain to describe.
98
+ #
99
+ # @return [String] a string describing the *target*. The contents of
100
+ # the string depend on the plugin.
101
+ #
102
+ def describe(target)
103
+ if target.is_a?(User)
104
+ if user_exists(target) then
105
+ return describe_user(target)
106
+ else
107
+ return 'User not found'
108
+ end
109
+ elsif target.is_a?(Domain)
110
+ if domain_exists(target) then
111
+ return describe_domain(target)
112
+ else
113
+ return 'Domain not found'
114
+ end
115
+ else
116
+ raise NotImplementedError
117
+ end
118
+ end
119
+
120
+
121
+ # Provide a description of the given *domain*. This is output along
122
+ # with the domain name and can be anything of use to the system
123
+ # administrator. The default doesn't do anything useful and should
124
+ # be overridden if possible.
125
+ #
126
+ # @param domain [Domain] the domain to describe.
127
+ #
128
+ # @return [String] a string description of *domain*.
129
+ #
130
+ def describe_domain(domain)
131
+ return domain.to_s()
132
+ end
133
+
134
+
135
+ # Provide a description of the given *user*. This is output along
136
+ # with the username and can be anything of use to the system
137
+ # administrator. The default doesn't do anything useful and should
138
+ # be overridden if possible.
139
+ #
140
+ # @param user [User] the domain to describe.
141
+ #
142
+ # @return [String] a string description of *user*.
143
+ #
144
+ def describe_user(user)
145
+ return user.to_s()
146
+ end
147
+
148
+
149
+ # Return a list of all users managed by this plugin. This must be
150
+ # supplied by the individual plugins (who know how to find their
151
+ # users).
152
+ #
153
+ # @return [Array<User>] a list of the users that this plugin knows
154
+ # about.
155
+ #
156
+ def list_users()
157
+ raise NotImplementedError
158
+ end
159
+
160
+
161
+ # Return a list of all domains managed by this plugin. This must be
162
+ # supplied by the individual plugins (who know how to find their
163
+ # domains). Many plugins will not have a separate concept of
164
+ # "domain", so the default implementation constructs a list of
165
+ # domains resulting from {#list_users}.
166
+ #
167
+ # For plugins that do know about domains, smarter implementations
168
+ # are surely possible.
169
+ #
170
+ # @return [Array<Domain>] a list of the domains that this plugin knows
171
+ # about.
172
+ #
173
+ def list_domains()
174
+ users = list_users()
175
+ domains = users.map{ |u| u.domain() }
176
+ return domains.uniq()
177
+ end
178
+
179
+
180
+ # Does the given *user* exist for this plugin? We use a naive
181
+ # implementation here based on {#list_users}. Plugins should override
182
+ # this with something faster.
183
+ #
184
+ # @param user [User] the user whose existence is in question.
185
+ #
186
+ # @return [Boolean] true if *user* exists for this plugin, and
187
+ # false otherwise.
188
+ #
189
+ def user_exists(user)
190
+ users = list_users()
191
+ return users.include?(user)
192
+ end
193
+
194
+
195
+ # Does the given *domain* exist for this plugin? We use a naive
196
+ # implementation here based on {#list_domains}. Plugins that know
197
+ # about domains should override this with something fast.
198
+ #
199
+ # @param domain [Domain] the domain whose existence is in question.
200
+ #
201
+ # @return [Boolean] true if *domain* exists for this plugin, and
202
+ # false otherwise.
203
+ #
204
+ def domain_exists(domain)
205
+ domains = list_domains()
206
+ return domains.include?(domain)
207
+ end
208
+
209
+
210
+ # List all users belonging to the given domains. We say that a user
211
+ # belongs to a domain "example.com" if the domain part of the user's
212
+ # email address is "example.com".
213
+ #
214
+ # This uses a naive loop, but relies only on the existence of
215
+ # {#list_users}. Plugins that know about domains should provide a
216
+ # more efficient implementation.
217
+ #
218
+ # @param domains [Array<Domain>] the domains whose users we want.
219
+ #
220
+ # @return [Array<User>] a list of {User} objects belonging to
221
+ # *domains* for this plugin.
222
+ #
223
+ def list_domains_users(domains)
224
+ domains_users = []
225
+
226
+ users = list_users();
227
+ domains.each do |d|
228
+ matches = users.select do |user|
229
+ user.domainpart() == d.to_s()
230
+ end
231
+
232
+ domains_users += matches
233
+ end
234
+
235
+ return domains_users
236
+ end
237
+
238
+ end