# Copyright 1999-2014. Parallels IP Holdings GmbH. All Rights Reserved.
""" mailmng-outgoing - Plesk outgoing mail rate limiting backend management application """
from __future__ import print_function

import sys
import os
import json

import plesk_log; plesk_log.setDefaultLevel('warning')

from optmatch import OptionMatcher, optmatcher, optset, UsageException
from plesk_outgoing_mail_db import OutgoingMailLimitsDb
from plesk_outgoing_mail_controller import OutgoingMailController
from plesk_outgoing_mail_batch_helper import OutgoingMailBatchOperationsHelper
from plesk_outgoing_mail_stats import OutgoingMailStatsHelper, format_statistics_as_json


log = plesk_log.getLogger(__name__)


class MailmngOutgoingApp(OptionMatcher):
    """ Main mailmng-outgoing application class.
        
        Parses command line options via optmatch (see http://coderazzi.net/python/optmatch/).
    """
    _OPTIONS_HELP = {
        'init':                     "Initialize backend DB",
        'destroy':                  "Delete backend DB",
        'cleanup':                  "Cleanup backend DB",
        'enable':                   "Enable outgoing mail tracking and rate limiting",
        'disable':                  "Disable outgoing mail tracking and rate limiting",
        'fetch-statistics':         "Show outgoing messages statistics",
        'install-cron-job':         "Install or modify a system cron job",
        'remove-cron-job':          "Remove a system cron job",
        'force':                    "Force the action",
        'out-limit':                "'Out' (aka 'hard') messages limit per hour, -1 for unlimited",
        'allow-sendmail':           "Control ability to use sendmail on subscription",
        'mailname':                 "Mailname (mail address part before '@')",
        'mailname-alias':           "Mailname alias (mail alias part before '@')",
        'main-domain-name':         "Name of the main domain on subscription",
        'new-name':                 "New entity name after rename operation",
        'old-name':                 "Old entity name before rename operation",
        'sysuser':                  "System user name",
        'vhost-id':                 "Virtual host unique identifier, usually a GUID; may be specified via VHOST_ID "
                                        "environment variable for improved security",
        'to-timestamp':             "Upper bound for messages' UTC timestamp to operate on, e.g. '2013-12-31 22:30:00'",
        'from-timestamp':           "Lower bound for messages' UTC timestamp to operate on, e.g. '2013-12-31 22:30:00'",
        'pretty-print':             "Print output in pretty form",
        'time-step':                "Time step for aggregated statistics, 'minute' or 'hour'",
        'with-limits':              "Include current limit values in statistics",
        'with-zero-counters':       "Include zero fields in statistics entries, e.g. \"rejected\": 0",
        'time-period':              "Time period for cron job execution in minutes from 1 up to 60",
        'job-name':                 "System cron job name, should not include '/' symbols",
        'command':                  "Shell command to put into system cron job",
        'no-vacuum':                "Prohibit DB optimization"
    }

    _OPTION_VAR_NAMES = {
        'out-limit':                'INT_LIMIT',
        'allow-sendmail':           'BOOL',
        'vhost-id':                 'GUID',
        'to-timestamp':             'UTC_TIMESTAMP',
        'from-timestamp':           'UTC_TIMESTAMP',
    }

    def __init__(self):
        super(MailmngOutgoingApp, self).__init__(optionsHelp=self._OPTIONS_HELP, 
                                                 optionVarNames=self._OPTION_VAR_NAMES)

        self.__db = None
        self._out_limit = None
        self._allow_sendmail = None

    def main(self, argv):
        """ Entire mailmng-outgoing, but in a callable form. """
        log.debug("argv = %s", argv)
        self.process(argv, handleUsageProblems=False)

    def printHelp(self):
        """ Show the help message. """
        # Overrides method from OptionMatcher.
        print(self.getUsage().getUsageString(width=120, column=32))

    def _db(self):
        """ Backend database accessor. """
        # This is not a property due to optmatch enumerating all attributes and calling it.
        if self.__db is None:
            log.debug("Initializing connection to backend DB")
            if os.getenv('PLESK_USE_IN_MEMORY_DB') == '1':
                self.__db = OutgoingMailLimitsDb(OutgoingMailLimitsDb.IN_MEMORY)
            else:
                self.__db = OutgoingMailLimitsDb()
        return self.__db

    def _ensure_db_changed(self, rc):
        if rc <= 0:
            raise Exception("No backend DB entries were modified.")

    def _check_db_changed(self, rc):
        if rc <= 0:
            log.warning("No backend DB entries were modified.")

    # General commands

    @optmatcher
    def do_init_db(self, initFlag, forceFlag=None):
        """ Initialize backend DB. Recreate it if --force is supplied. All data will be lost in the latter case. """
        self._db().recreate(forceFlag)

    @optmatcher
    def do_destroy_db(self, destroyFlag):
        """ Delete backend DB. All data will be lost. """
        self._db().remove()

    @optmatcher
    def do_enable(self, enableFlag, featuresOption=None):
        """ Enable outgoing mail tracking and rate limiting. Enable default features if --features is not supplied."""
        OutgoingMailController().enable(featuresOption)

    @optmatcher
    def do_disable(seld, disableFlag, featuresOption=None):
        """ Disable outgoing mail tracking and rate limiting. Disable all features if --features is not supplied. """
        OutgoingMailController().disable(featuresOption)

    # Helpers for limits

    @optset(applies='do_add_(subscription|domain|mailname), do_set_*_limits', intOptions='limit as out-limit')
    def handle_out_limit(self, limit=None):
        """ Handles --out-limit option. """
        self._out_limit = limit
        log.debug("Out limit option value is: %s", self._out_limit)

    @optset(applies='do_add_subscription, do_set_subscription_limits')
    def handle_allow_sendmail(self, allowSendmailOption=None):
        """ Handles --allow-sendmail option. """
        if allowSendmailOption is None:
            pass
        elif allowSendmailOption.lower() in ('1', 'on', 'true', 'yes'):
            self._allow_sendmail = True
        elif allowSendmailOption.lower() in ('0', 'off', 'false', 'no'):
            self._allow_sendmail = False
        else:
            raise UsageException("Incorrect value for --allow-sendmail option: expected boolean, got '%s'" %
                                 allowSendmailOption)
        log.debug("Allow-sendmail option value is: %s", self._allow_sendmail)

    # Subscription management

    @optmatcher
    def do_add_subscription(self, addSubscriptionFlag, mainDomainNameOption):
        """ Add new subscription together with its main domain. """
        self._ensure_db_changed(self._db().create_subscription(mainDomainNameOption.lower()))
        self._db().set_subscription_limits(mainDomainNameOption.lower(),
                                           self._out_limit, self._allow_sendmail)

    @optmatcher
    def do_remove_subscription(self, removeSubscriptionFlag, mainDomainNameOption):
        """ Remove existing subscription together with its sysusers, domains, and mailnames. """
        self._check_db_changed(self._db().delete_subscription(mainDomainNameOption.lower()))

    @optmatcher
    def do_set_subscription_limits(self, setSubscriptionLimitsFlag, mainDomainNameOption):
        """ Set new limit values for existing subscription. """
        self._check_db_changed(self._db().set_subscription_limits(mainDomainNameOption.lower(),
                                                                  self._out_limit,
                                                                  self._allow_sendmail))

    # Sysuser management

    @optmatcher
    def do_add_sysuser(self, addSysuserFlag, sysuserOption, mainDomainNameOption):
        """ Add new sysuser to existing subscription identified by its main domain. The user must exist in system. """
        self._ensure_db_changed(self._db().create_sysuser(sysuserOption, mainDomainNameOption.lower()))

    @optmatcher
    def do_remove_sysuser(self, removeSysuserFlag, sysuserOption):
        """ Remove sysuser belonging to existing subscription. The user may not exist in system. """
        self._check_db_changed(self._db().delete_sysuser(sysuserOption))

    @optmatcher
    def do_rename_sysuser(self, renameSysuserFlag, oldNameOption, newNameOption):
        """ Rename sysuser belonging to existing subscription. """
        self._ensure_db_changed(self._db().rename_sysuser(oldNameOption, newNameOption))

    # Domain management

    @optmatcher
    def do_add_domain(self, addDomainFlag, domainNameOption, mainDomainNameOption):
        """ Add new domain (addon or sub-domain) to existing subscription identified by its main domain. """
        self._ensure_db_changed(self._db().create_domain(domainNameOption.lower(), mainDomainNameOption.lower()))
        self._db().set_domain_limits(domainNameOption.lower(), self._out_limit)

    @optmatcher
    def do_remove_domain(self, removeDomainFlag, domainNameOption):
        """ Remove existing domain together with its vhost-ids and mailnames. 
            Removing main domain will also cause its subscription to be removed.
        """
        self._check_db_changed(self._db().delete_domain(domainNameOption.lower()))

    @optmatcher
    def do_rename_domain(self, renameDomainFlag, oldNameOption, newNameOption):
        """ Rename existing domain. """
        self._ensure_db_changed(self._db().rename_domain(oldNameOption.lower(), newNameOption.lower()))

    @optmatcher
    def do_set_domain_limits(self, setDomainLimitsFlag, domainNameOption):
        """ Set new limit values for existing domain. """
        self._check_db_changed(self._db().set_domain_limits(domainNameOption.lower(), self._out_limit))

    # Mailname management

    @optmatcher
    def do_add_mailname(self, addMailnameFlag, domainNameOption, mailnameOption):
        """ Add new mailname to existing domain. """
        self._ensure_db_changed(self._db().create_mailname(mailnameOption.lower(), domainNameOption.lower()))
        self._db().set_mailname_limits(mailnameOption.lower(), domainNameOption.lower(), self._out_limit)

    @optmatcher
    def do_remove_mailname(self, removeMailnameFlag, domainNameOption, mailnameOption):
        """ Remove existing mailname on a given domain. """
        self._check_db_changed(self._db().delete_mailname(mailnameOption.lower(), domainNameOption.lower()))

    @optmatcher
    def do_rename_mailname(self, renameMailnameFlag, domainNameOption, oldNameOption, newNameOption):
        """ Rename existing mailname on a given domain. """
        self._ensure_db_changed(self._db().rename_mailname(oldNameOption.lower(), newNameOption.lower(), domainNameOption.lower()))

    @optmatcher
    def do_set_mailname_limits(self, setMailnameLimitsFlag, domainNameOption, mailnameOption):
        """ Set new limit values for existing mailname on a given domain. """
        self._check_db_changed(self._db().set_mailname_limits(mailnameOption.lower(), domainNameOption.lower(), self._out_limit))

    # Whitelist management

    @optmatcher
    def do_whitelist_add(self, addToWhitelistFlag, sysuserOption):
        """ Add system user to whitelist. The user must exist in system.
            Whitelisted users are permitted to send mail via sendmail without restrictions.
        """
        self._ensure_db_changed(self._db().add_user_to_whitelist(sysuserOption))

    @optmatcher
    def do_whitelist_remove(self, removeFromWhitelistFlag, sysuserOption):
        """ Remove system user from whitelist. The user may not exist in system.
            Whitelisted users are permitted to send mail via sendmail without restrictions.
        """
        self._check_db_changed(self._db().remove_user_from_whitelist(sysuserOption))

    @optmatcher
    def do_show_whitelist(self, showWhitelistFlag):
        """ Show list of whitelisted users.
            Whitelisted users are permitted to send mail via sendmail without restrictions.
        """
        for user in self._db().get_whitelist_users():
            print(user)

    # Domain aliases management

    @optmatcher
    def do_add_domain_alias(self, addDomainAliasFlag, domainNameOption, domainAliasNameOption):
        """ Add new alias for existing domain. """
        self._ensure_db_changed(self._db().create_domain_alias(domainAliasNameOption.lower(), domainNameOption.lower()))

    @optmatcher
    def do_remove_domain_alias(self, removeDomainAliasFlag, domainAliasNameOption):
        """ Remove existing domain alias. """
        self._check_db_changed(self._db().delete_domain_alias(domainAliasNameOption.lower()))

    # Mail aliases management

    @optmatcher
    def do_add_mailname_alias(self, addMailnameAliasFlag, domainNameOption, mailnameOption, mailnameAliasOption):
        """ Add new alias for existing mailname on a given domain. """
        self._ensure_db_changed(self._db().create_mailname_alias(mailnameAliasOption.lower(), mailnameOption.lower(),
                                                                 domainNameOption.lower()))

    @optmatcher
    def do_remove_mailname_alias(self, removeMailnameAliasFlag, domainNameOption, mailnameAliasOption):
        """ Remove existing mailname alias on a given domain. """
        self._check_db_changed(self._db().delete_mailname_alias(mailnameAliasOption.lower(), domainNameOption.lower()))

    # Vhost-id management

    def __get_vhost_id(self, vhostIdOption):
        """ Get vhost-id either from environment or command line option. """
        if not vhostIdOption:
            vhostIdOption = os.getenv('VHOST_ID')
        if not vhostIdOption:
            raise UsageException("No valid value for --vhost-id option in command line or environment")
        return vhostIdOption

    @optmatcher
    def do_register_vhost_id(self, registerVhostIdFlag, domainNameOption, vhostIdOption=None):
        """ Register new vhost-id for a given domain. """
        self._ensure_db_changed(self._db().add_vhost_id(self.__get_vhost_id(vhostIdOption), domainNameOption.lower()))

    @optmatcher
    def do_unregister_vhost_id(self, unregisterVhostIdFlag, vhostIdOption=None):
        """ Unregister previously registered vhost-id. """
        self._check_db_changed(self._db().remove_vhost_id(self.__get_vhost_id(vhostIdOption)))

    # Batch operations

    @optmatcher
    def do_set_limits_batch(self, setLimitsBatchFlag):
        """ Set limits for list of subscriptions and related entries (domains and mails). """
        # Json-input format description:
        # {
        #     "subscriptions": [{
        #         "allow_sendmail": "bool /* true, false */",
        #         "out_limit": "int /* e.g. 4096 */",
        #         "domains": [{
        #             "name": "string /* e.g. sub.example.tld */",
        #             "is_main": true,
        #             "out_limit": "int",
        #             "mails": [{
        #                 "name": "string /* mail address part before '@' */",
        #                 "out_limit": "int"
        #             }]
        #         }]
        #     }]
        # }
        batch_helper = OutgoingMailBatchOperationsHelper(self._db())
        data = json.load(sys.stdin)
        for subscription in data["subscriptions"]:
            batch_helper.set_subscription_limits(subscription)

    # DB maintenance

    @optmatcher
    def do_cleanup_messages(self, cleanupFlag, toTimestampOption='now', forceFlag=None, noVacuumFlag=None):
        """ Remove old mail messages records with timestamps before a given one.
            By default at least one hour or records will be retained. Use --force to override this behavior.
            Use --no-vacuum flag to prohibit periodic DB compactization and optimization.
        """
        nrows = self._db().cleanup_messages(toTimestampOption, force=forceFlag, allow_vacuuming=not noVacuumFlag)
        print("%d records have been removed." % nrows)

    # Statistics

    @optmatcher
    def do_show_statistics(self, fetchStatisticsFlag, fromTimestampOption=None, timeStepOption='minute', 
                           withZeroCountersFlag=None, withLimitsFlag=None,
                           prettyPrintFlag=None):
        """ Generate and print aggregated statistics for outgoing mail messages. 
            By default all available records are processed and aggregated for each minute, 
            output is compact. Only non-zero statistics entries are shown.
        """
        stats = (OutgoingMailStatsHelper(self._db())
                    .starting_from(fromTimestampOption)
                    .with_time_step(timeStepOption)
                    .skipping_zero_counters(not withZeroCountersFlag)
                    .skipping_limits(not withLimitsFlag)
                    .fetch())
        print(format_statistics_as_json(stats, prettyPrintFlag))

    # Crontab management

    @optmatcher
    def do_add_cron_job(self, installCronJobFlag, timePeriodOption=60, jobNameOption=None, commandOption=None):
        """ Install a new system cron job. If a cron job with a given (or default) name 
            already exists, it will be modified instead. Without arguments schedules a 
            default job to run once an hour. 
        """
        OutgoingMailController().install_cron_job(jobNameOption, commandOption, timePeriodOption)

    @optmatcher
    def do_remove_cron_job(self, removeCronJobFlag, jobNameOption=None):
        """ Remove previously installed system cron job. Remove default one if name is not specified. """
        OutgoingMailController().remove_cron_job(jobNameOption)


def main():
    """ mailmng-outgoing main entry point for command-line execution. """
    try:
        MailmngOutgoingApp().main(sys.argv)
    except SystemExit:
        raise
    except UsageException as ex:
        sys.stderr.write('%s\n' % ex)
        sys.exit(1)
    except Exception as ex:
        log.error('%s', ex)
        log.debug('This exception happened at:', exc_info=sys.exc_info())
        sys.exit(1)

if __name__ == '__main__':
    main()

# vim: ts=4 sts=4 sw=4 et :
