VirtualMailManager/VirtualMailManager.py
changeset 0 bb0aa2102206
child 2 9b39f828aa8a
equal deleted inserted replaced
-1:000000000000 0:bb0aa2102206
       
     1 #!/usr/bin/env python
       
     2 # -*- coding: UTF-8 -*-
       
     3 # opyright 2007-2008 VEB IT
       
     4 # See COPYING for distribution information.
       
     5 # $Id$
       
     6 
       
     7 """The main class for vmm."""
       
     8 
       
     9 __author__ = 'Pascal Volk <p.volk@veb-it.de>'
       
    10 __version__ = 'rev '+'$Rev$'.split()[1]
       
    11 __date__ = '$Date$'.split()[1]
       
    12 
       
    13 import os
       
    14 import re
       
    15 import sys
       
    16 from encodings.idna import ToASCII, ToUnicode
       
    17 from shutil import rmtree
       
    18 from subprocess import Popen, PIPE
       
    19 
       
    20 from pyPgSQL import PgSQL # python-pgsql - http://pypgsql.sourceforge.net
       
    21 
       
    22 from Exceptions import *
       
    23 import constants.ERROR as ERR
       
    24 from Config import VMMConfig as Cfg
       
    25 from Account import Account
       
    26 from Alias import Alias
       
    27 from Domain import Domain
       
    28 
       
    29 RE_ASCII_CHARS = """^[\x20-\x7E]*$"""
       
    30 RE_DOMAIN = """^(?:[a-z0-9-]{1,63}\.){1,}[a-z]{2,6}$"""
       
    31 RE_LOCALPART = """[^\w!#$%&'\*\+-\.\/=?^_`{\|}~]"""
       
    32 re.compile(RE_ASCII_CHARS)
       
    33 re.compile(RE_DOMAIN)
       
    34 
       
    35 ENCODING_IN = sys.getfilesystemencoding()
       
    36 ENCODING_OUT = sys.stdout.encoding or sys.getfilesystemencoding()
       
    37 
       
    38 class VirtualMailManager:
       
    39     """The main class for vmm"""
       
    40     def __init__(self):
       
    41         """Creates a new VirtualMailManager instance.
       
    42         Throws a VMMNotRootException if your uid is greater 0.
       
    43         """
       
    44         self.__cfgFileName = '/usr/local/etc/vmm.cfg'
       
    45         self.__permWarnMsg = "fix permissions for '"+self.__cfgFileName \
       
    46             +"'.\n`chmod 0600 "+self.__cfgFileName+"` would be great.\n"
       
    47         self.__warnings = []
       
    48         self.__Cfg = None
       
    49         self.__dbh = None
       
    50 
       
    51         if os.geteuid():
       
    52             raise VMMNotRootException("You are not root.\n\tGood bye!\n")
       
    53         if self.__chkCfgFile():
       
    54             self.__Cfg = Cfg(self.__cfgFileName)
       
    55             self.__Cfg.load()
       
    56             self.__cfgSections = self.__Cfg.getsections()
       
    57         self.__chkenv()
       
    58 
       
    59     def __chkCfgFile(self):
       
    60         """Checks the configuration file, returns bool"""
       
    61         if not os.path.isfile(self.__cfgFileName):
       
    62             raise IOError("Fatal error: The file "+self.__cfgFileName+ \
       
    63                     " does not exists.\n")
       
    64         fstat = os.stat(self.__cfgFileName)
       
    65         try:
       
    66             fmode = self.__getFileMode()
       
    67         except:
       
    68             raise
       
    69         if fmode % 100 and fstat.st_uid != fstat.st_gid \
       
    70         or fmode % 10 and fstat.st_uid == fstat.st_gid:
       
    71             raise VMMPermException(self.__permWarnMsg)
       
    72         else:
       
    73             return True
       
    74 
       
    75     def __chkenv(self):
       
    76         """"""
       
    77         if not os.path.exists(self.__Cfg.get('maildir', 'base')):
       
    78             old_umask = os.umask(0007)
       
    79             os.makedirs(self.__Cfg.get('maildir', 'base'), 0770)
       
    80             os.umask(old_umask)
       
    81         elif not os.path.isdir(self.__Cfg.get('maildir', 'base')):
       
    82             raise VMMException(('%s is not a directory' %
       
    83                 self.__Cfg.get('maildir', 'base'), ERR.NO_SUCH_DIRECTORY))
       
    84         for opt, val in self.__Cfg.items('bin'):
       
    85             if not os.path.exists(val):
       
    86                 raise VMMException(("%s doesn't exists.", ERR.NO_SUCH_BINARY))
       
    87             elif not os.access(val, os.X_OK):
       
    88                 raise VMMException(("%s is not executable.", ERR.NOT_EXECUTABLE))
       
    89 
       
    90     def __getFileMode(self):
       
    91         """Determines the file access mode from file __cfgFileName,
       
    92         returns int.
       
    93         """
       
    94         try:
       
    95             return int(oct(os.stat(self.__cfgFileName).st_mode & 0777))
       
    96         except:
       
    97             raise
       
    98 
       
    99     def __dbConnect(self):
       
   100         """Creates a pyPgSQL.PgSQL.connection instance."""
       
   101         try:
       
   102             self.__dbh =  PgSQL.connect(
       
   103                     database=self.__Cfg.get('database', 'name'),
       
   104                     user=self.__Cfg.get('database', 'user'),
       
   105                     host=self.__Cfg.get('database', 'host'),
       
   106                     password=self.__Cfg.get('database', 'pass'),
       
   107                     client_encoding='utf8', unicode_results=True)
       
   108             dbc = self.__dbh.cursor()
       
   109             dbc.execute("SET NAMES 'UTF8'")
       
   110             dbc.close()
       
   111         except PgSQL.libpq.DatabaseError, e:
       
   112             raise VMMException((str(e), ERR.DATABASE_ERROR))
       
   113 
       
   114     def __chkLocalpart(self, localpart):
       
   115         """Validates the local part of an email address.
       
   116         
       
   117         Keyword arguments:
       
   118         localpart -- the email address that should be validated (str)
       
   119         """
       
   120         if len(localpart) > 64:
       
   121             raise VMMException(('The local part is too long',
       
   122                 ERR.LOCALPART_TOO_LONG))
       
   123         if re.compile(RE_LOCALPART).search(localpart):
       
   124             raise VMMException((
       
   125                 'The local part «%s» contains invalid characters.' % localpart,
       
   126                 ERR.LOCALPART_INVALID))
       
   127         return localpart
       
   128 
       
   129     def __idn2ascii(self, domainname):
       
   130         """Converts an idn domainname in punycode.
       
   131         
       
   132         Keyword arguments:
       
   133         domainname -- the domainname to convert (str)
       
   134         """
       
   135         tmp = []
       
   136         for label in domainname.split('.'):
       
   137             if len(label) == 0:
       
   138                 continue
       
   139             tmp.append(ToASCII(unicode(label, ENCODING_IN)))
       
   140         return '.'.join(tmp)
       
   141 
       
   142     def __ace2idna(self, domainname):
       
   143         """Convertis a domainname from ACE according to IDNA
       
   144         
       
   145         Keyword arguments:
       
   146         domainname -- the domainname to convert (str)
       
   147         """
       
   148         tmp = []
       
   149         for label in domainname.split('.'):
       
   150             if len(label) == 0:
       
   151                 continue
       
   152             tmp.append(ToUnicode(label))
       
   153         return '.'.join(tmp)
       
   154 
       
   155     def __chkDomainname(self, domainname):
       
   156         """Validates the domain name of an email address.
       
   157         
       
   158         Keyword arguments:
       
   159         domainname -- the domain name that should be validated
       
   160         """
       
   161         if not re.match(RE_ASCII_CHARS, domainname):
       
   162             domainname = self.__idn2ascii(domainname)
       
   163         if len(domainname) > 255:
       
   164             raise VMMException(('The domain name is too long.',
       
   165                 ERR.DOMAIN_TOO_LONG))
       
   166         if not re.match(RE_DOMAIN, domainname):
       
   167             raise VMMException(('The domain name is invalid.',
       
   168                 ERR.DOMAIN_INVALID))
       
   169         return domainname
       
   170 
       
   171     def __chkEmailadress(self, address):
       
   172         try:
       
   173             localpart, domain = address.split('@')
       
   174         except ValueError:
       
   175             raise VMMException(("Missing '@' sign in emailaddress «%s»." %
       
   176                 address, ERR.INVALID_ADDRESS))
       
   177         except AttributeError:
       
   178             raise VMMException(("'%s' looks not like an email address." %
       
   179                 address, ERR.INVALID_ADDRESS))
       
   180         domain = self.__chkDomainname(domain)
       
   181         localpart = self.__chkLocalpart(localpart)
       
   182         return '%s@%s' % (localpart, domain)
       
   183 
       
   184     def __getAccount(self, address, password=None):
       
   185         address = self.__chkEmailadress(address)
       
   186         self.__dbConnect()
       
   187         if not password is None:
       
   188             password = self.__pwhash(password)
       
   189         return Account(self.__dbh, self.__Cfg.get('maildir', 'base'), address,
       
   190                 password)
       
   191 
       
   192     def __getAlias(self, address, destination=None):
       
   193         address = self.__chkEmailadress(address)
       
   194         if not destination is None:
       
   195             if destination.count('@'):
       
   196                 destination = self.__chkEmailadress(destination)
       
   197             else:
       
   198                 destination = self.__chkLocalpart(destination)
       
   199         self.__dbConnect()
       
   200         return Alias(self.__dbh, address, self.__Cfg.get('maildir', 'base'),
       
   201                 destination)
       
   202 
       
   203     def __getDomain(self, domainname, transport=None):
       
   204         domainname = self.__chkDomainname(domainname)
       
   205         self.__dbConnect()
       
   206         return Domain(self.__dbh, domainname,
       
   207                 self.__Cfg.get('maildir', 'base'), transport)
       
   208 
       
   209     def __getDiskUsage(self, directory):
       
   210         """Estimate file space usage for the given directory.
       
   211         
       
   212         Keyword arguments:
       
   213         directory -- the directory to summarize recursively disk usage for
       
   214         """
       
   215         return Popen([self.__Cfg.get('bin', 'du'), "-hs", directory],
       
   216                 stdout=PIPE).communicate()[0].split('\t')[0]
       
   217 
       
   218     def __makedir(self, directory, mode=None, uid=None, gid=None):
       
   219         if mode is None:
       
   220             mode = self.__Cfg.getint('maildir', 'mode')
       
   221         if uid is None:
       
   222             uid = 0
       
   223         if gid is None:
       
   224             gid = 0
       
   225         os.makedirs(directory, mode)
       
   226         os.chown(directory, uid, gid)
       
   227 
       
   228     def __domdirmake(self, domdir, gid):
       
   229         os.umask(0006)
       
   230         oldpwd = os.getcwd()
       
   231         basedir = self.__Cfg.get('maildir', 'base')
       
   232         domdirdirs = domdir.replace(basedir+'/', '').split('/')
       
   233 
       
   234         os.chdir(basedir)
       
   235         if not os.path.isdir(domdirdirs[0]):
       
   236             self.__makedir(domdirdirs[0], 489, 0,
       
   237                     self.__Cfg.getint('misc', 'gid_mail'))
       
   238         os.chdir(domdirdirs[0])
       
   239         os.umask(0007)
       
   240         self.__makedir(domdirdirs[1], self.__Cfg.getint('domdir', 'mode'), 0,
       
   241                 gid)
       
   242         os.chdir(oldpwd)
       
   243 
       
   244     def __maildirmake(self, domdir, uid, gid):
       
   245         """Creates maildirs and maildir subfolders.
       
   246 
       
   247         Keyword arguments:
       
   248         uid -- user id from the account
       
   249         gid -- group id from the account
       
   250         """
       
   251         os.umask(0007)
       
   252         oldpwd = os.getcwd()
       
   253         os.chdir(domdir)
       
   254 
       
   255         maildir = '%s' % self.__Cfg.get('maildir', 'folder')
       
   256         folders = [maildir , maildir+'/.Drafts', maildir+'/.Sent',
       
   257                 maildir+'/.Templates', maildir+'/.Trash']
       
   258         subdirs = ['cur', 'new', 'tmp']
       
   259         mode = self.__Cfg.getint('maildir', 'mode')
       
   260 
       
   261         self.__makedir('%s' % uid, mode, uid, gid)
       
   262         os.chdir('%s' % uid)
       
   263         for folder in folders:
       
   264             self.__makedir(folder, mode, uid, gid)
       
   265             for subdir in subdirs:
       
   266                 self.__makedir(folder+'/'+subdir, mode, uid, gid)
       
   267         os.chdir(oldpwd)
       
   268 
       
   269     def __maildirdelete(self, domdir, uid, gid):
       
   270         if uid > 0 and gid > 0:
       
   271             maildir = '%s' % uid
       
   272             if maildir.count('..') or domdir.count('..'):
       
   273                 raise VMMException(('FATAL: ".." in maildir path detected.',
       
   274                     ERR.FOUND_DOTS_IN_PATH))
       
   275             if os.path.isdir(domdir):
       
   276                 os.chdir(domdir)
       
   277                 if os.path.isdir(maildir):
       
   278                     mdstat = os.stat(maildir)
       
   279                     if (mdstat.st_uid, mdstat.st_gid) != (uid, gid):
       
   280                         raise VMMException(
       
   281                             ('FATAL: owner/group mismatch in maildir detected',
       
   282                                 ERR.MAILDIR_PERM_MISMATCH))
       
   283                     rmtree(maildir, ignore_errors=True)
       
   284 
       
   285     def __domdirdelete(self, domdir, gid):
       
   286         if gid > 0:
       
   287             basedir = '%s' % self.__Cfg.get('maildir', 'base')
       
   288             domdirdirs = domdir.replace(basedir+'/', '').split('/')
       
   289             if basedir.count('..') or domdir.count('..'):
       
   290                 raise VMMException(
       
   291                         ('FATAL: ".." in domain directory path detected.',
       
   292                             ERR.FOUND_DOTS_IN_PATH))
       
   293             if os.path.isdir('%s/%s' % (basedir, domdirdirs[0])):
       
   294                 os.chdir('%s/%s' % (basedir, domdirdirs[0]))
       
   295                 if os.lstat(domdirdirs[1]).st_gid != gid:
       
   296                     raise VMMException(
       
   297                     ('FATAL: group mismatch in domain directory detected',
       
   298                         ERR.DOMAINDIR_GROUP_MISMATCH))
       
   299                 rmtree(domdirdirs[1], ignore_errors=True)
       
   300 
       
   301     def __pwhash(self, password, scheme=None, user=None):
       
   302         # XXX alle Schemen berücksichtigen XXX
       
   303         if scheme is None:
       
   304             scheme = self.__Cfg.get('misc', 'passwdscheme')
       
   305         return Popen([self.__Cfg.get('bin', 'dovecotpw'), '-s', scheme, '-p',
       
   306             password], stdout=PIPE).communicate()[0][len(scheme)+2:-1]
       
   307 
       
   308     def hasWarnings(self):
       
   309         """Checks if warnings are present, returns bool."""
       
   310         return bool(len(self.__warnings))
       
   311 
       
   312     def getWarnings(self):
       
   313         """Returns a list with all available warnings."""
       
   314         return self.__warnings
       
   315 
       
   316     def setupIsDone(self):
       
   317         """Checks if vmm is configured, returns bool"""
       
   318         try:
       
   319             return self.__Cfg.getboolean('config', 'done')
       
   320         except ValueError, e:
       
   321             raise VMMConfigException('Configurtion error: "'+str(e)
       
   322                 +'"\n(in section "Connfig", option "done")'
       
   323                 +'\nsee also: vmm.cfg(5)\n')
       
   324 
       
   325     def configure(self, section=None):
       
   326         """Starts interactive configuration.
       
   327 
       
   328         Configures in interactive mode options in the given section.
       
   329         If no section is given (default) all options from all sections
       
   330         will be prompted.
       
   331 
       
   332         Keyword arguments:
       
   333         section -- the section to configure (default None):
       
   334             'database', 'maildir', 'bin' or 'misc'
       
   335         """
       
   336         try:
       
   337             if not section:
       
   338                 self.__Cfg.configure(self.__cfgSections)
       
   339             elif section not in self.__cfgSections:
       
   340                 raise VMMException(("Invalid section: «%s»" % section,
       
   341                     ERR.INVALID_SECTION))
       
   342             else:
       
   343                 self.__Cfg.configure([section])
       
   344         except:
       
   345             raise
       
   346 
       
   347     def domain_add(self, domainname, transport=None):
       
   348         dom = self.__getDomain(domainname, transport)
       
   349         dom.save()
       
   350         self.__domdirmake(dom.getDir(), dom.getID())
       
   351 
       
   352     def domain_transport(self, domainname, transport):
       
   353         dom = self.__getDomain(domainname, None)
       
   354         dom.updateTransport(transport)
       
   355 
       
   356     def domain_delete(self, domainname, force=None):
       
   357         if not force is None and force not in ['deluser','delalias','delall']:
       
   358             raise VMMDomainException(('Invalid option: «%s»' % force,
       
   359                 ERR.INVALID_OPTION))
       
   360         dom = self.__getDomain(domainname)
       
   361         gid = dom.getID()
       
   362         domdir = dom.getDir()
       
   363         if self.__Cfg.getboolean('misc', 'forcedel') or force == 'delall':
       
   364             dom.delete(True, True)
       
   365         elif force == 'deluser':
       
   366             dom.delete(delUser=True)
       
   367         elif force == 'delalias':
       
   368             dom.delete(delAlias=True)
       
   369         else:
       
   370             dom.delete()
       
   371         if self.__Cfg.getboolean('domdir', 'delete'):
       
   372             self.__domdirdelete(domdir, gid)
       
   373 
       
   374     def domain_info(self, domainname, detailed=None):
       
   375         dom = self.__getDomain(domainname)
       
   376         dominfo = dom.getInfo()
       
   377         if dominfo['domainname'].startswith('xn--'):
       
   378             dominfo['domainname'] += ' (%s)'\
       
   379                 % self.__ace2idna(dominfo['domainname'])
       
   380         if dominfo['aliases'] is None:
       
   381             dominfo['aliases'] = 0
       
   382         if detailed is None:
       
   383             return dominfo
       
   384         elif detailed == 'detailed':
       
   385             return dominfo, dom.getAccounts(), dom.getAliases()
       
   386         else:
       
   387             raise VMMDomainException(('Invalid option: «%s»' % detailed,
       
   388                 ERR.INVALID_OPTION))
       
   389 
       
   390     def user_add(self, emailaddress, password):
       
   391         acc = self.__getAccount(emailaddress, password)
       
   392         acc.save(self.__Cfg.get('maildir', 'folder'))
       
   393         self.__maildirmake(acc.getDir('domain'), acc.getUID(), acc.getGID())
       
   394 
       
   395     def alias_add(self, aliasaddress, targetaddress):
       
   396         alias = self.__getAlias(aliasaddress, targetaddress)
       
   397         alias.save()
       
   398 
       
   399     def user_delete(self, emailaddress):
       
   400         acc = self.__getAccount(emailaddress)
       
   401         uid = acc.getUID()
       
   402         gid = acc.getGID()
       
   403         acc.delete()
       
   404         if self.__Cfg.getboolean('maildir', 'delete'):
       
   405             self.__maildirdelete(acc.getDir('domain'), uid, gid)
       
   406 
       
   407     def alias_info(self, aliasaddress):
       
   408         alias = self.__getAlias(aliasaddress)
       
   409         return alias.getInfo()
       
   410 
       
   411     def alias_delete(self, aliasaddress):
       
   412         alias = self.__getAlias(aliasaddress)
       
   413         alias.delete()
       
   414 
       
   415     def user_info(self, emailaddress, diskusage=False):
       
   416         acc = self.__getAccount(emailaddress)
       
   417         info = acc.getInfo()
       
   418         if self.__Cfg.getboolean('maildir', 'diskusage') or diskusage:
       
   419             info['disk usage'] = self.__getDiskUsage('%(home)s/%(mail)s' % info)
       
   420         return info
       
   421 
       
   422     def user_password(self, emailaddress, password):
       
   423         acc = self.__getAccount(emailaddress)
       
   424         acc.modify('password', self.__pwhash(password))
       
   425 
       
   426     def user_name(self, emailaddress, name):
       
   427         acc = self.__getAccount(emailaddress)
       
   428         acc.modify('name', name)
       
   429 
       
   430     def user_disable(self, emailaddress):
       
   431         acc = self.__getAccount(emailaddress)
       
   432         acc.disable()
       
   433 
       
   434     def user_enable(self, emailaddress):
       
   435         acc = self.__getAccount(emailaddress)
       
   436         acc.enable()
       
   437 
       
   438     def __del__(self):
       
   439         if not self.__dbh is None and self.__dbh._isOpen:
       
   440             self.__dbh.close()