|
1 # -*- coding: UTF-8 -*- |
|
2 # Copyright (c) 2007 - 2012, Pascal Volk |
|
3 # See COPYING for distribution information. |
|
4 """ |
|
5 VirtualMailManager.domain |
|
6 ~~~~~~~~~~~~~~~~~~~~~~~~~ |
|
7 |
|
8 Virtual Mail Manager's Domain class to manage e-mail domains. |
|
9 """ |
|
10 |
|
11 import os |
|
12 import re |
|
13 from random import choice |
|
14 |
|
15 from VirtualMailManager.constants import \ |
|
16 ACCOUNT_AND_ALIAS_PRESENT, DOMAIN_ALIAS_EXISTS, DOMAIN_EXISTS, \ |
|
17 DOMAIN_INVALID, DOMAIN_TOO_LONG, NO_SUCH_DOMAIN, VMM_ERROR |
|
18 from VirtualMailManager.errors import VMMError, DomainError as DomErr |
|
19 from VirtualMailManager.pycompat import all, any |
|
20 from VirtualMailManager.quotalimit import QuotaLimit |
|
21 from VirtualMailManager.serviceset import ServiceSet |
|
22 from VirtualMailManager.transport import Transport |
|
23 |
|
24 |
|
25 MAILDIR_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz' |
|
26 RE_DOMAIN = re.compile(r"^(?:[a-z0-9-]{1,63}\.){1,}[a-z]{2,6}$") |
|
27 _ = lambda msg: msg |
|
28 cfg_dget = lambda option: None |
|
29 |
|
30 |
|
31 class Domain(object): |
|
32 """Class to manage e-mail domains.""" |
|
33 __slots__ = ('_directory', '_gid', '_name', '_qlimit', '_services', |
|
34 '_transport', '_note', '_dbh', '_new') |
|
35 |
|
36 def __init__(self, dbh, domainname): |
|
37 """Creates a new Domain instance. |
|
38 |
|
39 Loads all relevant data from the database, if the domain could be |
|
40 found. To create a new domain call the methods set_directory() and |
|
41 set_transport() before save(). |
|
42 |
|
43 A DomainError will be thrown when the *domainname* is the name of |
|
44 an alias domain. |
|
45 |
|
46 Arguments: |
|
47 |
|
48 `dbh` : pyPgSQL.PgSQL.Connection |
|
49 a database connection for the database access |
|
50 `domainname` : basestring |
|
51 The name of the domain |
|
52 """ |
|
53 self._name = check_domainname(domainname) |
|
54 self._dbh = dbh |
|
55 self._gid = 0 |
|
56 self._qlimit = None |
|
57 self._services = None |
|
58 self._transport = None |
|
59 self._directory = None |
|
60 self._note = None |
|
61 self._new = True |
|
62 self._load() |
|
63 |
|
64 def _load(self): |
|
65 """Load information from the database and checks if the domain name |
|
66 is the primary one. |
|
67 |
|
68 Raises a DomainError if Domain._name isn't the primary name of the |
|
69 domain. |
|
70 """ |
|
71 dbc = self._dbh.cursor() |
|
72 dbc.execute('SELECT dd.gid, qid, ssid, tid, domaindir, is_primary, ' |
|
73 'note ' |
|
74 'FROM domain_data dd, domain_name dn WHERE domainname = ' |
|
75 '%s AND dn.gid = dd.gid', (self._name,)) |
|
76 result = dbc.fetchone() |
|
77 dbc.close() |
|
78 if result: |
|
79 if not result[5]: |
|
80 raise DomErr(_(u"The domain '%s' is an alias domain.") % |
|
81 self._name, DOMAIN_ALIAS_EXISTS) |
|
82 self._gid, self._directory = result[0], result[4] |
|
83 self._qlimit = QuotaLimit(self._dbh, qid=result[1]) |
|
84 self._services = ServiceSet(self._dbh, ssid=result[2]) |
|
85 self._transport = Transport(self._dbh, tid=result[3]) |
|
86 self._note = result[6] |
|
87 self._new = False |
|
88 |
|
89 def _set_gid(self): |
|
90 """Sets the ID of the domain - if not set yet.""" |
|
91 assert self._gid == 0 |
|
92 dbc = self._dbh.cursor() |
|
93 dbc.execute("SELECT nextval('domain_gid')") |
|
94 self._gid = dbc.fetchone()[0] |
|
95 dbc.close() |
|
96 |
|
97 def _check_for_addresses(self): |
|
98 """Checks dependencies for deletion. Raises a DomainError if there |
|
99 are accounts, aliases and/or relocated users. |
|
100 """ |
|
101 dbc = self._dbh.cursor() |
|
102 dbc.execute('SELECT ' |
|
103 '(SELECT count(gid) FROM users WHERE gid = %(gid)u)' |
|
104 ' as account_count, ' |
|
105 '(SELECT count(gid) FROM alias WHERE gid = %(gid)u)' |
|
106 ' as alias_count, ' |
|
107 '(SELECT count(gid) FROM relocated WHERE gid = %(gid)u)' |
|
108 ' as relocated_count' |
|
109 % {'gid': self._gid}) |
|
110 result = dbc.fetchall() |
|
111 dbc.close() |
|
112 result = result[0] |
|
113 if any(result): |
|
114 keys = ('account_count', 'alias_count', 'relocated_count') |
|
115 raise DomErr(_(u'There are %(account_count)u accounts, ' |
|
116 u'%(alias_count)u aliases and %(relocated_count)u ' |
|
117 u'relocated users.') % dict(zip(keys, result)), |
|
118 ACCOUNT_AND_ALIAS_PRESENT) |
|
119 |
|
120 def _chk_state(self, must_exist=True): |
|
121 """Checks the state of the Domain instance and will raise a |
|
122 VirtualMailManager.errors.DomainError: |
|
123 - if *must_exist* is `True` and the domain doesn't exist |
|
124 - or *must_exist* is `False` and the domain exists |
|
125 """ |
|
126 if must_exist and self._new: |
|
127 raise DomErr(_(u"The domain '%s' does not exist.") % self._name, |
|
128 NO_SUCH_DOMAIN) |
|
129 elif not must_exist and not self._new: |
|
130 raise DomErr(_(u"The domain '%s' already exists.") % self._name, |
|
131 DOMAIN_EXISTS) |
|
132 |
|
133 def _update_tables(self, column, value): |
|
134 """Update table columns in the domain_data table.""" |
|
135 dbc = self._dbh.cursor() |
|
136 dbc.execute('UPDATE domain_data SET %s = %%s WHERE gid = %%s' % column, |
|
137 (value, self._gid)) |
|
138 if dbc.rowcount > 0: |
|
139 self._dbh.commit() |
|
140 dbc.close() |
|
141 |
|
142 def _update_tables_ref(self, column, value, force=False): |
|
143 """Update various columns in the domain_data table. When *force* is |
|
144 `True`, the corresponding column in the users table will be reset to |
|
145 NULL. |
|
146 |
|
147 Arguments: |
|
148 |
|
149 `column` : basestring |
|
150 Name of the table column. Currently: qid, ssid and tid |
|
151 `value` : long |
|
152 The referenced key |
|
153 `force` : bool |
|
154 reset existing users. Default: `False` |
|
155 """ |
|
156 if column not in ('qid', 'ssid', 'tid'): |
|
157 raise ValueError('Unknown column: %r' % column) |
|
158 self._update_tables(column, value) |
|
159 if force: |
|
160 dbc = self._dbh.cursor() |
|
161 dbc.execute('UPDATE users SET %s = NULL WHERE gid = %%s' % column, |
|
162 (self._gid,)) |
|
163 if dbc.rowcount > 0: |
|
164 self._dbh.commit() |
|
165 dbc.close() |
|
166 |
|
167 @property |
|
168 def gid(self): |
|
169 """The GID of the Domain.""" |
|
170 return self._gid |
|
171 |
|
172 @property |
|
173 def name(self): |
|
174 """The Domain's name.""" |
|
175 return self._name |
|
176 |
|
177 @property |
|
178 def directory(self): |
|
179 """The Domain's directory.""" |
|
180 return self._directory |
|
181 |
|
182 @property |
|
183 def quotalimit(self): |
|
184 """The Domain's quota limit.""" |
|
185 return self._qlimit |
|
186 |
|
187 @property |
|
188 def serviceset(self): |
|
189 """The Domain's serviceset.""" |
|
190 return self._services |
|
191 |
|
192 @property |
|
193 def transport(self): |
|
194 """The Domain's transport.""" |
|
195 return self._transport |
|
196 |
|
197 @property |
|
198 def note(self): |
|
199 """The Domain's note.""" |
|
200 return self._note |
|
201 |
|
202 def set_directory(self, basedir): |
|
203 """Set the path value of the Domain's directory, inside *basedir*. |
|
204 |
|
205 Argument: |
|
206 |
|
207 `basedir` : basestring |
|
208 The base directory of all domains |
|
209 """ |
|
210 self._chk_state(False) |
|
211 assert self._directory is None |
|
212 self._set_gid() |
|
213 self._directory = os.path.join(basedir, choice(MAILDIR_CHARS), |
|
214 str(self._gid)) |
|
215 |
|
216 def set_quotalimit(self, quotalimit): |
|
217 """Set the quota limit for the new Domain. |
|
218 |
|
219 Argument: |
|
220 |
|
221 `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit |
|
222 The quota limit of the new Domain. |
|
223 """ |
|
224 self._chk_state(False) |
|
225 assert isinstance(quotalimit, QuotaLimit) |
|
226 self._qlimit = quotalimit |
|
227 |
|
228 def set_serviceset(self, serviceset): |
|
229 """Set the services for the new Domain. |
|
230 |
|
231 Argument: |
|
232 |
|
233 `serviceset` : VirtualMailManager.serviceset.ServiceSet |
|
234 The service set for the new Domain. |
|
235 """ |
|
236 self._chk_state(False) |
|
237 assert isinstance(serviceset, ServiceSet) |
|
238 self._services = serviceset |
|
239 |
|
240 def set_transport(self, transport): |
|
241 """Set the transport for the new Domain. |
|
242 |
|
243 Argument: |
|
244 |
|
245 `transport` : VirtualMailManager.Transport |
|
246 The transport of the new Domain |
|
247 """ |
|
248 self._chk_state(False) |
|
249 assert isinstance(transport, Transport) |
|
250 self._transport = transport |
|
251 |
|
252 def set_note(self, note): |
|
253 """Set the domain's (optional) note. |
|
254 |
|
255 Argument: |
|
256 |
|
257 `note` : basestring or None |
|
258 The note, or None to remove |
|
259 """ |
|
260 self._chk_state(False) |
|
261 assert note is None or isinstance(note, basestring) |
|
262 self._note = note |
|
263 |
|
264 def save(self): |
|
265 """Stores the new domain in the database.""" |
|
266 self._chk_state(False) |
|
267 assert all((self._directory, self._qlimit, self._services, |
|
268 self._transport)) |
|
269 dbc = self._dbh.cursor() |
|
270 dbc.execute('INSERT INTO domain_data (gid, qid, ssid, tid, domaindir, ' |
|
271 'note) ' |
|
272 'VALUES (%s, %s, %s, %s, %s, %s)', (self._gid, |
|
273 self._qlimit.qid, self._services.ssid, self._transport.tid, |
|
274 self._directory, self._note)) |
|
275 dbc.execute('INSERT INTO domain_name (domainname, gid, is_primary) ' |
|
276 'VALUES (%s, %s, TRUE)', (self._name, self._gid)) |
|
277 self._dbh.commit() |
|
278 dbc.close() |
|
279 self._new = False |
|
280 |
|
281 def delete(self, force=False): |
|
282 """Deletes the domain. |
|
283 |
|
284 Arguments: |
|
285 |
|
286 `force` : bool |
|
287 force the deletion of all available accounts, aliases and |
|
288 relocated users. When *force* is `False` and there are accounts, |
|
289 aliases and/or relocated users a DomainError will be raised. |
|
290 Default `False` |
|
291 """ |
|
292 if not isinstance(force, bool): |
|
293 raise TypeError('force must be a bool') |
|
294 self._chk_state() |
|
295 if not force: |
|
296 self._check_for_addresses() |
|
297 dbc = self._dbh.cursor() |
|
298 for tbl in ('alias', 'users', 'relocated', 'domain_name', |
|
299 'domain_data'): |
|
300 dbc.execute("DELETE FROM %s WHERE gid = %u" % (tbl, self._gid)) |
|
301 self._dbh.commit() |
|
302 dbc.close() |
|
303 self._gid = 0 |
|
304 self._directory = self._qlimit = self._transport = None |
|
305 self._services = None |
|
306 self._new = True |
|
307 |
|
308 def update_quotalimit(self, quotalimit, force=False): |
|
309 """Update the quota limit of the Domain. |
|
310 |
|
311 If *force* is `True`, accounts-specific overrides will be reset |
|
312 for all existing accounts of the domain. Otherwise, the limit |
|
313 will only affect accounts that use the default. |
|
314 |
|
315 Arguments: |
|
316 |
|
317 `quotalimit` : VirtualMailManager.quotalimit.QuotaLimit |
|
318 the new quota limit of the domain. |
|
319 `force` : bool |
|
320 enforce new quota limit for all accounts, default `False` |
|
321 """ |
|
322 if cfg_dget('misc.dovecot_version') < 0x10102f00: |
|
323 raise VMMError(_(u'PostgreSQL-based dictionary quota requires ' |
|
324 u'Dovecot >= v1.1.2.'), VMM_ERROR) |
|
325 self._chk_state() |
|
326 assert isinstance(quotalimit, QuotaLimit) |
|
327 if not force and quotalimit == self._qlimit: |
|
328 return |
|
329 self._update_tables_ref('qid', quotalimit.qid, force) |
|
330 self._qlimit = quotalimit |
|
331 |
|
332 def update_serviceset(self, serviceset, force=False): |
|
333 """Assign a different set of services to the Domain, |
|
334 |
|
335 If *force* is `True`, accounts-specific overrides will be reset |
|
336 for all existing accounts of the domain. Otherwise, the service |
|
337 set will only affect accounts that use the default. |
|
338 |
|
339 Arguments: |
|
340 `serviceset` : VirtualMailManager.serviceset.ServiceSet |
|
341 the new set of services |
|
342 `force` |
|
343 enforce the serviceset for all accounts, default `False` |
|
344 """ |
|
345 self._chk_state() |
|
346 assert isinstance(serviceset, ServiceSet) |
|
347 if not force and serviceset == self._services: |
|
348 return |
|
349 self._update_tables_ref('ssid', serviceset.ssid, force) |
|
350 self._services = serviceset |
|
351 |
|
352 def update_transport(self, transport, force=False): |
|
353 """Sets a new transport for the Domain. |
|
354 |
|
355 If *force* is `True`, accounts-specific overrides will be reset |
|
356 for all existing accounts of the domain. Otherwise, the transport |
|
357 setting will only affect accounts that use the default. |
|
358 |
|
359 Arguments: |
|
360 |
|
361 `transport` : VirtualMailManager.Transport |
|
362 the new transport |
|
363 `force` : bool |
|
364 enforce new transport setting for all accounts, default `False` |
|
365 """ |
|
366 self._chk_state() |
|
367 assert isinstance(transport, Transport) |
|
368 if not force and transport == self._transport: |
|
369 return |
|
370 self._update_tables_ref('tid', transport.tid, force) |
|
371 self._transport = transport |
|
372 |
|
373 def update_note(self, note): |
|
374 """Sets a new note for the Domain. |
|
375 |
|
376 Arguments: |
|
377 |
|
378 `transport` : basestring or None |
|
379 the new note |
|
380 """ |
|
381 self._chk_state() |
|
382 assert note is None or isinstance(note, basestring) |
|
383 if note == self._note: |
|
384 return |
|
385 self._update_tables('note', note) |
|
386 self._note = note |
|
387 |
|
388 def get_info(self): |
|
389 """Returns a dictionary with information about the domain.""" |
|
390 self._chk_state() |
|
391 dbc = self._dbh.cursor() |
|
392 dbc.execute('SELECT aliasdomains "alias domains", accounts, aliases, ' |
|
393 'relocated, catchall "catch-all dests" ' |
|
394 'FROM vmm_domain_info WHERE gid = %s', (self._gid,)) |
|
395 info = dbc.fetchone() |
|
396 dbc.close() |
|
397 keys = ('alias domains', 'accounts', 'aliases', 'relocated', |
|
398 'catch-all dests') |
|
399 info = dict(zip(keys, info)) |
|
400 info['gid'] = self._gid |
|
401 info['domain name'] = self._name |
|
402 info['transport'] = self._transport.transport |
|
403 info['domain directory'] = self._directory |
|
404 info['bytes'] = self._qlimit.bytes |
|
405 info['messages'] = self._qlimit.messages |
|
406 services = self._services.services |
|
407 services = [s.upper() for s in services if services[s]] |
|
408 if services: |
|
409 services.sort() |
|
410 else: |
|
411 services.append('None') |
|
412 info['active services'] = ' '.join(services) |
|
413 info['note'] = self._note |
|
414 return info |
|
415 |
|
416 def get_accounts(self): |
|
417 """Returns a list with all accounts of the domain.""" |
|
418 self._chk_state() |
|
419 dbc = self._dbh.cursor() |
|
420 dbc.execute('SELECT local_part from users where gid = %s ORDER BY ' |
|
421 'local_part', (self._gid,)) |
|
422 users = dbc.fetchall() |
|
423 dbc.close() |
|
424 accounts = [] |
|
425 if users: |
|
426 addr = u'@'.join |
|
427 _dom = self._name |
|
428 accounts = [addr((account[0], _dom)) for account in users] |
|
429 return accounts |
|
430 |
|
431 def get_aliases(self): |
|
432 """Returns a list with all aliases e-mail addresses of the domain.""" |
|
433 self._chk_state() |
|
434 dbc = self._dbh.cursor() |
|
435 dbc.execute('SELECT DISTINCT address FROM alias WHERE gid = %s ORDER ' |
|
436 'BY address', (self._gid,)) |
|
437 addresses = dbc.fetchall() |
|
438 dbc.close() |
|
439 aliases = [] |
|
440 if addresses: |
|
441 addr = u'@'.join |
|
442 _dom = self._name |
|
443 aliases = [addr((alias[0], _dom)) for alias in addresses] |
|
444 return aliases |
|
445 |
|
446 def get_relocated(self): |
|
447 """Returns a list with all addresses of relocated users.""" |
|
448 self._chk_state() |
|
449 dbc = self._dbh.cursor() |
|
450 dbc.execute('SELECT address FROM relocated WHERE gid = %s ORDER BY ' |
|
451 'address', (self._gid,)) |
|
452 addresses = dbc.fetchall() |
|
453 dbc.close() |
|
454 relocated = [] |
|
455 if addresses: |
|
456 addr = u'@'.join |
|
457 _dom = self._name |
|
458 relocated = [addr((address[0], _dom)) for address in addresses] |
|
459 return relocated |
|
460 |
|
461 def get_catchall(self): |
|
462 """Returns a list with all catchall e-mail addresses of the domain.""" |
|
463 self._chk_state() |
|
464 dbc = self._dbh.cursor() |
|
465 dbc.execute('SELECT DISTINCT destination FROM catchall WHERE gid = %s ORDER ' |
|
466 'BY destination', (self._gid,)) |
|
467 addresses = dbc.fetchall() |
|
468 dbc.close() |
|
469 return addresses |
|
470 |
|
471 def get_aliase_names(self): |
|
472 """Returns a list with all alias domain names of the domain.""" |
|
473 self._chk_state() |
|
474 dbc = self._dbh.cursor() |
|
475 dbc.execute('SELECT domainname FROM domain_name WHERE gid = %s AND ' |
|
476 'NOT is_primary ORDER BY domainname', (self._gid,)) |
|
477 anames = dbc.fetchall() |
|
478 dbc.close() |
|
479 aliasdomains = [] |
|
480 if anames: |
|
481 aliasdomains = [aname[0] for aname in anames] |
|
482 return aliasdomains |
|
483 |
|
484 |
|
485 def check_domainname(domainname): |
|
486 """Returns the validated domain name `domainname`. |
|
487 |
|
488 Throws an `DomainError`, if the domain name is too long or doesn't |
|
489 look like a valid domain name (label.label.label). |
|
490 |
|
491 """ |
|
492 if not RE_DOMAIN.match(domainname): |
|
493 domainname = domainname.encode('idna') |
|
494 if len(domainname) > 255: |
|
495 raise DomErr(_(u'The domain name is too long'), DOMAIN_TOO_LONG) |
|
496 if not RE_DOMAIN.match(domainname): |
|
497 raise DomErr(_(u"The domain name '%s' is invalid") % domainname, |
|
498 DOMAIN_INVALID) |
|
499 return domainname |
|
500 |
|
501 |
|
502 def get_gid(dbh, domainname): |
|
503 """Returns the group id of the domain *domainname*. |
|
504 |
|
505 If the domain couldn't be found in the database 0 will be returned. |
|
506 """ |
|
507 domainname = check_domainname(domainname) |
|
508 dbc = dbh.cursor() |
|
509 dbc.execute('SELECT gid FROM domain_name WHERE domainname = %s', |
|
510 (domainname,)) |
|
511 gid = dbc.fetchone() |
|
512 dbc.close() |
|
513 if gid: |
|
514 return gid[0] |
|
515 return 0 |
|
516 |
|
517 |
|
518 def search(dbh, pattern=None, like=False): |
|
519 """'Search' for domains by *pattern* in the database. |
|
520 |
|
521 *pattern* may be a domain name or a partial domain name - starting |
|
522 and/or ending with a '%' sign. When the *pattern* starts or ends with |
|
523 a '%' sign *like* has to be `True` to perform a wildcard search. |
|
524 To retrieve all available domains use the arguments' default values. |
|
525 |
|
526 This function returns a tuple with a list and a dict: (order, domains). |
|
527 The order list contains the domains' gid, alphabetical sorted by the |
|
528 primary domain name. The domains dict's keys are the gids of the |
|
529 domains. The value of item is a list. The first list element contains |
|
530 the primary domain name or `None`. The elements [1:] contains the |
|
531 names of alias domains. |
|
532 |
|
533 Arguments: |
|
534 |
|
535 `pattern` : basestring |
|
536 a (partial) domain name (starting and/or ending with a "%" sign) |
|
537 `like` : bool |
|
538 should be `True` when *pattern* starts/ends with a "%" sign |
|
539 """ |
|
540 if pattern and not like: |
|
541 pattern = check_domainname(pattern) |
|
542 sql = 'SELECT gid, domainname, is_primary FROM domain_name' |
|
543 if pattern: |
|
544 if like: |
|
545 sql += " WHERE domainname LIKE '%s'" % pattern |
|
546 else: |
|
547 sql += " WHERE domainname = '%s'" % pattern |
|
548 sql += ' ORDER BY is_primary DESC, domainname' |
|
549 dbc = dbh.cursor() |
|
550 dbc.execute(sql) |
|
551 result = dbc.fetchall() |
|
552 dbc.close() |
|
553 |
|
554 gids = [domain[0] for domain in result if domain[2]] |
|
555 domains = {} |
|
556 for gid, domain, is_primary in result: |
|
557 if is_primary: |
|
558 if not gid in domains: |
|
559 domains[gid] = [domain] |
|
560 else: |
|
561 domains[gid].insert(0, domain) |
|
562 else: |
|
563 if gid in gids: |
|
564 if gid in domains: |
|
565 domains[gid].append(domain) |
|
566 else: |
|
567 domains[gid] = [domain] |
|
568 else: |
|
569 gids.append(gid) |
|
570 domains[gid] = [None, domain] |
|
571 return gids, domains |
|
572 |
|
573 del _, cfg_dget |