--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/VirtualMailManager/domain.py Thu Jun 28 19:26:50 2012 +0000
@@ -0,0 +1,573 @@
+# -*- coding: UTF-8 -*-
+# Copyright (c) 2007 - 2012, Pascal Volk
+# See COPYING for distribution information.
+"""
+ VirtualMailManager.domain
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Virtual Mail Manager's Domain class to manage e-mail domains.
+"""
+
+import os
+import re
+from random import choice
+
+from VirtualMailManager.constants import \
+ ACCOUNT_AND_ALIAS_PRESENT, DOMAIN_ALIAS_EXISTS, DOMAIN_EXISTS, \
+ DOMAIN_INVALID, DOMAIN_TOO_LONG, NO_SUCH_DOMAIN, VMM_ERROR
+from VirtualMailManager.errors import VMMError, DomainError as DomErr
+from VirtualMailManager.pycompat import all, any
+from VirtualMailManager.quotalimit import QuotaLimit
+from VirtualMailManager.serviceset import ServiceSet
+from VirtualMailManager.transport import Transport
+
+
+MAILDIR_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'
+RE_DOMAIN = re.compile(r"^(?:[a-z0-9-]{1,63}\.){1,}[a-z]{2,6}$")
+_ = lambda msg: msg
+cfg_dget = lambda option: None
+
+
+class Domain(object):
+ """Class to manage e-mail domains."""
+ __slots__ = ('_directory', '_gid', '_name', '_qlimit', '_services',
+ '_transport', '_note', '_dbh', '_new')
+
+ def __init__(self, dbh, domainname):
+ """Creates a new Domain instance.
+
+ Loads all relevant data from the database, if the domain could be
+ found. To create a new domain call the methods set_directory() and
+ set_transport() before save().
+
+ A DomainError will be thrown when the *domainname* is the name of
+ an alias domain.
+
+ Arguments:
+
+ `dbh` : pyPgSQL.PgSQL.Connection
+ a database connection for the database access
+ `domainname` : basestring
+ The name of the domain
+ """
+ self._name = check_domainname(domainname)
+ self._dbh = dbh
+ self._gid = 0
+ self._qlimit = None
+ self._services = None
+ self._transport = None
+ self._directory = None
+ self._note = None
+ self._new = True
+ self._load()
+
+ def _load(self):
+ """Load information from the database and checks if the domain name
+ is the primary one.
+
+ Raises a DomainError if Domain._name isn't the primary name of the
+ domain.
+ """
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT dd.gid, qid, ssid, tid, domaindir, is_primary, '
+ 'note '
+ 'FROM domain_data dd, domain_name dn WHERE domainname = '
+ '%s AND dn.gid = dd.gid', (self._name,))
+ result = dbc.fetchone()
+ dbc.close()
+ if result:
+ if not result[5]:
+ raise DomErr(_(u"The domain '%s' is an alias domain.") %
+ self._name, DOMAIN_ALIAS_EXISTS)
+ self._gid, self._directory = result[0], result[4]
+ self._qlimit = QuotaLimit(self._dbh, qid=result[1])
+ self._services = ServiceSet(self._dbh, ssid=result[2])
+ self._transport = Transport(self._dbh, tid=result[3])
+ self._note = result[6]
+ self._new = False
+
+ def _set_gid(self):
+ """Sets the ID of the domain - if not set yet."""
+ assert self._gid == 0
+ dbc = self._dbh.cursor()
+ dbc.execute("SELECT nextval('domain_gid')")
+ self._gid = dbc.fetchone()[0]
+ dbc.close()
+
+ def _check_for_addresses(self):
+ """Checks dependencies for deletion. Raises a DomainError if there
+ are accounts, aliases and/or relocated users.
+ """
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT '
+ '(SELECT count(gid) FROM users WHERE gid = %(gid)u)'
+ ' as account_count, '
+ '(SELECT count(gid) FROM alias WHERE gid = %(gid)u)'
+ ' as alias_count, '
+ '(SELECT count(gid) FROM relocated WHERE gid = %(gid)u)'
+ ' as relocated_count'
+ % {'gid': self._gid})
+ result = dbc.fetchall()
+ dbc.close()
+ result = result[0]
+ if any(result):
+ keys = ('account_count', 'alias_count', 'relocated_count')
+ raise DomErr(_(u'There are %(account_count)u accounts, '
+ u'%(alias_count)u aliases and %(relocated_count)u '
+ u'relocated users.') % dict(zip(keys, result)),
+ ACCOUNT_AND_ALIAS_PRESENT)
+
+ def _chk_state(self, must_exist=True):
+ """Checks the state of the Domain instance and will raise a
+ VirtualMailManager.errors.DomainError:
+ - if *must_exist* is `True` and the domain doesn't exist
+ - or *must_exist* is `False` and the domain exists
+ """
+ if must_exist and self._new:
+ raise DomErr(_(u"The domain '%s' does not exist.") % self._name,
+ NO_SUCH_DOMAIN)
+ elif not must_exist and not self._new:
+ raise DomErr(_(u"The domain '%s' already exists.") % self._name,
+ DOMAIN_EXISTS)
+
+ def _update_tables(self, column, value):
+ """Update table columns in the domain_data table."""
+ dbc = self._dbh.cursor()
+ dbc.execute('UPDATE domain_data SET %s = %%s WHERE gid = %%s' % column,
+ (value, self._gid))
+ if dbc.rowcount > 0:
+ self._dbh.commit()
+ dbc.close()
+
+ def _update_tables_ref(self, column, value, force=False):
+ """Update various columns in the domain_data table. When *force* is
+ `True`, the corresponding column in the users table will be reset to
+ NULL.
+
+ Arguments:
+
+ `column` : basestring
+ Name of the table column. Currently: qid, ssid and tid
+ `value` : long
+ The referenced key
+ `force` : bool
+ reset existing users. Default: `False`
+ """
+ if column not in ('qid', 'ssid', 'tid'):
+ raise ValueError('Unknown column: %r' % column)
+ self._update_tables(column, value)
+ if force:
+ dbc = self._dbh.cursor()
+ dbc.execute('UPDATE users SET %s = NULL WHERE gid = %%s' % column,
+ (self._gid,))
+ if dbc.rowcount > 0:
+ self._dbh.commit()
+ dbc.close()
+
+ @property
+ def gid(self):
+ """The GID of the Domain."""
+ return self._gid
+
+ @property
+ def name(self):
+ """The Domain's name."""
+ return self._name
+
+ @property
+ def directory(self):
+ """The Domain's directory."""
+ return self._directory
+
+ @property
+ def quotalimit(self):
+ """The Domain's quota limit."""
+ return self._qlimit
+
+ @property
+ def serviceset(self):
+ """The Domain's serviceset."""
+ return self._services
+
+ @property
+ def transport(self):
+ """The Domain's transport."""
+ return self._transport
+
+ @property
+ def note(self):
+ """The Domain's note."""
+ return self._note
+
+ def set_directory(self, basedir):
+ """Set the path value of the Domain's directory, inside *basedir*.
+
+ Argument:
+
+ `basedir` : basestring
+ The base directory of all domains
+ """
+ self._chk_state(False)
+ assert self._directory is None
+ self._set_gid()
+ self._directory = os.path.join(basedir, choice(MAILDIR_CHARS),
+ str(self._gid))
+
+ def set_quotalimit(self, quotalimit):
+ """Set the quota limit for the new Domain.
+
+ Argument:
+
+ `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit
+ The quota limit of the new Domain.
+ """
+ self._chk_state(False)
+ assert isinstance(quotalimit, QuotaLimit)
+ self._qlimit = quotalimit
+
+ def set_serviceset(self, serviceset):
+ """Set the services for the new Domain.
+
+ Argument:
+
+ `serviceset` : VirtualMailManager.serviceset.ServiceSet
+ The service set for the new Domain.
+ """
+ self._chk_state(False)
+ assert isinstance(serviceset, ServiceSet)
+ self._services = serviceset
+
+ def set_transport(self, transport):
+ """Set the transport for the new Domain.
+
+ Argument:
+
+ `transport` : VirtualMailManager.Transport
+ The transport of the new Domain
+ """
+ self._chk_state(False)
+ assert isinstance(transport, Transport)
+ self._transport = transport
+
+ def set_note(self, note):
+ """Set the domain's (optional) note.
+
+ Argument:
+
+ `note` : basestring or None
+ The note, or None to remove
+ """
+ self._chk_state(False)
+ assert note is None or isinstance(note, basestring)
+ self._note = note
+
+ def save(self):
+ """Stores the new domain in the database."""
+ self._chk_state(False)
+ assert all((self._directory, self._qlimit, self._services,
+ self._transport))
+ dbc = self._dbh.cursor()
+ dbc.execute('INSERT INTO domain_data (gid, qid, ssid, tid, domaindir, '
+ 'note) '
+ 'VALUES (%s, %s, %s, %s, %s, %s)', (self._gid,
+ self._qlimit.qid, self._services.ssid, self._transport.tid,
+ self._directory, self._note))
+ dbc.execute('INSERT INTO domain_name (domainname, gid, is_primary) '
+ 'VALUES (%s, %s, TRUE)', (self._name, self._gid))
+ self._dbh.commit()
+ dbc.close()
+ self._new = False
+
+ def delete(self, force=False):
+ """Deletes the domain.
+
+ Arguments:
+
+ `force` : bool
+ force the deletion of all available accounts, aliases and
+ relocated users. When *force* is `False` and there are accounts,
+ aliases and/or relocated users a DomainError will be raised.
+ Default `False`
+ """
+ if not isinstance(force, bool):
+ raise TypeError('force must be a bool')
+ self._chk_state()
+ if not force:
+ self._check_for_addresses()
+ dbc = self._dbh.cursor()
+ for tbl in ('alias', 'users', 'relocated', 'domain_name',
+ 'domain_data'):
+ dbc.execute("DELETE FROM %s WHERE gid = %u" % (tbl, self._gid))
+ self._dbh.commit()
+ dbc.close()
+ self._gid = 0
+ self._directory = self._qlimit = self._transport = None
+ self._services = None
+ self._new = True
+
+ def update_quotalimit(self, quotalimit, force=False):
+ """Update the quota limit of the Domain.
+
+ If *force* is `True`, accounts-specific overrides will be reset
+ for all existing accounts of the domain. Otherwise, the limit
+ will only affect accounts that use the default.
+
+ Arguments:
+
+ `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit
+ the new quota limit of the domain.
+ `force` : bool
+ enforce new quota limit for all accounts, default `False`
+ """
+ if cfg_dget('misc.dovecot_version') < 0x10102f00:
+ raise VMMError(_(u'PostgreSQL-based dictionary quota requires '
+ u'Dovecot >= v1.1.2.'), VMM_ERROR)
+ self._chk_state()
+ assert isinstance(quotalimit, QuotaLimit)
+ if not force and quotalimit == self._qlimit:
+ return
+ self._update_tables_ref('qid', quotalimit.qid, force)
+ self._qlimit = quotalimit
+
+ def update_serviceset(self, serviceset, force=False):
+ """Assign a different set of services to the Domain,
+
+ If *force* is `True`, accounts-specific overrides will be reset
+ for all existing accounts of the domain. Otherwise, the service
+ set will only affect accounts that use the default.
+
+ Arguments:
+ `serviceset` : VirtualMailManager.serviceset.ServiceSet
+ the new set of services
+ `force`
+ enforce the serviceset for all accounts, default `False`
+ """
+ self._chk_state()
+ assert isinstance(serviceset, ServiceSet)
+ if not force and serviceset == self._services:
+ return
+ self._update_tables_ref('ssid', serviceset.ssid, force)
+ self._services = serviceset
+
+ def update_transport(self, transport, force=False):
+ """Sets a new transport for the Domain.
+
+ If *force* is `True`, accounts-specific overrides will be reset
+ for all existing accounts of the domain. Otherwise, the transport
+ setting will only affect accounts that use the default.
+
+ Arguments:
+
+ `transport` : VirtualMailManager.Transport
+ the new transport
+ `force` : bool
+ enforce new transport setting for all accounts, default `False`
+ """
+ self._chk_state()
+ assert isinstance(transport, Transport)
+ if not force and transport == self._transport:
+ return
+ self._update_tables_ref('tid', transport.tid, force)
+ self._transport = transport
+
+ def update_note(self, note):
+ """Sets a new note for the Domain.
+
+ Arguments:
+
+ `transport` : basestring or None
+ the new note
+ """
+ self._chk_state()
+ assert note is None or isinstance(note, basestring)
+ if note == self._note:
+ return
+ self._update_tables('note', note)
+ self._note = note
+
+ def get_info(self):
+ """Returns a dictionary with information about the domain."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT aliasdomains "alias domains", accounts, aliases, '
+ 'relocated, catchall "catch-all dests" '
+ 'FROM vmm_domain_info WHERE gid = %s', (self._gid,))
+ info = dbc.fetchone()
+ dbc.close()
+ keys = ('alias domains', 'accounts', 'aliases', 'relocated',
+ 'catch-all dests')
+ info = dict(zip(keys, info))
+ info['gid'] = self._gid
+ info['domain name'] = self._name
+ info['transport'] = self._transport.transport
+ info['domain directory'] = self._directory
+ info['bytes'] = self._qlimit.bytes
+ info['messages'] = self._qlimit.messages
+ services = self._services.services
+ services = [s.upper() for s in services if services[s]]
+ if services:
+ services.sort()
+ else:
+ services.append('None')
+ info['active services'] = ' '.join(services)
+ info['note'] = self._note
+ return info
+
+ def get_accounts(self):
+ """Returns a list with all accounts of the domain."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT local_part from users where gid = %s ORDER BY '
+ 'local_part', (self._gid,))
+ users = dbc.fetchall()
+ dbc.close()
+ accounts = []
+ if users:
+ addr = u'@'.join
+ _dom = self._name
+ accounts = [addr((account[0], _dom)) for account in users]
+ return accounts
+
+ def get_aliases(self):
+ """Returns a list with all aliases e-mail addresses of the domain."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT DISTINCT address FROM alias WHERE gid = %s ORDER '
+ 'BY address', (self._gid,))
+ addresses = dbc.fetchall()
+ dbc.close()
+ aliases = []
+ if addresses:
+ addr = u'@'.join
+ _dom = self._name
+ aliases = [addr((alias[0], _dom)) for alias in addresses]
+ return aliases
+
+ def get_relocated(self):
+ """Returns a list with all addresses of relocated users."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT address FROM relocated WHERE gid = %s ORDER BY '
+ 'address', (self._gid,))
+ addresses = dbc.fetchall()
+ dbc.close()
+ relocated = []
+ if addresses:
+ addr = u'@'.join
+ _dom = self._name
+ relocated = [addr((address[0], _dom)) for address in addresses]
+ return relocated
+
+ def get_catchall(self):
+ """Returns a list with all catchall e-mail addresses of the domain."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT DISTINCT destination FROM catchall WHERE gid = %s ORDER '
+ 'BY destination', (self._gid,))
+ addresses = dbc.fetchall()
+ dbc.close()
+ return addresses
+
+ def get_aliase_names(self):
+ """Returns a list with all alias domain names of the domain."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT domainname FROM domain_name WHERE gid = %s AND '
+ 'NOT is_primary ORDER BY domainname', (self._gid,))
+ anames = dbc.fetchall()
+ dbc.close()
+ aliasdomains = []
+ if anames:
+ aliasdomains = [aname[0] for aname in anames]
+ return aliasdomains
+
+
+def check_domainname(domainname):
+ """Returns the validated domain name `domainname`.
+
+ Throws an `DomainError`, if the domain name is too long or doesn't
+ look like a valid domain name (label.label.label).
+
+ """
+ if not RE_DOMAIN.match(domainname):
+ domainname = domainname.encode('idna')
+ if len(domainname) > 255:
+ raise DomErr(_(u'The domain name is too long'), DOMAIN_TOO_LONG)
+ if not RE_DOMAIN.match(domainname):
+ raise DomErr(_(u"The domain name '%s' is invalid") % domainname,
+ DOMAIN_INVALID)
+ return domainname
+
+
+def get_gid(dbh, domainname):
+ """Returns the group id of the domain *domainname*.
+
+ If the domain couldn't be found in the database 0 will be returned.
+ """
+ domainname = check_domainname(domainname)
+ dbc = dbh.cursor()
+ dbc.execute('SELECT gid FROM domain_name WHERE domainname = %s',
+ (domainname,))
+ gid = dbc.fetchone()
+ dbc.close()
+ if gid:
+ return gid[0]
+ return 0
+
+
+def search(dbh, pattern=None, like=False):
+ """'Search' for domains by *pattern* in the database.
+
+ *pattern* may be a domain name or a partial domain name - starting
+ and/or ending with a '%' sign. When the *pattern* starts or ends with
+ a '%' sign *like* has to be `True` to perform a wildcard search.
+ To retrieve all available domains use the arguments' default values.
+
+ This function returns a tuple with a list and a dict: (order, domains).
+ The order list contains the domains' gid, alphabetical sorted by the
+ primary domain name. The domains dict's keys are the gids of the
+ domains. The value of item is a list. The first list element contains
+ the primary domain name or `None`. The elements [1:] contains the
+ names of alias domains.
+
+ Arguments:
+
+ `pattern` : basestring
+ a (partial) domain name (starting and/or ending with a "%" sign)
+ `like` : bool
+ should be `True` when *pattern* starts/ends with a "%" sign
+ """
+ if pattern and not like:
+ pattern = check_domainname(pattern)
+ sql = 'SELECT gid, domainname, is_primary FROM domain_name'
+ if pattern:
+ if like:
+ sql += " WHERE domainname LIKE '%s'" % pattern
+ else:
+ sql += " WHERE domainname = '%s'" % pattern
+ sql += ' ORDER BY is_primary DESC, domainname'
+ dbc = dbh.cursor()
+ dbc.execute(sql)
+ result = dbc.fetchall()
+ dbc.close()
+
+ gids = [domain[0] for domain in result if domain[2]]
+ domains = {}
+ for gid, domain, is_primary in result:
+ if is_primary:
+ if not gid in domains:
+ domains[gid] = [domain]
+ else:
+ domains[gid].insert(0, domain)
+ else:
+ if gid in gids:
+ if gid in domains:
+ domains[gid].append(domain)
+ else:
+ domains[gid] = [domain]
+ else:
+ gids.append(gid)
+ domains[gid] = [None, domain]
+ return gids, domains
+
+del _, cfg_dget