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