Document everything with YARD and fix some bugs along the way.
authorMichael Orlitzky <michael@orlitzky.com>
Sun, 8 Nov 2015 03:34:38 +0000 (22:34 -0500)
committerMichael Orlitzky <michael@orlitzky.com>
Sun, 8 Nov 2015 03:34:38 +0000 (22:34 -0500)
38 files changed:
bin/mailshears
lib/common/agendav_plugin.rb
lib/common/configuration.rb
lib/common/davical_plugin.rb
lib/common/domain.rb
lib/common/dovecot_plugin.rb
lib/common/exit_codes.rb
lib/common/filesystem.rb
lib/common/plugin.rb
lib/common/postfixadmin_plugin.rb
lib/common/roundcube_plugin.rb
lib/common/runner.rb
lib/common/user.rb
lib/common/user_interface.rb
lib/mv/mv_dummy_runner.rb
lib/mv/mv_plugin.rb
lib/mv/mv_runner.rb
lib/mv/plugins/agendav.rb
lib/mv/plugins/davical.rb
lib/mv/plugins/dovecot.rb
lib/mv/plugins/postfixadmin.rb
lib/mv/plugins/roundcube.rb
lib/prune/plugins/agendav.rb
lib/prune/plugins/davical.rb
lib/prune/plugins/dovecot.rb
lib/prune/plugins/postfixadmin.rb
lib/prune/plugins/roundcube.rb
lib/prune/prune_dummy_runner.rb
lib/prune/prune_plugin.rb
lib/prune/prune_runner.rb
lib/rm/plugins/agendav.rb
lib/rm/plugins/davical.rb
lib/rm/plugins/dovecot.rb
lib/rm/plugins/postfixadmin.rb
lib/rm/plugins/roundcube.rb
lib/rm/rm_dummy_runner.rb
lib/rm/rm_plugin.rb
lib/rm/rm_runner.rb

index c5efabb32b5b1be125275c7f17279421d23b5f58..d285171b203144117600ca782db8462e51314fb4 100755 (executable)
@@ -7,8 +7,7 @@
 require 'mailshears'
 
 # Define a usage string using the program name.
-exe = File.basename($PROGRAM_NAME)
-usage = "#{exe} [prune | rm <target> | mv <src> <dst>]"
+program_name = File.basename($PROGRAM_NAME)
 
 # Defaults
 mode_name = 'prune'
@@ -48,7 +47,7 @@ end
 # here. Report it and exit with a failure code.
 if not args_error_message.nil? then
   STDERR.puts args_error_message
-  puts "Usage: #{usage}"
+  puts "Usage: #{UserInterface.usage(program_name)}"
   Kernel.exit(ExitCodes::BAD_COMMAND_LINE)
 end
 
@@ -115,7 +114,7 @@ ensure
   $stdout = STDOUT
 
   if output_buffer.size > 0 then
-    puts make_header(exe, plugin_module.to_s())
+    puts UserInterface.make_header(program_name, plugin_module.to_s())
     puts output_buffer.string()
   end
 end
index 9af7b01519b40aca2870176948ed4f0ac6f950cb..8eb73c19e029259426f9958e94f81af373ab0a14 100644 (file)
@@ -1,12 +1,18 @@
 require 'common/plugin'
 require 'common/user'
 
+# Code that all Agendav plugins ({AgendavPrune}, {AgendavRm},
+# {AgendavMv}) share.
 module AgendavPlugin
-  # Code that all Agendav plugins (Prune, Rm, Mv...) will
-  # share.  That is, we implement the Plugin interface.
+
+  # We implement the Plugin "interface."
   include Plugin
 
 
+  # Initialize this Agendav {Plugin} with values in *cfg*.
+  #
+  # @param cfg [Configuration] the configuration for this plugin.
+  #
   def initialize(cfg)
     @db_host = cfg.agendav_dbhost
     @db_port = cfg.agendav_dbport
@@ -18,11 +24,12 @@ module AgendavPlugin
   end
 
 
+  # Return a list of Agendav users.
+  #
+  # @return [Array<User>] a list of users contained in the
+  #   Agendav database.
+  #
   def list_users()
-    #
-    # Produce a list of AgenDAV users. This is public because it's
-    # useful in testing.
-    #
     users = []
 
     connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
index caeab92a655a8dd92aa9b4dfa8784b2edc64ff14..b810b02f86c9d44711fbdae7836c391035d72c80 100644 (file)
@@ -1,10 +1,32 @@
 require 'yaml'
 
+# A configuration object that knows how to read options out of a file
+# in <tt>~/.mailshears.conf.yml</tt>. The configuration options can be
+# accessed via methods even though the internal representation is a
+# hash.
+#
+# === Examples
+#
+#   >> cfg = Configuration.new()
+#   >> cfg.i_mean_business()
+#   => true
+#
 class Configuration
 
+  # The default path to the user's configuration file.
   USERCONF_PATH = ENV['HOME'] + '/.mailshears.conf.yml'
+
+  # The hash structure in which we store our configuration options
+  # internally.
   @dict = {}
 
+
+  # Initialize a {Configuration} object with the config file at *path*.
+  #
+  # @param path [String] the path to the configuration file to
+  #   load. We check for a file named ".mailshears.conf.yml" in the
+  #   user's home directory by default.
+  #
   def initialize(path = USERCONF_PATH)
     cfg = default_configuration()
 
@@ -28,14 +50,26 @@ class Configuration
   end
 
 
+  # Replace all missing method calls with hash lookups. This lets us
+  # retrieve the values in our option hash by using methods named
+  # after the associated keys.
+  #
+  # @param sym [Symbol] the method that was called.
+  #
+  # @return [Object] the config file value associated with *sym*.
+  #
   def method_missing(sym, *args)
-    # Replace all missing method calls with dictionary lookups.
     return @dict[sym]
   end
 
 
   private;
 
+
+  # A default config hash.
+  #
+  # @return [Hash] sensible default configuration values.
+  #
   def default_configuration()
     d = {}
 
index 363e08ef298cfc402aaf051ad486fedd3f553323..8684cca9e830a5005e98ef82c7ac916f511decf6 100644 (file)
@@ -1,11 +1,18 @@
 require 'common/plugin'
 require 'common/user'
 
+# Code that all DAViCal plugins ({DavicalPrune}, {DavicalRm}, and
+# {DavicalMv}) will share.
+#
 module DavicalPlugin
-  # Code that all Davical plugins (Prune, Rm, Mv...) will share.  That
-  # is, we implement the Plugin interface.
+
+  # We implement the Plugin "interface."
   include Plugin
 
+  # Initialize this DAViCal {Plugin} with values in *cfg*.
+  #
+  # @param cfg [Configuration] the configuration for this plugin.
+  #
   def initialize(cfg)
     @db_host = cfg.davical_dbhost
     @db_port = cfg.davical_dbport
@@ -17,17 +24,28 @@ module DavicalPlugin
   end
 
 
+  # Describe the given DAViCal user who is assumed to exist.
+  #
+  # @param user [User] the {User} object whose description we want.
+  #
+  # @return [String] a String describing the given *user* in terms
+  #   of his DAViCal "Principal ID".
+  #
   def describe_user(user)
     principal_id = self.get_principal_id(user)
     return "Principal ID: #{principal_id}"
   end
 
 
+  #
+  # Produce a list of DAViCal users.
+  #
+  # This method remains public for use in testing.
+  #
+  # @return [Array<User>] an array of {User} objects, one for each
+  #   user found in the DAViCal database.
+  #
   def list_users()
-    #
-    # Produce a list of DAViCal users. This is public because it's
-    # useful for testing.
-    #
     usernames = []
 
     connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
@@ -48,6 +66,14 @@ module DavicalPlugin
 
   protected;
 
+
+  # Find the "Principal ID" of the given user.
+  #
+  # @param user [User] the user whose Principal ID we want.
+  #
+  # @return [Fixnum] an integer representing the user's Principal ID
+  #   that we obtained from the DAViCal database.
+  #
   def get_principal_id(user)
     principal_id = nil
 
index ec4fecf35ee579cda7be81abbf8e50a9ec6f2ce5..a9e544053808857ef8cd7b1987cea95319247dd5 100644 (file)
@@ -1,9 +1,21 @@
 require 'common/errors'
 
+# A class representing a syntactically valid domain. Essentially, the
+# part after the "@" in an email address. Once constructed, you can be
+# sure that it's valid.
+#
 class Domain
 
+  # @domain contains the String representation of this domain.
   @domain = nil
 
+
+  # Initialize this Domain object. If the domain is invalid, then an
+  # {InvalidDomainError} will be raised containing the reason
+  # why the domain is invalid.
+  #
+  # @param domain [String] the string representation of this domain.
+  #
   def initialize(domain)
     if domain.empty? then
       msg = "domain cannot be empty"
@@ -13,14 +25,39 @@ class Domain
     @domain = domain
   end
 
+
+  # Convert this domain to a String.
+  #
+  # @return [String] the string representation of this Domain.
+  #
   def to_s()
     return @domain
   end
 
+
+  # Check if this Domain is equal to some other Domain. The comparison
+  # is based on their String representations.
+  #
+  # @param other [Domain] the Domain object to compare me to.
+  #
+  # @return [Boolean] If *self* and *other* have equal String
+  #   representations, then true is returned. Otherwise, false is
+  #   returned.
+  #
   def ==(other)
     return self.to_s() == other.to_s()
   end
 
+
+  # Compare two Domain objects for sorting purposes. The comparison
+  # is based on their String representations.
+  #
+  # @param other [Domain] the Domain object to compare me to.
+  #
+  # @return [0,1,-1] a trinary indicator of how *self* relates to *other*,
+  #   obtained by performing the same comparison on the String
+  #   representations of *self* and *other*.
+  #
   def <=>(other)
     return self.to_s() <=> other.to_s()
   end
index fa61d5367b593d959c1839de143f1f0c767490db..213e99fd7bcf266b6b436430cad149394f623a65 100644 (file)
@@ -3,20 +3,44 @@ require 'common/filesystem'
 require 'common/plugin'
 require 'common/user'
 
-
+# Code that all Dovecot plugins ({DovecotPrune}, {DovecotRm}, and
+# {DovecotMv}) will share.
+#
 module DovecotPlugin
-  # Code that all Dovecot plugins (Prune, Rm, Mv...) will
-  # share.  That is, we implement the Plugin interface.
+
+  # We implement the Plugin "interface."
   include Plugin
 
+
+  # Initialize this Dovecot {Plugin} with values in *cfg*.
+  #
+  # @param cfg [Configuration] the configuration for this plugin.
+  #
   def initialize(cfg)
     @domain_root = cfg.dovecot_mail_root
   end
 
+  # Describe the given Dovecot domain by its filesystem path. The
+  # domain need not exist to obtain its path.
+  #
+  # @param domain [Domain] the {Domain} object whose description we want.
+  #
+  # @return [String] a String giving the path under which this domain's
+  #   mailboxes would reside on the filesystem.
+  #
   def describe_domain(domain)
     return get_domain_path(domain)
   end
 
+
+  # Describe the given Dovecot user by its filesystem mailbox
+  # path. The user need not exist to obtain its mailbox path.
+  #
+  # @param user [User] the {User} object whose description we want.
+  #
+  # @return [String] a String giving the path where this user's
+  #   mailbox would reside on the filesystem.
+  #
   def describe_user(user)
     return get_user_path(user)
   end
@@ -24,25 +48,51 @@ module DovecotPlugin
 
   protected;
 
+  # Return the filesystem path for the given {Domain} object.
+  #
+  # @param domain [Domain] the {Domain} whose path we want.
+  #
+  # @return [String] the filesystem path where this domain's mail
+  #   would be located.
+  #
   def get_domain_path(domain)
-    # Return the filesystem path for the given domain.
-    # That is, the directory where its mail is stored.
-    # Only works if the domain directory exists!
     return File.join(@domain_root, domain.to_s())
   end
 
 
+  # Return the filesystem path of this {User}'s mailbox.
+  #
+  # @param user [User] the {User} whose mailbox path we want.
+  #
+  # @return [String] the filesystem path where this user's mail
+  #   would be located.
+  #
   def get_user_path(user)
-    # Return the filesystem path of this user's mailbox.
     domain_path = get_domain_path(user.domain())
     return File.join(domain_path, user.localpart())
   end
 
 
+  # Produce a list of domains that exist in the Dovecot mailstore.
+  #
+  # @return [Array<Domain>] an array of {Domain} objects that have
+  #   corresponding directories within the Dovecot mailstore.
+  #
   def list_domains()
     return Filesystem.get_subdirs(@domain_root).map{ |d| Domain.new(d) }
   end
 
+
+  # Produce a list of users belonging to the given *domains* in the
+  # Dovecot mailstore.
+  #
+  # @param domains [Array<Domain>] an array of {Domain} objects whose
+  #   users we'd like to find.
+  #
+  # @return [Array<User>] an array of {User} objects that have
+  #   corresponding directories within the Dovecot mailstore belonging
+  #   to the specified *domains*.
+  #
   def list_domains_users(domains)
     users = []
 
@@ -66,6 +116,11 @@ module DovecotPlugin
   end
 
 
+  # Produce a list of all users in the Dovecot mailstore.
+  #
+  # @return [Array<User>] a list of users who have mailbox directories
+  #   within the Dovecot mailstore.
+  #
   def list_users()
     domains = list_domains()
     users = list_domains_users(domains)
index b7928a74eb4b7c61733623536a236f676742bf33..a0d030b2648f3c8cdf07e546fc72907868547f03 100644 (file)
@@ -1,3 +1,5 @@
+# Command-line exit codes. In other words, what you'll see if you
+# <tt>echo $?</tt> after running the executable on the command-line.
 module ExitCodes
   # Everything went better than expected.
   SUCCESS = 0
index b44e52ce3979506a82155a9db9ac658257c6b19a..0c80b59e55f98488a118faf5251e8b1a372af551 100644 (file)
@@ -1,8 +1,8 @@
+# Convenience methods for working with the filesystem. This class
+# only provides static methods, to be used analogously to the File
+# class (for example, <tt>File.directory?</tt>).
+#
 class Filesystem
-  # Convenience methods for working with the filesystem. This class
-  # only provides static methods, to be used analogously to the File
-  # class (for example, File.directory?).
-
 
   # Return whether or not the given path begins with a dot (ASCII
   # period).
@@ -26,6 +26,7 @@ class Filesystem
   #
   def self.get_subdirs(dir)
     subdirs = []
+    return subdirs if not File.directory?(dir)
 
     Dir.open(dir) do |d|
       d.each do |entry|
index dba8becb2756c0324ecd252adb9b3602de218afb..02fc75ab0d53f90ac86032448d67be7cb5109f96 100644 (file)
@@ -1,38 +1,78 @@
 require 'common/domain'
 require 'common/user'
 
-# All plugins should include this module. It defines the basic
-# operations that all plugins are supposed to support.
+# Methods that all plugins must provide. Certain operations -- for
+# example, user listing -- must be supported by all plugins. These
+# operations are defined here, often with naive default
+# implementations, but it is up to each individual plugin to ensure
+# that they are in fact implemented (well).
+#
 module Plugin
 
+  # These are class methods for runnable plugins, meant to be
+  # _extended_. Those runnable plugins get a magic *run* method but
+  # need to define their own *runner* and *dummy_runner* to make it
+  # work.
+  #
   module Run
-    # Module methods, meant to be extended. Includers can explicitly
-    # extend this to get a run() method defined in terms of their own
-    # runner() and dummy_runner() methods.
 
+    # A callback function, called whenever another class or module
+    # includes this one. This is used to build a list of all things
+    # that inherited this class. Having such a list lets us run a
+    # collection of plugins without knowing in advance what they are.
+    #
+    # @param c [Class,Module] the name of the class or module that
+    #   included us.
+    #
     def included(c)
-      # Callback, called whenever another class or module includes this
-      # one. The parameter given is the name of the class or module
-      # that included us.
+      puts c.class().to_s()
       @includers ||= []
       @includers << c
     end
 
-    def includers
+
+    # Obtain the list of classes and modules that have included this one.
+    #
+    # @return [Array<Class,Module>] the list of classes and modules
+    #   that have included this one.
+    #
+    def includers()
       @includers ||= []
       return @includers
     end
 
+
+    # The runner class associated with this plugin. This method must
+    # be supplied by the child class, since they will all have
+    # different runners.
+    #
+    # @return [Class] the runner class associated with this plugin.
+    #
     def runner()
-      # The Runner associated with this plugin.
       raise NotImplementedError
     end
 
+    
+    # The "dummy" runner class associated with this plugin. This method
+    # must be supplied by the child class, since they will all have
+    # different dummy runners.
+    #
+    # @return [Class] the dummy runner class associated with this
+    #   plugin.
+    #
     def dummy_runner()
-      # The DummyRunner associated with this plugin.
       raise NotImplementedError
     end
 
+
+    # Run all of the plugins that have included this module.
+    #
+    # @param cfg [Configuration] the configuration options to pass to
+    #   each of the plugins.
+    #
+    # @param args [Array<Object>] a variable number of additional
+    #   arguments to be passed to the plugins we're running.
+    #
     def run(cfg, *args)
       includers().each do |includer|
         plugin = includer.new(cfg)
@@ -51,9 +91,16 @@ module Plugin
     end
   end
 
+
+  # A generic version of {#describe_user}/{#describe_domain} that
+  # dispatches base on the class of the target.
+  #
+  # @param target [User,Domain] either a user or a domain to describe.
+  #
+  # @return [String] a string describing the *target*. The contents of
+  #   the string depend on the plugin.
+  #
   def describe(target)
-    # A generic version of describe_user/describe_domain that
-    # dispatches base on the class of the target.
     if target.is_a?(User)
       if user_exists(target) then
         return describe_user(target)
@@ -71,59 +118,110 @@ module Plugin
     end
   end
 
+
+  # Provide a description of the given *domain*. This is output along
+  # with the domain name and can be anything of use to the system
+  # administrator. The default doesn't do anything useful and should
+  # be overridden if possible.
+  #
+  # @param domain [Domain] the domain to describe.
+  #
+  # @return [String] a string description of *domain*.
+  #
   def describe_domain(domain)
-    # Provide a "description" of the domain. This is output along with
-    # the domain name and can be anything of use to the system
-    # administrator. The default doesn't do anything useful and should
-    # be overridden.
     return domain.to_s()
   end
 
+
+  # Provide a description of the given *user*. This is output along
+  # with the username and can be anything of use to the system
+  # administrator. The default doesn't do anything useful and should
+  # be overridden if possible.
+  #
+  # @param user [User] the domain to describe.
+  #
+  # @return [String] a string description of *user*.
+  #
   def describe_user(user)
-    # Provide a "description" of the user. This is output along
-    # with the domain name and can be anything of use to the system
-    # administrator. The default doesn't do anything useful and should
-    # be overridden.
     return user.to_s()
   end
 
+
+  # Return a list of all users managed by this plugin. This must be
+  # supplied by the individual plugins (who know how to find their
+  # users).
+  #
+  # @return [Array<User>] a list of the users that this plugin knows
+  #   about.
+  #
   def list_users()
-    # Return a list of all users managed by this plugin.
     raise NotImplementedError
   end
 
+
+  # Return a list of all domains managed by this plugin. This must be
+  # supplied by the individual plugins (who know how to find their
+  # domains). Many plugins will not have a separate concept of
+  # "domain", so the default implementation constructs a list of
+  # domains resulting from {#list_users}.
+  #
+  # For plugins that do know about domains, smarter implementations
+  # are surely possible.
+  #
+  # @return [Array<Domain>] a list of the domains that this plugin knows
+  #   about.
+  #
   def list_domains()
-    # Compute the domains from a list of users. Obviously much worse
-    # than getting the domains the "smart" way, if such a way exists.
     users = list_users()
     domains = users.map{ |u| u.domain() }
     return domains.uniq()
   end
 
+
+  # Does the given *user* exist for this plugin? We use a naive
+  # implementation here based on {#list_users}. Plugins should override
+  # this with something faster.
+  #
+  # @param user [User] the user whose existence is in question.
+  #
+  # @return [Boolean] true if *user* exists for this plugin, and
+  #   false otherwise.
+  #
   def user_exists(user)
-    # Does the given username exist for this plugin? We use a naive
-    # implementation here based on list_users() which is required to
-    # exist above. Plugins can override this with something fast.
     users = list_users()
     return users.include?(user)
   end
 
+
+  # Does the given *domain* exist for this plugin? We use a naive
+  # implementation here based on {#list_domains}. Plugins that know
+  # about domains should override this with something fast.
+  #
+  # @param domain [Domain] the domain whose existence is in question.
+  #
+  # @return [Boolean] true if *domain* exists for this plugin, and
+  #   false otherwise.
+  #
   def domain_exists(domain)
-    # Does the given domain exist for this plugin? We use a naive
-    # implementation here based on list_domains() which is required to
-    # exist above. Plugins can override this with something fast.
     domains = list_domains()
     return domains.include?(domain)
   end
 
+
+  # List all users belonging to the given domains. We say that a user
+  # belongs to a domain "example.com" if the domain part of the user's
+  # email address is "example.com".
+  #
+  # This uses a naive loop, but relies only on the existence of
+  # {#list_users}. Plugins that know about domains should provide a
+  # more efficient implementation.
+  #
+  # @param domains [Array<Domain>] the domains whose users we want.
+  #
+  # @return [Array<User>] a list of {User} objects belonging to
+  #   *domains* for this plugin.
+  #
   def list_domains_users(domains)
-    # Get all users belonging to the given domains. If a user has
-    # domainpart "example.com" then it belongs to the domain
-    # "example.com".
-    #
-    # This uses a naive loop, but relies only on the existence of a
-    # list_users() method which is guaranteed above. More efficient
-    # implementations can usually be made within the plugin.
     domains_users = []
 
     users = list_users();
index 3a5494bced822593e7a63f7a65c4422a02f3fb75..8bf03f96f5a95d8bf8279cff6721035dc13f835f 100644 (file)
@@ -3,11 +3,18 @@ require 'common/plugin'
 require 'common/user'
 require 'pg'
 
+# Code that all Postfixadmin plugins ({PostfixadminPrune},
+# {PostfixadminRm}, {PostfixadminMv}) share.
+#
 module PostfixadminPlugin
-  # Code that all Postfixadmin plugins (Prune, Rm, Mv...) will
-  # share.  That is, we implement the Plugin interface.
+
+  # We implement the Plugin "interface."
   include Plugin
 
+  # Initialize this Postfixadmin {Plugin} with values in *cfg*.
+  #
+  # @param cfg [Configuration] the configuration for this plugin.
+  #
   def initialize(cfg)
     @db_host = cfg.postfixadmin_dbhost
     @db_port = cfg.postfixadmin_dbport
@@ -19,6 +26,13 @@ module PostfixadminPlugin
   end
 
 
+  # Obtain a list of domains from Postfixadmin. This is more efficient
+  # than the {Plugin} default implementation because domains have
+  # their own table in the database and we can easily select them
+  # rather than filtering the list of users.
+  #
+  # @return [Array<Domain>] a list of the domains in Postfixadmin.
+  #
   def list_domains()
     domains = []
 
@@ -37,7 +51,11 @@ module PostfixadminPlugin
   end
 
 
-
+  # Return a list of Postfixadmin users.
+  #
+  # @return [Array<User>] a list of users contained in the
+  #   Postfixadmin database.
+  #
   def list_users()
     users = []
 
@@ -55,15 +73,31 @@ module PostfixadminPlugin
   end
 
 
+
+  # Efficiently list all Postfixadmin users belonging to the given
+  # Postfixadmin *domains*.
+  #
+  # @param domains [Array<Domain>] the domains whose users we want.
+  #
+  # @return [Array<User>] a list of {User} objects belonging to
+  #   *domains* for this plugin.
+  #
   def list_domains_users(domains)
     usernames = []
+    return usernames if domains.length() == 0
 
     connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
                                 @db_name, @db_user, @db_pass)
 
-    sql_query  = 'SELECT username FROM mailbox WHERE domain IN $1;'
+    # The number of parameters that we'll pass into our prepared query
+    # is the number of domains that we're given. It's important that
+    # we have at least one domain here.
+    params = 1.upto(domains.length()).map{ |i| '$' + i.to_s() }.join(',')
+    sql_query  = "SELECT username FROM mailbox WHERE domain IN (#{params});"
 
-    connection.query(sql_query, domains.map{|d| d.to_s()}) do |result|
+    # Now replace each Domain with its string representation and pass
+    # those in as our individual parameters.
+    connection.query(sql_query, domains.map{ |d| d.to_s() }) do |result|
       usernames = result.field_values('username')
     end
 
@@ -73,10 +107,14 @@ module PostfixadminPlugin
   end
 
 
+  # Get a list of all Postfixadmin aliases as a <tt>from => to</tt>
+  # hash. This is useful for testing, since aliases should be removed
+  # when either the "from user" or "to user" are removed.
+  #
+  # @return [Hash] all aliases known to Postfixadmin in the form of a
+  #   <tt>from => to</tt> hash.
+  #
   def list_aliases()
-    #
-    # Get a list of all aliases, useful for testing.
-    #
     aliases = []
 
     connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
@@ -85,7 +123,8 @@ module PostfixadminPlugin
     sql_query = 'SELECT address,goto FROM alias;'
     results = connection.query(sql_query)
     results.each do |row|
-      aliases << row # row should be a hash
+      # row should be a hash
+      aliases << row
     end
 
     connection.close()
@@ -93,4 +132,34 @@ module PostfixadminPlugin
     return aliases
   end
 
+
+  # A fast implementation of the "does this domain exist?"
+  # operation. It only queries the database for the existence of
+  # *domain* rather than a list of all domains (which is the default
+  # implementation).
+  #
+  # @param domain [Domain] the domain whose existence is in question.
+  #
+  # @return [Boolean] true if *domain* exists in the Postfixadmin
+  #   database and false otherwise.
+  #
+  def domain_exists(domain)
+    count = 0
+
+    connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
+                                @db_name, @db_user, @db_pass)
+
+    sql_query = 'SELECT COUNT(domain) as count FROM domain WHERE domain = $1;'
+    connection.query(sql_query, [domain.to_s()]) do |result|
+      return false if result.ntuples() < 1
+      count = result.getvalue(0,0).to_i()
+
+      return false if count.nil?
+    end
+
+    connection.close()
+
+    return (count > 0)
+  end
+
 end
index 948a5b91b5577dc5ebd3c45e24ad150b99556c97..031e3f9ddd725e6b2bc427a323e567c1291300e3 100644 (file)
@@ -1,11 +1,19 @@
 require 'common/plugin'
 require 'common/user'
 
+# Code that all Roundcube plugins ({RoundcubePrune}, {RoundcubeRm},
+# {RoundcubeMv}) share.
+#
 module RoundcubePlugin
-  # Code that all Roundcube plugins (Prune, Rm, Mv...) will share.
-  # That is, we implement the Plugin interface.
+
+  # We implement the Plugin "interface."
   include Plugin
 
+
+  # Initialize this Roundcube {Plugin} with values in *cfg*.
+  #
+  # @param cfg [Configuration] the configuration for this plugin.
+  #
   def initialize(cfg)
     @db_host = cfg.roundcube_dbhost
     @db_port = cfg.roundcube_dbport
@@ -17,15 +25,25 @@ module RoundcubePlugin
   end
 
 
+  # Describe the given Roundcube *user*.
+  #
+  # @param user [User] the user whose description we want.
+  #
+  # @return [String] a string containing the Roundcube "User ID"
+  #   associated with *user*.
+  #
   def describe_user(user)
     user_id = self.get_user_id(user)
     return "User ID: #{user_id}"
   end
 
 
+  # Return a list of Roundcube users.
+  #
+  # @return [Array<User>] a list of users contained in the
+  #   Roundcube database.
+  #
   def list_users()
-    # Produce a list of Roundcube users. This is used in prune/rm, and
-    # is public because it is useful in testing.
     usernames = []
 
     connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
@@ -43,6 +61,13 @@ module RoundcubePlugin
 
   protected;
 
+
+  # Find the Roundcube "User ID" associated with the given *user*.
+  #
+  # @param user [User] the user whose Roundcube "User ID" we want.
+  #
+  # @return [Fixnum] the Roundcube "User ID" for *user*.
+  #
   def get_user_id(user)
     user_id = nil
 
index 01fcc176e3cedd62935a9b81c5b3657437abd610..2bb66e03aebe2145f1eaf34c246eef08e363c49f 100644 (file)
@@ -1,9 +1,30 @@
+# Methods inherited by the various runner classes ({PruneRunner},
+# {MvRunner}, {RmRunner}).
+#
 module Runner
 
-  def run(cfg, plugin, targets)
+
+  # The main thing a runner does is <tt>run()</tt>. Each runner will
+  # actually take a different number of arguments, so their
+  # <tt>run()</tt> signatures will differ. This stub is only here to
+  # let you know that it needs to be implemented.
+  #
+  # @param args [Array<Object>] whatever arguments the real implementation
+  #   would take.
+  #
+  def run(*args)
     raise NotImplementedError
   end
 
+
+  # Report a message from the given *plugin*. All this does is prefix
+  # the message with the plugin name and then print it to stdout.
+  #
+  # @param plugin [Object] t plugin object that generated the message
+  #   we're reporting.
+  #
+  # @param msg [String] the message to report.
+  #
   def report(plugin, msg)
     print "#{plugin.class.to_s()} - "
     puts msg
index f67d0443da1180fa2b9ffc3778542b0e0feb8bf4..a5d3559c970b64af6611549847e28885969bc1ed 100644 (file)
@@ -1,25 +1,46 @@
 require 'common/domain'
 require 'common/errors'
 
+# A class representing a syntactically valid user; that is, an email
+# address. Once constructed, you can be sure that it's valid.
+#
 class User
-  # A class representing a syntactically valid user; that is, an email
-  # address. Once constructed, you can be sure that it's valid.
 
+  # @localpart is a String containing the "user" part of "user@domain".
   @localpart = nil
+
+  # @domain contains a {Domain} object representing the domain part of
+  # "user@domain".
   @domain = nil
 
+
+  # Obtain the {Domain} object corresponding to this User.
+  #
+  # @return [Domain] the domain corresponding to this User.
+  #
   def domain()
     return @domain
   end
 
+
+  # Obtain the domain part of this User object as a string.
+  #
+  # @return [String] a String representation of this User's domain.
+  #
   def domainpart()
     return @domain.to_s()
   end
 
+
+  # Initialize this User object. If either of the local/domain parts
+  # is invalid, then either an {InvalidUserError} or an {InvalidDomainError}
+  # will be raised containing the reason why that part is invalid.
+  #
+  # @param username [String] an email address from which to construct
+  #   this User.
+  #
   def initialize(username)
-    # Initialize this user object, but throw an error if either the
-    # localpart or domainpart are invalid. The one argument is an
-    # email address string.
+
     if not username.is_a?(String)
       msg =  'username must be a String '
       msg += "but a #{username.class.to_s()} was given"
@@ -49,18 +70,50 @@ class User
     @domain = Domain.new(parts[1])
   end
 
+
+  # Obtain the "user" part of this User object as a String.
+  #
+  # @return [String] the "user" part of this User's "user@domain"
+  #   address.
+  #
   def localpart()
     return @localpart
   end
 
+
+  # Convert this User to an email address string.
+  #
+  # @return [String] an email address String constructed from this
+  #   user's local and domain parts.
+  #
   def to_s()
     return @localpart + '@' + @domain.to_s()
   end
 
+
+  # Check if this User is equal to some other User. The comparison
+  # is based on their String representations.
+  #
+  # @param other [User] the User object to compare me to.
+  #
+  # @return [Boolean] If *self* and *other* have equal String
+  #   representations, then true is returned. Otherwise, false is
+  #   returned.
+  #
   def ==(other)
     return self.to_s() == other.to_s()
   end
 
+
+  # Compare two User objects for sorting purposes. The comparison is
+  # is based on their String representations.
+  #
+  # @param other [User] the User object to compare me to.
+  #
+  # @return [0,1,-1] a trinary indicator of how *self* relates to *other*,
+  #   obtained by performing the same comparison on the String
+  #   representations of *self* and *other*.
+  #
   def <=>(other)
     return self.to_s() <=> other.to_s()
   end
index 6a60d041b56a39465024b04add715de23d13df5a..00d3d7e52ddf649547503384301c304d613ad173 100644 (file)
@@ -1,19 +1,51 @@
-def make_header(program_name, plugin_name)
-  # The header that we output before the list of domains/users.
-  # Just the path of this script, the current time, and the plugin name.
-  header = "#{program_name}, "
-
-  current_time = Time.now()
-  if current_time.respond_to?(:iso8601)
-    # Somehow this method is missing on some machines.
-    header += current_time.iso8601.to_s
-  else
-    # Fall back to whatever this looks like.
-    header += current_time.to_s
+# Class methods for the creation and manipulation of our command-line
+# user interface.
+#
+class UserInterface
+
+  # Construct a usage string showing how to invoke the program.
+  #
+  # @param program_name [String] the name of this program, used to
+  #   construct the usage string.
+  #
+  # @return [String] a string showing the format of a correct program
+  #   invocation.
+  #
+  def self.usage(program_name)
+    return "#{program_name} [prune | rm <target> | mv <src> <dst>]"
+  end
+
+
+  # Construct the header that precedes our other output. An example is,
+  #
+  #   mailshears, 2015-11-06 09:57:06 -0500 (Plugin: PrunePlugin)
+  #   ------------------------------------------------------------
+  #
+  # @param program_name [String] the name of this program, to appear
+  #   in the header.
+  #
+  # @param plugin_name [String] the name of the mode (prune, mv, etc.)
+  #   plugin that is being run.
+  #
+  # @return [String] a string containing the output header.
+  #
+  def self.make_header(program_name, plugin_name)
+    header = "#{program_name}, "
+
+    current_time = Time.now()
+    if current_time.respond_to?(:iso8601)
+      # Somehow this method is missing on some machines.
+      header += current_time.iso8601.to_s()
+    else
+      # Fall back to whatever this looks like.
+      header += current_time.to_s()
+    end
+
+    header += ' (Plugin: ' + plugin_name + ")\n"
+    header += '-' * header.size() # Underline the header.
+
+    return header
   end
 
-  header += ' (Plugin: ' + plugin_name + ")\n"
-  header += '-' * header.size # Underline the header.
 
-  return header
 end
index 7db7c06c9536947b32ad05305fa8601fc7d22da5..9ed655a4719ad30785b9a917624bc3dadfd7c768 100644 (file)
@@ -1,8 +1,26 @@
 require 'common/runner'
 
+# Dummy implementation of a {MvRunner}. Its <tt>run()</tt> method will
+# tell you what would have been moved, but will not actually perform
+# the operation.
+#
 class MvDummyRunner
   include Runner
 
+  # Pretend to move *src* to *dst* with *plugin*. Some "what if"
+  # information will be output to stdout. This is useful to see if
+  # there would be (for example) a username collision at *dst* before
+  # attempting the move in earnest.
+  #
+  # @param cfg [Configuration] the configuration options to pass to
+  #   the *plugin* we're runnning.
+  #
+  # @param plugin [Class] plugin class that will perform the move.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user, to which we will move *src*.
+  #
   def run(cfg, plugin, src, dst)
 
     if src.is_a?(Domain) or dst.is_a?(Domain) then
index 089e6c772bc7d9ddec80db26359449aae08b2c73..5a3192356e7b033dbe35cd3c695c45c2fd378e7b 100644 (file)
@@ -1,20 +1,39 @@
+require 'common/plugin.rb'
+
+# Plugins for moving (renaming) users. Moving domains is not supported.
+#
 module MvPlugin
-  #
-  # Plugins for moving (renaming) users.
-  #
 
+  # Absorb the subclass run() magic from the Plugin::Run module.
   extend Plugin::Run
 
+  # The runner class associated with move plugins.
+  #
+  # @return [Class] the {MvRunner} class.
+  #
   def self.runner()
     return MvRunner
   end
 
+
+  # The "dummy" runner class associated with move plugins.
+  #
+  # @return [Class] the {MvDummyRunner} class.
+  #
   def self.dummy_runner()
     return MvDummyRunner
   end
 
+
+  # The interface for the "move a user" operation. Subclasses need to
+  # implement this method so that it moves (renames) the user *src* to
+  # the user *dst*.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user to which we'll move *src*.
+  #
   def mv_user(src, dst)
-    # Rename the given user.
     raise NotImplementedError
   end
 
index 0e5e909e268944c25f2003e82eceb84bcc5b7bda..fe44e5c244d3fe4cff66f140bb4d684ca903364a 100644 (file)
@@ -2,9 +2,23 @@ require 'common/domain'
 require 'common/errors'
 require 'common/runner'
 
+# Perform the moving (renaming) of users/domains using {MvPlugin}s.
+#
 class MvRunner
   include Runner
 
+  # Run *plugin* to move the user *src* to *dst*. The method
+  # signature includes the unused *cfg* for consistency with the
+  # runners that do need a {Configuration}.
+  #
+  # @param cfg [Configuration] unused.
+  #
+  # @param plugin [Class] plugin class that will perform the move.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user being moved to.
+  #
   def run(cfg, plugin, src, dst)
 
     if src.is_a?(Domain) or dst.is_a?(Domain) then
index ad1e9c2bfcf9f88b6fb67cf893a4c0ecb4d0348a..80ab1b6013c4096395ff0ec33a96a02dc023ddb0 100644 (file)
@@ -3,18 +3,29 @@ require 'pg'
 require 'common/agendav_plugin'
 require 'mv/mv_plugin'
 
+
+# Handle moving (renaming) Agendav users in its database. Agendav has
+# no concept of domains.
+#
 class AgendavMv
 
   include AgendavPlugin
   include MvPlugin
 
+  # Move the user *src* to *dst* within the Agendav database. This
+  # should "rename" him in _every_ table where he is referenced.
+  #
+  # This can fail is *src* does not exist, or if *dst* already exists
+  # before the move. It should also be an error if the destination
+  # domain doesn't exist. But Agendav doesn't know about domains, so
+  # we let that slide.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user being moved to.
+  #
   def mv_user(src, dst)
-    # It's obviously an error if the source user does not exist. It
-    # would also be an error if the destination domain didn't exist;
-    # however, Agendav doesn't know about domains, so we let that slide.
     raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
-
-    # And it's an error if the destination user exists already.
     raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
 
     sql_queries = ['UPDATE prefs SET username = $1 WHERE username = $2;']
index 20c8a32af50d668fe8ccba1f690091cbddcafe9a..58287b3b69937eaa32b2823d3ffd62f13de83566 100644 (file)
@@ -3,26 +3,30 @@ require 'pg'
 require 'common/davical_plugin'
 require 'rm/rm_plugin'
 
+# Handle moving (renaming) DAViCal users in its database. DAViCal has
+# no concept of domains.
+#
 class DavicalMv
-  #
-  # DAViCal only supports Postgres, so even if we ever are
-  # database-agnostic, this plugin can't be.
-  #
   include DavicalPlugin
   include MvPlugin
 
 
+  # Move the user *src* to *dst* within the DAViCal database. This
+  # should "rename" him in _every_ table where he is referenced.
+  # DAViCal uses foreign keys properly, so we let the ON UPDATE
+  # CASCADE trigger handle most of the work.
+  #
+  # This can fail is *src* does not exist, or if *dst* already exists
+  # before the move. It should also be an error if the destination
+  # domain doesn't exist. But DAViCal doesn't know about domains, so
+  # we let that slide.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user being moved to.
+  #
   def mv_user(src, dst)
-    # Switch the given usernames. DAViCal uses foreign keys properly
-    # and only supports postgres, so we let the ON UPDATE CASCADE
-    # trigger handle most of the work.
-
-    # It's obviously an error if the source user does not exist. It
-    # would also be an error if the destination domain didn't exist;
-    # however, DAViCal doesn't know about domains, so we let that slide.
     raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
-
-    # And it's an error if the destination user exists already.
     raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
 
     sql_queries = ['UPDATE usr SET username = $1 WHERE username = $2']
index bd3084edde576d7e9d1eb57dc6a25644f2d4e46d..78219a2f2fbf5ceb212f468f0bd11cf97b1cfd6a 100644 (file)
@@ -4,34 +4,47 @@ require 'common/filesystem'
 require 'common/dovecot_plugin'
 require 'mv/mv_plugin'
 
+
+# Handle moving (renaming) Dovecot users on the filesystem.
+#
 class DovecotMv
 
   include DovecotPlugin
   include MvPlugin
 
+
+  # Move the Dovecot user *src* to *dst*. This relocates the user's
+  # directory within the Dovecot mailstore (on the filesystem).
+  #
+  # This fails if the source user does not exist, or if the
+  # destination user already exists before the move.
+  #
+  # But is it an error if the target domain does not exist? That's a
+  # bit subtle... The domain may exist in the database, but if it
+  # has not received any mail yet, then its directory won't exist
+  # on-disk.
+  #
+  # There are two possible "oops" scenarios resulting from the fact
+  # that we may run either the Postfixadmin move first or the
+  # Dovecot move first. If we move the user in the database, we
+  # definitely want to move him on disk (that is, we should create
+  # the directory here). But if we move him on disk first, then we
+  # don't know if the database move will fail! We don't want to move
+  # his mail files if he won't get moved in the database.
+  #
+  # Faced with two equally-bad (but easy-to-fix) options, we do the
+  # simplest thing and fail if the destination domain directory
+  # doesn't exist. If nothing else, this is at least consistent.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user being moved to.
+  #
   def mv_user(src, dst)
-    # It's obviously an error if the source user does not exist.
     raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
-
-    # And it's an error if the destination user exists already.
     raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
 
-    # But is it an error if the target domain does not exist? That's a
-    # bit subtle... The domain may exist in the database, but if it
-    # has not received any mail yet, then its directory won't exist
-    # on-disk.
-    #
-    # There are two possible "oops" scenarios resulting from the fact
-    # that we may run either the Postfixadmin move first or the
-    # Dovecot move first. If we move the user in the database, we
-    # definitely want to move him on disk (that is, we should create
-    # the directory here). But if we move him on disk first, then we
-    # don't know if the database move will fail! We don't want to move
-    # his mail files if he won't get moved in the database.
-    #
-    # Faced with two equally-bad (but easy-to-fix) options, we do the
-    # simplest thing and fail if the destination domain directory
-    # doesn't exist. If nothing else, this is at least consistent.
+    # See the docstring...
     if not self.domain_exists(dst.domain()) then
       raise NonexistentDomainError.new(dst.domainpart())
     end
index 9317b4b7d12d6c0dd64702793a072bc951e23178..e22df6a53f7637945460ae3b61063fe8843278d3 100644 (file)
@@ -3,22 +3,34 @@ require 'pg'
 require 'common/postfixadmin_plugin'
 require 'mv/mv_plugin'
 
+
+# Handle moving (renaming) of users in the Postfixadmin database.
+#
 class PostfixadminMv
 
   include PostfixadminPlugin
   include MvPlugin
 
+
+  # Move the user *src* to *dst* within the Postfixadmin
+  # database. This should "rename" him in _every_ table where he is
+  # referenced. Unfortunately that must be done manually.
+  #
+  # This can fail is *src* does not exist, or if *dst* already exists
+  # before the move. It will also fail if the domain associated with
+  # the user *dst* does not exist.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user being moved to.
+  #
   def mv_user(src, dst)
-    # It's obviously an error if the source user does not exist.
     raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
 
-    # And if the destination domain does not exist.
     if not domain_exists(dst.domain())
       raise NonexistentDomainError.new(dst.domain.to_s())
     end
 
-    # But the destination *user* should *not* exist.
-    # And it's an error if the destination user exists already.
     raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
 
     mailbox_query  = 'UPDATE mailbox SET '
index 7f345a4a70bb73094d02110ce8f6ff05d6a315e5..4d8196461ca570a8e51a5aa0880365dda8d950f5 100644 (file)
@@ -3,19 +3,31 @@ require 'pg'
 require 'common/roundcube_plugin'
 require 'mv/mv_plugin'
 
+# Handle moving (renaming) of users in the Roundcube
+# database. Roundcube has no concept of domains.
+#
 class RoundcubeMv
 
   include RoundcubePlugin
   include MvPlugin
 
 
+  # Move the user *src* to *dst* within the Roundcube database. This
+  # should "rename" him in _every_ table where he is referenced.
+  # Roundcube uses foreign keys properly, so we let the ON UPDATE
+  # CASCADE trigger handle most of the work.
+  #
+  # This can fail is *src* does not exist, or if *dst* already exists
+  # before the move. It should also be an error if the destination
+  # domain doesn't exist. But Roundcube doesn't know about domains, so
+  # we let that slide.
+  #
+  # @param src [User] the source user to be moved.
+  #
+  # @param dst [User] the destination user being moved to.
+  #
   def mv_user(src, dst)
-    # It's obviously an error if the source user does not exist. It
-    # would also be an error if the destination domain didn't exist;
-    # however, Roundcube doesn't know about domains, so we let that slide.
     raise NonexistentUserError.new(src.to_s()) if not user_exists(src)
-
-    # And it's an error if the destination user exists already.
     raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst)
 
     sql_queries = ['UPDATE users SET username = $1 WHERE username = $2;']
index 86a437bd699727a64f05ea38982ffe59fc53007f..f0f018381b7d0719a7eba1759b1a05fa6a11952a 100644 (file)
@@ -3,6 +3,11 @@ require 'pg'
 require 'prune/prune_plugin'
 require 'rm/plugins/agendav'
 
+# Handle the pruning of Agendav users from its database. This class
+# doesn't need to do anything; by inheriting from {AgendavRm}, we get
+# its {AgendavRm#remove_user} method and that's all we need to prune.
+#
 class AgendavPrune < AgendavRm
+  # Needed for the magic includers <tt>run()</tt> method.
   include PrunePlugin
 end
index f7f4b39cf64d5c0ca86d32fcf542738f1ecbc2a8..743356d039778d22322f22a8930f7f0672a0791d 100644 (file)
@@ -3,6 +3,11 @@ require 'pg'
 require 'prune/prune_plugin'
 require 'rm/plugins/davical'
 
+# Handle the pruning of DAViCal users from its database. This class
+# doesn't need to do anything; by inheriting from {DavicalRm}, we get
+# its {DavicalRm#remove_user} method and that's all we need to prune.
+#
 class DavicalPrune < DavicalRm
+  # Needed for the magic includers <tt>run()</tt> method.
   include PrunePlugin
 end
index 7d346ac23b22adce05fe92b46716f7614abb9c47..9fbeb58f14170f3d56bf0edad78e39ed28ae1144 100644 (file)
@@ -1,6 +1,11 @@
 require 'prune/prune_plugin'
 require 'rm/plugins/dovecot'
 
+# Handle the pruning of Dovecot users from its database. This class
+# doesn't need to do anything; by inheriting from {DovecotRm}, we get
+# its {DovecotRm#remove_user} method and that's all we need to prune.
+#
 class DovecotPrune < DovecotRm
+  # Needed for the magic includers <tt>run()</tt> method.
   include PrunePlugin
 end
index 395fb240cd47e96c6751fc163d762b9f9d8f7467..7059e07b16d8ecd7b5e634fd869513ada204c213 100644 (file)
@@ -1,10 +1,13 @@
 require 'prune/prune_plugin'
 require 'rm/plugins/postfixadmin'
 
-class PostfixadminPrune < PostfixadminRm
-  # We don't need the ability to remove "left over" users or
-  # domains, since "left over" is with respect to what's in
-  # PostfixAdmin. In other words, the other plugins check themselves
-  # against PostfixAdmin, it doesn't make sense to check PostfixAdmin
-  # against itself.
-end
+# This class does absolutely nothing except allow us to blindly
+# instantiate classes based on the mode name and configured plugins.
+#
+# We don't need the ability to remove "left over" Postfixadmin users
+# or domains, since "left over" is with respect to what's in
+# Postfixadmin itself. The other pruning plugins check themselves
+# against Postfixadmin -- it doesn't make sense to check Postfixadmin
+# against itself.
+#
+class PostfixadminPrune < PostfixadminRm; end
index 5869b02cabe20aeb2e78ebc5012173d624427a0b..929739297b46a9fe1d1a51ea347816091a5ece1f 100644 (file)
@@ -3,6 +3,12 @@ require 'pg'
 require 'prune/prune_plugin'
 require 'rm/plugins/roundcube'
 
+
+# Handle the pruning of Roundcube users from its database. This class
+# doesn't need to do anything; by inheriting from {RoundcubeRm}, we get
+# its {RoundcubeRm#remove_user} method and that's all we need to prune.
+#
 class RoundcubePrune < RoundcubeRm
+  # Needed for the magic includers <tt>run()</tt> method.
   include PrunePlugin
 end
index d488f7ea155c7a77633212aeee3776d844921bbe..f90c9d06c50f774eb61e41d3970770cc4b1893d9 100644 (file)
@@ -2,9 +2,29 @@ require 'common/runner'
 require 'prune/plugins/postfixadmin'
 require 'rm/rm_dummy_runner'
 
+# Dummy implementation of a {PruneRunner}. Its <tt>run()</tt> method will
+# tell you what would have been pruned, but will not actually perform
+# the operation.
+#
 class PruneDummyRunner
   include Runner
 
+
+  # Pretend to prune unused domains and users. Some "what if"
+  # information will be output to stdout.
+  #
+  # The prune mode is the main application of the "dummy" runners,
+  # since it performs some computation outside of the plugins
+  # themselves. This lets the user know which users and domains would
+  # be removed and can help prevent mistakes or even find bugs in the
+  # prune code, if it looks like something will be removed that
+  # shouldn't be!
+  #
+  # @param cfg [Configuration] the configuration options to pass to
+  #   the *plugin* we're runnning.
+  #
+  # @param plugin [Class] plugin class that will do the pruning.
+  #
   def run(cfg, plugin)
     # We don't want to check the PostfixAdmin database against itself.
     return if plugin.class == PostfixadminPrune
index 4757c028780baa972e84891032cf484ad596374a..df621b8aeb79644874adc091e9c6081219cc0cd6 100644 (file)
@@ -1,33 +1,65 @@
-require 'rm/rm_plugin'
+require 'common/plugin.rb'
 
+# Plugins for pruning users. By "pruning," we mean the removal of
+# leftover non-PostfixAdmin users after the associated user has been
+# removed from the Postfixadmin database.
+#
 module PrunePlugin
-  #
-  # Plugins for the removal of leftover non-PostfixAdmin users,
-  # i.e. after an user has been removed from the PostfixAdmin
-  # database.
-  #
+
+  # Absorb the subclass run() magic from the Plugin::Run module.
   extend Plugin::Run
 
+  # The runner class associated with pruning plugins.
+  #
+  # @return [Class] the {PruneRunner} class.
+  #
   def self.runner()
     return PruneRunner
   end
 
+
+  # The "dummy" runner class associated with pruning plugins.
+  #
+  # @return [Class] the {PruneDummyRunner} class.
+  #
   def self.dummy_runner
     return PruneDummyRunner
   end
 
-  def get_leftover_domains(db_domains)
-    # Given a list of domains, determine which domains belonging to
-    # this plugin are not contained in the given list.
 
+  # Determine which domains are "left over" for this plugin. A domain
+  # is considered "left over" if it has been removed from Postfixadmin
+  # but not some other plugin.
+  #
+  # The leftovers are determined with respect to the list *db_domains*
+  # of domains that Postfixadmin knows about.
+  #
+  # @param db_domains [Array<Domain>] a list of domains that are present
+  #   in the Postfixadmin database.
+  #
+  # @return [Array<Domain>] a list of domains known to this plugin but
+  #   not to Postfixadmin.
+  #
+  def get_leftover_domains(db_domains)
     # WARNING! Array difference doesn't work for some reason.
     return list_domains().select{ |d| !db_domains.include?(d) }
   end
 
-  def get_leftover_users(db_users)
-    # Given a list of users, determine which users belonging to
-    # this plugin are not contained in the given list.
 
+  # Determine which users are "left over" for this plugin. A user
+  # is considered "left over" if it has been removed from Postfixadmin
+  # but not some other plugin.
+  #
+  # The leftovers are determined with respect to the list *db_users*
+  # of users that Postfixadmin knows about.
+  #
+  # @param db_users [Array<User>] a list of users that are present
+  #   in the Postfixadmin database.
+  #
+  # @return [Array<User>] a list of users known to this plugin but
+  #   not to Postfixadmin.
+  #
+  def get_leftover_users(db_users)
     # WARNING! Array difference doesn't work for some reason.
     return list_users().select{ |u| !db_users.include?(u) }
   end
index fbad8e8f11c4e5cb71f7dcc2ccbdc0caf7ad40b5..a9e2282857fc3be027a14d0410ae75436c25f934 100644 (file)
@@ -2,9 +2,18 @@ require 'common/runner'
 require 'prune/plugins/postfixadmin'
 require 'rm/rm_runner'
 
+# Perform the pruning of users/domains using {PrunePlugin}s.
+#
 class PruneRunner
   include Runner
 
+  # Run *plugin* to prune leftover users and directories.
+  #
+  # @param cfg [Configuration] configuration options passed to
+  # {PostfixadminPrune}.
+  #
+  # @param plugin [Class] plugin class that will perform the pruning.
+  #
   def run(cfg, plugin)
     # We don't want to check the PostfixAdmin database against itself.
     return if plugin.class == PostfixadminPrune
@@ -17,6 +26,7 @@ class PruneRunner
     leftovers  = plugin.get_leftover_users(db_users)
     leftovers += plugin.get_leftover_domains(db_domains)
 
+    # We're counting on our PrunePlugin also being an RmPlugin here.
     rm_runner = RmRunner.new()
     rm_runner.run(cfg, plugin, *leftovers)
   end
index 6d92c26d6fb054d9de44ba5d30b60abec76c2f8d..28f1eb4167a4a63bec61889ea47b97492d87f80f 100644 (file)
@@ -3,15 +3,22 @@ require 'pg'
 require 'common/agendav_plugin'
 require 'rm/rm_plugin'
 
+
+# Handle the removal of Agendav users from its database. Agendav has
+# no concept of domains.
+#
 class AgendavRm
 
   include AgendavPlugin
   include RmPlugin
 
 
-  def delete_user(user)
-    # Delete the given username and any records in other tables
-    # belonging to it.
+  # Remove *user* from the Agendav database. This should remove him
+  # from _every_ table in which he is referenced.
+  #
+  # @param user [User] the user to remove.
+  #
+  def remove_user(user)
     raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
 
     sql_queries = ['DELETE FROM prefs WHERE username = $1;']
index 77b06b4777714a50a7ea18aa15e2116389128b2b..852d9c4c66b1fa97467de79c2628a34fd6320338 100644 (file)
@@ -3,19 +3,24 @@ require 'pg'
 require 'common/davical_plugin'
 require 'rm/rm_plugin'
 
+# Handle the removal of DAViCal users from its database. DAViCal has
+# no concept of domains.
+#
 class DavicalRm
-  #
-  # DAViCal only supports Postgres, so even if we ever are
-  # database-agnostic, this plugin can't be.
-  #
+
   include DavicalPlugin
   include RmPlugin
 
 
-  def delete_user(user)
-    # Delete the given username. DAViCal uses foreign keys properly
-    # and only supports postgres, so we let the ON DELETE CASCADE
-    # trigger handle most of the work.
+  # Remove *user* from the DAViCal database. This should remove him
+  # from _every_ table in which he is referenced. Fortunately, DAViCal
+  # uses foreign keys properly (and only supports postgres, where they
+  # work!), so we can let the ON DELETE CASCADE trigger handle most of
+  # the work.
+  #
+  # @param user [User] the user to remove.
+  #
+  def remove_user(user)
     raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
 
     sql_queries = ['DELETE FROM usr WHERE username = $1']
index 0ced5bec3acf4aca70bbdd7008882112c78ba412..7babfbeb40d353f212459f0a040da3c9d0d6f8bb 100644 (file)
@@ -1,16 +1,24 @@
-# Needed for rm_r.
 require 'fileutils'
 
 require 'common/dovecot_plugin'
 require 'rm/rm_plugin'
 
+
+# Handle the removal of users and domains from the Dovecot mailstore
+# (the filesystem).
+#
 class DovecotRm
 
   include DovecotPlugin
   include RmPlugin
 
 
-  def delete_domain(domain)
+  # Remove *domain* from the Dovecot mailstore. This just runs "rm -r"
+  # on the domain directory if it exists.
+  #
+  # @param domain [Domain] the domain to remove.
+  #
+  def remove_domain(domain)
     domain_path = self.get_domain_path(domain)
 
     if not File.directory?(domain_path)
@@ -20,7 +28,14 @@ class DovecotRm
     FileUtils.rm_r(domain_path)
   end
 
-  def delete_user(user)
+
+  # Remove *user* from the Dovecot mailstore. This just runs "rm -r"
+  # on the *user*'s mailbox directory, if it exists.
+  #
+  # @param user [User] the user whose mailbox directory we want to
+  #   remove.
+  #
+  def remove_user(user)
     user_path = self.get_user_path(user)
 
     if not File.directory?(user_path)
index 103912b648e71ce8edf02a6be0745d76cded190b..b950b7a76d6c27e9d80c1d9d0b0972427681baf3 100644 (file)
@@ -3,18 +3,39 @@ require 'pg'
 require 'common/postfixadmin_plugin'
 require 'rm/rm_plugin'
 
+
+# Handle the removal of users and domains from the Postfixadmin database.
+#
 class PostfixadminRm
 
   include PostfixadminPlugin
   include RmPlugin
 
 
-  def delete_user(user)
+  # Remove *user* from the Postfixadmin database. This should remove
+  # him from _every_ table in which he is referenced. Unfortunately,
+  # Postfixadmin does not use foreign keys or ON DELETE CASCADE
+  # triggers so we need to delete the associated child table records
+  # ourselves.
+  #
+  # @param user [User] the user to remove.
+  #
+  def remove_user(user)
     raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
 
+    # Remove aliases FROM our user to some other address.
     sql_queries = ['DELETE FROM alias WHERE address = $1;']
-    # Wipe out any aliases pointed at our user.
+
+    # Also delete aliases that point SOLELY TO our user.
+    sql_queries << "DELETE FROM alias WHERE goto = $1;"
+
+    # But aliases don't need to point to a single user! If our user
+    # was part of a multi-recipient alias, we want to remove our user
+    # from the alias and leave the other recipients. If you're
+    # wondering about the leftover double-commas, look towards the end
+    # of the function.
     sql_queries << "UPDATE alias SET goto=REPLACE(goto, $1, '');"
+
     sql_queries << 'DELETE FROM mailbox WHERE username = $1;'
     sql_queries << 'DELETE FROM quota WHERE username = $1;'
     sql_queries << 'DELETE FROM quota2 WHERE username = $1;'
@@ -41,14 +62,37 @@ class PostfixadminRm
   end
 
 
-  def delete_domain(domain)
+  # Remove *domain* from the Postfixadmin database. This should remove
+  # the domain from _every_ table in which it is referenced. It should
+  # also remove every user that belongs to the doomed domain
+  # Postfixadmin has some experimental support for triggers, but they
+  # don't do a very good job of cleaning up. Therefore we remove all
+  # users in the domain manually before removing the domain itself.
+  #
+  # Log entries (from the "log" table) are not removed since they may
+  # still contain valuable information (although they won't mention
+  # this removal).
+  #
+  # @param domain [Domain] the domain to remove.
+  #
+  def remove_domain(domain)
     raise NonexistentDomainError.new(domain.to_s()) if not domain_exists(domain)
 
+    # First remove all users belonging to the domain. This will handle
+    # alias updates and all the sensitive crap we need to do when
+    # removing a user.
+    users = list_domains_users([domain])
+    users.each { |u| remove_user(u) }
+
+    # The domain_admins table contains one record per domain
+    # (repeating the user as necessary), so this really is sufficient.
     sql_queries = ['DELETE FROM domain_admins WHERE domain = $1;']
+
+    # Some of the following queries should be redundant now that we've
+    # removed all users in the domain.
     sql_queries << 'DELETE FROM alias WHERE domain = $1;'
     sql_queries << 'DELETE FROM mailbox WHERE domain = $1;'
     sql_queries << 'DELETE FROM alias_domain WHERE alias_domain = $1;'
-    sql_queries << 'DELETE FROM log WHERE domain = $1;'
     sql_queries << 'DELETE FROM vacation WHERE domain = $1;'
     sql_queries << 'DELETE FROM domain WHERE domain = $1;'
 
@@ -62,29 +106,4 @@ class PostfixadminRm
     connection.close()
   end
 
-
-  protected;
-
-  def domain_exists(domain)
-    count = 0
-
-    connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
-                                @db_name, @db_user, @db_pass)
-
-    sql_query = 'SELECT COUNT(domain) as count FROM domain WHERE domain = $1;'
-    connection.query(sql_query, [domain.to_s()]) do |result|
-      return false if result.ntuples() < 1
-      begin
-        count = result.getvalue(0,0).to_i()
-        return false if count.nil?
-      rescue StandardError
-        return false
-      end
-    end
-
-    connection.close()
-
-    return (count > 0)
-  end
-
 end
index c25874ef856beda9deda824d0d5d31a77d5fb4a3..88e88c42ab4a7683c18c246fefcc329572e1333a 100644 (file)
@@ -3,27 +3,30 @@ require 'pg'
 require 'common/roundcube_plugin'
 require 'rm/rm_plugin'
 
+# Handle removal of Roundcube users from its database. Roundcube has
+# no concept of domains.
+#
 class RoundcubeRm
 
   include RoundcubePlugin
   include RmPlugin
 
-  def delete_user(user)
-    # Delete the given username and any records in other tables
-    # belonging to it.
+  # Remove *user* from the Roundcube database. This should remove him
+  # from _every_ table in which he is referenced. Fortunately the
+  # Roundcube developers were nice enough to include DBMS-specific
+  # install and upgrade scripts, so Postgres can take advantage of ON
+  # DELETE triggers.
+  #
+  # @param user [User] the user to remove.
+  #
+  def remove_user(user)
     raise NonexistentUserError.new(user.to_s()) if not user_exists(user)
 
+    # Get the primary key for this user in the "users" table.
     user_id = self.get_user_id(user)
 
-    # The Roundcube developers were nice enough to include
-    # DBMS-specific install and upgrade scripts, so Postgres can take
-    # advantage of ON DELETE triggers. Here's an example:
-    #
-    #  ...
-    #  user_id integer NOT NULL
-    #    REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE
-    #
-    # This query is of course necessary with any DBMS:
+    # Thanks to the ON DELETE triggers, this will remove all child
+    # records associated with user_id too.
     sql_queries = ['DELETE FROM users WHERE user_id = $1::int;']
 
     connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty,
index 62d9b628dd7db4b69c27a0804acae27e73325385..54a8969bcc6925d5c55590b0a537e2367a66846d 100644 (file)
@@ -1,8 +1,29 @@
 require 'common/runner'
 
+# Dummy implementation of a {RmRunner}. Its <tt>run()</tt> method will
+# tell you what would have been removed, but will not actually perform
+# the operation.
+#
 class RmDummyRunner
   include Runner
 
+
+  # Pretend to remove *targets*. Some "what if"
+  # information will be output to stdout.
+  #
+  # This dummy runner is not particularly useful on its own. About the
+  # only thing it does is let you know that the users/domains in
+  # *targets* do in fact exist (through their descriptions). It's used
+  # to good effect by {PruneDummyRunner}, though.
+  #
+  # @param cfg [Configuration] the configuration options to pass to
+  #   the *plugin* we're runnning.
+  #
+  # @param plugin [RmPlugin] plugin that will perform the move.
+  #
+  # @param targets [Array<User,Domain>] the users and domains to be
+  #   removed.
+  #
   def run(cfg, plugin, *targets)
     targets.each do |target|
       target_description = plugin.describe(target)
index fd0c541a44cb25ed7ffbe7e96db3c19b59426265..ff165b4e8081c653c047276a5fb7045ba9a0d938 100644 (file)
@@ -1,47 +1,76 @@
 require 'common/plugin.rb'
 
+#
+# Plugins for the removal of users and domains.
+#
 module RmPlugin
-  #
-  # Plugins for the removal of users.
-  #
 
+  # Absorb the subclass run() magic from the Plugin::Run module.
   extend Plugin::Run
 
+  # The runner class associated with removal plugins.
+  #
+  # @return [Class] the {RmRunner} class.
+  #
   def self.runner()
     return RmRunner
   end
 
+
+  # The "dummy" runner class associated with removal plugins.
+  #
+  # @return [Class] the {RmDummyRunner} class.
+  #
   def self.dummy_runner()
     return RmDummyRunner
   end
 
-  def delete(target)
-    # A generic version of delete_user/delete_domain that
+
+  # Remove the *target* domain or user. This is a generic version of
+  # the {#remove_user} and {#remove_domain} operations that will
+  # dispatch based on the type of *target*.
+  #
+  # @param target [User,Domain] the user or domain to remove.
+  #
+  def remove(target)
+    # A generic version of remove_user/remove_domain that
     # dispatches base on the class of the target.
     if target.is_a?(User)
-      return delete_user(target)
+      return remove_user(target)
     elsif target.is_a?(Domain)
-      return delete_domain(target)
+      return remove_domain(target)
     else
       raise NotImplementedError
     end
   end
 
-  def delete_domain(domain)
-    # Delete the given domain. Some plugins don't have a concept of
-    # domains, so just delete all users with a username that looks
-    # like it's in the given domain.
+
+  # Remove the given *domain*. Some plugins don't have a concept of
+  # domains, so the default implementation here removes all users that
+  # look like they belong to *domain*. Subclasses can be smarter.
+  #
+  # @param domain [Domain] the domain to remove.
+  #
+  def remove_domain(domain)
     users = list_domains_users([domain])
 
+    # It's possible for a domain to exist with no users, but this
+    # default implementation is based on the assumption that it should
+    # work for plugins having no "domain" concept.
     raise NonexistentDomainError.new(domain.to_s()) if users.empty?
 
     users.each do |u|
-      delete_user(u)
+      remove_user(u)
     end
   end
 
-  def delete_user(user)
-    # Delete the given user.
+
+  # The interface for the "remove a user" operation. Subclasses
+  # need to implement this method so that it removes *user*.
+  #
+  # @param user [User] the user to remove.
+  #
+  def remove_user(user)
     raise NotImplementedError
   end
 
index a19004a7a5440fcd23bb22173d3e5fe4d963ed76..4162ddf531192e549b48eaf10a7760271e402f78 100644 (file)
@@ -1,24 +1,44 @@
 require 'common/errors'
 require 'common/runner'
 
+# Perform the removal of users/domains using {RmPlugin}s.
+#
 class RmRunner
   include Runner
 
+  # Run *plugin* to remove the users/domains in *targets*. The method
+  # signature includes the unused *cfg* for consistency with the
+  # runners that do need a {Configuration}.
+  #
+  # @param cfg [Configuration] unused.
+  #
+  # @param plugin [Class] plugin class that will perform the removal.
+  #
+  # @param targets [Array<User,Domain>] the users and domains to be
+  #   removed.
+  #
   def run(cfg, plugin, *targets)
     targets.each do |target|
       remove_target(plugin, target)
     end
   end
 
-  private;
 
+  protected;
+
+  # Remove *target* using *plugin*. This operation is separate from
+  # the <tt>run()</tt> method so that it can be accessed by the prune
+  # runner.
+  #
+  # @param plugin [RmPlugin] the plugin that will remove the *target*.
+  #
+  # @param target [User,Domain] the user or domain to remove.
+  #
   def remove_target(plugin, target)
-    # Wrap the "remove this thing" operation so that it can be reused
-    # in the prine plugin.
     target_description = plugin.describe(target)
 
     begin
-      plugin.delete(target)
+      plugin.remove(target)
       msg =  "Removed #{target.class.to_s().downcase()} #{target}"
 
       # Only append the extra description if it's useful.