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,64 @@
|
|
1
|
+
require 'common/errors'
|
2
|
+
|
3
|
+
# A class representing a syntactically valid domain. Essentially, the
|
4
|
+
# part after the "@" in an email address. Once constructed, you can be
|
5
|
+
# sure that it's valid.
|
6
|
+
#
|
7
|
+
class Domain
|
8
|
+
|
9
|
+
# @domain contains the String representation of this domain.
|
10
|
+
@domain = nil
|
11
|
+
|
12
|
+
|
13
|
+
# Initialize this Domain object. If the domain is invalid, then an
|
14
|
+
# {InvalidDomainError} will be raised containing the reason
|
15
|
+
# why the domain is invalid.
|
16
|
+
#
|
17
|
+
# @param domain [String] the string representation of this domain.
|
18
|
+
#
|
19
|
+
def initialize(domain)
|
20
|
+
if domain.empty? then
|
21
|
+
msg = "domain cannot be empty"
|
22
|
+
raise InvalidDomainError.new(msg)
|
23
|
+
end
|
24
|
+
|
25
|
+
@domain = domain
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
# Convert this domain to a String.
|
30
|
+
#
|
31
|
+
# @return [String] the string representation of this Domain.
|
32
|
+
#
|
33
|
+
def to_s()
|
34
|
+
return @domain
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
# Check if this Domain is equal to some other Domain. The comparison
|
39
|
+
# is based on their String representations.
|
40
|
+
#
|
41
|
+
# @param other [Domain] the Domain object to compare me to.
|
42
|
+
#
|
43
|
+
# @return [Boolean] If *self* and *other* have equal String
|
44
|
+
# representations, then true is returned. Otherwise, false is
|
45
|
+
# returned.
|
46
|
+
#
|
47
|
+
def ==(other)
|
48
|
+
return self.to_s() == other.to_s()
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
# Compare two Domain objects for sorting purposes. The comparison
|
53
|
+
# is based on their String representations.
|
54
|
+
#
|
55
|
+
# @param other [Domain] the Domain object to compare me to.
|
56
|
+
#
|
57
|
+
# @return [0,1,-1] a trinary indicator of how *self* relates to *other*,
|
58
|
+
# obtained by performing the same comparison on the String
|
59
|
+
# representations of *self* and *other*.
|
60
|
+
#
|
61
|
+
def <=>(other)
|
62
|
+
return self.to_s() <=> other.to_s()
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'common/domain'
|
2
|
+
require 'common/filesystem'
|
3
|
+
require 'common/plugin'
|
4
|
+
require 'common/user'
|
5
|
+
|
6
|
+
# Code that all Dovecot plugins ({DovecotPrune}, {DovecotRm}, and
|
7
|
+
# {DovecotMv}) will share.
|
8
|
+
#
|
9
|
+
module DovecotPlugin
|
10
|
+
|
11
|
+
# We implement the Plugin "interface."
|
12
|
+
include Plugin
|
13
|
+
|
14
|
+
|
15
|
+
# Initialize this Dovecot {Plugin} with values in *cfg*.
|
16
|
+
#
|
17
|
+
# @param cfg [Configuration] the configuration for this plugin.
|
18
|
+
#
|
19
|
+
def initialize(cfg)
|
20
|
+
@domain_root = cfg.dovecot_mail_root
|
21
|
+
end
|
22
|
+
|
23
|
+
# Describe the given Dovecot domain by its filesystem path. The
|
24
|
+
# domain need not exist to obtain its path.
|
25
|
+
#
|
26
|
+
# @param domain [Domain] the {Domain} object whose description we want.
|
27
|
+
#
|
28
|
+
# @return [String] a String giving the path under which this domain's
|
29
|
+
# mailboxes would reside on the filesystem.
|
30
|
+
#
|
31
|
+
def describe_domain(domain)
|
32
|
+
return get_domain_path(domain)
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
# Describe the given Dovecot user by its filesystem mailbox
|
37
|
+
# path. The user need not exist to obtain its mailbox path.
|
38
|
+
#
|
39
|
+
# @param user [User] the {User} object whose description we want.
|
40
|
+
#
|
41
|
+
# @return [String] a String giving the path where this user's
|
42
|
+
# mailbox would reside on the filesystem.
|
43
|
+
#
|
44
|
+
def describe_user(user)
|
45
|
+
return get_user_path(user)
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
protected;
|
50
|
+
|
51
|
+
# Return the filesystem path for the given {Domain} object.
|
52
|
+
#
|
53
|
+
# @param domain [Domain] the {Domain} whose path we want.
|
54
|
+
#
|
55
|
+
# @return [String] the filesystem path where this domain's mail
|
56
|
+
# would be located.
|
57
|
+
#
|
58
|
+
def get_domain_path(domain)
|
59
|
+
return File.join(@domain_root, domain.to_s())
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
# Return the filesystem path of this {User}'s mailbox.
|
64
|
+
#
|
65
|
+
# @param user [User] the {User} whose mailbox path we want.
|
66
|
+
#
|
67
|
+
# @return [String] the filesystem path where this user's mail
|
68
|
+
# would be located.
|
69
|
+
#
|
70
|
+
def get_user_path(user)
|
71
|
+
domain_path = get_domain_path(user.domain())
|
72
|
+
return File.join(domain_path, user.localpart())
|
73
|
+
end
|
74
|
+
|
75
|
+
|
76
|
+
# Produce a list of domains that exist in the Dovecot mailstore.
|
77
|
+
#
|
78
|
+
# @return [Array<Domain>] an array of {Domain} objects that have
|
79
|
+
# corresponding directories within the Dovecot mailstore.
|
80
|
+
#
|
81
|
+
def list_domains()
|
82
|
+
return Filesystem.get_subdirs(@domain_root).map{ |d| Domain.new(d) }
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
# Produce a list of users belonging to the given *domains* in the
|
87
|
+
# Dovecot mailstore.
|
88
|
+
#
|
89
|
+
# @param domains [Array<Domain>] an array of {Domain} objects whose
|
90
|
+
# users we'd like to find.
|
91
|
+
#
|
92
|
+
# @return [Array<User>] an array of {User} objects that have
|
93
|
+
# corresponding directories within the Dovecot mailstore belonging
|
94
|
+
# to the specified *domains*.
|
95
|
+
#
|
96
|
+
def list_domains_users(domains)
|
97
|
+
users = []
|
98
|
+
|
99
|
+
domains.each do |domain|
|
100
|
+
begin
|
101
|
+
# Throws a NonexistentDomainError if the domain's path
|
102
|
+
# doesn't exist on the filesystem. In this case, we want
|
103
|
+
# to report zero users.
|
104
|
+
domain_path = get_domain_path(domain)
|
105
|
+
usernames = Filesystem.get_subdirs(domain_path)
|
106
|
+
|
107
|
+
usernames.each do |username|
|
108
|
+
users << User.new("#{username}@#{domain}")
|
109
|
+
end
|
110
|
+
rescue NonexistentDomainError
|
111
|
+
# Party hard.
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
return users
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
# Produce a list of all users in the Dovecot mailstore.
|
120
|
+
#
|
121
|
+
# @return [Array<User>] a list of users who have mailbox directories
|
122
|
+
# within the Dovecot mailstore.
|
123
|
+
#
|
124
|
+
def list_users()
|
125
|
+
domains = list_domains()
|
126
|
+
users = list_domains_users(domains)
|
127
|
+
return users
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# An error indicating that a username is syntactically invalid.
|
2
|
+
class InvalidUserError < StandardError; end
|
3
|
+
|
4
|
+
# An error indicating that a domain is syntactically invalid.
|
5
|
+
class InvalidDomainError < StandardError; end
|
6
|
+
|
7
|
+
# An error indicating that a user does not exist.
|
8
|
+
class NonexistentUserError < StandardError; end
|
9
|
+
|
10
|
+
# An error indicating that a domain does not exist.
|
11
|
+
class NonexistentDomainError < StandardError; end
|
12
|
+
|
13
|
+
# An error indicating that some user already exists. For example, if
|
14
|
+
# one tries to rename a user and the destination user already exists.
|
15
|
+
class UserAlreadyExistsError < StandardError; end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# Command-line exit codes. In other words, what you'll see if you
|
2
|
+
# <tt>echo $?</tt> after running the executable on the command-line.
|
3
|
+
module ExitCodes
|
4
|
+
# Everything went better than expected.
|
5
|
+
SUCCESS = 0
|
6
|
+
|
7
|
+
# The command-line arguments were not what we expected.
|
8
|
+
BAD_COMMAND_LINE = 1
|
9
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# Convenience methods for working with the filesystem. This class
|
2
|
+
# only provides static methods, to be used analogously to the File
|
3
|
+
# class (for example, <tt>File.directory?</tt>).
|
4
|
+
#
|
5
|
+
class Filesystem
|
6
|
+
|
7
|
+
# Return whether or not the given path begins with a dot (ASCII
|
8
|
+
# period).
|
9
|
+
#
|
10
|
+
# @param path [String] the path whose first character you want to check.
|
11
|
+
#
|
12
|
+
# @return [Boolean] whether or not *path* begins with an ASCII period.
|
13
|
+
#
|
14
|
+
def self.begins_with_dot?(path)
|
15
|
+
return (path[0..0] == '.')
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
# Get a list of all real subdirectories of the given directory.
|
20
|
+
#
|
21
|
+
# @param dir [String] the directory whose subdirectories you want.
|
22
|
+
#
|
23
|
+
# @return [Array<String>] a list of subdirectories of *dir*, not
|
24
|
+
# including the pseudo-directories "." and ".." (the current/parent
|
25
|
+
# directories).
|
26
|
+
#
|
27
|
+
def self.get_subdirs(dir)
|
28
|
+
subdirs = []
|
29
|
+
return subdirs if not File.directory?(dir)
|
30
|
+
|
31
|
+
Dir.open(dir) do |d|
|
32
|
+
d.each do |entry|
|
33
|
+
relative_path = File.join(dir, entry)
|
34
|
+
if File.directory?(relative_path) and not self.begins_with_dot?(entry)
|
35
|
+
subdirs << entry
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
return subdirs
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
@@ -0,0 +1,238 @@
|
|
1
|
+
require 'common/domain'
|
2
|
+
require 'common/user'
|
3
|
+
|
4
|
+
# Methods that all plugins must provide. Certain operations -- for
|
5
|
+
# example, user listing -- must be supported by all plugins. These
|
6
|
+
# operations are defined here, often with naive default
|
7
|
+
# implementations, but it is up to each individual plugin to ensure
|
8
|
+
# that they are in fact implemented (well).
|
9
|
+
#
|
10
|
+
module Plugin
|
11
|
+
|
12
|
+
# These are class methods for runnable plugins, meant to be
|
13
|
+
# _extended_. Those runnable plugins get a magic *run* method but
|
14
|
+
# need to define their own *runner* and *dummy_runner* to make it
|
15
|
+
# work.
|
16
|
+
#
|
17
|
+
module Run
|
18
|
+
|
19
|
+
# A callback function, called whenever another class or module
|
20
|
+
# includes this one. This is used to build a list of all things
|
21
|
+
# that inherited this class. Having such a list lets us run a
|
22
|
+
# collection of plugins without knowing in advance what they are.
|
23
|
+
#
|
24
|
+
# @param c [Class,Module] the name of the class or module that
|
25
|
+
# included us.
|
26
|
+
#
|
27
|
+
def included(c)
|
28
|
+
@includers ||= []
|
29
|
+
@includers << c
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
# Obtain the list of classes and modules that have included this one.
|
34
|
+
#
|
35
|
+
# @return [Array<Class,Module>] the list of classes and modules
|
36
|
+
# that have included this one.
|
37
|
+
#
|
38
|
+
def includers()
|
39
|
+
@includers ||= []
|
40
|
+
return @includers
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
# The runner class associated with this plugin. This method must
|
45
|
+
# be supplied by the child class, since they will all have
|
46
|
+
# different runners.
|
47
|
+
#
|
48
|
+
# @return [Class] the runner class associated with this plugin.
|
49
|
+
#
|
50
|
+
def runner()
|
51
|
+
raise NotImplementedError
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
# The "dummy" runner class associated with this plugin. This method
|
56
|
+
# must be supplied by the child class, since they will all have
|
57
|
+
# different dummy runners.
|
58
|
+
#
|
59
|
+
# @return [Class] the dummy runner class associated with this
|
60
|
+
# plugin.
|
61
|
+
#
|
62
|
+
def dummy_runner()
|
63
|
+
raise NotImplementedError
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
# Run all of the plugins that have included this module.
|
68
|
+
#
|
69
|
+
# @param cfg [Configuration] the configuration options to pass to
|
70
|
+
# each of the plugins.
|
71
|
+
#
|
72
|
+
# @param args [Array<Object>] a variable number of additional
|
73
|
+
# arguments to be passed to the plugins we're running.
|
74
|
+
#
|
75
|
+
def run(cfg, *args)
|
76
|
+
includers().each do |includer|
|
77
|
+
plugin = includer.new(cfg)
|
78
|
+
|
79
|
+
if cfg.i_mean_business then
|
80
|
+
runner = runner().new()
|
81
|
+
else
|
82
|
+
runner = dummy_runner().new()
|
83
|
+
end
|
84
|
+
|
85
|
+
# The splat passes the correct (we hope) number of arguments to the
|
86
|
+
# appropriate runner. The Rm(Dummy)Runner have splats on their
|
87
|
+
# *target arguments as well, to turn ARGV back into an array.
|
88
|
+
runner.run(cfg, plugin, *args)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
# A generic version of {#describe_user}/{#describe_domain} that
|
95
|
+
# dispatches base on the class of the target.
|
96
|
+
#
|
97
|
+
# @param target [User,Domain] either a user or a domain to describe.
|
98
|
+
#
|
99
|
+
# @return [String] a string describing the *target*. The contents of
|
100
|
+
# the string depend on the plugin.
|
101
|
+
#
|
102
|
+
def describe(target)
|
103
|
+
if target.is_a?(User)
|
104
|
+
if user_exists(target) then
|
105
|
+
return describe_user(target)
|
106
|
+
else
|
107
|
+
return 'User not found'
|
108
|
+
end
|
109
|
+
elsif target.is_a?(Domain)
|
110
|
+
if domain_exists(target) then
|
111
|
+
return describe_domain(target)
|
112
|
+
else
|
113
|
+
return 'Domain not found'
|
114
|
+
end
|
115
|
+
else
|
116
|
+
raise NotImplementedError
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
|
121
|
+
# Provide a description of the given *domain*. This is output along
|
122
|
+
# with the domain name and can be anything of use to the system
|
123
|
+
# administrator. The default doesn't do anything useful and should
|
124
|
+
# be overridden if possible.
|
125
|
+
#
|
126
|
+
# @param domain [Domain] the domain to describe.
|
127
|
+
#
|
128
|
+
# @return [String] a string description of *domain*.
|
129
|
+
#
|
130
|
+
def describe_domain(domain)
|
131
|
+
return domain.to_s()
|
132
|
+
end
|
133
|
+
|
134
|
+
|
135
|
+
# Provide a description of the given *user*. This is output along
|
136
|
+
# with the username and can be anything of use to the system
|
137
|
+
# administrator. The default doesn't do anything useful and should
|
138
|
+
# be overridden if possible.
|
139
|
+
#
|
140
|
+
# @param user [User] the domain to describe.
|
141
|
+
#
|
142
|
+
# @return [String] a string description of *user*.
|
143
|
+
#
|
144
|
+
def describe_user(user)
|
145
|
+
return user.to_s()
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
# Return a list of all users managed by this plugin. This must be
|
150
|
+
# supplied by the individual plugins (who know how to find their
|
151
|
+
# users).
|
152
|
+
#
|
153
|
+
# @return [Array<User>] a list of the users that this plugin knows
|
154
|
+
# about.
|
155
|
+
#
|
156
|
+
def list_users()
|
157
|
+
raise NotImplementedError
|
158
|
+
end
|
159
|
+
|
160
|
+
|
161
|
+
# Return a list of all domains managed by this plugin. This must be
|
162
|
+
# supplied by the individual plugins (who know how to find their
|
163
|
+
# domains). Many plugins will not have a separate concept of
|
164
|
+
# "domain", so the default implementation constructs a list of
|
165
|
+
# domains resulting from {#list_users}.
|
166
|
+
#
|
167
|
+
# For plugins that do know about domains, smarter implementations
|
168
|
+
# are surely possible.
|
169
|
+
#
|
170
|
+
# @return [Array<Domain>] a list of the domains that this plugin knows
|
171
|
+
# about.
|
172
|
+
#
|
173
|
+
def list_domains()
|
174
|
+
users = list_users()
|
175
|
+
domains = users.map{ |u| u.domain() }
|
176
|
+
return domains.uniq()
|
177
|
+
end
|
178
|
+
|
179
|
+
|
180
|
+
# Does the given *user* exist for this plugin? We use a naive
|
181
|
+
# implementation here based on {#list_users}. Plugins should override
|
182
|
+
# this with something faster.
|
183
|
+
#
|
184
|
+
# @param user [User] the user whose existence is in question.
|
185
|
+
#
|
186
|
+
# @return [Boolean] true if *user* exists for this plugin, and
|
187
|
+
# false otherwise.
|
188
|
+
#
|
189
|
+
def user_exists(user)
|
190
|
+
users = list_users()
|
191
|
+
return users.include?(user)
|
192
|
+
end
|
193
|
+
|
194
|
+
|
195
|
+
# Does the given *domain* exist for this plugin? We use a naive
|
196
|
+
# implementation here based on {#list_domains}. Plugins that know
|
197
|
+
# about domains should override this with something fast.
|
198
|
+
#
|
199
|
+
# @param domain [Domain] the domain whose existence is in question.
|
200
|
+
#
|
201
|
+
# @return [Boolean] true if *domain* exists for this plugin, and
|
202
|
+
# false otherwise.
|
203
|
+
#
|
204
|
+
def domain_exists(domain)
|
205
|
+
domains = list_domains()
|
206
|
+
return domains.include?(domain)
|
207
|
+
end
|
208
|
+
|
209
|
+
|
210
|
+
# List all users belonging to the given domains. We say that a user
|
211
|
+
# belongs to a domain "example.com" if the domain part of the user's
|
212
|
+
# email address is "example.com".
|
213
|
+
#
|
214
|
+
# This uses a naive loop, but relies only on the existence of
|
215
|
+
# {#list_users}. Plugins that know about domains should provide a
|
216
|
+
# more efficient implementation.
|
217
|
+
#
|
218
|
+
# @param domains [Array<Domain>] the domains whose users we want.
|
219
|
+
#
|
220
|
+
# @return [Array<User>] a list of {User} objects belonging to
|
221
|
+
# *domains* for this plugin.
|
222
|
+
#
|
223
|
+
def list_domains_users(domains)
|
224
|
+
domains_users = []
|
225
|
+
|
226
|
+
users = list_users();
|
227
|
+
domains.each do |d|
|
228
|
+
matches = users.select do |user|
|
229
|
+
user.domainpart() == d.to_s()
|
230
|
+
end
|
231
|
+
|
232
|
+
domains_users += matches
|
233
|
+
end
|
234
|
+
|
235
|
+
return domains_users
|
236
|
+
end
|
237
|
+
|
238
|
+
end
|