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,180 @@
1
+ require 'common/domain'
2
+ require 'common/plugin'
3
+ require 'common/user'
4
+ require 'pg'
5
+
6
+ # Code that all Postfixadmin plugins ({PostfixadminPrune},
7
+ # {PostfixadminRm}, {PostfixadminMv}) share.
8
+ #
9
+ module PostfixadminPlugin
10
+
11
+ # We implement the Plugin "interface."
12
+ include Plugin
13
+
14
+ # Initialize this Postfixadmin {Plugin} with values in *cfg*.
15
+ #
16
+ # @param cfg [Configuration] the configuration for this plugin.
17
+ #
18
+ def initialize(cfg)
19
+ @db_hash = {
20
+ :host => cfg.postfixadmin_dbhost,
21
+ :port => cfg.postfixadmin_dbport,
22
+ :options => cfg.postfixadmin_dbopts,
23
+ :tty => cfg.postfixadmin_dbtty,
24
+ :dbname => cfg.postfixadmin_dbname,
25
+ :user => cfg.postfixadmin_dbuser,
26
+ :password => cfg.postfixadmin_dbpass }
27
+ end
28
+
29
+
30
+ # Obtain a list of domains from Postfixadmin. This is more efficient
31
+ # than the {Plugin} default implementation because domains have
32
+ # their own table in the database and we can easily select them
33
+ # rather than filtering the list of users.
34
+ #
35
+ # @return [Array<Domain>] a list of the domains in Postfixadmin.
36
+ #
37
+ def list_domains()
38
+ domains = []
39
+
40
+ connection = PG::Connection.new(@db_hash)
41
+
42
+ # 'ALL' is a magic domain, and we don't want it.
43
+ sql_query = "SELECT domain FROM domain WHERE domain <> 'ALL';"
44
+
45
+ begin
46
+ connection.query(sql_query) do |result|
47
+ domains = result.field_values('domain')
48
+ end
49
+ ensure
50
+ # Make sure the connection gets closed even if the query explodes.
51
+ connection.close()
52
+ end
53
+
54
+ return domains.map{ |d| Domain.new(d) }
55
+ end
56
+
57
+
58
+ # Return a list of Postfixadmin users.
59
+ #
60
+ # @return [Array<User>] a list of users contained in the
61
+ # Postfixadmin database.
62
+ #
63
+ def list_users()
64
+ users = []
65
+
66
+ connection = PG::Connection.new(@db_hash)
67
+
68
+ sql_query = 'SELECT username FROM mailbox;'
69
+
70
+ begin
71
+ connection.query(sql_query) do |result|
72
+ users = result.field_values('username')
73
+ end
74
+ ensure
75
+ # Make sure the connection gets closed even if the query explodes.
76
+ connection.close()
77
+ end
78
+
79
+ return users.map{ |u| User.new(u) }
80
+ end
81
+
82
+
83
+
84
+ # Efficiently list all Postfixadmin users belonging to the given
85
+ # Postfixadmin *domains*.
86
+ #
87
+ # @param domains [Array<Domain>] the domains whose users we want.
88
+ #
89
+ # @return [Array<User>] a list of {User} objects belonging to
90
+ # *domains* for this plugin.
91
+ #
92
+ def list_domains_users(domains)
93
+ usernames = []
94
+ return usernames if domains.length() == 0
95
+
96
+ connection = PG::Connection.new(@db_hash)
97
+
98
+ # The number of parameters that we'll pass into our prepared query
99
+ # is the number of domains that we're given. It's important that
100
+ # we have at least one domain here.
101
+ params = 1.upto(domains.length()).map{ |i| '$' + i.to_s() }.join(',')
102
+ sql_query = "SELECT username FROM mailbox WHERE domain IN (#{params});"
103
+
104
+ begin
105
+ # Now replace each Domain with its string representation and pass
106
+ # those in as our individual parameters.
107
+ connection.query(sql_query, domains.map{ |d| d.to_s() }) do |result|
108
+ usernames = result.field_values('username')
109
+ end
110
+ ensure
111
+ # Make sure the connection gets closed even if the query explodes.
112
+ connection.close()
113
+ end
114
+
115
+ return usernames.map{ |u| User.new(u) }
116
+ end
117
+
118
+
119
+ # Get a list of all Postfixadmin aliases as a <tt>from => to</tt>
120
+ # hash. This is useful for testing, since aliases should be removed
121
+ # when either the "from user" or "to user" are removed.
122
+ #
123
+ # @return [Hash] all aliases known to Postfixadmin in the form of a
124
+ # <tt>from => to</tt> hash.
125
+ #
126
+ def list_aliases()
127
+ aliases = []
128
+
129
+ connection = PG::Connection.new(@db_hash)
130
+
131
+ sql_query = 'SELECT address,goto FROM alias;'
132
+
133
+ begin
134
+ results = connection.query(sql_query)
135
+ results.each do |row|
136
+ # row should be a hash
137
+ aliases << row
138
+ end
139
+ ensure
140
+ # Make sure the connection gets closed even if the query explodes.
141
+ connection.close()
142
+ end
143
+
144
+ return aliases
145
+ end
146
+
147
+
148
+ # A fast implementation of the "does this domain exist?"
149
+ # operation. It only queries the database for the existence of
150
+ # *domain* rather than a list of all domains (which is the default
151
+ # implementation).
152
+ #
153
+ # @param domain [Domain] the domain whose existence is in question.
154
+ #
155
+ # @return [Boolean] true if *domain* exists in the Postfixadmin
156
+ # database and false otherwise.
157
+ #
158
+ def domain_exists(domain)
159
+ count = 0
160
+
161
+ connection = PG::Connection.new(@db_hash)
162
+
163
+ sql_query = 'SELECT COUNT(domain) as count FROM domain WHERE domain = $1;'
164
+
165
+ begin
166
+ connection.query(sql_query, [domain.to_s()]) do |result|
167
+ return false if result.ntuples() < 1
168
+ count = result.getvalue(0,0).to_i()
169
+
170
+ return false if count.nil?
171
+ end
172
+ ensure
173
+ # Make sure the connection gets closed even if the query explodes.
174
+ connection.close()
175
+ end
176
+
177
+ return (count > 0)
178
+ end
179
+
180
+ end
@@ -0,0 +1,96 @@
1
+ require 'common/plugin'
2
+ require 'common/user'
3
+
4
+ # Code that all Roundcube plugins ({RoundcubePrune}, {RoundcubeRm},
5
+ # {RoundcubeMv}) share.
6
+ #
7
+ module RoundcubePlugin
8
+
9
+ # We implement the Plugin "interface."
10
+ include Plugin
11
+
12
+
13
+ # Initialize this Roundcube {Plugin} with values in *cfg*.
14
+ #
15
+ # @param cfg [Configuration] the configuration for this plugin.
16
+ #
17
+ def initialize(cfg)
18
+ @db_hash = {
19
+ :host => cfg.roundcube_dbhost,
20
+ :port => cfg.roundcube_dbport,
21
+ :options => cfg.roundcube_dbopts,
22
+ :tty => cfg.roundcube_dbtty,
23
+ :dbname => cfg.roundcube_dbname,
24
+ :user => cfg.roundcube_dbuser,
25
+ :password => cfg.roundcube_dbpass }
26
+ end
27
+
28
+
29
+ # Describe the given Roundcube *user*.
30
+ #
31
+ # @param user [User] the user whose description we want.
32
+ #
33
+ # @return [String] a string containing the Roundcube "User ID"
34
+ # associated with *user*.
35
+ #
36
+ def describe_user(user)
37
+ user_id = self.get_user_id(user)
38
+ return "User ID: #{user_id}"
39
+ end
40
+
41
+
42
+ # Return a list of Roundcube users.
43
+ #
44
+ # @return [Array<User>] a list of users contained in the
45
+ # Roundcube database.
46
+ #
47
+ def list_users()
48
+ usernames = []
49
+
50
+ connection = PG::Connection.connect(@db_hash)
51
+
52
+ sql_query = 'SELECT username FROM users;'
53
+
54
+ begin
55
+ connection.query(sql_query) do |result|
56
+ usernames = result.field_values('username')
57
+ end
58
+ ensure
59
+ # Make sure the connection gets closed even if the query explodes.
60
+ connection.close()
61
+ end
62
+
63
+ return usernames.map{ |u| User.new(u) }
64
+ end
65
+
66
+ protected;
67
+
68
+
69
+ # Find the Roundcube "User ID" associated with the given *user*.
70
+ #
71
+ # @param user [User] the user whose Roundcube "User ID" we want.
72
+ #
73
+ # @return [Fixnum] the Roundcube "User ID" for *user*.
74
+ #
75
+ def get_user_id(user)
76
+ user_id = nil
77
+
78
+ connection = PG::Connection.new(@db_hash)
79
+ sql_query = 'SELECT user_id FROM users WHERE username = $1;'
80
+
81
+ begin
82
+ connection.query(sql_query, [user.to_s()]) do |result|
83
+ if result.num_tuples > 0
84
+ user_id = result[0]['user_id']
85
+ end
86
+ end
87
+ ensure
88
+ # Make sure the connection gets closed even if the query explodes.
89
+ connection.close()
90
+ end
91
+
92
+ return user_id
93
+ end
94
+
95
+
96
+ end
@@ -0,0 +1,73 @@
1
+ # Methods inherited by the various runner classes ({PruneRunner},
2
+ # {MvRunner}, {RmRunner}).
3
+ #
4
+ module Runner
5
+
6
+
7
+ # The main thing a runner does is <tt>run()</tt>. Each runner will
8
+ # actually take a different number of arguments, so their
9
+ # <tt>run()</tt> signatures will differ. This stub is only here to
10
+ # let you know that it needs to be implemented.
11
+ #
12
+ # @param args [Array<Object>] whatever arguments the real implementation
13
+ # would take.
14
+ #
15
+ def run(*args)
16
+ raise NotImplementedError
17
+ end
18
+
19
+
20
+ # When we're describing a user or domain, we often want to output
21
+ # some additional description of it. But then, sometimes that
22
+ # additional description is pointless, like when it's exactly the
23
+ # same as the username that we're already outputting. This function
24
+ # determines whether or not *desc* is pointless for *target*.
25
+ #
26
+ # @param target [User,Domain] the user or domain described by *desc*.
27
+ #
28
+ # @param desc [String] a string description of *target*.
29
+ #
30
+ # @return [Boolean] true if *desc* is a pointless description of
31
+ # *target* and false otherwise.
32
+ #
33
+ def pointless?(target, desc)
34
+ return (desc.nil? or desc.empty? or desc == target.to_s())
35
+ end
36
+
37
+
38
+ # If *desc* is not a pointless description of *target*, return the
39
+ # string representation of *target* followed by *desc* in
40
+ # parentheses. If *desc* is pointless, we return only the string
41
+ # representation of *target*
42
+ #
43
+ # @param target [User,Domain] the user or domain we want to describe
44
+ # as a string.
45
+ #
46
+ # @param desc [String] a string description of *target*.
47
+ #
48
+ # @return [String] the string representation of *target*, possibly
49
+ # followed by the non-pointless description *desc*.
50
+ #
51
+ def add_description(target, desc)
52
+ if pointless?(target, desc)
53
+ return target.to_s()
54
+ else
55
+ return target.to_s() + " (#{desc})"
56
+ end
57
+ end
58
+
59
+
60
+ # Report a message from the given *plugin*. All this does is prefix
61
+ # the message with the plugin name and then print it to stdout.
62
+ #
63
+ # @param plugin [Object] t plugin object that generated the message
64
+ # we're reporting.
65
+ #
66
+ # @param msg [String] the message to report.
67
+ #
68
+ def report(plugin, msg)
69
+ print "#{plugin.class.to_s()} - "
70
+ puts msg
71
+ end
72
+
73
+ end
@@ -0,0 +1,120 @@
1
+ require 'common/domain'
2
+ require 'common/errors'
3
+
4
+ # A class representing a syntactically valid user; that is, an email
5
+ # address. Once constructed, you can be sure that it's valid.
6
+ #
7
+ class User
8
+
9
+ # @localpart is a String containing the "user" part of "user@domain".
10
+ @localpart = nil
11
+
12
+ # @domain contains a {Domain} object representing the domain part of
13
+ # "user@domain".
14
+ @domain = nil
15
+
16
+
17
+ # Obtain the {Domain} object corresponding to this User.
18
+ #
19
+ # @return [Domain] the domain corresponding to this User.
20
+ #
21
+ def domain()
22
+ return @domain
23
+ end
24
+
25
+
26
+ # Obtain the domain part of this User object as a string.
27
+ #
28
+ # @return [String] a String representation of this User's domain.
29
+ #
30
+ def domainpart()
31
+ return @domain.to_s()
32
+ end
33
+
34
+
35
+ # Initialize this User object. If either of the local/domain parts
36
+ # is invalid, then either an {InvalidUserError} or an {InvalidDomainError}
37
+ # will be raised containing the reason why that part is invalid.
38
+ #
39
+ # @param username [String] an email address from which to construct
40
+ # this User.
41
+ #
42
+ def initialize(username)
43
+
44
+ if not username.is_a?(String)
45
+ msg = 'username must be a String '
46
+ msg += "but a #{username.class.to_s()} was given"
47
+ raise InvalidUserError.new(msg)
48
+ end
49
+
50
+ parts = username.split('@')
51
+
52
+ if parts.length() < 2 then
53
+ msg = "the username #{username} does not contain an '@' symbol"
54
+ raise InvalidUserError.new(msg)
55
+ end
56
+
57
+ localpart = parts[0]
58
+
59
+ if localpart.length() > 64 then
60
+ msg = "the local part of #{username} cannot have more than 64 characters"
61
+ raise InvalidUserError(msg)
62
+ end
63
+
64
+ if localpart.empty? then
65
+ msg = "the local part of #{username} cannot be empty"
66
+ raise InvalidUserError.new(msg)
67
+ end
68
+
69
+ @localpart = localpart
70
+ @domain = Domain.new(parts[1])
71
+ end
72
+
73
+
74
+ # Obtain the "user" part of this User object as a String.
75
+ #
76
+ # @return [String] the "user" part of this User's "user@domain"
77
+ # address.
78
+ #
79
+ def localpart()
80
+ return @localpart
81
+ end
82
+
83
+
84
+ # Convert this User to an email address string.
85
+ #
86
+ # @return [String] an email address String constructed from this
87
+ # user's local and domain parts.
88
+ #
89
+ def to_s()
90
+ return @localpart + '@' + @domain.to_s()
91
+ end
92
+
93
+
94
+ # Check if this User is equal to some other User. The comparison
95
+ # is based on their String representations.
96
+ #
97
+ # @param other [User] the User object to compare me to.
98
+ #
99
+ # @return [Boolean] If *self* and *other* have equal String
100
+ # representations, then true is returned. Otherwise, false is
101
+ # returned.
102
+ #
103
+ def ==(other)
104
+ return self.to_s() == other.to_s()
105
+ end
106
+
107
+
108
+ # Compare two User objects for sorting purposes. The comparison is
109
+ # is based on their String representations.
110
+ #
111
+ # @param other [User] the User object to compare me to.
112
+ #
113
+ # @return [0,1,-1] a trinary indicator of how *self* relates to *other*,
114
+ # obtained by performing the same comparison on the String
115
+ # representations of *self* and *other*.
116
+ #
117
+ def <=>(other)
118
+ return self.to_s() <=> other.to_s()
119
+ end
120
+ end