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