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