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,13 @@
1
+ require 'pg'
2
+
3
+ require 'prune/prune_plugin'
4
+ require 'rm/plugins/agendav'
5
+
6
+ # Handle the pruning of Agendav users from its database. This class
7
+ # doesn't need to do anything; by inheriting from {AgendavRm}, we get
8
+ # its {AgendavRm#remove_user} method and that's all we need to prune.
9
+ #
10
+ class AgendavPrune < AgendavRm
11
+ # Needed for the magic includers <tt>run()</tt> method.
12
+ include PrunePlugin
13
+ end
@@ -0,0 +1,13 @@
1
+ require 'pg'
2
+
3
+ require 'prune/prune_plugin'
4
+ require 'rm/plugins/davical'
5
+
6
+ # Handle the pruning of DAViCal users from its database. This class
7
+ # doesn't need to do anything; by inheriting from {DavicalRm}, we get
8
+ # its {DavicalRm#remove_user} method and that's all we need to prune.
9
+ #
10
+ class DavicalPrune < DavicalRm
11
+ # Needed for the magic includers <tt>run()</tt> method.
12
+ include PrunePlugin
13
+ end
@@ -0,0 +1,11 @@
1
+ require 'prune/prune_plugin'
2
+ require 'rm/plugins/dovecot'
3
+
4
+ # Handle the pruning of Dovecot users from its database. This class
5
+ # doesn't need to do anything; by inheriting from {DovecotRm}, we get
6
+ # its {DovecotRm#remove_user} method and that's all we need to prune.
7
+ #
8
+ class DovecotPrune < DovecotRm
9
+ # Needed for the magic includers <tt>run()</tt> method.
10
+ include PrunePlugin
11
+ end
@@ -0,0 +1,13 @@
1
+ require 'prune/prune_plugin'
2
+ require 'rm/plugins/postfixadmin'
3
+
4
+ # This class does absolutely nothing except allow us to blindly
5
+ # instantiate classes based on the mode name and configured plugins.
6
+ #
7
+ # We don't need the ability to remove "left over" Postfixadmin users
8
+ # or domains, since "left over" is with respect to what's in
9
+ # Postfixadmin itself. The other pruning plugins check themselves
10
+ # against Postfixadmin -- it doesn't make sense to check Postfixadmin
11
+ # against itself.
12
+ #
13
+ class PostfixadminPrune < PostfixadminRm; end
@@ -0,0 +1,14 @@
1
+ require 'pg'
2
+
3
+ require 'prune/prune_plugin'
4
+ require 'rm/plugins/roundcube'
5
+
6
+
7
+ # Handle the pruning of Roundcube users from its database. This class
8
+ # doesn't need to do anything; by inheriting from {RoundcubeRm}, we get
9
+ # its {RoundcubeRm#remove_user} method and that's all we need to prune.
10
+ #
11
+ class RoundcubePrune < RoundcubeRm
12
+ # Needed for the magic includers <tt>run()</tt> method.
13
+ include PrunePlugin
14
+ end
@@ -0,0 +1,44 @@
1
+ require 'common/runner'
2
+ require 'prune/plugins/postfixadmin'
3
+ require 'rm/rm_dummy_runner'
4
+
5
+ # Dummy implementation of a {PruneRunner}. Its <tt>run()</tt> method will
6
+ # tell you what would have been pruned, but will not actually perform
7
+ # the operation.
8
+ #
9
+ class PruneDummyRunner
10
+ include Runner
11
+
12
+
13
+ # Pretend to prune unused domains and users. Some "what if"
14
+ # information will be output to stdout.
15
+ #
16
+ # The prune mode is the main application of the "dummy" runners,
17
+ # since it performs some computation outside of the plugins
18
+ # themselves. This lets the user know which users and domains would
19
+ # be removed and can help prevent mistakes or even find bugs in the
20
+ # prune code, if it looks like something will be removed that
21
+ # shouldn't be!
22
+ #
23
+ # @param cfg [Configuration] the configuration options to pass to
24
+ # the *plugin* we're runnning.
25
+ #
26
+ # @param plugin [Class] plugin class that will do the pruning.
27
+ #
28
+ def run(cfg, plugin)
29
+ # We don't want to check the PostfixAdmin database against itself.
30
+ return if plugin.class == PostfixadminPrune
31
+
32
+ pfa = PostfixadminPrune.new(cfg)
33
+
34
+ db_users = pfa.list_users()
35
+ db_domains = pfa.list_domains()
36
+
37
+ leftovers = plugin.get_leftover_users(db_users)
38
+ leftovers += plugin.get_leftover_domains(db_domains)
39
+
40
+ rm_dummy_runner = RmDummyRunner.new()
41
+ rm_dummy_runner.run(cfg, plugin, *leftovers)
42
+ end
43
+
44
+ end
@@ -0,0 +1,66 @@
1
+ require 'common/plugin.rb'
2
+
3
+ # Plugins for pruning users. By "pruning," we mean the removal of
4
+ # leftover non-PostfixAdmin users after the associated user has been
5
+ # removed from the Postfixadmin database.
6
+ #
7
+ module PrunePlugin
8
+
9
+ # Absorb the subclass run() magic from the Plugin::Run module.
10
+ extend Plugin::Run
11
+
12
+ # The runner class associated with pruning plugins.
13
+ #
14
+ # @return [Class] the {PruneRunner} class.
15
+ #
16
+ def self.runner()
17
+ return PruneRunner
18
+ end
19
+
20
+
21
+ # The "dummy" runner class associated with pruning plugins.
22
+ #
23
+ # @return [Class] the {PruneDummyRunner} class.
24
+ #
25
+ def self.dummy_runner
26
+ return PruneDummyRunner
27
+ end
28
+
29
+
30
+ # Determine which domains are "left over" for this plugin. A domain
31
+ # is considered "left over" if it has been removed from Postfixadmin
32
+ # but not some other plugin.
33
+ #
34
+ # The leftovers are determined with respect to the list *db_domains*
35
+ # of domains that Postfixadmin knows about.
36
+ #
37
+ # @param db_domains [Array<Domain>] a list of domains that are present
38
+ # in the Postfixadmin database.
39
+ #
40
+ # @return [Array<Domain>] a list of domains known to this plugin but
41
+ # not to Postfixadmin.
42
+ #
43
+ def get_leftover_domains(db_domains)
44
+ # WARNING! Array difference doesn't work for some reason.
45
+ return list_domains().select{ |d| !db_domains.include?(d) }
46
+ end
47
+
48
+
49
+ # Determine which users are "left over" for this plugin. A user
50
+ # is considered "left over" if it has been removed from Postfixadmin
51
+ # but not some other plugin.
52
+ #
53
+ # The leftovers are determined with respect to the list *db_users*
54
+ # of users that Postfixadmin knows about.
55
+ #
56
+ # @param db_users [Array<User>] a list of users that are present
57
+ # in the Postfixadmin database.
58
+ #
59
+ # @return [Array<User>] a list of users known to this plugin but
60
+ # not to Postfixadmin.
61
+ #
62
+ def get_leftover_users(db_users)
63
+ # WARNING! Array difference doesn't work for some reason.
64
+ return list_users().select{ |u| !db_users.include?(u) }
65
+ end
66
+ end
@@ -0,0 +1,34 @@
1
+ require 'common/runner'
2
+ require 'prune/plugins/postfixadmin'
3
+ require 'rm/rm_runner'
4
+
5
+ # Perform the pruning of users/domains using {PrunePlugin}s.
6
+ #
7
+ class PruneRunner
8
+ include Runner
9
+
10
+ # Run *plugin* to prune leftover users and directories.
11
+ #
12
+ # @param cfg [Configuration] configuration options passed to
13
+ # {PostfixadminPrune}.
14
+ #
15
+ # @param plugin [Class] plugin class that will perform the pruning.
16
+ #
17
+ def run(cfg, plugin)
18
+ # We don't want to check the PostfixAdmin database against itself.
19
+ return if plugin.class == PostfixadminPrune
20
+
21
+ pfa = PostfixadminPrune.new(cfg)
22
+
23
+ db_users = pfa.list_users()
24
+ db_domains = pfa.list_domains()
25
+
26
+ leftovers = plugin.get_leftover_users(db_users)
27
+ leftovers += plugin.get_leftover_domains(db_domains)
28
+
29
+ # We're counting on our PrunePlugin also being an RmPlugin here.
30
+ rm_runner = RmRunner.new()
31
+ rm_runner.run(cfg, plugin, *leftovers)
32
+ end
33
+
34
+ end
@@ -0,0 +1,38 @@
1
+ require 'pg'
2
+
3
+ require 'common/agendav_plugin'
4
+ require 'rm/rm_plugin'
5
+
6
+
7
+ # Handle the removal of Agendav users from its database. Agendav has
8
+ # no concept of domains.
9
+ #
10
+ class AgendavRm
11
+
12
+ include AgendavPlugin
13
+ include RmPlugin
14
+
15
+
16
+ # Remove *user* from the Agendav database. This should remove him
17
+ # from _every_ table in which he is referenced.
18
+ #
19
+ # @param user [User] the user to remove.
20
+ #
21
+ def remove_user(user)
22
+ raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
23
+
24
+ sql_queries = ['DELETE FROM prefs WHERE username = $1;']
25
+ sql_queries << 'DELETE FROM shared WHERE user_from = $1;'
26
+
27
+ connection = PG::Connection.new(@db_hash)
28
+ begin
29
+ sql_queries.each do |sql_query|
30
+ connection.query(sql_query, [user.to_s()])
31
+ end
32
+ ensure
33
+ # Make sure the connection gets closed even if a query explodes.
34
+ connection.close()
35
+ end
36
+ end
37
+
38
+ end
@@ -0,0 +1,38 @@
1
+ require 'pg'
2
+
3
+ require 'common/davical_plugin'
4
+ require 'rm/rm_plugin'
5
+
6
+ # Handle the removal of DAViCal users from its database. DAViCal has
7
+ # no concept of domains.
8
+ #
9
+ class DavicalRm
10
+
11
+ include DavicalPlugin
12
+ include RmPlugin
13
+
14
+
15
+ # Remove *user* from the DAViCal database. This should remove him
16
+ # from _every_ table in which he is referenced. Fortunately, DAViCal
17
+ # uses foreign keys properly (and only supports postgres, where they
18
+ # work!), so we can let the ON DELETE CASCADE trigger handle most of
19
+ # the work.
20
+ #
21
+ # @param user [User] the user to remove.
22
+ #
23
+ def remove_user(user)
24
+ raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
25
+
26
+ sql_query = 'DELETE FROM usr WHERE username = $1;'
27
+
28
+ connection = PG::Connection.new(@db_hash)
29
+ begin
30
+ connection.query(sql_query, [user.to_s()])
31
+ ensure
32
+ # Make sure the connection gets closed even if the query explodes.
33
+ connection.close()
34
+ end
35
+ end
36
+
37
+
38
+ end
@@ -0,0 +1,48 @@
1
+ require 'fileutils'
2
+
3
+ require 'common/dovecot_plugin'
4
+ require 'rm/rm_plugin'
5
+
6
+
7
+ # Handle the removal of users and domains from the Dovecot mailstore
8
+ # (the filesystem).
9
+ #
10
+ class DovecotRm
11
+
12
+ include DovecotPlugin
13
+ include RmPlugin
14
+
15
+
16
+ # Remove *domain* from the Dovecot mailstore. This just runs "rm -r"
17
+ # on the domain directory if it exists.
18
+ #
19
+ # @param domain [Domain] the domain to remove.
20
+ #
21
+ def remove_domain(domain)
22
+ domain_path = self.get_domain_path(domain)
23
+
24
+ if not File.directory?(domain_path)
25
+ raise NonexistentDomainError.new(domain.to_s())
26
+ end
27
+
28
+ FileUtils.rm_r(domain_path)
29
+ end
30
+
31
+
32
+ # Remove *user* from the Dovecot mailstore. This just runs "rm -r"
33
+ # on the *user*'s mailbox directory, if it exists.
34
+ #
35
+ # @param user [User] the user whose mailbox directory we want to
36
+ # remove.
37
+ #
38
+ def remove_user(user)
39
+ user_path = self.get_user_path(user)
40
+
41
+ if not File.directory?(user_path)
42
+ raise NonexistentUserError.new(user.to_s())
43
+ end
44
+
45
+ FileUtils.rm_r(user_path)
46
+ end
47
+
48
+ end
@@ -0,0 +1,114 @@
1
+ require 'pg'
2
+
3
+ require 'common/postfixadmin_plugin'
4
+ require 'rm/rm_plugin'
5
+
6
+
7
+ # Handle the removal of users and domains from the Postfixadmin database.
8
+ #
9
+ class PostfixadminRm
10
+
11
+ include PostfixadminPlugin
12
+ include RmPlugin
13
+
14
+
15
+ # Remove *user* from the Postfixadmin database. This should remove
16
+ # him from _every_ table in which he is referenced. Unfortunately,
17
+ # Postfixadmin does not use foreign keys or ON DELETE CASCADE
18
+ # triggers so we need to delete the associated child table records
19
+ # ourselves.
20
+ #
21
+ # @param user [User] the user to remove.
22
+ #
23
+ def remove_user(user)
24
+ raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
25
+
26
+ # Remove aliases FROM our user to some other address.
27
+ sql_queries = ['DELETE FROM alias WHERE address = $1;']
28
+
29
+ # Also delete aliases that point SOLELY TO our user.
30
+ sql_queries << "DELETE FROM alias WHERE goto = $1;"
31
+
32
+ # But aliases don't need to point to a single user! If our user
33
+ # was part of a multi-recipient alias, we want to remove our user
34
+ # from the alias and leave the other recipients.
35
+ #
36
+ # We want to delete the comma that precedes/follows the address,
37
+ # too. Since the address to be replaced can appear at either the
38
+ # beginning or the end of the list (as well as in the middle), we
39
+ # have to try to fix both cases: comma before, and comma after.
40
+ comma_before = "CONCAT(',', $1)"
41
+ comma_after = "CONCAT($1, ',')"
42
+ sql_queries << "UPDATE alias SET goto=REPLACE(goto, #{comma_before}, '');"
43
+ sql_queries << "UPDATE alias SET goto=REPLACE(goto, #{comma_after}, '');"
44
+
45
+ sql_queries << 'DELETE FROM mailbox WHERE username = $1;'
46
+ sql_queries << 'DELETE FROM quota WHERE username = $1;'
47
+ sql_queries << 'DELETE FROM quota2 WHERE username = $1;'
48
+ sql_queries << 'DELETE FROM vacation WHERE email = $1;'
49
+
50
+ # Should be handled by a trigger, according to PostfixAdmin code.
51
+ sql_queries << 'DELETE FROM vacation_notification WHERE on_vacation = $1;'
52
+
53
+ connection = PG::Connection.new(@db_hash)
54
+
55
+ begin
56
+ sql_queries.each do |sql_query|
57
+ varchar = 1043 # from pg_type.h
58
+ params = [{:value => user.to_s(), :type => varchar}]
59
+ connection.query(sql_query, params)
60
+ end
61
+ ensure
62
+ # Make sure the connection gets closed even if a query explodes.
63
+ connection.close()
64
+ end
65
+ end
66
+
67
+
68
+ # Remove *domain* from the Postfixadmin database. This should remove
69
+ # the domain from _every_ table in which it is referenced. It should
70
+ # also remove every user that belongs to the doomed domain
71
+ # Postfixadmin has some experimental support for triggers, but they
72
+ # don't do a very good job of cleaning up. Therefore we remove all
73
+ # users in the domain manually before removing the domain itself.
74
+ #
75
+ # Log entries (from the "log" table) are not removed since they may
76
+ # still contain valuable information (although they won't mention
77
+ # this removal).
78
+ #
79
+ # @param domain [Domain] the domain to remove.
80
+ #
81
+ def remove_domain(domain)
82
+ raise NonexistentDomainError.new(domain.to_s()) if not domain_exists(domain)
83
+
84
+ # First remove all users belonging to the domain. This will handle
85
+ # alias updates and all the sensitive crap we need to do when
86
+ # removing a user.
87
+ users = list_domains_users([domain])
88
+ users.each { |u| remove_user(u) }
89
+
90
+ # The domain_admins table contains one record per domain
91
+ # (repeating the user as necessary), so this really is sufficient.
92
+ sql_queries = ['DELETE FROM domain_admins WHERE domain = $1;']
93
+
94
+ # Some of the following queries should be redundant now that we've
95
+ # removed all users in the domain.
96
+ sql_queries << 'DELETE FROM alias WHERE domain = $1;'
97
+ sql_queries << 'DELETE FROM mailbox WHERE domain = $1;'
98
+ sql_queries << 'DELETE FROM alias_domain WHERE alias_domain = $1;'
99
+ sql_queries << 'DELETE FROM vacation WHERE domain = $1;'
100
+ sql_queries << 'DELETE FROM domain WHERE domain = $1;'
101
+
102
+ connection = PG::Connection.new(@db_hash)
103
+
104
+ begin
105
+ sql_queries.each do |sql_query|
106
+ connection.query(sql_query, [domain.to_s()])
107
+ end
108
+ ensure
109
+ # Make sure the connection gets closed even if a query explodes.
110
+ connection.close()
111
+ end
112
+ end
113
+
114
+ end