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
data/doc/TODO ADDED
@@ -0,0 +1,16 @@
1
+ * There is essentially no error handling. We report errors, but we
2
+ don't fail when we see one. The main reason for this is that we
3
+ don't know when each plugin will be run. If the first plugin
4
+ encounters an error, we could quit right there. But what if the
5
+ third one fails after the first two succeed? We would need some kind
6
+ of rollback mechanism.
7
+
8
+ For "mv", a rollback is conceivable. But with "rm", there's no going
9
+ back. Maybe relying on the user to interpret the output and go
10
+ fix stuff himself is the best we can do?
11
+
12
+ * Add OpenDKIM support.
13
+
14
+ * Make a release.
15
+
16
+ * Implement moving of domains.
@@ -0,0 +1,37 @@
1
+ i_mean_business: false
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
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
19
+
20
+ dovecot_mail_root: /var/spool/mail/vhosts
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
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
37
+
@@ -0,0 +1,184 @@
1
+ .TH mailshears 1
2
+
3
+ .SH NAME
4
+ mailshears \- mangle your mail garden
5
+ .SH SYNOPSIS
6
+
7
+ \fBmailshears\fR [ [\fBprune\fR] | [\fBrm\fR <\fItargets\fR>] | [\fBmv\fR <\fIsrc\fR> <\fIdst\fR>] ]
8
+
9
+ .SH DESCRIPTION
10
+
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
+
26
+ It is assumed that you use the \fBPostfixAdmin schema\fI with
27
+ \fBPostgreSQL\fR for your virtual users and domains. The rest of the
28
+ functionality is provided by plugins. The following are supported at
29
+ the moment:
30
+ \#
31
+ .IP \(bu 2
32
+ Agendav (database)
33
+ .IP \(bu
34
+ DAViCal (datbase)
35
+ .IP \(bu
36
+ Dovecot (filesystem)
37
+ .IP \(bu
38
+ PostfixAdmin (database)
39
+ .IP \(bu
40
+ Roundcube (database)
41
+
42
+ You are free to pick and choose the plugins that you need. The Dovecot
43
+ plugin is a misnomer: as long as your mail is stored on disk in
44
+ directories of the form \(dqexample.com/user\(dq, it should work for
45
+ you.
46
+
47
+ .SH MODES
48
+ Mailshears has three modes, each of which takes different
49
+ arguments. The default mode prunes leftover stuff.
50
+ \#
51
+ .IP \(bu 2
52
+ \fBprune\fR
53
+
54
+ The default \(dqprune\(dq mode removes leftovers from your mail
55
+ system. The list of current users is collected from PostfixAdmin. The
56
+ other plugins are then queried to find any extra users/domains to
57
+ remove.
58
+ \#
59
+ .IP \(bu
60
+ \fBrm\fR
61
+
62
+ The \(dqrm\(dq mode removes a list of users and/or domains called
63
+ \fItargets\fR.
64
+ \#
65
+ .IP \(bu
66
+ \fBmv\fR
67
+
68
+ The \(dqmv\(dq mode renames the \fIsrc\fR user to \fIdst\fR. The
69
+ source user and destination domains must exist. Domains may not be
70
+ moved.
71
+
72
+ .SH EXAMPLES
73
+
74
+ Pruning leftover users and domains:
75
+
76
+ .nf
77
+ .I $ mailshears
78
+ mailshears, 2015-11-08 17:01:47 -0500 (Plugin: PrunePlugin)
79
+ -----------------------------------------------------------
80
+ AgendavPrune - Removed user booger@example.com.
81
+ DavicalPrune - Removed user booger@example.com (Principal ID: 2).
82
+ DovecotPrune - Removed user booger@example.com (/tmp/mailshears-test/example.com/booger).
83
+ DovecotPrune - Removed user jeremy@example.com (/tmp/mailshears-test/example.com/jeremy).
84
+ RoundcubePrune - Removed user booger@example.com (User ID: 2).
85
+ .fi
86
+
87
+ Removing a specific user:
88
+
89
+ .nf
90
+ .I $ mailshears rm adam@example.net
91
+ mailshears, 2015-11-08 17:04:42 -0500 (Plugin: RmPlugin)
92
+ --------------------------------------------------------
93
+ AgendavRm - Removed user adam@example.net.
94
+ DavicalRm - User adam@example.net not found.
95
+ DovecotRm - Removed user adam@example.net (/tmp/mailshears-test/example.net/adam).
96
+ PostfixadminRm - Removed user adam@example.net.
97
+ RoundcubeRm - Removed user adam@example.net (User ID: 3).
98
+ .fi
99
+
100
+ Removing a domain:
101
+
102
+ .nf
103
+ .I $ mailshears rm example.net
104
+ mailshears, 2015-11-08 17:05:42 -0500 (Plugin: RmPlugin)
105
+ --------------------------------------------------------
106
+ AgendavRm - Removed domain example.net.
107
+ DavicalRm - Domain example.net not found.
108
+ DovecotRm - Removed domain example.net (/tmp/mailshears-test/example.net).
109
+ PostfixadminRm - Removed domain example.net.
110
+ RoundcubeRm - Removed domain example.net.
111
+ .fi
112
+
113
+ Renaming an existing user:
114
+
115
+ .nf
116
+ .I $ mailshears mv alice@example.com alice@example.net
117
+ mailshears, 2015-11-08 17:06:29 -0500 (Plugin: MvPlugin)
118
+ --------------------------------------------------------
119
+ AgendavMv - Source user alice@example.com not found.
120
+ DavicalMv - Moved user alice@example.com (Principal ID: 1) to alice@example.net (Principal ID: 1).
121
+ DovecotMv - Moved user alice@example.com (/tmp/mailshears-test/example.com/alice) to alice@example.net (/tmp/mailshears-test/example.net/alice).
122
+ PostfixadminMv - Moved user alice@example.com to alice@example.net.
123
+ RoundcubeMv - Moved user alice@example.com (User ID: 1) to alice@example.net (User ID: 1).
124
+ .fi
125
+
126
+ .SH CONFIGURATION
127
+
128
+ Mailshears is configured with a YAML file containing all of the
129
+ database settings and plugin information. This file should be located
130
+ at ~/.mailshears.conf.yml, in your home directory.
131
+
132
+ The following two settings are global:
133
+ \#
134
+ .IP \(bu 2
135
+ \fIi_mean_business\fR (default: false) If this is set to false, mailshears
136
+ will output some \(dqwhat if\(dq information but won't actually
137
+ (re)move anything.
138
+ \#
139
+ .IP \(bu
140
+ \fIplugins\fR (default: ['postfixadmin'])
141
+ A list of enabled plugins. Valid values are \(dqagendav\(dq,
142
+ \(dqdavical\(dq, \(dqdovecot\(dq, \(dqpostfixadmin\(dq, and
143
+ \(dqroundcube\(dq.
144
+ .P
145
+ The \(dqdovecot\(dq plugin supports the following:
146
+ \#
147
+ .IP \(bu 2
148
+ \fIdovecot_mail_root\fR (default: /tmp/mailshears-test)
149
+
150
+ The location of your mail store. The domain directories should be
151
+ located one level beneath dovecot_mail_root.
152
+ .P
153
+ The database plugins all have the same configutation options:
154
+ connections settings preceded by the plugin name. So in what follows,
155
+ <plugin> would be replaced by \(dqagendav\(dq, \(dqdavical\(dq, or so
156
+ on. Their meanings should be self-explanatory.
157
+ \#
158
+ .IP \(bu 2
159
+ \fI<plugin>_dbhost\fR (default: localhost)
160
+ \#
161
+ .IP \(bu
162
+ \fI<plugin>_dbport\fR (default: 5432)
163
+ \#
164
+ .IP \(bu
165
+ \fI<plugin>_dbopts\fR (default: empty)
166
+ \#
167
+ .IP \(bu
168
+ \fI<plugin>_dbtty\fR (default: empty)
169
+ \#
170
+ .IP \(bu
171
+ \fI<plugin>_dbuser\fR (default: 'postgres')
172
+ \#
173
+ .IP \(bu
174
+ \fI<plugin>_dbpass\fR (default: empty)
175
+ \#
176
+ .IP \(bu
177
+ \fI<plugin>_dbname\fR (default: <plugin>)
178
+ .P
179
+ An example configuration file, mailshears.example.conf.yml, is shipped
180
+ with mailshears.
181
+
182
+ .SH BUGS
183
+
184
+ Send bugs to michael@orlitzky.com.
@@ -0,0 +1,54 @@
1
+ require 'common/plugin'
2
+ require 'common/user'
3
+
4
+ # Code that all Agendav plugins ({AgendavPrune}, {AgendavRm},
5
+ # {AgendavMv}) share.
6
+ module AgendavPlugin
7
+
8
+ # We implement the Plugin "interface."
9
+ include Plugin
10
+
11
+
12
+ # Initialize this Agendav {Plugin} with values in *cfg*.
13
+ #
14
+ # @param cfg [Configuration] the configuration for this plugin.
15
+ #
16
+ def initialize(cfg)
17
+ @db_hash = {
18
+ :host => cfg.agendav_dbhost,
19
+ :port => cfg.agendav_dbport,
20
+ :options => cfg.agendav_dbopts,
21
+ :tty => cfg.agendav_dbtty,
22
+ :dbname => cfg.agendav_dbname,
23
+ :user => cfg.agendav_dbuser,
24
+ :password => cfg.agendav_dbpass }
25
+ end
26
+
27
+
28
+ # Return a list of Agendav users.
29
+ #
30
+ # @return [Array<User>] a list of users contained in the
31
+ # Agendav database.
32
+ #
33
+ def list_users()
34
+ users = []
35
+
36
+ connection = PG::Connection.new(@db_hash)
37
+
38
+ sql_query = '(SELECT username FROM prefs)'
39
+ sql_query += 'UNION'
40
+ sql_query += '(SELECT user_from FROM shared);'
41
+
42
+ begin
43
+ connection.query(sql_query) do |result|
44
+ users = result.field_values('username')
45
+ end
46
+ ensure
47
+ # Make sure the connection gets closed even if the query explodes.
48
+ connection.close()
49
+ end
50
+
51
+ return users.map{ |u| User.new(u) }
52
+ end
53
+
54
+ end
@@ -0,0 +1,116 @@
1
+ require 'yaml'
2
+
3
+ # A configuration object that knows how to read options out of a file
4
+ # in <tt>~/.mailshears.conf.yml</tt>. The configuration options can be
5
+ # accessed via methods even though the internal representation is a
6
+ # hash.
7
+ #
8
+ # === Examples
9
+ #
10
+ # >> cfg = Configuration.new()
11
+ # >> cfg.i_mean_business()
12
+ # => true
13
+ #
14
+ class Configuration
15
+
16
+ # The default path to the user's configuration file.
17
+ USERCONF_PATH = ENV['HOME'] + '/.mailshears.conf.yml'
18
+
19
+ # The hash structure in which we store our configuration options
20
+ # internally.
21
+ @dict = {}
22
+
23
+
24
+ # Initialize a {Configuration} object with the config file at *path*.
25
+ #
26
+ # @param path [String] the path to the configuration file to
27
+ # load. We check for a file named ".mailshears.conf.yml" in the
28
+ # user's home directory by default.
29
+ #
30
+ def initialize(path = USERCONF_PATH)
31
+ cfg = default_configuration()
32
+
33
+ # Now, load the user configuration which will override the
34
+ # variables defined above.
35
+ begin
36
+ user_config = YAML.load(File.open(path))
37
+
38
+ # Write our own update() method for Ruby 1.8.
39
+ user_config.each do |key, value|
40
+ cfg[key] = value
41
+ end
42
+ rescue Errno::ENOENT
43
+ # If the user config file doesn't exist, whatever.
44
+ end
45
+
46
+ # Convert all of the keys to symbols.
47
+ cfg = cfg.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo}
48
+
49
+ @dict = cfg
50
+ end
51
+
52
+
53
+ # Replace all missing method calls with hash lookups. This lets us
54
+ # retrieve the values in our option hash by using methods named
55
+ # after the associated keys.
56
+ #
57
+ # @param sym [Symbol] the method that was called.
58
+ #
59
+ # @return [Object] the config file value associated with *sym*.
60
+ #
61
+ def method_missing(sym, *args)
62
+ return @dict[sym]
63
+ end
64
+
65
+
66
+ private;
67
+
68
+
69
+ # A default config hash.
70
+ #
71
+ # @return [Hash] sensible default configuration values.
72
+ #
73
+ def default_configuration()
74
+ d = {}
75
+
76
+ d['i_mean_business'] = false
77
+ d['plugins'] = ['postfixadmin']
78
+
79
+ d['agendav_dbhost'] = 'localhost'
80
+ d['agendav_dbport'] = 5432
81
+ d['agendav_dbopts'] = ''
82
+ d['agendav_dbtty'] = ''
83
+ d['agendav_dbuser'] = 'postgres'
84
+ d['agendav_dbpass'] = ''
85
+ d['agendav_dbname'] = 'agendav'
86
+
87
+ d['davical_dbhost'] = 'localhost'
88
+ d['davical_dbport'] = 5432
89
+ d['davical_dbopts'] = ''
90
+ d['davical_dbtty'] = ''
91
+ d['davical_dbuser'] = 'postgres'
92
+ d['davical_dbpass'] = ''
93
+ d['davical_dbname'] = 'davical'
94
+
95
+ d['dovecot_mail_root'] = '/tmp/mailshears-test'
96
+
97
+ d['postfixadmin_dbhost'] = 'localhost'
98
+ d['postfixadmin_dbport'] = 5432
99
+ d['postfixadmin_dbopts'] = ''
100
+ d['postfixadmin_dbtty'] = ''
101
+ d['postfixadmin_dbuser'] = 'postgres'
102
+ d['postfixadmin_dbpass'] = ''
103
+ d['postfixadmin_dbname'] = 'postfixadmin'
104
+
105
+ d['roundcube_dbhost'] = 'localhost'
106
+ d['roundcube_dbport'] = 5432
107
+ d['roundcube_dbopts'] = ''
108
+ d['roundcube_dbtty'] = ''
109
+ d['roundcube_dbuser'] = 'postgres'
110
+ d['roundcube_dbpass'] = ''
111
+ d['roundcube_dbname'] = 'roundcube'
112
+
113
+ return d
114
+ end
115
+
116
+ end
@@ -0,0 +1,104 @@
1
+ require 'common/plugin'
2
+ require 'common/user'
3
+
4
+ # Code that all DAViCal plugins ({DavicalPrune}, {DavicalRm}, and
5
+ # {DavicalMv}) will share.
6
+ #
7
+ module DavicalPlugin
8
+
9
+ # We implement the Plugin "interface."
10
+ include Plugin
11
+
12
+ # Initialize this DAViCal {Plugin} with values in *cfg*.
13
+ #
14
+ # @param cfg [Configuration] the configuration for this plugin.
15
+ #
16
+ def initialize(cfg)
17
+ @db_hash = {
18
+ :host => cfg.davical_dbhost,
19
+ :port => cfg.davical_dbport,
20
+ :options => cfg.davical_dbopts,
21
+ :tty => cfg.davical_dbtty,
22
+ :dbname => cfg.davical_dbname,
23
+ :user => cfg.davical_dbuser,
24
+ :password => cfg.davical_dbpass }
25
+ end
26
+
27
+
28
+ # Describe the given DAViCal user who is assumed to exist.
29
+ #
30
+ # @param user [User] the {User} object whose description we want.
31
+ #
32
+ # @return [String] a String describing the given *user* in terms
33
+ # of his DAViCal "Principal ID".
34
+ #
35
+ def describe_user(user)
36
+ principal_id = self.get_principal_id(user)
37
+ return "Principal ID: #{principal_id}"
38
+ end
39
+
40
+
41
+ #
42
+ # Produce a list of DAViCal users.
43
+ #
44
+ # This method remains public for use in testing.
45
+ #
46
+ # @return [Array<User>] an array of {User} objects, one for each
47
+ # user found in the DAViCal database.
48
+ #
49
+ def list_users()
50
+ usernames = []
51
+
52
+ connection = PG::Connection.new(@db_hash)
53
+
54
+ # User #1 is the super-user, and not tied to an email address.
55
+ sql_query = 'SELECT username FROM usr WHERE user_no > 1;'
56
+
57
+ begin
58
+ connection.query(sql_query) do |result|
59
+ usernames = result.field_values('username')
60
+ end
61
+ ensure
62
+ # Make sure the connection gets closed even if the query explodes.
63
+ connection.close()
64
+ end
65
+
66
+ return usernames.map{ |u| User.new(u) }
67
+ end
68
+
69
+
70
+ protected;
71
+
72
+
73
+ # Find the "Principal ID" of the given user.
74
+ #
75
+ # @param user [User] the user whose Principal ID we want.
76
+ #
77
+ # @return [Fixnum] an integer representing the user's Principal ID
78
+ # that we obtained from the DAViCal database.
79
+ #
80
+ def get_principal_id(user)
81
+ principal_id = nil
82
+
83
+ connection = PG::Connection.new(@db_hash)
84
+
85
+ sql_query = 'SELECT principal.principal_id '
86
+ sql_query += 'FROM (principal INNER JOIN usr '
87
+ sql_query += ' ON principal.user_no = usr.user_no) '
88
+ sql_query += 'WHERE usr.username = $1;'
89
+
90
+ begin
91
+ connection.query(sql_query, [user.to_s()]) do |result|
92
+ if result.num_tuples > 0
93
+ principal_id = result[0]['principal_id']
94
+ end
95
+ end
96
+ ensure
97
+ # Make sure the connection gets closed even if the query explodes.
98
+ connection.close()
99
+ end
100
+
101
+ return principal_id
102
+ end
103
+
104
+ end