|
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 |