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.
- checksums.yaml +7 -0
- data/Rakefile +32 -0
- data/bin/install-fixtures.sh +27 -0
- data/bin/mailshears +124 -0
- data/doc/LICENSE +661 -0
- data/doc/TODO +16 -0
- data/doc/mailshears.example.conf.yml +37 -0
- data/doc/man1/mailshears.1 +184 -0
- data/lib/common/agendav_plugin.rb +54 -0
- data/lib/common/configuration.rb +116 -0
- data/lib/common/davical_plugin.rb +104 -0
- data/lib/common/domain.rb +64 -0
- data/lib/common/dovecot_plugin.rb +130 -0
- data/lib/common/errors.rb +15 -0
- data/lib/common/exit_codes.rb +9 -0
- data/lib/common/filesystem.rb +43 -0
- data/lib/common/plugin.rb +238 -0
- data/lib/common/postfixadmin_plugin.rb +180 -0
- data/lib/common/roundcube_plugin.rb +96 -0
- data/lib/common/runner.rb +73 -0
- data/lib/common/user.rb +120 -0
- data/lib/common/user_interface.rb +53 -0
- data/lib/mailshears.rb +7 -0
- data/lib/mv/mv_dummy_runner.rb +45 -0
- data/lib/mv/mv_plugin.rb +40 -0
- data/lib/mv/mv_runner.rb +56 -0
- data/lib/mv/plugins/agendav.rb +46 -0
- data/lib/mv/plugins/davical.rb +43 -0
- data/lib/mv/plugins/dovecot.rb +64 -0
- data/lib/mv/plugins/postfixadmin.rb +70 -0
- data/lib/mv/plugins/roundcube.rb +44 -0
- data/lib/prune/plugins/agendav.rb +13 -0
- data/lib/prune/plugins/davical.rb +13 -0
- data/lib/prune/plugins/dovecot.rb +11 -0
- data/lib/prune/plugins/postfixadmin.rb +13 -0
- data/lib/prune/plugins/roundcube.rb +14 -0
- data/lib/prune/prune_dummy_runner.rb +44 -0
- data/lib/prune/prune_plugin.rb +66 -0
- data/lib/prune/prune_runner.rb +34 -0
- data/lib/rm/plugins/agendav.rb +38 -0
- data/lib/rm/plugins/davical.rb +38 -0
- data/lib/rm/plugins/dovecot.rb +48 -0
- data/lib/rm/plugins/postfixadmin.rb +114 -0
- data/lib/rm/plugins/roundcube.rb +42 -0
- data/lib/rm/rm_dummy_runner.rb +39 -0
- data/lib/rm/rm_plugin.rb +77 -0
- data/lib/rm/rm_runner.rb +51 -0
- data/mailshears.gemspec +39 -0
- data/test/mailshears.test.conf.yml +36 -0
- data/test/mailshears_test.rb +250 -0
- data/test/sql/agendav-fixtures.sql +9 -0
- data/test/sql/agendav.sql +157 -0
- data/test/sql/davical-fixtures.sql +23 -0
- data/test/sql/davical.sql +4371 -0
- data/test/sql/postfixadmin-fixtures.sql +48 -0
- data/test/sql/postfixadmin.sql +737 -0
- data/test/sql/roundcube-fixtures.sql +4 -0
- data/test/sql/roundcube.sql +608 -0
- data/test/test_mv.rb +174 -0
- data/test/test_prune.rb +121 -0
- data/test/test_rm.rb +154 -0
- 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
|
data/lib/common/user.rb
ADDED
@@ -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
|