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,53 @@
1
+ # Class methods for the creation and manipulation of our command-line
2
+ # user interface.
3
+ #
4
+ class UserInterface
5
+
6
+ # Construct a usage string showing how to invoke the program.
7
+ #
8
+ # @param program_name [String] the name of this program, used to
9
+ # construct the usage string.
10
+ #
11
+ # @return [String] a string showing the format of a correct program
12
+ # invocation.
13
+ #
14
+ def self.usage(program_name)
15
+ return "#{program_name} [prune | rm <target> | mv <src> <dst>]"
16
+ end
17
+
18
+
19
+ # Construct the header that precedes our other output. An example is,
20
+ #
21
+ # mailshears, 2015-11-06 09:57:06 -0500 (Plugin: PrunePlugin)
22
+ # ------------------------------------------------------------
23
+ #
24
+ # @param program_name [String] the name of this program, to appear
25
+ # in the header.
26
+ #
27
+ # @param plugin_name [String] the name of the mode (prune, mv, etc.)
28
+ # plugin that is being run.
29
+ #
30
+ # @return [String] a string containing the output header.
31
+ #
32
+ def self.make_header(program_name, plugin_name)
33
+ header = "#{program_name}, "
34
+
35
+ current_time = Time.now()
36
+ if current_time.respond_to?(:iso8601)
37
+ # Somehow this method is missing on some machines.
38
+ header += current_time.iso8601.to_s()
39
+ else
40
+ # Fall back to whatever this looks like.
41
+ header += current_time.to_s()
42
+ end
43
+
44
+ header += ' (Plugin: ' + plugin_name + ")\n"
45
+
46
+ # Underline the header, accounting for the newline.
47
+ header += '-' * (header.size() - 1)
48
+
49
+ return header
50
+ end
51
+
52
+
53
+ end
data/lib/mailshears.rb ADDED
@@ -0,0 +1,7 @@
1
+ # Load only the files needed by our executable. The library files are
2
+ # supposed to require what they need themselves.
3
+
4
+ require 'common/configuration'
5
+ require 'common/errors'
6
+ require 'common/exit_codes'
7
+ require 'common/user_interface'
@@ -0,0 +1,45 @@
1
+ require 'common/runner'
2
+
3
+ # Dummy implementation of a {MvRunner}. Its <tt>run()</tt> method will
4
+ # tell you what would have been moved, but will not actually perform
5
+ # the operation.
6
+ #
7
+ class MvDummyRunner
8
+ include Runner
9
+
10
+ # Pretend to move *src* to *dst* with *plugin*. Some "what if"
11
+ # information will be output to stdout. This is useful to see if
12
+ # there would be (for example) a username collision at *dst* before
13
+ # attempting the move in earnest.
14
+ #
15
+ # @param cfg [Configuration] the configuration options to pass to
16
+ # the *plugin* we're runnning.
17
+ #
18
+ # @param plugin [Class] plugin class that will perform the move.
19
+ #
20
+ # @param src [User] the source user to be moved.
21
+ #
22
+ # @param dst [User] the destination user, to which we will move *src*.
23
+ #
24
+ def run(cfg, plugin, src, dst)
25
+
26
+ if src.is_a?(Domain) or dst.is_a?(Domain) then
27
+ msg = 'only users can be moved'
28
+ raise NotImplementedError.new(msg)
29
+ end
30
+
31
+ # Since we're not actually moving anything, the destination
32
+ # description is really only useful for seeing whether or not we'd
33
+ # be trying to move in on top of an existing account.
34
+ src_description = plugin.describe(src)
35
+ dst_description = plugin.describe(dst)
36
+
37
+ msg = "Would move user "
38
+ msg += add_description(src, src_description)
39
+ msg += " to "
40
+ add_description(dst, dst_description)
41
+ msg += "."
42
+ report(plugin, msg)
43
+ end
44
+
45
+ end
@@ -0,0 +1,40 @@
1
+ require 'common/plugin.rb'
2
+
3
+ # Plugins for moving (renaming) users. Moving domains is not supported.
4
+ #
5
+ module MvPlugin
6
+
7
+ # Absorb the subclass run() magic from the Plugin::Run module.
8
+ extend Plugin::Run
9
+
10
+ # The runner class associated with move plugins.
11
+ #
12
+ # @return [Class] the {MvRunner} class.
13
+ #
14
+ def self.runner()
15
+ return MvRunner
16
+ end
17
+
18
+
19
+ # The "dummy" runner class associated with move plugins.
20
+ #
21
+ # @return [Class] the {MvDummyRunner} class.
22
+ #
23
+ def self.dummy_runner()
24
+ return MvDummyRunner
25
+ end
26
+
27
+
28
+ # The interface for the "move a user" operation. Subclasses need to
29
+ # implement this method so that it moves (renames) the user *src* to
30
+ # the user *dst*.
31
+ #
32
+ # @param src [User] the source user to be moved.
33
+ #
34
+ # @param dst [User] the destination user to which we'll move *src*.
35
+ #
36
+ def mv_user(src, dst)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ end
@@ -0,0 +1,56 @@
1
+ require 'common/domain'
2
+ require 'common/errors'
3
+ require 'common/runner'
4
+
5
+ # Perform the moving (renaming) of users/domains using {MvPlugin}s.
6
+ #
7
+ class MvRunner
8
+ include Runner
9
+
10
+ # Run *plugin* to move the user *src* to *dst*. The method
11
+ # signature includes the unused *cfg* for consistency with the
12
+ # runners that do need a {Configuration}.
13
+ #
14
+ # @param cfg [Configuration] unused.
15
+ #
16
+ # @param plugin [Class] plugin class that will perform the move.
17
+ #
18
+ # @param src [User] the source user to be moved.
19
+ #
20
+ # @param dst [User] the destination user being moved to.
21
+ #
22
+ def run(cfg, plugin, src, dst)
23
+
24
+ if src.is_a?(Domain) or dst.is_a?(Domain) then
25
+ msg = 'only users can be moved'
26
+ raise NotImplementedError.new(msg)
27
+ end
28
+
29
+ begin
30
+ src_description = plugin.describe(src)
31
+ plugin.mv_user(src, dst)
32
+ dst_description = plugin.describe(dst)
33
+
34
+ msg = "Moved user "
35
+ msg += add_description(src, src_description)
36
+ msg += " to "
37
+ msg += add_description(dst, dst_description)
38
+ msg += "."
39
+ report(plugin, msg)
40
+
41
+ rescue NonexistentUserError => e
42
+ # This means that the SOURCE user didn't exist, since a
43
+ # nonexistent destination user is perfectly expected.
44
+ report(plugin, "Source user #{src.to_s()} not found.")
45
+ rescue NonexistentDomainError => e
46
+ # This could mean that the source domain doesn't exist, but in
47
+ # that case, we just report that the source user doesn't
48
+ # exist. So a nonexistent domain refers to a nonexistent
49
+ # DESTINATION domain.
50
+ report(plugin, "Destination domain #{dst.domainpart()} not found.")
51
+ rescue UserAlreadyExistsError => e
52
+ report(plugin, "Destination user #{dst.to_s()} already exists.")
53
+ end
54
+ end
55
+
56
+ end
@@ -0,0 +1,46 @@
1
+ require 'pg'
2
+
3
+ require 'common/agendav_plugin'
4
+ require 'mv/mv_plugin'
5
+
6
+
7
+ # Handle moving (renaming) Agendav users in its database. Agendav has
8
+ # no concept of domains.
9
+ #
10
+ class AgendavMv
11
+
12
+ include AgendavPlugin
13
+ include MvPlugin
14
+
15
+ # Move the user *src* to *dst* within the Agendav database. This
16
+ # should "rename" him in _every_ table where he is referenced.
17
+ #
18
+ # This can fail is *src* does not exist, or if *dst* already exists
19
+ # before the move. It should also be an error if the destination
20
+ # domain doesn't exist. But Agendav doesn't know about domains, so
21
+ # we let that slide.
22
+ #
23
+ # @param src [User] the source user to be moved.
24
+ #
25
+ # @param dst [User] the destination user being moved to.
26
+ #
27
+ def mv_user(src, dst)
28
+ raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
29
+ raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
30
+
31
+ sql_queries = ['UPDATE prefs SET username = $1 WHERE username = $2;']
32
+ sql_queries << 'UPDATE shared SET user_from = $1 WHERE user_from = $2;'
33
+ sql_queries << 'UPDATE shared SET user_which = $1 WHERE user_which = $2;'
34
+
35
+ connection = PG::Connection.new(@db_hash)
36
+ begin
37
+ sql_queries.each do |sql_query|
38
+ connection.query(sql_query, [dst.to_s(), src.to_s()])
39
+ end
40
+ ensure
41
+ # Make sure the connection gets closed even if a query explodes.
42
+ connection.close()
43
+ end
44
+ end
45
+
46
+ end
@@ -0,0 +1,43 @@
1
+ require 'pg'
2
+
3
+ require 'common/davical_plugin'
4
+ require 'rm/rm_plugin'
5
+
6
+ # Handle moving (renaming) DAViCal users in its database. DAViCal has
7
+ # no concept of domains.
8
+ #
9
+ class DavicalMv
10
+ include DavicalPlugin
11
+ include MvPlugin
12
+
13
+
14
+ # Move the user *src* to *dst* within the DAViCal database. This
15
+ # should "rename" him in _every_ table where he is referenced.
16
+ # DAViCal uses foreign keys properly, so we let the ON UPDATE
17
+ # CASCADE trigger handle most of the work.
18
+ #
19
+ # This can fail is *src* does not exist, or if *dst* already exists
20
+ # before the move. It should also be an error if the destination
21
+ # domain doesn't exist. But DAViCal doesn't know about domains, so
22
+ # we let that slide.
23
+ #
24
+ # @param src [User] the source user to be moved.
25
+ #
26
+ # @param dst [User] the destination user being moved to.
27
+ #
28
+ def mv_user(src, dst)
29
+ raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
30
+ raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
31
+
32
+ sql_query = 'UPDATE usr SET username = $1 WHERE username = $2;'
33
+
34
+ connection = PG::Connection.new(@db_hash)
35
+ begin
36
+ connection.query(sql_query, [dst.to_s(), src.to_s()])
37
+ ensure
38
+ # Make sure the connection gets closed even if the query explodes.
39
+ connection.close()
40
+ end
41
+ end
42
+
43
+ end
@@ -0,0 +1,64 @@
1
+ require 'fileutils'
2
+
3
+ require 'common/filesystem'
4
+ require 'common/dovecot_plugin'
5
+ require 'mv/mv_plugin'
6
+
7
+
8
+ # Handle moving (renaming) Dovecot users on the filesystem.
9
+ #
10
+ class DovecotMv
11
+
12
+ include DovecotPlugin
13
+ include MvPlugin
14
+
15
+
16
+ # Move the Dovecot user *src* to *dst*. This relocates the user's
17
+ # directory within the Dovecot mailstore (on the filesystem).
18
+ #
19
+ # This fails if the source user does not exist, or if the
20
+ # destination user already exists before the move.
21
+ #
22
+ # But is it an error if the target domain does not exist? That's a
23
+ # bit subtle... The domain may exist in the database, but if it
24
+ # has not received any mail yet, then its directory won't exist
25
+ # on-disk.
26
+ #
27
+ # There are two possible "oops" scenarios resulting from the fact
28
+ # that we may run either the Postfixadmin move first or the
29
+ # Dovecot move first. If we move the user in the database, we
30
+ # definitely want to move him on disk (that is, we should create
31
+ # the directory here). But if we move him on disk first, then we
32
+ # don't know if the database move will fail! We don't want to move
33
+ # his mail files if he won't get moved in the database.
34
+ #
35
+ # Faced with two equally-bad (but easy-to-fix) options, we do the
36
+ # simplest thing and fail if the destination domain directory
37
+ # doesn't exist. If nothing else, this is at least consistent.
38
+ #
39
+ # @param src [User] the source user to be moved.
40
+ #
41
+ # @param dst [User] the destination user being moved to.
42
+ #
43
+ def mv_user(src, dst)
44
+ raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
45
+ raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
46
+
47
+ # See the docstring...
48
+ if not self.domain_exists(dst.domain()) then
49
+ raise NonexistentDomainError.new(dst.domainpart())
50
+ end
51
+
52
+ # We may need to create the target domain directory, even if the
53
+ # domain exists in the database.
54
+ FileUtils.mkdir_p(self.get_domain_path(dst.domain()))
55
+
56
+ # The parent of dst_path exists because we just created it.The
57
+ # source path should exist too, because the "source user" does,
58
+ # and, well, how did we determine that?
59
+ src_path = self.get_user_path(src)
60
+ dst_path = self.get_user_path(dst)
61
+ FileUtils.mv(src_path, dst_path)
62
+ end
63
+
64
+ end
@@ -0,0 +1,70 @@
1
+ require 'pg'
2
+
3
+ require 'common/postfixadmin_plugin'
4
+ require 'mv/mv_plugin'
5
+
6
+
7
+ # Handle moving (renaming) of users in the Postfixadmin database.
8
+ #
9
+ class PostfixadminMv
10
+
11
+ include PostfixadminPlugin
12
+ include MvPlugin
13
+
14
+
15
+ # Move the user *src* to *dst* within the Postfixadmin
16
+ # database. This should "rename" him in _every_ table where he is
17
+ # referenced. Unfortunately that must be done manually.
18
+ #
19
+ # This can fail is *src* does not exist, or if *dst* already exists
20
+ # before the move. It will also fail if the domain associated with
21
+ # the user *dst* does not exist.
22
+ #
23
+ # @param src [User] the source user to be moved.
24
+ #
25
+ # @param dst [User] the destination user being moved to.
26
+ #
27
+ def mv_user(src, dst)
28
+ raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
29
+
30
+ if not domain_exists(dst.domain())
31
+ raise NonexistentDomainError.new(dst.domain.to_s())
32
+ end
33
+
34
+ raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
35
+
36
+ mailbox_query = 'UPDATE mailbox SET '
37
+ mailbox_query += ' username=$1,'
38
+ mailbox_query += ' domain=$2,'
39
+ mailbox_query += " maildir=CONCAT($2, '/', $3, '/'),"
40
+ mailbox_query += ' local_part=$3 '
41
+ mailbox_query += 'WHERE username=$4;'
42
+
43
+ alias_query1 = 'UPDATE alias SET '
44
+ alias_query1 += ' address=$1,'
45
+ alias_query1 += ' domain=$2,'
46
+ alias_query1 += ' goto=REPLACE(goto, $4, $1) '
47
+ alias_query1 += 'WHERE address=$4;'
48
+
49
+ alias_query2 = 'UPDATE alias SET '
50
+ alias_query2 += 'goto=REPLACE(goto, $4, $1);'
51
+
52
+ sql_queries = [mailbox_query, alias_query1, alias_query2]
53
+
54
+ connection = PG::Connection.new(@db_hash)
55
+ begin
56
+ sql_queries.each do |sql_query|
57
+ varchar = 1043 # from pg_type.h
58
+ params = [{:value => dst.to_s(), :type => varchar},
59
+ {:value => dst.domainpart(), :type => varchar},
60
+ {:value => dst.localpart(), :type => varchar},
61
+ {:value => src.to_s(), :type => varchar}]
62
+ connection.query(sql_query, params)
63
+ end
64
+ ensure
65
+ # Make sure the connection gets closed even if a query explodes.
66
+ connection.close()
67
+ end
68
+ end
69
+
70
+ end
@@ -0,0 +1,44 @@
1
+ require 'pg'
2
+
3
+ require 'common/roundcube_plugin'
4
+ require 'mv/mv_plugin'
5
+
6
+ # Handle moving (renaming) of users in the Roundcube
7
+ # database. Roundcube has no concept of domains.
8
+ #
9
+ class RoundcubeMv
10
+
11
+ include RoundcubePlugin
12
+ include MvPlugin
13
+
14
+
15
+ # Move the user *src* to *dst* within the Roundcube database. This
16
+ # should "rename" him in _every_ table where he is referenced.
17
+ # Roundcube uses foreign keys properly, so we let the ON UPDATE
18
+ # CASCADE trigger handle most of the work.
19
+ #
20
+ # This can fail is *src* does not exist, or if *dst* already exists
21
+ # before the move. It should also be an error if the destination
22
+ # domain doesn't exist. But Roundcube doesn't know about domains, so
23
+ # we let that slide.
24
+ #
25
+ # @param src [User] the source user to be moved.
26
+ #
27
+ # @param dst [User] the destination user being moved to.
28
+ #
29
+ def mv_user(src, dst)
30
+ raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
31
+ raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
32
+
33
+ sql_query = 'UPDATE users SET username = $1 WHERE username = $2;'
34
+
35
+ connection = PG::Connection.new(@db_hash)
36
+ begin
37
+ connection.query(sql_query, [dst.to_s(), src.to_s()])
38
+ ensure
39
+ # Make sure the connection gets closed even if the query explodes.
40
+ connection.close()
41
+ end
42
+ end
43
+
44
+ end