diff -r 000000000000 -r bb0aa2102206 VirtualMailManager/VirtualMailManager.py --- /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 ' +__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()