VirtualMailManager/VirtualMailManager.py
changeset 0 bb0aa2102206
child 2 9b39f828aa8a
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/VirtualMailManager/VirtualMailManager.py	Sun Jan 06 18:22:10 2008 +0000
@@ -0,0 +1,440 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+# opyright 2007-2008 VEB IT
+# See COPYING for distribution information.
+# $Id$
+
+"""The main class for vmm."""
+
+__author__ = 'Pascal Volk <p.volk@veb-it.de>'
+__version__ = 'rev '+'$Rev$'.split()[1]
+__date__ = '$Date$'.split()[1]
+
+import os
+import re
+import sys
+from encodings.idna import ToASCII, ToUnicode
+from shutil import rmtree
+from subprocess import Popen, PIPE
+
+from pyPgSQL import PgSQL # python-pgsql - http://pypgsql.sourceforge.net
+
+from Exceptions import *
+import constants.ERROR as ERR
+from Config import VMMConfig as Cfg
+from Account import Account
+from Alias import Alias
+from Domain import Domain
+
+RE_ASCII_CHARS = """^[\x20-\x7E]*$"""
+RE_DOMAIN = """^(?:[a-z0-9-]{1,63}\.){1,}[a-z]{2,6}$"""
+RE_LOCALPART = """[^\w!#$%&'\*\+-\.\/=?^_`{\|}~]"""
+re.compile(RE_ASCII_CHARS)
+re.compile(RE_DOMAIN)
+
+ENCODING_IN = sys.getfilesystemencoding()
+ENCODING_OUT = sys.stdout.encoding or sys.getfilesystemencoding()
+
+class VirtualMailManager:
+    """The main class for vmm"""
+    def __init__(self):
+        """Creates a new VirtualMailManager instance.
+        Throws a VMMNotRootException if your uid is greater 0.
+        """
+        self.__cfgFileName = '/usr/local/etc/vmm.cfg'
+        self.__permWarnMsg = "fix permissions for '"+self.__cfgFileName \
+            +"'.\n`chmod 0600 "+self.__cfgFileName+"` would be great.\n"
+        self.__warnings = []
+        self.__Cfg = None
+        self.__dbh = None
+
+        if os.geteuid():
+            raise VMMNotRootException("You are not root.\n\tGood bye!\n")
+        if self.__chkCfgFile():
+            self.__Cfg = Cfg(self.__cfgFileName)
+            self.__Cfg.load()
+            self.__cfgSections = self.__Cfg.getsections()
+        self.__chkenv()
+
+    def __chkCfgFile(self):
+        """Checks the configuration file, returns bool"""
+        if not os.path.isfile(self.__cfgFileName):
+            raise IOError("Fatal error: The file "+self.__cfgFileName+ \
+                    " does not exists.\n")
+        fstat = os.stat(self.__cfgFileName)
+        try:
+            fmode = self.__getFileMode()
+        except:
+            raise
+        if fmode % 100 and fstat.st_uid != fstat.st_gid \
+        or fmode % 10 and fstat.st_uid == fstat.st_gid:
+            raise VMMPermException(self.__permWarnMsg)
+        else:
+            return True
+
+    def __chkenv(self):
+        """"""
+        if not os.path.exists(self.__Cfg.get('maildir', 'base')):
+            old_umask = os.umask(0007)
+            os.makedirs(self.__Cfg.get('maildir', 'base'), 0770)
+            os.umask(old_umask)
+        elif not os.path.isdir(self.__Cfg.get('maildir', 'base')):
+            raise VMMException(('%s is not a directory' %
+                self.__Cfg.get('maildir', 'base'), ERR.NO_SUCH_DIRECTORY))
+        for opt, val in self.__Cfg.items('bin'):
+            if not os.path.exists(val):
+                raise VMMException(("%s doesn't exists.", ERR.NO_SUCH_BINARY))
+            elif not os.access(val, os.X_OK):
+                raise VMMException(("%s is not executable.", ERR.NOT_EXECUTABLE))
+
+    def __getFileMode(self):
+        """Determines the file access mode from file __cfgFileName,
+        returns int.
+        """
+        try:
+            return int(oct(os.stat(self.__cfgFileName).st_mode & 0777))
+        except:
+            raise
+
+    def __dbConnect(self):
+        """Creates a pyPgSQL.PgSQL.connection instance."""
+        try:
+            self.__dbh =  PgSQL.connect(
+                    database=self.__Cfg.get('database', 'name'),
+                    user=self.__Cfg.get('database', 'user'),
+                    host=self.__Cfg.get('database', 'host'),
+                    password=self.__Cfg.get('database', 'pass'),
+                    client_encoding='utf8', unicode_results=True)
+            dbc = self.__dbh.cursor()
+            dbc.execute("SET NAMES 'UTF8'")
+            dbc.close()
+        except PgSQL.libpq.DatabaseError, e:
+            raise VMMException((str(e), ERR.DATABASE_ERROR))
+
+    def __chkLocalpart(self, localpart):
+        """Validates the local part of an email address.
+        
+        Keyword arguments:
+        localpart -- the email address that should be validated (str)
+        """
+        if len(localpart) > 64:
+            raise VMMException(('The local part is too long',
+                ERR.LOCALPART_TOO_LONG))
+        if re.compile(RE_LOCALPART).search(localpart):
+            raise VMMException((
+                'The local part «%s» contains invalid characters.' % localpart,
+                ERR.LOCALPART_INVALID))
+        return localpart
+
+    def __idn2ascii(self, domainname):
+        """Converts an idn domainname in punycode.
+        
+        Keyword arguments:
+        domainname -- the domainname to convert (str)
+        """
+        tmp = []
+        for label in domainname.split('.'):
+            if len(label) == 0:
+                continue
+            tmp.append(ToASCII(unicode(label, ENCODING_IN)))
+        return '.'.join(tmp)
+
+    def __ace2idna(self, domainname):
+        """Convertis a domainname from ACE according to IDNA
+        
+        Keyword arguments:
+        domainname -- the domainname to convert (str)
+        """
+        tmp = []
+        for label in domainname.split('.'):
+            if len(label) == 0:
+                continue
+            tmp.append(ToUnicode(label))
+        return '.'.join(tmp)
+
+    def __chkDomainname(self, domainname):
+        """Validates the domain name of an email address.
+        
+        Keyword arguments:
+        domainname -- the domain name that should be validated
+        """
+        if not re.match(RE_ASCII_CHARS, domainname):
+            domainname = self.__idn2ascii(domainname)
+        if len(domainname) > 255:
+            raise VMMException(('The domain name is too long.',
+                ERR.DOMAIN_TOO_LONG))
+        if not re.match(RE_DOMAIN, domainname):
+            raise VMMException(('The domain name is invalid.',
+                ERR.DOMAIN_INVALID))
+        return domainname
+
+    def __chkEmailadress(self, address):
+        try:
+            localpart, domain = address.split('@')
+        except ValueError:
+            raise VMMException(("Missing '@' sign in emailaddress «%s»." %
+                address, ERR.INVALID_ADDRESS))
+        except AttributeError:
+            raise VMMException(("'%s' looks not like an email address." %
+                address, ERR.INVALID_ADDRESS))
+        domain = self.__chkDomainname(domain)
+        localpart = self.__chkLocalpart(localpart)
+        return '%s@%s' % (localpart, domain)
+
+    def __getAccount(self, address, password=None):
+        address = self.__chkEmailadress(address)
+        self.__dbConnect()
+        if not password is None:
+            password = self.__pwhash(password)
+        return Account(self.__dbh, self.__Cfg.get('maildir', 'base'), address,
+                password)
+
+    def __getAlias(self, address, destination=None):
+        address = self.__chkEmailadress(address)
+        if not destination is None:
+            if destination.count('@'):
+                destination = self.__chkEmailadress(destination)
+            else:
+                destination = self.__chkLocalpart(destination)
+        self.__dbConnect()
+        return Alias(self.__dbh, address, self.__Cfg.get('maildir', 'base'),
+                destination)
+
+    def __getDomain(self, domainname, transport=None):
+        domainname = self.__chkDomainname(domainname)
+        self.__dbConnect()
+        return Domain(self.__dbh, domainname,
+                self.__Cfg.get('maildir', 'base'), transport)
+
+    def __getDiskUsage(self, directory):
+        """Estimate file space usage for the given directory.
+        
+        Keyword arguments:
+        directory -- the directory to summarize recursively disk usage for
+        """
+        return Popen([self.__Cfg.get('bin', 'du'), "-hs", directory],
+                stdout=PIPE).communicate()[0].split('\t')[0]
+
+    def __makedir(self, directory, mode=None, uid=None, gid=None):
+        if mode is None:
+            mode = self.__Cfg.getint('maildir', 'mode')
+        if uid is None:
+            uid = 0
+        if gid is None:
+            gid = 0
+        os.makedirs(directory, mode)
+        os.chown(directory, uid, gid)
+
+    def __domdirmake(self, domdir, gid):
+        os.umask(0006)
+        oldpwd = os.getcwd()
+        basedir = self.__Cfg.get('maildir', 'base')
+        domdirdirs = domdir.replace(basedir+'/', '').split('/')
+
+        os.chdir(basedir)
+        if not os.path.isdir(domdirdirs[0]):
+            self.__makedir(domdirdirs[0], 489, 0,
+                    self.__Cfg.getint('misc', 'gid_mail'))
+        os.chdir(domdirdirs[0])
+        os.umask(0007)
+        self.__makedir(domdirdirs[1], self.__Cfg.getint('domdir', 'mode'), 0,
+                gid)
+        os.chdir(oldpwd)
+
+    def __maildirmake(self, domdir, uid, gid):
+        """Creates maildirs and maildir subfolders.
+
+        Keyword arguments:
+        uid -- user id from the account
+        gid -- group id from the account
+        """
+        os.umask(0007)
+        oldpwd = os.getcwd()
+        os.chdir(domdir)
+
+        maildir = '%s' % self.__Cfg.get('maildir', 'folder')
+        folders = [maildir , maildir+'/.Drafts', maildir+'/.Sent',
+                maildir+'/.Templates', maildir+'/.Trash']
+        subdirs = ['cur', 'new', 'tmp']
+        mode = self.__Cfg.getint('maildir', 'mode')
+
+        self.__makedir('%s' % uid, mode, uid, gid)
+        os.chdir('%s' % uid)
+        for folder in folders:
+            self.__makedir(folder, mode, uid, gid)
+            for subdir in subdirs:
+                self.__makedir(folder+'/'+subdir, mode, uid, gid)
+        os.chdir(oldpwd)
+
+    def __maildirdelete(self, domdir, uid, gid):
+        if uid > 0 and gid > 0:
+            maildir = '%s' % uid
+            if maildir.count('..') or domdir.count('..'):
+                raise VMMException(('FATAL: ".." in maildir path detected.',
+                    ERR.FOUND_DOTS_IN_PATH))
+            if os.path.isdir(domdir):
+                os.chdir(domdir)
+                if os.path.isdir(maildir):
+                    mdstat = os.stat(maildir)
+                    if (mdstat.st_uid, mdstat.st_gid) != (uid, gid):
+                        raise VMMException(
+                            ('FATAL: owner/group mismatch in maildir detected',
+                                ERR.MAILDIR_PERM_MISMATCH))
+                    rmtree(maildir, ignore_errors=True)
+
+    def __domdirdelete(self, domdir, gid):
+        if gid > 0:
+            basedir = '%s' % self.__Cfg.get('maildir', 'base')
+            domdirdirs = domdir.replace(basedir+'/', '').split('/')
+            if basedir.count('..') or domdir.count('..'):
+                raise VMMException(
+                        ('FATAL: ".." in domain directory path detected.',
+                            ERR.FOUND_DOTS_IN_PATH))
+            if os.path.isdir('%s/%s' % (basedir, domdirdirs[0])):
+                os.chdir('%s/%s' % (basedir, domdirdirs[0]))
+                if os.lstat(domdirdirs[1]).st_gid != gid:
+                    raise VMMException(
+                    ('FATAL: group mismatch in domain directory detected',
+                        ERR.DOMAINDIR_GROUP_MISMATCH))
+                rmtree(domdirdirs[1], ignore_errors=True)
+
+    def __pwhash(self, password, scheme=None, user=None):
+        # XXX alle Schemen berücksichtigen XXX
+        if scheme is None:
+            scheme = self.__Cfg.get('misc', 'passwdscheme')
+        return Popen([self.__Cfg.get('bin', 'dovecotpw'), '-s', scheme, '-p',
+            password], stdout=PIPE).communicate()[0][len(scheme)+2:-1]
+
+    def hasWarnings(self):
+        """Checks if warnings are present, returns bool."""
+        return bool(len(self.__warnings))
+
+    def getWarnings(self):
+        """Returns a list with all available warnings."""
+        return self.__warnings
+
+    def setupIsDone(self):
+        """Checks if vmm is configured, returns bool"""
+        try:
+            return self.__Cfg.getboolean('config', 'done')
+        except ValueError, e:
+            raise VMMConfigException('Configurtion error: "'+str(e)
+                +'"\n(in section "Connfig", option "done")'
+                +'\nsee also: vmm.cfg(5)\n')
+
+    def configure(self, section=None):
+        """Starts interactive configuration.
+
+        Configures in interactive mode options in the given section.
+        If no section is given (default) all options from all sections
+        will be prompted.
+
+        Keyword arguments:
+        section -- the section to configure (default None):
+            'database', 'maildir', 'bin' or 'misc'
+        """
+        try:
+            if not section:
+                self.__Cfg.configure(self.__cfgSections)
+            elif section not in self.__cfgSections:
+                raise VMMException(("Invalid section: «%s»" % section,
+                    ERR.INVALID_SECTION))
+            else:
+                self.__Cfg.configure([section])
+        except:
+            raise
+
+    def domain_add(self, domainname, transport=None):
+        dom = self.__getDomain(domainname, transport)
+        dom.save()
+        self.__domdirmake(dom.getDir(), dom.getID())
+
+    def domain_transport(self, domainname, transport):
+        dom = self.__getDomain(domainname, None)
+        dom.updateTransport(transport)
+
+    def domain_delete(self, domainname, force=None):
+        if not force is None and force not in ['deluser','delalias','delall']:
+            raise VMMDomainException(('Invalid option: «%s»' % force,
+                ERR.INVALID_OPTION))
+        dom = self.__getDomain(domainname)
+        gid = dom.getID()
+        domdir = dom.getDir()
+        if self.__Cfg.getboolean('misc', 'forcedel') or force == 'delall':
+            dom.delete(True, True)
+        elif force == 'deluser':
+            dom.delete(delUser=True)
+        elif force == 'delalias':
+            dom.delete(delAlias=True)
+        else:
+            dom.delete()
+        if self.__Cfg.getboolean('domdir', 'delete'):
+            self.__domdirdelete(domdir, gid)
+
+    def domain_info(self, domainname, detailed=None):
+        dom = self.__getDomain(domainname)
+        dominfo = dom.getInfo()
+        if dominfo['domainname'].startswith('xn--'):
+            dominfo['domainname'] += ' (%s)'\
+                % self.__ace2idna(dominfo['domainname'])
+        if dominfo['aliases'] is None:
+            dominfo['aliases'] = 0
+        if detailed is None:
+            return dominfo
+        elif detailed == 'detailed':
+            return dominfo, dom.getAccounts(), dom.getAliases()
+        else:
+            raise VMMDomainException(('Invalid option: «%s»' % detailed,
+                ERR.INVALID_OPTION))
+
+    def user_add(self, emailaddress, password):
+        acc = self.__getAccount(emailaddress, password)
+        acc.save(self.__Cfg.get('maildir', 'folder'))
+        self.__maildirmake(acc.getDir('domain'), acc.getUID(), acc.getGID())
+
+    def alias_add(self, aliasaddress, targetaddress):
+        alias = self.__getAlias(aliasaddress, targetaddress)
+        alias.save()
+
+    def user_delete(self, emailaddress):
+        acc = self.__getAccount(emailaddress)
+        uid = acc.getUID()
+        gid = acc.getGID()
+        acc.delete()
+        if self.__Cfg.getboolean('maildir', 'delete'):
+            self.__maildirdelete(acc.getDir('domain'), uid, gid)
+
+    def alias_info(self, aliasaddress):
+        alias = self.__getAlias(aliasaddress)
+        return alias.getInfo()
+
+    def alias_delete(self, aliasaddress):
+        alias = self.__getAlias(aliasaddress)
+        alias.delete()
+
+    def user_info(self, emailaddress, diskusage=False):
+        acc = self.__getAccount(emailaddress)
+        info = acc.getInfo()
+        if self.__Cfg.getboolean('maildir', 'diskusage') or diskusage:
+            info['disk usage'] = self.__getDiskUsage('%(home)s/%(mail)s' % info)
+        return info
+
+    def user_password(self, emailaddress, password):
+        acc = self.__getAccount(emailaddress)
+        acc.modify('password', self.__pwhash(password))
+
+    def user_name(self, emailaddress, name):
+        acc = self.__getAccount(emailaddress)
+        acc.modify('name', name)
+
+    def user_disable(self, emailaddress):
+        acc = self.__getAccount(emailaddress)
+        acc.disable()
+
+    def user_enable(self, emailaddress):
+        acc = self.__getAccount(emailaddress)
+        acc.enable()
+
+    def __del__(self):
+        if not self.__dbh is None and self.__dbh._isOpen:
+            self.__dbh.close()