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,42 @@
1
+ require 'pg'
2
+
3
+ require 'common/roundcube_plugin'
4
+ require 'rm/rm_plugin'
5
+
6
+ # Handle removal of Roundcube users from its database. Roundcube has
7
+ # no concept of domains.
8
+ #
9
+ class RoundcubeRm
10
+
11
+ include RoundcubePlugin
12
+ include RmPlugin
13
+
14
+ # Remove *user* from the Roundcube database. This should remove him
15
+ # from _every_ table in which he is referenced. Fortunately the
16
+ # Roundcube developers were nice enough to include DBMS-specific
17
+ # install and upgrade scripts, so Postgres can take advantage of ON
18
+ # DELETE triggers.
19
+ #
20
+ # @param user [User] the user to remove.
21
+ #
22
+ def remove_user(user)
23
+ raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
24
+
25
+ # Get the primary key for this user in the "users" table.
26
+ user_id = self.get_user_id(user)
27
+
28
+ # Thanks to the ON DELETE triggers, this will remove all child
29
+ # records associated with user_id too.
30
+ sql_query = 'DELETE FROM users WHERE user_id = $1::int;'
31
+
32
+ connection = PG::Connection.connect(@db_hash)
33
+
34
+ begin
35
+ connection.query(sql_query, [user_id])
36
+ ensure
37
+ # Make sure the connection gets closed even if the query explodes.
38
+ connection.close()
39
+ end
40
+ end
41
+
42
+ end
@@ -0,0 +1,39 @@
1
+ require 'common/runner'
2
+
3
+ # Dummy implementation of a {RmRunner}. Its <tt>run()</tt> method will
4
+ # tell you what would have been removed, but will not actually perform
5
+ # the operation.
6
+ #
7
+ class RmDummyRunner
8
+ include Runner
9
+
10
+
11
+ # Pretend to remove *targets*. Some "what if"
12
+ # information will be output to stdout.
13
+ #
14
+ # This dummy runner is not particularly useful on its own. About the
15
+ # only thing it does is let you know that the users/domains in
16
+ # *targets* do in fact exist (through their descriptions). It's used
17
+ # to good effect by {PruneDummyRunner}, though.
18
+ #
19
+ # @param cfg [Configuration] the configuration options to pass to
20
+ # the *plugin* we're runnning.
21
+ #
22
+ # @param plugin [RmPlugin] plugin that will perform the move.
23
+ #
24
+ # @param targets [Array<User,Domain>] the users and domains to be
25
+ # removed.
26
+ #
27
+ def run(cfg, plugin, *targets)
28
+ targets.each do |target|
29
+ target_description = plugin.describe(target)
30
+ msg = "Would remove #{target.class.to_s().downcase()} "
31
+ msg += add_description(target, target_description)
32
+ msg += '.'
33
+
34
+ report(plugin, msg)
35
+ end
36
+
37
+ end
38
+
39
+ end
@@ -0,0 +1,77 @@
1
+ require 'common/plugin.rb'
2
+
3
+ #
4
+ # Plugins for the removal of users and domains.
5
+ #
6
+ module RmPlugin
7
+
8
+ # Absorb the subclass run() magic from the Plugin::Run module.
9
+ extend Plugin::Run
10
+
11
+ # The runner class associated with removal plugins.
12
+ #
13
+ # @return [Class] the {RmRunner} class.
14
+ #
15
+ def self.runner()
16
+ return RmRunner
17
+ end
18
+
19
+
20
+ # The "dummy" runner class associated with removal plugins.
21
+ #
22
+ # @return [Class] the {RmDummyRunner} class.
23
+ #
24
+ def self.dummy_runner()
25
+ return RmDummyRunner
26
+ end
27
+
28
+
29
+ # Remove the *target* domain or user. This is a generic version of
30
+ # the {#remove_user} and {#remove_domain} operations that will
31
+ # dispatch based on the type of *target*.
32
+ #
33
+ # @param target [User,Domain] the user or domain to remove.
34
+ #
35
+ def remove(target)
36
+ # A generic version of remove_user/remove_domain that
37
+ # dispatches base on the class of the target.
38
+ if target.is_a?(User)
39
+ return remove_user(target)
40
+ elsif target.is_a?(Domain)
41
+ return remove_domain(target)
42
+ else
43
+ raise NotImplementedError
44
+ end
45
+ end
46
+
47
+
48
+ # Remove the given *domain*. Some plugins don't have a concept of
49
+ # domains, so the default implementation here removes all users that
50
+ # look like they belong to *domain*. Subclasses can be smarter.
51
+ #
52
+ # @param domain [Domain] the domain to remove.
53
+ #
54
+ def remove_domain(domain)
55
+ users = list_domains_users([domain])
56
+
57
+ # It's possible for a domain to exist with no users, but this
58
+ # default implementation is based on the assumption that it should
59
+ # work for plugins having no "domain" concept.
60
+ raise NonexistentDomainError.new(domain.to_s()) if users.empty?
61
+
62
+ users.each do |u|
63
+ remove_user(u)
64
+ end
65
+ end
66
+
67
+
68
+ # The interface for the "remove a user" operation. Subclasses
69
+ # need to implement this method so that it removes *user*.
70
+ #
71
+ # @param user [User] the user to remove.
72
+ #
73
+ def remove_user(user)
74
+ raise NotImplementedError
75
+ end
76
+
77
+ end
@@ -0,0 +1,51 @@
1
+ require 'common/errors'
2
+ require 'common/runner'
3
+
4
+ # Perform the removal of users/domains using {RmPlugin}s.
5
+ #
6
+ class RmRunner
7
+ include Runner
8
+
9
+ # Run *plugin* to remove the users/domains in *targets*. The method
10
+ # signature includes the unused *cfg* for consistency with the
11
+ # runners that do need a {Configuration}.
12
+ #
13
+ # @param cfg [Configuration] unused.
14
+ #
15
+ # @param plugin [Class] plugin class that will perform the removal.
16
+ #
17
+ # @param targets [Array<User,Domain>] the users and domains to be
18
+ # removed.
19
+ #
20
+ def run(cfg, plugin, *targets)
21
+ targets.each do |target|
22
+ remove_target(plugin, target)
23
+ end
24
+ end
25
+
26
+
27
+ protected;
28
+
29
+ # Remove *target* using *plugin*. This operation is separate from
30
+ # the <tt>run()</tt> method so that it can be accessed by the prune
31
+ # runner.
32
+ #
33
+ # @param plugin [RmPlugin] the plugin that will remove the *target*.
34
+ #
35
+ # @param target [User,Domain] the user or domain to remove.
36
+ #
37
+ def remove_target(plugin, target)
38
+ target_description = plugin.describe(target)
39
+
40
+ begin
41
+ plugin.remove(target)
42
+ msg = "Removed #{target.class.to_s().downcase()} "
43
+ msg += add_description(target, target_description)
44
+ msg += '.'
45
+
46
+ report(plugin, msg)
47
+ rescue NonexistentDomainError, NonexistentUserError => e
48
+ report(plugin, "#{target.class.to_s()} #{e.to_s} not found.")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,39 @@
1
+ Gem::Specification.new do |s|
2
+
3
+ s.name = 'mailshears'
4
+ s.version = '0.0.1'
5
+ s.platform = Gem::Platform::RUBY
6
+ s.authors = ['Michael Orlitzky']
7
+ s.email = ['michael@orlitzky.com']
8
+ s.homepage = 'http://michael.orlitzky.com/code/mailshears.php'
9
+ s.summary = 'Prune unused mail directories.'
10
+ s.description = <<-EOF
11
+ Managing a mail system with virtual users is annoying. The
12
+ authoritative database of users is stored in one table, but every
13
+ other piece of software keeps its own database of users.
14
+
15
+ If you're using PostfixAdmin to manage your users, what happens when
16
+ you delete a user? Chances are, nothing happens: mail directories are
17
+ left behind, webmail preferences are saved, address books become
18
+ orphaned. That's what mailshears was designed to fix. It cleans up
19
+ after you remove a user or domain.
20
+
21
+ Another stupidly difficult task is renaming a single email
22
+ account. It's easy to move the user in one database, but then all of
23
+ the remaining filesystem directories and databases need to be updated
24
+ as well. Since these two tasks are related, mailshears does them both.
25
+ EOF
26
+
27
+ s.license = 'AGPL-3'
28
+ s.required_rubygems_version = '>= 1.3.6'
29
+
30
+ # If you have runtime dependencies, add them here
31
+ s.add_runtime_dependency 'pg', '~> 0.11'
32
+
33
+ # The list of files to be contained in the gem
34
+ s.files = `git ls-files`.split("\n")
35
+ s.executables = ['mailshears']
36
+
37
+ s.require_path = 'lib'
38
+
39
+ end
@@ -0,0 +1,36 @@
1
+ i_mean_business: true
2
+ plugins: [agendav, davical, dovecot, postfixadmin, roundcube]
3
+
4
+ agendav_dbhost: localhost
5
+ agendav_dbport: 5432
6
+ agendav_dbopts:
7
+ agendav_dbtty:
8
+ agendav_dbuser: postgres
9
+ agendav_dbpass:
10
+ agendav_dbname: agendav_test
11
+
12
+ davical_dbhost: localhost
13
+ davical_dbport: 5432
14
+ davical_dbopts:
15
+ davical_dbtty:
16
+ davical_dbuser: postgres
17
+ davical_dbpass:
18
+ davical_dbname: davical_test
19
+
20
+ dovecot_mail_root: /tmp/mailshears-test
21
+
22
+ postfixadmin_dbhost: localhost
23
+ postfixadmin_dbport: 5432
24
+ postfixadmin_dbopts:
25
+ postfixadmin_dbtty:
26
+ postfixadmin_dbuser: postgres
27
+ postfixadmin_dbpass:
28
+ postfixadmin_dbname: postfixadmin_test
29
+
30
+ roundcube_dbhost: localhost
31
+ roundcube_dbport: 5432
32
+ roundcube_dbopts:
33
+ roundcube_dbtty:
34
+ roundcube_dbuser: postgres
35
+ roundcube_dbpass:
36
+ roundcube_dbname: roundcube_test
@@ -0,0 +1,250 @@
1
+ require 'common/configuration'
2
+ require 'fileutils'
3
+ require 'minitest/autorun'
4
+ require 'pg'
5
+
6
+ class MailshearsTest < MiniTest::Test
7
+ # This is that class that most (if not all) of our test cases will
8
+ # inherit. It provides the automatic setup and teardown of the
9
+ # filesystem and database that the test cases will exercise.
10
+
11
+ def configuration()
12
+ # Return the test config object.
13
+ return Configuration.new('test/mailshears.test.conf.yml')
14
+ end
15
+
16
+ def maildir_exists(dir)
17
+ # Check if the given mail directory of the form "example.com/user"
18
+ # exists.
19
+ cfg = configuration()
20
+ return File.directory?("#{cfg.dovecot_mail_root()}/#{dir}")
21
+ end
22
+
23
+ def connect_superuser()
24
+ # Connect to the database (specified in the test configuration) as
25
+ # the superuser. Your local configuration is expected to be such
26
+ # that this "just works."
27
+ db_host = 'localhost'
28
+ db_port = 5432
29
+ db_opts = nil
30
+ db_tty = nil
31
+ db_name = 'postgres'
32
+ db_user = 'postgres'
33
+ db_pass = nil
34
+
35
+ connection = PG::Connection.new(db_host, db_port, db_opts, db_tty,
36
+ db_name, db_user, db_pass)
37
+
38
+ return connection
39
+ end
40
+
41
+ def setup
42
+ # Connect to the database specified in the test configutation as
43
+ # the super user. Then, run all of the SQL scripts contained in
44
+ # test/sql. These scripts create the tables for the plugins listed
45
+ # in the test configuration, and then create some sample data.
46
+ #
47
+ # Here is the full list of what gets created. Every test case
48
+ # inheriting from this class can expect this schema and data to
49
+ # exist. The filesystem entries are located beneath mail_root from
50
+ # the configuration file.
51
+ #
52
+ # == Filesystem ==
53
+ #
54
+ # * example.com/alice
55
+ # * example.com/booger
56
+ # * example.com/jeremy
57
+ # * example.net/adam
58
+ #
59
+ # == Databases ==
60
+ #
61
+ # 1. agendav_test
62
+ #
63
+ # +------------------------------+
64
+ # | prefs |
65
+ # +--------------------+---------+
66
+ # | username | options |
67
+ # +--------------------+---------+
68
+ # | adam@example.net | herp |
69
+ # +--------------------+---------+
70
+ # | booger@example.com | herp |
71
+ # +------------------ +---------+
72
+ #
73
+ #
74
+ # +---------------------------------------------------------+
75
+ # | shared |
76
+ # +-----+--------------------+----------+-------------------+
77
+ # | sid | user_from | calendar | user_which |
78
+ # +-----+--------------------+----------+-------------------+
79
+ # | 1 | adam@example.net | derp | beth@example.net |
80
+ # +-----+--------------------+----------+-------------------+
81
+ # | 2 | booger@example.com | derp | carol@example.net |
82
+ # +-----+--------------------+----------+-------------------+
83
+ #
84
+ #
85
+ # 2. davical_test
86
+ #
87
+ # +--------------------------------------------------------+
88
+ # | usr |
89
+ # +---------+--------+----------------+--------------------+
90
+ # | user_no | active | joined | username |
91
+ # +---------+--------+----------------+--------------------+
92
+ # | 17 | t | 2014-01-04 ... | alice@example.com |
93
+ # +---------+--------+----------------+--------------------+
94
+ # | 18 | t | 2014-01-04 ... | booger@example.com |
95
+ # +---------+--------+----------------+--------------------+
96
+ #
97
+ #
98
+ # +-----------------------------------------+
99
+ # | usr_setting |
100
+ # +---------+--------------+----------------+
101
+ # | user_no | setting_name | setting_value |
102
+ # +---------+--------------+----------------+
103
+ # | 17 | dumb setting | its dumb value |
104
+ # +---------+--------------+----------------+
105
+ # | 18 | dumb setting | its dumb value |
106
+ # +---------+--------------+----------------+
107
+ #
108
+ #
109
+ # 3. postfixadmin_test
110
+ #
111
+ # +-------------+
112
+ # | domain |
113
+ # +-------------+
114
+ # | domain |
115
+ # +-------------+
116
+ # | ALL |
117
+ # +-------------+
118
+ # | example.com |
119
+ # +-------------+
120
+ # | example.net |
121
+ # +-------------+
122
+ #
123
+ #
124
+ # +----------------------------------------------+
125
+ # | mailbox |
126
+ # +-------------------+-------------+------------+
127
+ # | username | domain | local_part |
128
+ # +-------------------+-------------+------------+
129
+ # | alice@example.com | example.com | alice |
130
+ # +-------------------+-------------+------------+
131
+ # | bob@example.com | example.com | bob |
132
+ # +-------------------+-------------+------------+
133
+ # | adam@example.net | example.net | adam |
134
+ # +-------------------+-------------+------------+
135
+ # | beth@example.net | example.net | beth |
136
+ # +-------------------+-------------+------------+
137
+ # | carol@example.net | example.net | carol |
138
+ # +-------------------+-------------+------------+
139
+ #
140
+ #
141
+ # +-------------------------------------------------------+
142
+ # | alias |
143
+ # +-------------------+--------------------+--------------+
144
+ # | address | goto | domain |
145
+ # +-------------------+--------------------+--------------+
146
+ # | alice@example.com | alice@example.com, | example.com |
147
+ # | | adam@example.net, | |
148
+ # | | bob@example.com, | |
149
+ # | | carol@example.net | |
150
+ # +-------------------+--------------------+--------------+
151
+ # | bob@example.com | bob@example.com | example.com |
152
+ # +-------------------+--------------------+--------------+
153
+ # | adam@example.net | adam@example.net | example.net |
154
+ # +-------------------+--------------------+--------------+
155
+ # | beth@example.net | beth@example.net | example.net |
156
+ # +-------------------+--------------------+--------------+
157
+ # | carol@example.net | carol@example.net | example.net |
158
+ # +-------------------+--------------------+--------------+
159
+ #
160
+ #
161
+ # +---------------------------------+
162
+ # | domain_admins |
163
+ # +-------------------+-------------+
164
+ # | username | domain |
165
+ # +-------------------+-------------+
166
+ # | admin@example.com | example.com |
167
+ # +-------------------+-------------+
168
+ # | admin@example.com | example.net |
169
+ # +-------------------+-------------+
170
+ #
171
+ # 4. roundcube_test
172
+ #
173
+ #
174
+ # +---------+--------------------+
175
+ # | user_id | username |
176
+ # +---------+--------------------+
177
+ # | 1 | alice@example.com |
178
+ # +---------+--------------------+
179
+ # | 2 | booger@example.com |
180
+ # +---------+--------------------+
181
+ # | 3 | adam@example.net |
182
+ # +---------+--------------------+
183
+
184
+ # First make sure we get rid of everything so we don't get random
185
+ # failures from databases and directories that already exist.
186
+ teardown()
187
+
188
+ cfg = configuration()
189
+
190
+ # First create the "mail directories".
191
+ FileUtils.mkdir_p("#{cfg.dovecot_mail_root()}/example.com/alice")
192
+ FileUtils.mkdir_p("#{cfg.dovecot_mail_root()}/example.com/booger")
193
+ FileUtils.mkdir_p("#{cfg.dovecot_mail_root()}/example.com/jeremy")
194
+ FileUtils.mkdir_p("#{cfg.dovecot_mail_root()}/example.net/adam")
195
+
196
+ # Now the databases and their content.
197
+ connection = connect_superuser()
198
+
199
+ cfg.plugins.each do |plugin|
200
+ plugin_dbname = cfg.send("#{plugin}_dbname")
201
+ next if plugin_dbname.nil? # Skip the dovecot plugin
202
+ query = "CREATE DATABASE #{plugin_dbname};"
203
+ connection.query(query)
204
+
205
+ plugin_dbhost = cfg.send("#{plugin}_dbhost")
206
+ plugin_dbport = cfg.send("#{plugin}_dbport")
207
+ plugin_dbopts = cfg.send("#{plugin}_dbopts")
208
+ plugin_dbtty = cfg.send("#{plugin}_dbtty")
209
+ plugin_dbuser = cfg.send("#{plugin}_dbuser")
210
+ plugin_dbpass = cfg.send("#{plugin}_dbpass")
211
+
212
+ plugin_conn = PG::Connection.new(plugin_dbhost, plugin_dbport,
213
+ plugin_dbopts, plugin_dbtty,
214
+ plugin_dbname, plugin_dbuser,
215
+ plugin_dbpass)
216
+
217
+ sql = File.open("test/sql/#{plugin}.sql").read()
218
+ plugin_conn.query(sql)
219
+ sql = File.open("test/sql/#{plugin}-fixtures.sql").read()
220
+ plugin_conn.query(sql)
221
+ plugin_conn.close()
222
+ end
223
+
224
+ connection.close()
225
+ end
226
+
227
+
228
+ def teardown
229
+ # Drop all of the databases that were created in setup().
230
+ cfg = configuration()
231
+ connection = connect_superuser()
232
+
233
+ # Don't emit notices about missing tables. Why this happens when I
234
+ # explicitly say IF EXISTS is beyond me.
235
+ connection.set_notice_processor{}
236
+
237
+ cfg.plugins.each do |plugin|
238
+ plugin_dbname = cfg.send("#{plugin}_dbname")
239
+ next if plugin_dbname.nil? # Skip the dovecot plugin
240
+ query = "DROP DATABASE IF EXISTS #{plugin_dbname};"
241
+ connection.query(query)
242
+ end
243
+
244
+ connection.close()
245
+
246
+ # Get rid of the maildirs.
247
+ FileUtils.rm_rf(cfg.dovecot_mail_root())
248
+ end
249
+
250
+ end