VirtualMailManager/domain.py
changeset 571 a4aead244f75
parent 568 14abdd04ddf5
child 611 8e9b0046bc8f
equal deleted inserted replaced
465:c0e1fb1b0145 571:a4aead244f75
       
     1 # -*- coding: UTF-8 -*-
       
     2 # Copyright (c) 2007 - 2012, Pascal Volk
       
     3 # See COPYING for distribution information.
       
     4 """
       
     5     VirtualMailManager.domain
       
     6     ~~~~~~~~~~~~~~~~~~~~~~~~~
       
     7 
       
     8     Virtual Mail Manager's Domain class to manage e-mail domains.
       
     9 """
       
    10 
       
    11 import os
       
    12 import re
       
    13 from random import choice
       
    14 
       
    15 from VirtualMailManager.constants import \
       
    16      ACCOUNT_AND_ALIAS_PRESENT, DOMAIN_ALIAS_EXISTS, DOMAIN_EXISTS, \
       
    17      DOMAIN_INVALID, DOMAIN_TOO_LONG, NO_SUCH_DOMAIN, VMM_ERROR
       
    18 from VirtualMailManager.errors import VMMError, DomainError as DomErr
       
    19 from VirtualMailManager.pycompat import all, any
       
    20 from VirtualMailManager.quotalimit import QuotaLimit
       
    21 from VirtualMailManager.serviceset import ServiceSet
       
    22 from VirtualMailManager.transport import Transport
       
    23 
       
    24 
       
    25 MAILDIR_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'
       
    26 RE_DOMAIN = re.compile(r"^(?:[a-z0-9-]{1,63}\.){1,}[a-z]{2,6}$")
       
    27 _ = lambda msg: msg
       
    28 cfg_dget = lambda option: None
       
    29 
       
    30 
       
    31 class Domain(object):
       
    32     """Class to manage e-mail domains."""
       
    33     __slots__ = ('_directory', '_gid', '_name', '_qlimit', '_services',
       
    34                  '_transport', '_note', '_dbh', '_new')
       
    35 
       
    36     def __init__(self, dbh, domainname):
       
    37         """Creates a new Domain instance.
       
    38 
       
    39         Loads all relevant data from the database, if the domain could be
       
    40         found.  To create a new domain call the methods set_directory() and
       
    41         set_transport() before save().
       
    42 
       
    43         A DomainError will be thrown when the *domainname* is the name of
       
    44         an alias domain.
       
    45 
       
    46         Arguments:
       
    47 
       
    48         `dbh` : pyPgSQL.PgSQL.Connection
       
    49           a database connection for the database access
       
    50         `domainname` : basestring
       
    51           The name of the domain
       
    52         """
       
    53         self._name = check_domainname(domainname)
       
    54         self._dbh = dbh
       
    55         self._gid = 0
       
    56         self._qlimit = None
       
    57         self._services = None
       
    58         self._transport = None
       
    59         self._directory = None
       
    60         self._note = None
       
    61         self._new = True
       
    62         self._load()
       
    63 
       
    64     def _load(self):
       
    65         """Load information from the database and checks if the domain name
       
    66         is the primary one.
       
    67 
       
    68         Raises a DomainError if Domain._name isn't the primary name of the
       
    69         domain.
       
    70         """
       
    71         dbc = self._dbh.cursor()
       
    72         dbc.execute('SELECT dd.gid, qid, ssid, tid, domaindir, is_primary, '
       
    73                     'note '
       
    74                     'FROM domain_data dd, domain_name dn WHERE domainname = '
       
    75                     '%s AND dn.gid = dd.gid', (self._name,))
       
    76         result = dbc.fetchone()
       
    77         dbc.close()
       
    78         if result:
       
    79             if not result[5]:
       
    80                 raise DomErr(_(u"The domain '%s' is an alias domain.") %
       
    81                              self._name, DOMAIN_ALIAS_EXISTS)
       
    82             self._gid, self._directory = result[0], result[4]
       
    83             self._qlimit = QuotaLimit(self._dbh, qid=result[1])
       
    84             self._services = ServiceSet(self._dbh, ssid=result[2])
       
    85             self._transport = Transport(self._dbh, tid=result[3])
       
    86             self._note = result[6]
       
    87             self._new = False
       
    88 
       
    89     def _set_gid(self):
       
    90         """Sets the ID of the domain - if not set yet."""
       
    91         assert self._gid == 0
       
    92         dbc = self._dbh.cursor()
       
    93         dbc.execute("SELECT nextval('domain_gid')")
       
    94         self._gid = dbc.fetchone()[0]
       
    95         dbc.close()
       
    96 
       
    97     def _check_for_addresses(self):
       
    98         """Checks dependencies for deletion. Raises a DomainError if there
       
    99         are accounts, aliases and/or relocated users.
       
   100         """
       
   101         dbc = self._dbh.cursor()
       
   102         dbc.execute('SELECT '
       
   103                     '(SELECT count(gid) FROM users WHERE gid = %(gid)u)'
       
   104                     '  as account_count, '
       
   105                     '(SELECT count(gid) FROM alias WHERE gid = %(gid)u)'
       
   106                     '  as alias_count, '
       
   107                     '(SELECT count(gid) FROM relocated WHERE gid = %(gid)u)'
       
   108                     '  as relocated_count'
       
   109                     % {'gid': self._gid})
       
   110         result = dbc.fetchall()
       
   111         dbc.close()
       
   112         result = result[0]
       
   113         if any(result):
       
   114             keys = ('account_count', 'alias_count', 'relocated_count')
       
   115             raise DomErr(_(u'There are %(account_count)u accounts, '
       
   116                            u'%(alias_count)u aliases and %(relocated_count)u '
       
   117                            u'relocated users.') % dict(zip(keys, result)),
       
   118                          ACCOUNT_AND_ALIAS_PRESENT)
       
   119 
       
   120     def _chk_state(self, must_exist=True):
       
   121         """Checks the state of the Domain instance and will raise a
       
   122         VirtualMailManager.errors.DomainError:
       
   123           - if *must_exist* is `True` and the domain doesn't exist
       
   124           - or *must_exist* is `False` and the domain exists
       
   125         """
       
   126         if must_exist and self._new:
       
   127             raise DomErr(_(u"The domain '%s' does not exist.") % self._name,
       
   128                          NO_SUCH_DOMAIN)
       
   129         elif not must_exist and not self._new:
       
   130             raise DomErr(_(u"The domain '%s' already exists.") % self._name,
       
   131                          DOMAIN_EXISTS)
       
   132 
       
   133     def _update_tables(self, column, value):
       
   134         """Update table columns in the domain_data table."""
       
   135         dbc = self._dbh.cursor()
       
   136         dbc.execute('UPDATE domain_data SET %s = %%s WHERE gid = %%s' % column,
       
   137                     (value, self._gid))
       
   138         if dbc.rowcount > 0:
       
   139             self._dbh.commit()
       
   140         dbc.close()
       
   141 
       
   142     def _update_tables_ref(self, column, value, force=False):
       
   143         """Update various columns in the domain_data table. When *force* is
       
   144         `True`, the corresponding column in the users table will be reset to
       
   145         NULL.
       
   146 
       
   147         Arguments:
       
   148 
       
   149         `column` : basestring
       
   150           Name of the table column. Currently: qid, ssid and tid
       
   151         `value` : long
       
   152           The referenced key
       
   153         `force` : bool
       
   154           reset existing users. Default: `False`
       
   155         """
       
   156         if column not in ('qid', 'ssid', 'tid'):
       
   157             raise ValueError('Unknown column: %r' % column)
       
   158         self._update_tables(column, value)
       
   159         if force:
       
   160             dbc = self._dbh.cursor()
       
   161             dbc.execute('UPDATE users SET %s = NULL WHERE gid = %%s' % column,
       
   162                         (self._gid,))
       
   163             if dbc.rowcount > 0:
       
   164                 self._dbh.commit()
       
   165             dbc.close()
       
   166 
       
   167     @property
       
   168     def gid(self):
       
   169         """The GID of the Domain."""
       
   170         return self._gid
       
   171 
       
   172     @property
       
   173     def name(self):
       
   174         """The Domain's name."""
       
   175         return self._name
       
   176 
       
   177     @property
       
   178     def directory(self):
       
   179         """The Domain's directory."""
       
   180         return self._directory
       
   181 
       
   182     @property
       
   183     def quotalimit(self):
       
   184         """The Domain's quota limit."""
       
   185         return self._qlimit
       
   186 
       
   187     @property
       
   188     def serviceset(self):
       
   189         """The Domain's serviceset."""
       
   190         return self._services
       
   191 
       
   192     @property
       
   193     def transport(self):
       
   194         """The Domain's transport."""
       
   195         return self._transport
       
   196 
       
   197     @property
       
   198     def note(self):
       
   199         """The Domain's note."""
       
   200         return self._note
       
   201 
       
   202     def set_directory(self, basedir):
       
   203         """Set the path value of the Domain's directory, inside *basedir*.
       
   204 
       
   205         Argument:
       
   206 
       
   207         `basedir` : basestring
       
   208           The base directory of all domains
       
   209         """
       
   210         self._chk_state(False)
       
   211         assert self._directory is None
       
   212         self._set_gid()
       
   213         self._directory = os.path.join(basedir, choice(MAILDIR_CHARS),
       
   214                                        str(self._gid))
       
   215 
       
   216     def set_quotalimit(self, quotalimit):
       
   217         """Set the quota limit for the new Domain.
       
   218 
       
   219         Argument:
       
   220 
       
   221         `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit
       
   222           The quota limit of the new Domain.
       
   223         """
       
   224         self._chk_state(False)
       
   225         assert isinstance(quotalimit, QuotaLimit)
       
   226         self._qlimit = quotalimit
       
   227 
       
   228     def set_serviceset(self, serviceset):
       
   229         """Set the services for the new Domain.
       
   230 
       
   231         Argument:
       
   232 
       
   233        `serviceset` : VirtualMailManager.serviceset.ServiceSet
       
   234          The service set for the new Domain.
       
   235         """
       
   236         self._chk_state(False)
       
   237         assert isinstance(serviceset, ServiceSet)
       
   238         self._services = serviceset
       
   239 
       
   240     def set_transport(self, transport):
       
   241         """Set the transport for the new Domain.
       
   242 
       
   243         Argument:
       
   244 
       
   245         `transport` : VirtualMailManager.Transport
       
   246           The transport of the new Domain
       
   247         """
       
   248         self._chk_state(False)
       
   249         assert isinstance(transport, Transport)
       
   250         self._transport = transport
       
   251 
       
   252     def set_note(self, note):
       
   253         """Set the domain's (optional) note.
       
   254 
       
   255         Argument:
       
   256 
       
   257         `note` : basestring or None
       
   258           The note, or None to remove
       
   259         """
       
   260         self._chk_state(False)
       
   261         assert note is None or isinstance(note, basestring)
       
   262         self._note = note
       
   263 
       
   264     def save(self):
       
   265         """Stores the new domain in the database."""
       
   266         self._chk_state(False)
       
   267         assert all((self._directory, self._qlimit, self._services,
       
   268                     self._transport))
       
   269         dbc = self._dbh.cursor()
       
   270         dbc.execute('INSERT INTO domain_data (gid, qid, ssid, tid, domaindir, '
       
   271                     'note) '
       
   272                     'VALUES (%s, %s, %s, %s, %s, %s)', (self._gid,
       
   273                     self._qlimit.qid, self._services.ssid, self._transport.tid,
       
   274                     self._directory, self._note))
       
   275         dbc.execute('INSERT INTO domain_name (domainname, gid, is_primary) '
       
   276                     'VALUES (%s, %s, TRUE)', (self._name, self._gid))
       
   277         self._dbh.commit()
       
   278         dbc.close()
       
   279         self._new = False
       
   280 
       
   281     def delete(self, force=False):
       
   282         """Deletes the domain.
       
   283 
       
   284         Arguments:
       
   285 
       
   286         `force` : bool
       
   287           force the deletion of all available accounts, aliases and
       
   288           relocated users.  When *force* is `False` and there are accounts,
       
   289           aliases and/or relocated users a DomainError will be raised.
       
   290           Default `False`
       
   291         """
       
   292         if not isinstance(force, bool):
       
   293             raise TypeError('force must be a bool')
       
   294         self._chk_state()
       
   295         if not force:
       
   296             self._check_for_addresses()
       
   297         dbc = self._dbh.cursor()
       
   298         for tbl in ('alias', 'users', 'relocated', 'domain_name',
       
   299                     'domain_data'):
       
   300             dbc.execute("DELETE FROM %s WHERE gid = %u" % (tbl, self._gid))
       
   301         self._dbh.commit()
       
   302         dbc.close()
       
   303         self._gid = 0
       
   304         self._directory = self._qlimit = self._transport = None
       
   305         self._services = None
       
   306         self._new = True
       
   307 
       
   308     def update_quotalimit(self, quotalimit, force=False):
       
   309         """Update the quota limit of the Domain.
       
   310 
       
   311         If *force* is `True`, accounts-specific overrides will be reset
       
   312         for all existing accounts of the domain. Otherwise, the limit
       
   313         will only affect accounts that use the default.
       
   314 
       
   315         Arguments:
       
   316 
       
   317         `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit
       
   318           the new quota limit of the domain.
       
   319         `force` : bool
       
   320           enforce new quota limit for all accounts, default `False`
       
   321         """
       
   322         if cfg_dget('misc.dovecot_version') < 0x10102f00:
       
   323             raise VMMError(_(u'PostgreSQL-based dictionary quota requires '
       
   324                              u'Dovecot >= v1.1.2.'), VMM_ERROR)
       
   325         self._chk_state()
       
   326         assert isinstance(quotalimit, QuotaLimit)
       
   327         if not force and quotalimit == self._qlimit:
       
   328             return
       
   329         self._update_tables_ref('qid', quotalimit.qid, force)
       
   330         self._qlimit = quotalimit
       
   331 
       
   332     def update_serviceset(self, serviceset, force=False):
       
   333         """Assign a different set of services to the Domain,
       
   334 
       
   335         If *force* is `True`, accounts-specific overrides will be reset
       
   336         for all existing accounts of the domain. Otherwise, the service
       
   337         set will only affect accounts that use the default.
       
   338 
       
   339         Arguments:
       
   340         `serviceset` : VirtualMailManager.serviceset.ServiceSet
       
   341           the new set of services
       
   342         `force`
       
   343           enforce the serviceset for all accounts, default `False`
       
   344         """
       
   345         self._chk_state()
       
   346         assert isinstance(serviceset, ServiceSet)
       
   347         if not force and serviceset == self._services:
       
   348             return
       
   349         self._update_tables_ref('ssid', serviceset.ssid, force)
       
   350         self._services = serviceset
       
   351 
       
   352     def update_transport(self, transport, force=False):
       
   353         """Sets a new transport for the Domain.
       
   354 
       
   355         If *force* is `True`, accounts-specific overrides will be reset
       
   356         for all existing accounts of the domain. Otherwise, the transport
       
   357         setting will only affect accounts that use the default.
       
   358 
       
   359         Arguments:
       
   360 
       
   361         `transport` : VirtualMailManager.Transport
       
   362           the new transport
       
   363         `force` : bool
       
   364           enforce new transport setting for all accounts, default `False`
       
   365         """
       
   366         self._chk_state()
       
   367         assert isinstance(transport, Transport)
       
   368         if not force and transport == self._transport:
       
   369             return
       
   370         self._update_tables_ref('tid', transport.tid, force)
       
   371         self._transport = transport
       
   372 
       
   373     def update_note(self, note):
       
   374         """Sets a new note for the Domain.
       
   375 
       
   376         Arguments:
       
   377 
       
   378         `transport` : basestring or None
       
   379           the new note
       
   380         """
       
   381         self._chk_state()
       
   382         assert note is None or isinstance(note, basestring)
       
   383         if note == self._note:
       
   384             return
       
   385         self._update_tables('note', note)
       
   386         self._note = note
       
   387 
       
   388     def get_info(self):
       
   389         """Returns a dictionary with information about the domain."""
       
   390         self._chk_state()
       
   391         dbc = self._dbh.cursor()
       
   392         dbc.execute('SELECT aliasdomains "alias domains", accounts, aliases, '
       
   393                     'relocated, catchall "catch-all dests" '
       
   394                     'FROM vmm_domain_info WHERE gid = %s', (self._gid,))
       
   395         info = dbc.fetchone()
       
   396         dbc.close()
       
   397         keys = ('alias domains', 'accounts', 'aliases', 'relocated',
       
   398                 'catch-all dests')
       
   399         info = dict(zip(keys, info))
       
   400         info['gid'] = self._gid
       
   401         info['domain name'] = self._name
       
   402         info['transport'] = self._transport.transport
       
   403         info['domain directory'] = self._directory
       
   404         info['bytes'] = self._qlimit.bytes
       
   405         info['messages'] = self._qlimit.messages
       
   406         services = self._services.services
       
   407         services = [s.upper() for s in services if services[s]]
       
   408         if services:
       
   409             services.sort()
       
   410         else:
       
   411             services.append('None')
       
   412         info['active services'] = ' '.join(services)
       
   413         info['note'] = self._note
       
   414         return info
       
   415 
       
   416     def get_accounts(self):
       
   417         """Returns a list with all accounts of the domain."""
       
   418         self._chk_state()
       
   419         dbc = self._dbh.cursor()
       
   420         dbc.execute('SELECT local_part from users where gid = %s ORDER BY '
       
   421                     'local_part', (self._gid,))
       
   422         users = dbc.fetchall()
       
   423         dbc.close()
       
   424         accounts = []
       
   425         if users:
       
   426             addr = u'@'.join
       
   427             _dom = self._name
       
   428             accounts = [addr((account[0], _dom)) for account in users]
       
   429         return accounts
       
   430 
       
   431     def get_aliases(self):
       
   432         """Returns a list with all aliases e-mail addresses of the domain."""
       
   433         self._chk_state()
       
   434         dbc = self._dbh.cursor()
       
   435         dbc.execute('SELECT DISTINCT address FROM alias WHERE gid = %s ORDER '
       
   436                     'BY address', (self._gid,))
       
   437         addresses = dbc.fetchall()
       
   438         dbc.close()
       
   439         aliases = []
       
   440         if addresses:
       
   441             addr = u'@'.join
       
   442             _dom = self._name
       
   443             aliases = [addr((alias[0], _dom)) for alias in addresses]
       
   444         return aliases
       
   445 
       
   446     def get_relocated(self):
       
   447         """Returns a list with all addresses of relocated users."""
       
   448         self._chk_state()
       
   449         dbc = self._dbh.cursor()
       
   450         dbc.execute('SELECT address FROM relocated WHERE gid = %s ORDER BY '
       
   451                     'address', (self._gid,))
       
   452         addresses = dbc.fetchall()
       
   453         dbc.close()
       
   454         relocated = []
       
   455         if addresses:
       
   456             addr = u'@'.join
       
   457             _dom = self._name
       
   458             relocated = [addr((address[0], _dom)) for address in addresses]
       
   459         return relocated
       
   460 
       
   461     def get_catchall(self):
       
   462         """Returns a list with all catchall e-mail addresses of the domain."""
       
   463         self._chk_state()
       
   464         dbc = self._dbh.cursor()
       
   465         dbc.execute('SELECT DISTINCT destination FROM catchall WHERE gid = %s ORDER '
       
   466                     'BY destination', (self._gid,))
       
   467         addresses = dbc.fetchall()
       
   468         dbc.close()
       
   469         return addresses
       
   470 
       
   471     def get_aliase_names(self):
       
   472         """Returns a list with all alias domain names of the domain."""
       
   473         self._chk_state()
       
   474         dbc = self._dbh.cursor()
       
   475         dbc.execute('SELECT domainname FROM domain_name WHERE gid = %s AND '
       
   476                     'NOT is_primary ORDER BY domainname', (self._gid,))
       
   477         anames = dbc.fetchall()
       
   478         dbc.close()
       
   479         aliasdomains = []
       
   480         if anames:
       
   481             aliasdomains = [aname[0] for aname in anames]
       
   482         return aliasdomains
       
   483 
       
   484 
       
   485 def check_domainname(domainname):
       
   486     """Returns the validated domain name `domainname`.
       
   487 
       
   488     Throws an `DomainError`, if the domain name is too long or doesn't
       
   489     look like a valid domain name (label.label.label).
       
   490 
       
   491     """
       
   492     if not RE_DOMAIN.match(domainname):
       
   493         domainname = domainname.encode('idna')
       
   494     if len(domainname) > 255:
       
   495         raise DomErr(_(u'The domain name is too long'), DOMAIN_TOO_LONG)
       
   496     if not RE_DOMAIN.match(domainname):
       
   497         raise DomErr(_(u"The domain name '%s' is invalid") % domainname,
       
   498                      DOMAIN_INVALID)
       
   499     return domainname
       
   500 
       
   501 
       
   502 def get_gid(dbh, domainname):
       
   503     """Returns the group id of the domain *domainname*.
       
   504 
       
   505     If the domain couldn't be found in the database 0 will be returned.
       
   506     """
       
   507     domainname = check_domainname(domainname)
       
   508     dbc = dbh.cursor()
       
   509     dbc.execute('SELECT gid FROM domain_name WHERE domainname = %s',
       
   510                 (domainname,))
       
   511     gid = dbc.fetchone()
       
   512     dbc.close()
       
   513     if gid:
       
   514         return gid[0]
       
   515     return 0
       
   516 
       
   517 
       
   518 def search(dbh, pattern=None, like=False):
       
   519     """'Search' for domains by *pattern* in the database.
       
   520 
       
   521     *pattern* may be a domain name or a partial domain name - starting
       
   522     and/or ending with a '%' sign.  When the *pattern* starts or ends with
       
   523     a '%' sign *like* has to be `True` to perform a wildcard search.
       
   524     To retrieve all available domains use the arguments' default values.
       
   525 
       
   526     This function returns a tuple with a list and a dict: (order, domains).
       
   527     The order list contains the domains' gid, alphabetical sorted by the
       
   528     primary domain name.  The domains dict's keys are the gids of the
       
   529     domains. The value of item is a list.  The first list element contains
       
   530     the primary domain name or `None`.  The elements [1:] contains the
       
   531     names of alias domains.
       
   532 
       
   533     Arguments:
       
   534 
       
   535     `pattern` : basestring
       
   536       a (partial) domain name (starting and/or ending with a "%" sign)
       
   537     `like` : bool
       
   538       should be `True` when *pattern* starts/ends with a "%" sign
       
   539     """
       
   540     if pattern and not like:
       
   541         pattern = check_domainname(pattern)
       
   542     sql = 'SELECT gid, domainname, is_primary FROM domain_name'
       
   543     if pattern:
       
   544         if like:
       
   545             sql += " WHERE domainname LIKE '%s'" % pattern
       
   546         else:
       
   547             sql += " WHERE domainname = '%s'" % pattern
       
   548     sql += ' ORDER BY is_primary DESC, domainname'
       
   549     dbc = dbh.cursor()
       
   550     dbc.execute(sql)
       
   551     result = dbc.fetchall()
       
   552     dbc.close()
       
   553 
       
   554     gids = [domain[0] for domain in result if domain[2]]
       
   555     domains = {}
       
   556     for gid, domain, is_primary in result:
       
   557         if is_primary:
       
   558             if not gid in domains:
       
   559                 domains[gid] = [domain]
       
   560             else:
       
   561                 domains[gid].insert(0, domain)
       
   562         else:
       
   563             if gid in gids:
       
   564                 if gid in domains:
       
   565                     domains[gid].append(domain)
       
   566                 else:
       
   567                     domains[gid] = [domain]
       
   568             else:
       
   569                 gids.append(gid)
       
   570                 domains[gid] = [None, domain]
       
   571     return gids, domains
       
   572 
       
   573 del _, cfg_dget