--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/VirtualMailManager/account.py Thu Jun 28 19:26:50 2012 +0000
@@ -0,0 +1,506 @@
+# -*- coding: UTF-8 -*-
+# Copyright (c) 2007 - 2012, Pascal Volk
+# See COPYING for distribution information.
+"""
+ VirtualMailManager.account
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Virtual Mail Manager's Account class to manage e-mail accounts.
+"""
+
+from VirtualMailManager.common import version_str, \
+ format_domain_default
+from VirtualMailManager.constants import \
+ ACCOUNT_EXISTS, ACCOUNT_MISSING_PASSWORD, ALIAS_PRESENT, \
+ INVALID_ARGUMENT, INVALID_MAIL_LOCATION, NO_SUCH_ACCOUNT, \
+ NO_SUCH_DOMAIN, VMM_ERROR
+from VirtualMailManager.domain import Domain
+from VirtualMailManager.emailaddress import EmailAddress
+from VirtualMailManager.errors import VMMError, AccountError as AErr
+from VirtualMailManager.maillocation import MailLocation
+from VirtualMailManager.password import pwhash
+from VirtualMailManager.quotalimit import QuotaLimit
+from VirtualMailManager.transport import Transport
+from VirtualMailManager.serviceset import ServiceSet
+
+__all__ = ('Account', 'get_account_by_uid')
+
+_ = lambda msg: msg
+cfg_dget = lambda option: None
+
+
+class Account(object):
+ """Class to manage e-mail accounts."""
+ __slots__ = ('_addr', '_dbh', '_domain', '_mail', '_new', '_passwd',
+ '_qlimit', '_services', '_transport', '_note', '_uid')
+
+ def __init__(self, dbh, address):
+ """Creates a new Account instance.
+
+ When an account with the given *address* could be found in the
+ database all relevant data will be loaded.
+
+ Arguments:
+
+ `dbh` : pyPgSQL.PgSQL.Connection
+ A database connection for the database access.
+ `address` : VirtualMailManager.EmailAddress.EmailAddress
+ The e-mail address of the (new) Account.
+ """
+ if not isinstance(address, EmailAddress):
+ raise TypeError("Argument 'address' is not an EmailAddress")
+ self._addr = address
+ self._dbh = dbh
+ self._domain = Domain(self._dbh, self._addr.domainname)
+ if not self._domain.gid:
+ # TP: Hm, what “quotation marks” should be used?
+ # If you are unsure have a look at:
+ # http://en.wikipedia.org/wiki/Quotation_mark,_non-English_usage
+ raise AErr(_(u"The domain '%s' does not exist.") %
+ self._addr.domainname, NO_SUCH_DOMAIN)
+ self._uid = 0
+ self._mail = None
+ self._qlimit = None
+ self._services = None
+ self._transport = None
+ self._note = None
+ self._passwd = None
+ self._new = True
+ self._load()
+
+ def __nonzero__(self):
+ """Returns `True` if the Account is known, `False` if it's new."""
+ return not self._new
+
+ def _load(self):
+ """Load 'uid', 'mid', 'qid', 'ssid', 'tid' and 'note' from the
+ database and set _new to `False` - if the user could be found. """
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT uid, mid, qid, ssid, tid, note FROM users '
+ 'WHERE gid = %s AND local_part = %s',
+ (self._domain.gid, self._addr.localpart))
+ result = dbc.fetchone()
+ dbc.close()
+ if result:
+ self._uid, _mid, _qid, _ssid, _tid, _note = result
+
+ def load_helper(ctor, own, field, dbresult):
+ # Py25: cur = None if own is None else getattr(own, field)
+ if own is None:
+ cur = None
+ else:
+ cur = getattr(own, field)
+ if cur != dbresult:
+ kwargs = {field: dbresult}
+ if dbresult is None:
+ return dbresult
+ else:
+ return ctor(self._dbh, **kwargs)
+
+ self._qlimit = load_helper(QuotaLimit, self._qlimit, 'qid', _qid)
+ self._services = load_helper(ServiceSet, self._services, 'ssid',
+ _ssid)
+ self._transport = load_helper(Transport, self._transport, 'tid',
+ _tid)
+ self._mail = MailLocation(self._dbh, mid=_mid)
+ self._note = _note
+ self._new = False
+
+ def _set_uid(self):
+ """Set the unique ID for the new Account."""
+ assert self._uid == 0
+ dbc = self._dbh.cursor()
+ dbc.execute("SELECT nextval('users_uid')")
+ self._uid = dbc.fetchone()[0]
+ dbc.close()
+
+ def _prepare(self, maillocation):
+ """Check and set different attributes - before we store the
+ information in the database.
+ """
+ if maillocation.dovecot_version > cfg_dget('misc.dovecot_version'):
+ raise AErr(_(u"The mailbox format '%(mbfmt)s' requires Dovecot "
+ u">= v%(version)s.") % {
+ 'mbfmt': maillocation.mbformat,
+ 'version': version_str(maillocation.dovecot_version)},
+ INVALID_MAIL_LOCATION)
+ if self._transport and not maillocation.postfix and \
+ self._transport.transport.lower() in ('virtual:', 'virtual'):
+ raise AErr(_(u"Invalid transport '%(transport)s' for mailbox "
+ u"format '%(mbfmt)s'.") %
+ {'transport': self._transport,
+ 'mbfmt': maillocation.mbformat}, INVALID_MAIL_LOCATION)
+ self._mail = maillocation
+ self._set_uid()
+
+ def _update_tables(self, column, value):
+ """Update various columns in the users table.
+
+ Arguments:
+
+ `column` : basestring
+ Name of the table column. Currently: qid, ssid and tid
+ `value` : long
+ The referenced key
+ """
+ if column not in ('qid', 'ssid', 'tid'):
+ raise ValueError('Unknown column: %r' % column)
+ dbc = self._dbh.cursor()
+ dbc.execute('UPDATE users SET %s = %%s WHERE uid = %%s' % column,
+ (value, self._uid))
+ if dbc.rowcount > 0:
+ self._dbh.commit()
+ dbc.close()
+
+ def _count_aliases(self):
+ """Count all alias addresses where the destination address is the
+ address of the Account."""
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT COUNT(destination) FROM alias WHERE destination '
+ '= %s', (str(self._addr),))
+ a_count = dbc.fetchone()[0]
+ dbc.close()
+ return a_count
+
+ def _chk_state(self):
+ """Raise an AccountError if the Account is new - not yet saved in the
+ database."""
+ if self._new:
+ raise AErr(_(u"The account '%s' does not exist.") % self._addr,
+ NO_SUCH_ACCOUNT)
+
+ @property
+ def address(self):
+ """The Account's EmailAddress instance."""
+ return self._addr
+
+ @property
+ def domain(self):
+ """The Domain to which the Account belongs to."""
+ if self._domain:
+ return self._domain
+ return None
+
+ @property
+ def gid(self):
+ """The Account's group ID."""
+ if self._domain:
+ return self._domain.gid
+ return None
+
+ @property
+ def home(self):
+ """The Account's home directory."""
+ if not self._new:
+ return '%s/%s' % (self._domain.directory, self._uid)
+ return None
+
+ @property
+ def mail_location(self):
+ """The Account's MailLocation."""
+ return self._mail
+
+ @property
+ def note(self):
+ """The Account's note."""
+ return self._note
+
+ @property
+ def uid(self):
+ """The Account's unique ID."""
+ return self._uid
+
+ def set_password(self, password):
+ """Set a password for the new Account.
+
+ If you want to update the password of an existing Account use
+ Account.modify().
+
+ Argument:
+
+ `password` : basestring
+ The password for the new Account.
+ """
+ if not self._new:
+ raise AErr(_(u"The account '%s' already exists.") % self._addr,
+ ACCOUNT_EXISTS)
+ if not isinstance(password, basestring) or not password:
+ raise AErr(_(u"Could not accept password: '%s'") % password,
+ ACCOUNT_MISSING_PASSWORD)
+ self._passwd = password
+
+ def set_note(self, note):
+ """Set the account's (optional) note.
+
+ Argument:
+
+ `note` : basestring or None
+ The note, or None to remove
+ """
+ assert note is None or isinstance(note, basestring)
+ self._note = note
+
+ def save(self):
+ """Save the new Account in the database."""
+ if not self._new:
+ raise AErr(_(u"The account '%s' already exists.") % self._addr,
+ ACCOUNT_EXISTS)
+ if not self._passwd:
+ raise AErr(_(u"No password set for account: '%s'") % self._addr,
+ ACCOUNT_MISSING_PASSWORD)
+ self._prepare(MailLocation(self._dbh, mbfmt=cfg_dget('mailbox.format'),
+ directory=cfg_dget('mailbox.root')))
+ dbc = self._dbh.cursor()
+ qid = ssid = tid = None
+ if self._qlimit:
+ qid = self._qlimit.qid
+ if self._services:
+ ssid = self._services.ssid
+ if self._transport:
+ tid = self._transport.tid
+ dbc.execute('INSERT INTO users (local_part, passwd, uid, gid, mid, '
+ 'qid, ssid, tid, note) '
+ 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
+ (self._addr.localpart,
+ pwhash(self._passwd, user=self._addr), self._uid,
+ self._domain.gid, self._mail.mid, qid, ssid, tid,
+# self._qlimit.qid if self._qlimit else None,
+# self._services.ssid if self._services else None,
+# self._transport.tid if self._transport else None,
+ self._note))
+ self._dbh.commit()
+ dbc.close()
+ self._new = False
+
+ def modify(self, field, value):
+ """Update the Account's *field* to the new *value*.
+
+ Possible values for *field* are: 'name', 'password', 'note'.
+
+ Arguments:
+
+ `field` : basestring
+ The attribute name: 'name', 'password' or 'note'
+ `value` : basestring
+ The new value of the attribute.
+ """
+ if field not in ('name', 'password', 'note'):
+ raise AErr(_(u"Unknown field: '%s'") % field, INVALID_ARGUMENT)
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ if field == 'password':
+ dbc.execute('UPDATE users SET passwd = %s WHERE uid = %s',
+ (pwhash(value, user=self._addr), self._uid))
+ else:
+ dbc.execute('UPDATE users SET %s = %%s WHERE uid = %%s' % field,
+ (value, self._uid))
+ if dbc.rowcount > 0:
+ self._dbh.commit()
+ dbc.close()
+
+ def update_quotalimit(self, quotalimit):
+ """Update the user's quota limit.
+
+ Arguments:
+
+ `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit
+ the new quota limit of the domain.
+ """
+ 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()
+ if quotalimit == self._qlimit:
+ return
+ self._qlimit = quotalimit
+ if quotalimit is not None:
+ assert isinstance(quotalimit, QuotaLimit)
+ quotalimit = quotalimit.qid
+ self._update_tables('qid', quotalimit)
+
+ def update_serviceset(self, serviceset):
+ """Assign a different set of services to the Account.
+
+ Argument:
+
+ `serviceset` : VirtualMailManager.serviceset.ServiceSet
+ the new service set.
+ """
+ self._chk_state()
+ if serviceset == self._services:
+ return
+ self._services = serviceset
+ if serviceset is not None:
+ assert isinstance(serviceset, ServiceSet)
+ serviceset = serviceset.ssid
+ self._update_tables('ssid', serviceset)
+
+ def update_transport(self, transport):
+ """Sets a new transport for the Account.
+
+ Arguments:
+
+ `transport` : VirtualMailManager.transport.Transport
+ the new transport
+ """
+ self._chk_state()
+ if transport == self._transport:
+ return
+ self._transport = transport
+ if transport is not None:
+ assert isinstance(transport, Transport)
+ if transport.transport.lower() in ('virtual', 'virtual:') and \
+ not self._mail.postfix:
+ raise AErr(_(u"Invalid transport '%(transport)s' for mailbox "
+ u"format '%(mbfmt)s'.") %
+ {'transport': transport, 'mbfmt': self._mail.mbformat},
+ INVALID_MAIL_LOCATION)
+ transport = transport.tid
+ self._update_tables('tid', transport)
+
+ def _get_info_transport(self):
+ if self._transport:
+ return self._transport.transport
+ return format_domain_default(self._domain.transport.transport)
+
+ def _get_info_serviceset(self):
+ if self._services:
+ services = self._services.services
+ fmt = lambda s: s
+ else:
+ services = self._domain.serviceset.services
+ fmt = format_domain_default
+
+ ret = {}
+ for service, state in services.iteritems():
+ # TP: A service (e.g. pop3 or imap) may be enabled/usable or
+ # disabled/unusable for a user.
+ ret[service] = fmt((_('disabled'), _('enabled'))[state])
+ return ret
+
+ def get_info(self):
+ """Returns a dict with some information about the Account.
+
+ The keys of the dict are: 'address', 'gid', 'home', 'imap'
+ 'mail_location', 'name', 'pop3', 'sieve', 'smtp', transport', 'uid',
+ 'uq_bytes', 'uq_messages', 'ql_bytes', 'ql_messages', and
+ 'ql_domaindefault'.
+ """
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute('SELECT name, CASE WHEN bytes IS NULL THEN 0 ELSE bytes '
+ 'END, CASE WHEN messages IS NULL THEN 0 ELSE messages END '
+ 'FROM users LEFT JOIN userquota USING (uid) WHERE '
+ 'users.uid = %s', (self._uid,))
+ info = dbc.fetchone()
+ dbc.close()
+ if info:
+ info = dict(zip(('name', 'uq_bytes', 'uq_messages'), info))
+ info.update(self._get_info_serviceset())
+ info['address'] = self._addr
+ info['gid'] = self._domain.gid
+ info['home'] = '%s/%s' % (self._domain.directory, self._uid)
+ info['mail_location'] = self._mail.mail_location
+ if self._qlimit:
+ info['ql_bytes'] = self._qlimit.bytes
+ info['ql_messages'] = self._qlimit.messages
+ info['ql_domaindefault'] = False
+ else:
+ info['ql_bytes'] = self._domain.quotalimit.bytes
+ info['ql_messages'] = self._domain.quotalimit.messages
+ info['ql_domaindefault'] = True
+ info['transport'] = self._get_info_transport()
+ info['note'] = self._note
+ info['uid'] = self._uid
+ return info
+ # nearly impossible‽
+ raise AErr(_(u"Could not fetch information for account: '%s'") %
+ self._addr, NO_SUCH_ACCOUNT)
+
+ def get_aliases(self):
+ """Return a list with all alias e-mail addresses, whose destination
+ is the address of the Account."""
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ dbc.execute("SELECT address ||'@'|| domainname FROM alias, "
+ "domain_name WHERE destination = %s AND domain_name.gid = "
+ "alias.gid AND domain_name.is_primary ORDER BY address",
+ (str(self._addr),))
+ addresses = dbc.fetchall()
+ dbc.close()
+ aliases = []
+ if addresses:
+ aliases = [alias[0] for alias in addresses]
+ return aliases
+
+ def delete(self, force=False):
+ """Delete the Account from the database.
+
+ Argument:
+
+ `force` : bool
+ if *force* is `True`, all aliases, which points to the Account,
+ will be also deleted. If there are aliases and *force* is
+ `False`, an AccountError will be raised.
+ """
+ if not isinstance(force, bool):
+ raise TypeError('force must be a bool')
+ self._chk_state()
+ dbc = self._dbh.cursor()
+ if force:
+ dbc.execute('DELETE FROM users WHERE uid = %s', (self._uid),)
+ # delete also all aliases where the destination address is the same
+ # as for this account.
+ dbc.execute("DELETE FROM alias WHERE destination = %s",
+ (str(self._addr),))
+ self._dbh.commit()
+ else: # check first for aliases
+ a_count = self._count_aliases()
+ if a_count > 0:
+ dbc.close()
+ raise AErr(_(u"There are %(count)d aliases with the "
+ u"destination address '%(address)s'.") %
+ {'count': a_count, 'address': self._addr},
+ ALIAS_PRESENT)
+ dbc.execute('DELETE FROM users WHERE uid = %s', (self._uid,))
+ self._dbh.commit()
+ dbc.close()
+ self._new = True
+ self._uid = 0
+ self._addr = self._dbh = self._domain = self._passwd = None
+ self._mail = self._qlimit = self._services = self._transport = None
+
+
+def get_account_by_uid(uid, dbh):
+ """Search an Account by its UID.
+
+ This function returns a dict (keys: 'address', 'gid' and 'uid'), if an
+ Account with the given *uid* exists.
+
+ Argument:
+
+ `uid` : long
+ The Account unique ID.
+ `dbh` : pyPgSQL.PgSQL.Connection
+ a database connection for the database access.
+ """
+ try:
+ uid = long(uid)
+ except ValueError:
+ raise AErr(_(u'UID must be an int/long.'), INVALID_ARGUMENT)
+ if uid < 1:
+ raise AErr(_(u'UID must be greater than 0.'), INVALID_ARGUMENT)
+ dbc = dbh.cursor()
+ dbc.execute("SELECT local_part||'@'|| domain_name.domainname AS address, "
+ "uid, users.gid, note FROM users LEFT JOIN domain_name ON "
+ "(domain_name.gid = users.gid AND is_primary) WHERE uid = %s",
+ (uid,))
+ info = dbc.fetchone()
+ dbc.close()
+ if not info:
+ raise AErr(_(u"There is no account with the UID: '%d'") % uid,
+ NO_SUCH_ACCOUNT)
+ info = dict(zip(('address', 'uid', 'gid', 'note'), info))
+ return info
+
+del _, cfg_dget