VirtualMailManager/account.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.account
       
     6     ~~~~~~~~~~~~~~~~~~~~~~~~~~
       
     7 
       
     8     Virtual Mail Manager's Account class to manage e-mail accounts.
       
     9 """
       
    10 
       
    11 from VirtualMailManager.common import version_str, \
       
    12      format_domain_default
       
    13 from VirtualMailManager.constants import \
       
    14      ACCOUNT_EXISTS, ACCOUNT_MISSING_PASSWORD, ALIAS_PRESENT, \
       
    15      INVALID_ARGUMENT, INVALID_MAIL_LOCATION, NO_SUCH_ACCOUNT, \
       
    16      NO_SUCH_DOMAIN, VMM_ERROR
       
    17 from VirtualMailManager.common import validate_transport
       
    18 from VirtualMailManager.domain import Domain
       
    19 from VirtualMailManager.emailaddress import EmailAddress
       
    20 from VirtualMailManager.errors import VMMError, AccountError as AErr
       
    21 from VirtualMailManager.maillocation import MailLocation
       
    22 from VirtualMailManager.password import pwhash
       
    23 from VirtualMailManager.quotalimit import QuotaLimit
       
    24 from VirtualMailManager.transport import Transport
       
    25 from VirtualMailManager.serviceset import ServiceSet
       
    26 
       
    27 __all__ = ('Account', 'get_account_by_uid')
       
    28 
       
    29 _ = lambda msg: msg
       
    30 cfg_dget = lambda option: None
       
    31 
       
    32 
       
    33 class Account(object):
       
    34     """Class to manage e-mail accounts."""
       
    35     __slots__ = ('_addr', '_dbh', '_domain', '_mail', '_new', '_passwd',
       
    36                  '_qlimit', '_services', '_transport', '_note', '_uid')
       
    37 
       
    38     def __init__(self, dbh, address):
       
    39         """Creates a new Account instance.
       
    40 
       
    41         When an account with the given *address* could be found in the
       
    42         database all relevant data will be loaded.
       
    43 
       
    44         Arguments:
       
    45 
       
    46         `dbh` : pyPgSQL.PgSQL.Connection
       
    47           A database connection for the database access.
       
    48         `address` : VirtualMailManager.EmailAddress.EmailAddress
       
    49           The e-mail address of the (new) Account.
       
    50         """
       
    51         if not isinstance(address, EmailAddress):
       
    52             raise TypeError("Argument 'address' is not an EmailAddress")
       
    53         self._addr = address
       
    54         self._dbh = dbh
       
    55         self._domain = Domain(self._dbh, self._addr.domainname)
       
    56         if not self._domain.gid:
       
    57             # TP: Hm, what “quotation marks” should be used?
       
    58             # If you are unsure have a look at:
       
    59             # http://en.wikipedia.org/wiki/Quotation_mark,_non-English_usage
       
    60             raise AErr(_(u"The domain '%s' does not exist.") %
       
    61                        self._addr.domainname, NO_SUCH_DOMAIN)
       
    62         self._uid = 0
       
    63         self._mail = None
       
    64         self._qlimit = None
       
    65         self._services = None
       
    66         self._transport = None
       
    67         self._note = None
       
    68         self._passwd = None
       
    69         self._new = True
       
    70         self._load()
       
    71 
       
    72     def __nonzero__(self):
       
    73         """Returns `True` if the Account is known, `False` if it's new."""
       
    74         return not self._new
       
    75 
       
    76     def _load(self):
       
    77         """Load 'uid', 'mid', 'qid', 'ssid', 'tid' and 'note' from the
       
    78         database and set _new to `False` - if the user could be found. """
       
    79         dbc = self._dbh.cursor()
       
    80         dbc.execute('SELECT uid, mid, qid, ssid, tid, note FROM users '
       
    81                     'WHERE gid = %s AND local_part = %s',
       
    82                     (self._domain.gid, self._addr.localpart))
       
    83         result = dbc.fetchone()
       
    84         dbc.close()
       
    85         if result:
       
    86             self._uid, _mid, _qid, _ssid, _tid, _note = result
       
    87 
       
    88             def load_helper(ctor, own, field, dbresult):
       
    89                 #  Py25: cur = None if own is None else getattr(own, field)
       
    90                 if own is None:
       
    91                     cur = None
       
    92                 else:
       
    93                     cur = getattr(own, field)
       
    94                 if cur != dbresult:
       
    95                     kwargs = {field: dbresult}
       
    96                     if dbresult is None:
       
    97                         return dbresult
       
    98                     else:
       
    99                         return ctor(self._dbh, **kwargs)
       
   100 
       
   101             self._qlimit = load_helper(QuotaLimit, self._qlimit, 'qid', _qid)
       
   102             self._services = load_helper(ServiceSet, self._services, 'ssid',
       
   103                                          _ssid)
       
   104             self._transport = load_helper(Transport, self._transport, 'tid',
       
   105                                           _tid)
       
   106             self._mail = MailLocation(self._dbh, mid=_mid)
       
   107             self._note = _note
       
   108             self._new = False
       
   109 
       
   110     def _set_uid(self):
       
   111         """Set the unique ID for the new Account."""
       
   112         assert self._uid == 0
       
   113         dbc = self._dbh.cursor()
       
   114         dbc.execute("SELECT nextval('users_uid')")
       
   115         self._uid = dbc.fetchone()[0]
       
   116         dbc.close()
       
   117 
       
   118     def _prepare(self, maillocation):
       
   119         """Check and set different attributes - before we store the
       
   120         information in the database.
       
   121         """
       
   122         if maillocation.dovecot_version > cfg_dget('misc.dovecot_version'):
       
   123             raise AErr(_(u"The mailbox format '%(mbfmt)s' requires Dovecot "
       
   124                          u">= v%(version)s.") % {
       
   125                        'mbfmt': maillocation.mbformat,
       
   126                        'version': version_str(maillocation.dovecot_version)},
       
   127                        INVALID_MAIL_LOCATION)
       
   128         transport = self._transport or self._domain.transport
       
   129         validate_transport(transport, maillocation)
       
   130         self._mail = maillocation
       
   131         self._set_uid()
       
   132 
       
   133     def _update_tables(self, column, value):
       
   134         """Update various columns in the users table.
       
   135 
       
   136         Arguments:
       
   137 
       
   138         `column` : basestring
       
   139           Name of the table column. Currently: qid, ssid and tid
       
   140         `value` : long
       
   141           The referenced key
       
   142         """
       
   143         if column not in ('qid', 'ssid', 'tid'):
       
   144             raise ValueError('Unknown column: %r' % column)
       
   145         dbc = self._dbh.cursor()
       
   146         dbc.execute('UPDATE users SET %s = %%s WHERE uid = %%s' % column,
       
   147                     (value, self._uid))
       
   148         if dbc.rowcount > 0:
       
   149             self._dbh.commit()
       
   150         dbc.close()
       
   151 
       
   152     def _count_aliases(self):
       
   153         """Count all alias addresses where the destination address is the
       
   154         address of the Account."""
       
   155         dbc = self._dbh.cursor()
       
   156         dbc.execute('SELECT COUNT(destination) FROM alias WHERE destination '
       
   157                     '= %s', (str(self._addr),))
       
   158         a_count = dbc.fetchone()[0]
       
   159         dbc.close()
       
   160         return a_count
       
   161 
       
   162     def _chk_state(self):
       
   163         """Raise an AccountError if the Account is new - not yet saved in the
       
   164         database."""
       
   165         if self._new:
       
   166             raise AErr(_(u"The account '%s' does not exist.") % self._addr,
       
   167                        NO_SUCH_ACCOUNT)
       
   168 
       
   169     @property
       
   170     def address(self):
       
   171         """The Account's EmailAddress instance."""
       
   172         return self._addr
       
   173 
       
   174     @property
       
   175     def domain(self):
       
   176         """The Domain to which the Account belongs to."""
       
   177         if self._domain:
       
   178             return self._domain
       
   179         return None
       
   180 
       
   181     @property
       
   182     def gid(self):
       
   183         """The Account's group ID."""
       
   184         if self._domain:
       
   185             return self._domain.gid
       
   186         return None
       
   187 
       
   188     @property
       
   189     def home(self):
       
   190         """The Account's home directory."""
       
   191         if not self._new:
       
   192             return '%s/%s' % (self._domain.directory, self._uid)
       
   193         return None
       
   194 
       
   195     @property
       
   196     def mail_location(self):
       
   197         """The Account's MailLocation."""
       
   198         return self._mail
       
   199 
       
   200     @property
       
   201     def note(self):
       
   202         """The Account's note."""
       
   203         return self._note
       
   204 
       
   205     @property
       
   206     def uid(self):
       
   207         """The Account's unique ID."""
       
   208         return self._uid
       
   209 
       
   210     def set_password(self, password):
       
   211         """Set a password for the new Account.
       
   212 
       
   213         If you want to update the password of an existing Account use
       
   214         Account.modify().
       
   215 
       
   216         Argument:
       
   217 
       
   218         `password` : basestring
       
   219           The password for the new Account.
       
   220         """
       
   221         if not self._new:
       
   222             raise AErr(_(u"The account '%s' already exists.") % self._addr,
       
   223                        ACCOUNT_EXISTS)
       
   224         if not isinstance(password, basestring) or not password:
       
   225             raise AErr(_(u"Could not accept password: '%s'") % password,
       
   226                        ACCOUNT_MISSING_PASSWORD)
       
   227         self._passwd = password
       
   228 
       
   229     def set_note(self, note):
       
   230         """Set the account's (optional) note.
       
   231 
       
   232         Argument:
       
   233 
       
   234         `note` : basestring or None
       
   235           The note, or None to remove
       
   236         """
       
   237         assert note is None or isinstance(note, basestring)
       
   238         self._note = note
       
   239 
       
   240     def save(self):
       
   241         """Save the new Account in the database."""
       
   242         if not self._new:
       
   243             raise AErr(_(u"The account '%s' already exists.") % self._addr,
       
   244                        ACCOUNT_EXISTS)
       
   245         if not self._passwd:
       
   246             raise AErr(_(u"No password set for account: '%s'") % self._addr,
       
   247                        ACCOUNT_MISSING_PASSWORD)
       
   248         self._prepare(MailLocation(self._dbh, mbfmt=cfg_dget('mailbox.format'),
       
   249                                    directory=cfg_dget('mailbox.root')))
       
   250         dbc = self._dbh.cursor()
       
   251         qid = ssid = tid = None
       
   252         if self._qlimit:
       
   253             qid = self._qlimit.qid
       
   254         if self._services:
       
   255             ssid = self._services.ssid
       
   256         if self._transport:
       
   257             tid = self._transport.tid
       
   258         dbc.execute('INSERT INTO users (local_part, passwd, uid, gid, mid, '
       
   259                     'qid, ssid, tid, note) '
       
   260                     'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
       
   261                     (self._addr.localpart,
       
   262                      pwhash(self._passwd, user=self._addr), self._uid,
       
   263                      self._domain.gid, self._mail.mid, qid, ssid, tid,
       
   264 #                     self._qlimit.qid if self._qlimit else None,
       
   265 #                     self._services.ssid if self._services else None,
       
   266 #                     self._transport.tid if self._transport else None,
       
   267                      self._note))
       
   268         self._dbh.commit()
       
   269         dbc.close()
       
   270         self._new = False
       
   271 
       
   272     def modify(self, field, value):
       
   273         """Update the Account's *field* to the new *value*.
       
   274 
       
   275         Possible values for *field* are: 'name', 'password', 'note'.
       
   276 
       
   277         Arguments:
       
   278 
       
   279         `field` : basestring
       
   280           The attribute name: 'name', 'password' or 'note'
       
   281         `value` : basestring
       
   282           The new value of the attribute.
       
   283         """
       
   284         if field not in ('name', 'password', 'note'):
       
   285             raise AErr(_(u"Unknown field: '%s'") % field, INVALID_ARGUMENT)
       
   286         self._chk_state()
       
   287         dbc = self._dbh.cursor()
       
   288         if field == 'password':
       
   289             dbc.execute('UPDATE users SET passwd = %s WHERE uid = %s',
       
   290                         (pwhash(value, user=self._addr), self._uid))
       
   291         else:
       
   292             dbc.execute('UPDATE users SET %s = %%s WHERE uid = %%s' % field,
       
   293                         (value, self._uid))
       
   294         if dbc.rowcount > 0:
       
   295             self._dbh.commit()
       
   296         dbc.close()
       
   297 
       
   298     def update_quotalimit(self, quotalimit):
       
   299         """Update the user's quota limit.
       
   300 
       
   301         Arguments:
       
   302 
       
   303         `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit
       
   304           the new quota limit of the domain.
       
   305         """
       
   306         if cfg_dget('misc.dovecot_version') < 0x10102f00:
       
   307             raise VMMError(_(u'PostgreSQL-based dictionary quota requires '
       
   308                              u'Dovecot >= v1.1.2.'), VMM_ERROR)
       
   309         self._chk_state()
       
   310         if quotalimit == self._qlimit:
       
   311             return
       
   312         self._qlimit = quotalimit
       
   313         if quotalimit is not None:
       
   314             assert isinstance(quotalimit, QuotaLimit)
       
   315             quotalimit = quotalimit.qid
       
   316         self._update_tables('qid', quotalimit)
       
   317 
       
   318     def update_serviceset(self, serviceset):
       
   319         """Assign a different set of services to the Account.
       
   320 
       
   321         Argument:
       
   322 
       
   323         `serviceset` : VirtualMailManager.serviceset.ServiceSet
       
   324           the new service set.
       
   325         """
       
   326         self._chk_state()
       
   327         if serviceset == self._services:
       
   328             return
       
   329         self._services = serviceset
       
   330         if serviceset is not None:
       
   331             assert isinstance(serviceset, ServiceSet)
       
   332             serviceset = serviceset.ssid
       
   333         self._update_tables('ssid', serviceset)
       
   334 
       
   335     def update_transport(self, transport):
       
   336         """Sets a new transport for the Account.
       
   337 
       
   338         Arguments:
       
   339 
       
   340         `transport` : VirtualMailManager.transport.Transport
       
   341           the new transport
       
   342         """
       
   343         self._chk_state()
       
   344         if transport == self._transport:
       
   345             return
       
   346         self._transport = transport
       
   347         if transport is not None:
       
   348             assert isinstance(transport, Transport)
       
   349             validate_transport(transport, self._mail)
       
   350             transport = transport.tid
       
   351         self._update_tables('tid', transport)
       
   352 
       
   353     def _get_info_transport(self):
       
   354         if self._transport:
       
   355             return self._transport.transport
       
   356         return format_domain_default(self._domain.transport.transport)
       
   357 
       
   358     def _get_info_serviceset(self):
       
   359         if self._services:
       
   360             services = self._services.services
       
   361             fmt = lambda s: s
       
   362         else:
       
   363             services = self._domain.serviceset.services
       
   364             fmt = format_domain_default
       
   365 
       
   366         ret = {}
       
   367         for service, state in services.iteritems():
       
   368             # TP: A service (e.g. pop3 or imap) may be enabled/usable or
       
   369             # disabled/unusable for a user.
       
   370             ret[service] = fmt((_('disabled'), _('enabled'))[state])
       
   371         return ret
       
   372 
       
   373     def get_info(self):
       
   374         """Returns a dict with some information about the Account.
       
   375 
       
   376         The keys of the dict are: 'address', 'gid', 'home', 'imap'
       
   377         'mail_location', 'name', 'pop3', 'sieve', 'smtp', transport', 'uid',
       
   378         'uq_bytes', 'uq_messages', 'ql_bytes', 'ql_messages', and
       
   379         'ql_domaindefault'.
       
   380         """
       
   381         self._chk_state()
       
   382         dbc = self._dbh.cursor()
       
   383         dbc.execute('SELECT name, CASE WHEN bytes IS NULL THEN 0 ELSE bytes '
       
   384                     'END, CASE WHEN messages IS NULL THEN 0 ELSE messages END '
       
   385                     'FROM users LEFT JOIN userquota USING (uid) WHERE '
       
   386                     'users.uid = %s', (self._uid,))
       
   387         info = dbc.fetchone()
       
   388         dbc.close()
       
   389         if info:
       
   390             info = dict(zip(('name', 'uq_bytes', 'uq_messages'), info))
       
   391             info.update(self._get_info_serviceset())
       
   392             info['address'] = self._addr
       
   393             info['gid'] = self._domain.gid
       
   394             info['home'] = '%s/%s' % (self._domain.directory, self._uid)
       
   395             info['mail_location'] = self._mail.mail_location
       
   396             if self._qlimit:
       
   397                 info['ql_bytes'] = self._qlimit.bytes
       
   398                 info['ql_messages'] = self._qlimit.messages
       
   399                 info['ql_domaindefault'] = False
       
   400             else:
       
   401                 info['ql_bytes'] = self._domain.quotalimit.bytes
       
   402                 info['ql_messages'] = self._domain.quotalimit.messages
       
   403                 info['ql_domaindefault'] = True
       
   404             info['transport'] = self._get_info_transport()
       
   405             info['note'] = self._note
       
   406             info['uid'] = self._uid
       
   407             return info
       
   408         # nearly impossible‽
       
   409         raise AErr(_(u"Could not fetch information for account: '%s'") %
       
   410                    self._addr, NO_SUCH_ACCOUNT)
       
   411 
       
   412     def get_aliases(self):
       
   413         """Return a list with all alias e-mail addresses, whose destination
       
   414         is the address of the Account."""
       
   415         self._chk_state()
       
   416         dbc = self._dbh.cursor()
       
   417         dbc.execute("SELECT address ||'@'|| domainname FROM alias, "
       
   418                     "domain_name WHERE destination = %s AND domain_name.gid = "
       
   419                     "alias.gid AND domain_name.is_primary ORDER BY address",
       
   420                     (str(self._addr),))
       
   421         addresses = dbc.fetchall()
       
   422         dbc.close()
       
   423         aliases = []
       
   424         if addresses:
       
   425             aliases = [alias[0] for alias in addresses]
       
   426         return aliases
       
   427 
       
   428     def delete(self, force=False):
       
   429         """Delete the Account from the database.
       
   430 
       
   431         Argument:
       
   432 
       
   433         `force` : bool
       
   434           if *force* is `True`, all aliases, which points to the Account,
       
   435           will be also deleted.  If there are aliases and *force* is
       
   436           `False`, an AccountError will be raised.
       
   437         """
       
   438         if not isinstance(force, bool):
       
   439             raise TypeError('force must be a bool')
       
   440         self._chk_state()
       
   441         dbc = self._dbh.cursor()
       
   442         if force:
       
   443             dbc.execute('DELETE FROM users WHERE uid = %s', (self._uid,))
       
   444             # delete also all aliases where the destination address is the same
       
   445             # as for this account.
       
   446             dbc.execute("DELETE FROM alias WHERE destination = %s",
       
   447                         (str(self._addr),))
       
   448             self._dbh.commit()
       
   449         else:  # check first for aliases
       
   450             a_count = self._count_aliases()
       
   451             if a_count > 0:
       
   452                 dbc.close()
       
   453                 raise AErr(_(u"There are %(count)d aliases with the "
       
   454                              u"destination address '%(address)s'.") %
       
   455                            {'count': a_count, 'address': self._addr},
       
   456                            ALIAS_PRESENT)
       
   457             dbc.execute('DELETE FROM users WHERE uid = %s', (self._uid,))
       
   458             self._dbh.commit()
       
   459         dbc.close()
       
   460         self._new = True
       
   461         self._uid = 0
       
   462         self._addr = self._dbh = self._domain = self._passwd = None
       
   463         self._mail = self._qlimit = self._services = self._transport = None
       
   464 
       
   465 
       
   466 def get_account_by_uid(uid, dbh):
       
   467     """Search an Account by its UID.
       
   468 
       
   469     This function returns a dict (keys: 'address', 'gid' and 'uid'), if an
       
   470     Account with the given *uid* exists.
       
   471 
       
   472     Argument:
       
   473 
       
   474     `uid` : long
       
   475       The Account unique ID.
       
   476     `dbh` : pyPgSQL.PgSQL.Connection
       
   477       a database connection for the database access.
       
   478     """
       
   479     try:
       
   480         uid = long(uid)
       
   481     except ValueError:
       
   482         raise AErr(_(u'UID must be an int/long.'), INVALID_ARGUMENT)
       
   483     if uid < 1:
       
   484         raise AErr(_(u'UID must be greater than 0.'), INVALID_ARGUMENT)
       
   485     dbc = dbh.cursor()
       
   486     dbc.execute("SELECT local_part||'@'|| domain_name.domainname AS address, "
       
   487                 "uid, users.gid, note FROM users LEFT JOIN domain_name ON "
       
   488                 "(domain_name.gid = users.gid AND is_primary) WHERE uid = %s",
       
   489                 (uid,))
       
   490     info = dbc.fetchone()
       
   491     dbc.close()
       
   492     if not info:
       
   493         raise AErr(_(u"There is no account with the UID: '%d'") % uid,
       
   494                    NO_SUCH_ACCOUNT)
       
   495     info = dict(zip(('address', 'uid', 'gid', 'note'), info))
       
   496     return info
       
   497 
       
   498 del _, cfg_dget