30 RE_LOCALPART = """[^\w!#$%&'\*\+-\.\/=?^_`{\|}~]""" |
30 RE_LOCALPART = """[^\w!#$%&'\*\+-\.\/=?^_`{\|}~]""" |
31 RE_MBOX_NAMES = """^[\x20-\x25\x27-\x7E]*$""" |
31 RE_MBOX_NAMES = """^[\x20-\x25\x27-\x7E]*$""" |
32 |
32 |
33 class VirtualMailManager(object): |
33 class VirtualMailManager(object): |
34 """The main class for vmm""" |
34 """The main class for vmm""" |
35 __slots__ = ('__Cfg', '__cfgFileName', '__cfgSections', '__dbh', '__scheme', |
35 __slots__ = ('__Cfg', '__cfgFileName', '__dbh', '__scheme', '__warnings', |
36 '__warnings', '_postconf') |
36 '_postconf') |
37 def __init__(self): |
37 def __init__(self): |
38 """Creates a new VirtualMailManager instance. |
38 """Creates a new VirtualMailManager instance. |
39 Throws a VMMNotRootException if your uid is greater 0. |
39 Throws a VMMNotRootException if your uid is greater 0. |
40 """ |
40 """ |
41 self.__cfgFileName = '' |
41 self.__cfgFileName = '' |
48 ERR.CONF_NOPERM) |
48 ERR.CONF_NOPERM) |
49 if self.__chkCfgFile(): |
49 if self.__chkCfgFile(): |
50 self.__Cfg = Cfg(self.__cfgFileName) |
50 self.__Cfg = Cfg(self.__cfgFileName) |
51 self.__Cfg.load() |
51 self.__Cfg.load() |
52 self.__Cfg.check() |
52 self.__Cfg.check() |
53 self.__cfgSections = self.__Cfg.getsections() |
53 self.__scheme = self.__Cfg.dget('misc.password_scheme') |
54 self.__scheme = self.__Cfg.get('misc', 'password_scheme') |
54 self._postconf = Postconf(self.__Cfg.dget('bin.postconf')) |
55 self._postconf = Postconf(self.__Cfg.get('bin', 'postconf')) |
|
56 if not os.sys.argv[1] in ['cf', 'configure']: |
55 if not os.sys.argv[1] in ['cf', 'configure']: |
57 self.__chkenv() |
56 self.__chkenv() |
58 |
57 |
59 def __findCfgFile(self): |
58 def __findCfgFile(self): |
60 for path in ['/root', '/usr/local/etc', '/etc']: |
59 for path in ['/root', '/usr/local/etc', '/etc']: |
81 else: |
80 else: |
82 return True |
81 return True |
83 |
82 |
84 def __chkenv(self): |
83 def __chkenv(self): |
85 """""" |
84 """""" |
86 basedir = self.__Cfg.get('misc', 'base_directory') |
85 basedir = self.__Cfg.dget('misc.base_directory') |
87 if not os.path.exists(basedir): |
86 if not os.path.exists(basedir): |
88 old_umask = os.umask(0006) |
87 old_umask = os.umask(0006) |
89 os.makedirs(basedir, 0771) |
88 os.makedirs(basedir, 0771) |
90 os.chown(basedir, 0, self.__Cfg.getint('misc', 'gid_mail')) |
89 os.chown(basedir, 0, self.__Cfg.dget('misc.gid_mail')) |
91 os.umask(old_umask) |
90 os.umask(old_umask) |
92 elif not os.path.isdir(basedir): |
91 elif not os.path.isdir(basedir): |
93 raise VMMException(_(u'“%s” is not a directory.\n\ |
92 raise VMMException(_(u'“%s” is not a directory.\n\ |
94 (vmm.cfg: section "misc", option "base_directory")') % |
93 (vmm.cfg: section "misc", option "base_directory")') % |
95 basedir, ERR.NO_SUCH_DIRECTORY) |
94 basedir, ERR.NO_SUCH_DIRECTORY) |
106 def __dbConnect(self): |
105 def __dbConnect(self): |
107 """Creates a pyPgSQL.PgSQL.connection instance.""" |
106 """Creates a pyPgSQL.PgSQL.connection instance.""" |
108 if self.__dbh is None or not self.__dbh._isOpen: |
107 if self.__dbh is None or not self.__dbh._isOpen: |
109 try: |
108 try: |
110 self.__dbh = PgSQL.connect( |
109 self.__dbh = PgSQL.connect( |
111 database=self.__Cfg.get('database', 'name'), |
110 database=self.__Cfg.dget('database.name'), |
112 user=self.__Cfg.get('database', 'user'), |
111 user=self.__Cfg.pget('database.user'), |
113 host=self.__Cfg.get('database', 'host'), |
112 host=self.__Cfg.dget('database.host'), |
114 password=self.__Cfg.get('database', 'pass'), |
113 password=self.__Cfg.pget('database.pass'), |
115 client_encoding='utf8', unicode_results=True) |
114 client_encoding='utf8', unicode_results=True) |
116 dbc = self.__dbh.cursor() |
115 dbc = self.__dbh.cursor() |
117 dbc.execute("SET NAMES 'UTF8'") |
116 dbc.execute("SET NAMES 'UTF8'") |
118 dbc.close() |
117 dbc.close() |
119 except PgSQL.libpq.DatabaseError, e: |
118 except PgSQL.libpq.DatabaseError, e: |
232 destination = EmailAddress(destination) |
231 destination = EmailAddress(destination) |
233 return Relocated(self.__dbh, address, destination) |
232 return Relocated(self.__dbh, address, destination) |
234 |
233 |
235 def __getDomain(self, domainname, transport=None): |
234 def __getDomain(self, domainname, transport=None): |
236 if transport is None: |
235 if transport is None: |
237 transport = self.__Cfg.get('misc', 'transport') |
236 transport = self.__Cfg.dget('misc.transport') |
238 self.__dbConnect() |
237 self.__dbConnect() |
239 return Domain(self.__dbh, domainname, |
238 return Domain(self.__dbh, domainname, |
240 self.__Cfg.get('misc', 'base_directory'), transport) |
239 self.__Cfg.dget('misc.base_directory'), transport) |
241 |
240 |
242 def __getDiskUsage(self, directory): |
241 def __getDiskUsage(self, directory): |
243 """Estimate file space usage for the given directory. |
242 """Estimate file space usage for the given directory. |
244 |
243 |
245 Keyword arguments: |
244 Keyword arguments: |
246 directory -- the directory to summarize recursively disk usage for |
245 directory -- the directory to summarize recursively disk usage for |
247 """ |
246 """ |
248 if self.__isdir(directory): |
247 if self.__isdir(directory): |
249 return Popen([self.__Cfg.get('bin', 'du'), "-hs", directory], |
248 return Popen([self.__Cfg.dget('bin.du'), "-hs", directory], |
250 stdout=PIPE).communicate()[0].split('\t')[0] |
249 stdout=PIPE).communicate()[0].split('\t')[0] |
251 else: |
250 else: |
252 return 0 |
251 return 0 |
253 |
252 |
254 def __isdir(self, directory): |
253 def __isdir(self, directory): |
257 self.__warnings.append(_('No such directory: %s') % directory) |
256 self.__warnings.append(_('No such directory: %s') % directory) |
258 return isdir |
257 return isdir |
259 |
258 |
260 def __makedir(self, directory, mode=None, uid=None, gid=None): |
259 def __makedir(self, directory, mode=None, uid=None, gid=None): |
261 if mode is None: |
260 if mode is None: |
262 mode = self.__Cfg.getint('account', 'directory_mode') |
261 mode = self.__Cfg.dget('account.directory_mode') |
263 if uid is None: |
262 if uid is None: |
264 uid = 0 |
263 uid = 0 |
265 if gid is None: |
264 if gid is None: |
266 gid = 0 |
265 gid = 0 |
267 os.makedirs(directory, mode) |
266 os.makedirs(directory, mode) |
268 os.chown(directory, uid, gid) |
267 os.chown(directory, uid, gid) |
269 |
268 |
270 def __domDirMake(self, domdir, gid): |
269 def __domDirMake(self, domdir, gid): |
271 os.umask(0006) |
270 os.umask(0006) |
272 oldpwd = os.getcwd() |
271 oldpwd = os.getcwd() |
273 basedir = self.__Cfg.get('misc', 'base_directory') |
272 basedir = self.__Cfg.dget('misc.base_directory') |
274 domdirdirs = domdir.replace(basedir+'/', '').split('/') |
273 domdirdirs = domdir.replace(basedir+'/', '').split('/') |
275 |
274 |
276 os.chdir(basedir) |
275 os.chdir(basedir) |
277 if not os.path.isdir(domdirdirs[0]): |
276 if not os.path.isdir(domdirdirs[0]): |
278 self.__makedir(domdirdirs[0], 489, 0, |
277 self.__makedir(domdirdirs[0], 489, 0, |
279 self.__Cfg.getint('misc', 'gid_mail')) |
278 self.__Cfg.dget('misc.gid_mail')) |
280 os.chdir(domdirdirs[0]) |
279 os.chdir(domdirdirs[0]) |
281 os.umask(0007) |
280 os.umask(0007) |
282 self.__makedir(domdirdirs[1], |
281 self.__makedir(domdirdirs[1], self.__Cfg.dget('domain.directory_mode'), |
283 self.__Cfg.getint('domain', 'directory_mode'), 0, gid) |
282 0, gid) |
284 os.chdir(oldpwd) |
283 os.chdir(oldpwd) |
285 |
284 |
286 def __subscribeFL(self, folderlist, uid, gid): |
285 def __subscribeFL(self, folderlist, uid, gid): |
287 fname = os.path.join(self.__Cfg.get('maildir','name'), 'subscriptions') |
286 fname = os.path.join(self.__Cfg.dget('maildir.name'), 'subscriptions') |
288 sf = file(fname, 'w') |
287 sf = file(fname, 'w') |
289 for f in folderlist: |
288 for f in folderlist: |
290 sf.write(f+'\n') |
289 sf.write(f+'\n') |
291 sf.flush() |
290 sf.flush() |
292 sf.close() |
291 sf.close() |
303 """ |
302 """ |
304 os.umask(0007) |
303 os.umask(0007) |
305 oldpwd = os.getcwd() |
304 oldpwd = os.getcwd() |
306 os.chdir(domdir) |
305 os.chdir(domdir) |
307 |
306 |
308 maildir = self.__Cfg.get('maildir', 'name') |
307 maildir = self.__Cfg.dget('maildir.name') |
309 folders = [maildir] |
308 folders = [maildir] |
310 for folder in self.__Cfg.get('maildir', 'folders').split(':'): |
309 for folder in self.__Cfg.dget('maildir.folders').split(':'): |
311 folder = folder.strip() |
310 folder = folder.strip() |
312 if len(folder) and not folder.count('..')\ |
311 if len(folder) and not folder.count('..')\ |
313 and re.match(RE_MBOX_NAMES, folder): |
312 and re.match(RE_MBOX_NAMES, folder): |
314 folders.append('%s/.%s' % (maildir, folder)) |
313 folders.append('%s/.%s' % (maildir, folder)) |
315 subdirs = ['cur', 'new', 'tmp'] |
314 subdirs = ['cur', 'new', 'tmp'] |
316 mode = self.__Cfg.getint('account', 'directory_mode') |
315 mode = self.__Cfg.dget('account.directory_mode') |
317 |
316 |
318 self.__makedir('%s' % uid, mode, uid, gid) |
317 self.__makedir('%s' % uid, mode, uid, gid) |
319 os.chdir('%s' % uid) |
318 os.chdir('%s' % uid) |
320 for folder in folders: |
319 for folder in folders: |
321 self.__makedir(folder, mode, uid, gid) |
320 self.__makedir(folder, mode, uid, gid) |
346 |
345 |
347 def __domDirDelete(self, domdir, gid): |
346 def __domDirDelete(self, domdir, gid): |
348 if gid > 0: |
347 if gid > 0: |
349 if not self.__isdir(domdir): |
348 if not self.__isdir(domdir): |
350 return |
349 return |
351 basedir = self.__Cfg.get('misc', 'base_directory') |
350 basedir = self.__Cfg.dget('misc.base_directory') |
352 domdirdirs = domdir.replace(basedir+'/', '').split('/') |
351 domdirdirs = domdir.replace(basedir+'/', '').split('/') |
353 domdirparent = os.path.join(basedir, domdirdirs[0]) |
352 domdirparent = os.path.join(basedir, domdirdirs[0]) |
354 if basedir.count('..') or domdir.count('..'): |
353 if basedir.count('..') or domdir.count('..'): |
355 raise VMMException(_(u'Found ".." in domain directory path.'), |
354 raise VMMException(_(u'Found ".." in domain directory path.'), |
356 ERR.FOUND_DOTS_IN_PATH) |
355 ERR.FOUND_DOTS_IN_PATH) |
413 return '{%s}%s' % (self.__scheme, self.__pwMD5(password, user)) |
412 return '{%s}%s' % (self.__scheme, self.__pwMD5(password, user)) |
414 elif self.__scheme == 'MD4': |
413 elif self.__scheme == 'MD4': |
415 return '{%s}%s' % (self.__scheme, self.__pwMD4(password)) |
414 return '{%s}%s' % (self.__scheme, self.__pwMD4(password)) |
416 elif self.__scheme in ['SMD5', 'SSHA', 'CRAM-MD5', 'HMAC-MD5', |
415 elif self.__scheme in ['SMD5', 'SSHA', 'CRAM-MD5', 'HMAC-MD5', |
417 'LANMAN', 'NTLM', 'RPA']: |
416 'LANMAN', 'NTLM', 'RPA']: |
418 return Popen([self.__Cfg.get('bin', 'dovecotpw'), '-s', |
417 return Popen([self.__Cfg.dget('bin.dovecotpw'), '-s', |
419 self.__scheme,'-p',password],stdout=PIPE).communicate()[0][:-1] |
418 self.__scheme,'-p',password],stdout=PIPE).communicate()[0][:-1] |
420 else: |
419 else: |
421 return '{%s}%s' % (self.__scheme, password) |
420 return '{%s}%s' % (self.__scheme, password) |
422 |
421 |
423 def hasWarnings(self): |
422 def hasWarnings(self): |
426 |
425 |
427 def getWarnings(self): |
426 def getWarnings(self): |
428 """Returns a list with all available warnings.""" |
427 """Returns a list with all available warnings.""" |
429 return self.__warnings |
428 return self.__warnings |
430 |
429 |
431 def cfgGetBoolean(self, section, option): |
430 def cfgDget(self, option): |
432 return self.__Cfg.getboolean(section, option) |
431 return self.__Cfg.dget(option) |
433 |
432 |
434 def cfgGetInt(self, section, option): |
433 def cfgPget(self, option): |
435 return self.__Cfg.getint(section, option) |
434 return self.__Cfg.pget(option) |
436 |
435 |
437 def cfgGetString(self, section, option): |
436 def cfgSet(self, option, value): |
438 return self.__Cfg.get(section, option) |
437 return self.__Cfg.set(option, value) |
439 |
438 |
440 def setupIsDone(self): |
439 def setupIsDone(self): |
441 """Checks if vmm is configured, returns bool""" |
440 """Checks if vmm is configured, returns bool""" |
442 try: |
441 try: |
443 return self.__Cfg.getboolean('config', 'done') |
442 return self.__Cfg.dget('config.done') |
444 except ValueError, e: |
443 except ValueError, e: |
445 raise VMMConfigException(_(u"""Configuration error: "%s" |
444 raise VMMConfigException(_(u"""Configuration error: "%s" |
446 (in section "config", option "done") see also: vmm.cfg(5)\n""") % str(e), |
445 (in section "config", option "done") see also: vmm.cfg(5)\n""") % str(e), |
447 ERR.CONF_ERROR) |
446 ERR.CONF_ERROR) |
448 |
447 |
456 Keyword arguments: |
455 Keyword arguments: |
457 section -- the section to configure (default None): |
456 section -- the section to configure (default None): |
458 'database', 'maildir', 'bin' or 'misc' |
457 'database', 'maildir', 'bin' or 'misc' |
459 """ |
458 """ |
460 if section is None: |
459 if section is None: |
461 self.__Cfg.configure(self.__cfgSections) |
460 self.__Cfg.configure(self.__Cfg.getsections()) |
462 elif section in self.__cfgSections: |
461 elif section in self.__Cfg.getsections(): |
463 self.__Cfg.configure([section]) |
462 self.__Cfg.configure([section]) |
464 else: |
463 else: |
465 raise VMMException(_(u"Invalid section: “%s”") % section, |
464 raise VMMException(_(u"Invalid section: “%s”") % section, |
466 ERR.INVALID_SECTION) |
465 ERR.INVALID_SECTION) |
467 |
466 |
485 raise VMMDomainException(_(u"Invalid argument: “%s”") % force, |
484 raise VMMDomainException(_(u"Invalid argument: “%s”") % force, |
486 ERR.INVALID_OPTION) |
485 ERR.INVALID_OPTION) |
487 dom = self.__getDomain(domainname) |
486 dom = self.__getDomain(domainname) |
488 gid = dom.getID() |
487 gid = dom.getID() |
489 domdir = dom.getDir() |
488 domdir = dom.getDir() |
490 if self.__Cfg.getboolean('domain', 'force_deletion')\ |
489 if self.__Cfg.dget('domain.force_deletion') or force == 'delall': |
491 or force == 'delall': |
|
492 dom.delete(True, True) |
490 dom.delete(True, True) |
493 elif force == 'deluser': |
491 elif force == 'deluser': |
494 dom.delete(delUser=True) |
492 dom.delete(delUser=True) |
495 elif force == 'delalias': |
493 elif force == 'delalias': |
496 dom.delete(delAlias=True) |
494 dom.delete(delAlias=True) |
497 else: |
495 else: |
498 dom.delete() |
496 dom.delete() |
499 if self.__Cfg.getboolean('domain', 'delete_directory'): |
497 if self.__Cfg.dget('domain.delete_directory'): |
500 self.__domDirDelete(domdir, gid) |
498 self.__domDirDelete(domdir, gid) |
501 |
499 |
502 def domainInfo(self, domainname, details=None): |
500 def domainInfo(self, domainname, details=None): |
503 if details not in [None, 'accounts', 'aliasdomains', 'aliases', 'full', |
501 if details not in [None, 'accounts', 'aliasdomains', 'aliases', 'full', |
504 'relocated', 'detailed']: |
502 'relocated', 'detailed']: |
587 def userAdd(self, emailaddress, password): |
585 def userAdd(self, emailaddress, password): |
588 acc = self.__getAccount(emailaddress, password) |
586 acc = self.__getAccount(emailaddress, password) |
589 if password is None: |
587 if password is None: |
590 password = self._readpass() |
588 password = self._readpass() |
591 acc.setPassword(self.__pwhash(password)) |
589 acc.setPassword(self.__pwhash(password)) |
592 acc.save(self.__Cfg.get('maildir', 'name'), |
590 acc.save(self.__Cfg.dget('maildir.name'), |
593 self.__Cfg.getint('misc', 'dovecot_version'), |
591 self.__Cfg.dget('misc.dovecot_version'), |
594 self.__Cfg.getboolean('account', 'smtp'), |
592 self.__Cfg.dget('account.smtp'), |
595 self.__Cfg.getboolean('account', 'pop3'), |
593 self.__Cfg.dget('account.pop3'), |
596 self.__Cfg.getboolean('account', 'imap'), |
594 self.__Cfg.dget('account.imap'), |
597 self.__Cfg.getboolean('account', 'sieve')) |
595 self.__Cfg.dget('account.sieve')) |
598 self.__mailDirMake(acc.getDir('domain'), acc.getUID(), acc.getGID()) |
596 self.__mailDirMake(acc.getDir('domain'), acc.getUID(), acc.getGID()) |
599 |
597 |
600 def aliasAdd(self, aliasaddress, targetaddress): |
598 def aliasAdd(self, aliasaddress, targetaddress): |
601 alias = self.__getAlias(aliasaddress, targetaddress) |
599 alias = self.__getAlias(aliasaddress, targetaddress) |
602 alias.save(long(self._postconf.read('virtual_alias_expansion_limit'))) |
600 alias.save(long(self._postconf.read('virtual_alias_expansion_limit'))) |
614 ERR.INVALID_AGUMENT) |
612 ERR.INVALID_AGUMENT) |
615 acc = self.__getAccount(emailaddress) |
613 acc = self.__getAccount(emailaddress) |
616 uid = acc.getUID() |
614 uid = acc.getUID() |
617 gid = acc.getGID() |
615 gid = acc.getGID() |
618 acc.delete(force) |
616 acc.delete(force) |
619 if self.__Cfg.getboolean('account', 'delete_directory'): |
617 if self.__Cfg.dget('account.delete_directory'): |
620 try: |
618 try: |
621 self.__userDirDelete(acc.getDir('domain'), uid, gid) |
619 self.__userDirDelete(acc.getDir('domain'), uid, gid) |
622 except VMMException, e: |
620 except VMMException, e: |
623 if e.code() in [ERR.FOUND_DOTS_IN_PATH, |
621 if e.code() in [ERR.FOUND_DOTS_IN_PATH, |
624 ERR.MAILDIR_PERM_MISMATCH, ERR.NO_SUCH_DIRECTORY]: |
622 ERR.MAILDIR_PERM_MISMATCH, ERR.NO_SUCH_DIRECTORY]: |
638 def aliasDelete(self, aliasaddress, targetaddress=None): |
636 def aliasDelete(self, aliasaddress, targetaddress=None): |
639 alias = self.__getAlias(aliasaddress, targetaddress) |
637 alias = self.__getAlias(aliasaddress, targetaddress) |
640 alias.delete() |
638 alias.delete() |
641 |
639 |
642 def userInfo(self, emailaddress, details=None): |
640 def userInfo(self, emailaddress, details=None): |
643 if details not in [None, 'du', 'aliases', 'full']: |
641 if details not in (None, 'du', 'aliases', 'full'): |
644 raise VMMException(_(u'Invalid argument: “%s”') % details, |
642 raise VMMException(_(u'Invalid argument: “%s”') % details, |
645 ERR.INVALID_AGUMENT) |
643 ERR.INVALID_AGUMENT) |
646 acc = self.__getAccount(emailaddress) |
644 acc = self.__getAccount(emailaddress) |
647 info = acc.getInfo(self.__Cfg.getint('misc', 'dovecot_version')) |
645 info = acc.getInfo(self.__Cfg.dget('misc.dovecot_version')) |
648 if self.__Cfg.getboolean('account', 'disk_usage')\ |
646 if self.__Cfg.dget('account.disk_usage') or details in ('du', 'full'): |
649 or details in ['du', 'full']: |
|
650 info['disk usage'] = self.__getDiskUsage('%(maildir)s' % info) |
647 info['disk usage'] = self.__getDiskUsage('%(maildir)s' % info) |
651 if details in [None, 'du']: |
648 if details in (None, 'du'): |
652 return info |
649 return info |
653 if details in ['aliases', 'full']: |
650 if details in ('aliases', 'full'): |
654 return (info, acc.getAliases()) |
651 return (info, acc.getAliases()) |
655 return info |
652 return info |
656 |
653 |
657 def userByID(self, uid): |
654 def userByID(self, uid): |
658 from Account import getAccountByID |
655 from Account import getAccountByID |
681 self.__warnings.append(_(u'\ |
678 self.__warnings.append(_(u'\ |
682 The service name “managesieve” is deprecated and will be removed\n\ |
679 The service name “managesieve” is deprecated and will be removed\n\ |
683 in a future release.\n\ |
680 in a future release.\n\ |
684 Please use the service name “sieve” instead.')) |
681 Please use the service name “sieve” instead.')) |
685 acc = self.__getAccount(emailaddress) |
682 acc = self.__getAccount(emailaddress) |
686 acc.disable(self.__Cfg.getint('misc', 'dovecot_version'), service) |
683 acc.disable(self.__Cfg.dget('misc.dovecot_version'), service) |
687 |
684 |
688 def userEnable(self, emailaddress, service=None): |
685 def userEnable(self, emailaddress, service=None): |
689 if service == 'managesieve': |
686 if service == 'managesieve': |
690 service = 'sieve' |
687 service = 'sieve' |
691 self.__warnings.append(_(u'\ |
688 self.__warnings.append(_(u'\ |
692 The service name “managesieve” is deprecated and will be removed\n\ |
689 The service name “managesieve” is deprecated and will be removed\n\ |
693 in a future release.\n\ |
690 in a future release.\n\ |
694 Please use the service name “sieve” instead.')) |
691 Please use the service name “sieve” instead.')) |
695 acc = self.__getAccount(emailaddress) |
692 acc = self.__getAccount(emailaddress) |
696 acc.enable(self.__Cfg.getint('misc', 'dovecot_version'), service) |
693 acc.enable(self.__Cfg.dget('misc.dovecot_version'), service) |
697 |
694 |
698 def relocatedAdd(self, emailaddress, targetaddress): |
695 def relocatedAdd(self, emailaddress, targetaddress): |
699 relocated = self.__getRelocated(emailaddress, targetaddress) |
696 relocated = self.__getRelocated(emailaddress, targetaddress) |
700 relocated.save() |
697 relocated.save() |
701 |
698 |