diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index c8d6faff1..15fe9df30 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -17,9 +17,10 @@ from . import manifest version = 1 managed_packages = ['postfix', 'dovecot-pop3d', 'dovecot-imapd', - 'dovecot-lmtpd', 'dovecot-ldap', 'dovecot-managesieved', - 'rspamd'] -managed_services = ['postfix', 'dovecot'] + 'dovecot-ldap', 'dovecot-lmtpd', 'dovecot-managesieved', + 'rspamd', 'clamav', 'clamav-daemon'] +managed_services = ['postfix', 'dovecot', 'rspamd', 'redis', 'clamav-daemon', + 'clamav-freshclam'] app = None @@ -62,7 +63,7 @@ class EmailServerApp(plinth.app.App): name=info.name, short_description=info.short_description, icon='roundcube', - configure_url=reverse_lazy('email_server'), + url=reverse_lazy('email_server:my_aliases'), clients=manifest.clients, login_required=True ) @@ -85,6 +86,9 @@ class EmailServerApp(plinth.app.App): listen_ports=dovecot_ports) self.add(postfixd) self.add(dovecotd) + for name in ('rspamd', 'redis', 'clamav-daemon', 'clamav-freshclam'): + daemon = plinth.daemon.Daemon('daemon-' + name, name) + self.add(daemon) # Ports firewall = Firewall('firewall-email', info.name, diff --git a/plinth/modules/email_server/aliases/__init__.py b/plinth/modules/email_server/aliases/__init__.py new file mode 100644 index 000000000..e510dd3c3 --- /dev/null +++ b/plinth/modules/email_server/aliases/__init__.py @@ -0,0 +1,151 @@ +"""Manages email aliases""" +# SPDX-License-Identifier: AGPL-3.0-or-later + +import contextlib +import dbm +import logging +import os +import pwd +import sqlite3 + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from plinth.modules.email_server import lock +from . import models + +map_db_schema_script = """ +PRAGMA journal_mode=WAL; +BEGIN; +CREATE TABLE IF NOT EXISTS Alias ( + email_name TEXT NOT NULL, + uid_number INTEGER NOT NULL, + status INTEGER NOT NULL, + PRIMARY KEY (email_name) +); +COMMIT; +""" + +mailsrv_dir = '/var/lib/plinth/mailsrv' +hash_db_path = mailsrv_dir + '/aliases' +sqlite_db_path = mailsrv_dir + '/aliases.sqlite3' + +alias_sync_mutex = lock.Mutex('alias-sync') +logger = logging.getLogger(__name__) + + +@contextlib.contextmanager +def db_cursor(): + # Turn ON autocommit mode + con = sqlite3.connect(sqlite_db_path, isolation_level=None) + con.row_factory = sqlite3.Row + try: + cur = con.cursor() + yield cur + finally: + con.close() + + +def get(uid_number): + s = 'SELECT * FROM Alias WHERE uid_number=?' + with db_cursor() as cur: + rows = cur.execute(s, (uid_number,)) + result = [models.Alias(**r) for r in rows] + return result + + +def put(uid_number, email_name): + s = """INSERT INTO Alias(email_name, uid_number, status) + SELECT ?,?,? WHERE NOT EXISTS( + SELECT 1 FROM Alias WHERE email_name=? + )""" + email_name = models.sanitize_email_name(email_name) + # email_name cannot be the same as a user name + try: + pwd.getpwnam(email_name) + raise ValidationError(_('The alias was taken')) + except KeyError: + pass + + with db_cursor() as cur: + cur.execute(s, (email_name, uid_number, 1, email_name)) + if cur.rowcount == 0: + raise ValidationError(_('The alias was taken')) + + schedule_hash_update() + + +def delete(uid_number, alias_list): + s = 'DELETE FROM Alias WHERE uid_number=? AND email_name=?' + for i in range(len(alias_list)): + alias_list[i] = models.sanitize_email_name(alias_list[i]) + + parameter_seq = ((uid_number, a) for a in alias_list) + with db_cursor() as cur: + cur.execute('BEGIN') + cur.executemany(s, parameter_seq) + cur.execute('COMMIT') + schedule_hash_update() + + +def set_enabled(uid_number, alias_list): + return _set_status(uid_number, alias_list, 1) + + +def set_disabled(uid_number, alias_list): + return _set_status(uid_number, alias_list, 0) + + +def _set_status(uid_number, alias_list, status): + s = 'UPDATE Alias SET status=? WHERE uid_number=? AND email_name=?' + for i in range(len(alias_list)): + alias_list[i] = models.sanitize_email_name(alias_list[i]) + + parameter_seq = ((status, uid_number, a) for a in alias_list) + with db_cursor() as cur: + cur.execute('BEGIN') + cur.executemany(s, parameter_seq) + cur.execute('COMMIT') + schedule_hash_update() + + +def schedule_hash_update(): + tmp = hash_db_path + '-tmp' + with alias_sync_mutex.lock_all(), db_cursor() as cur: + all_aliases = cur.execute('SELECT * FROM Alias') + + # Delete the temp file if exists + if os.path.exists(tmp): + os.unlink(tmp) + + # Create new alias db at temp path + db = dbm.ndbm.open(tmp, 'c') + try: + for row in all_aliases: + alias = models.Alias(**row) + key = alias.email_name.encode('ascii') + b'\0' + if alias.enabled: + value = str(alias.uid_number).encode('ascii') + b'\0' + else: + value = b'/dev/null\0' + db[key] = value + finally: + db.close() + + # Atomically replace old alias db, rename(2) + os.rename(tmp + '.db', hash_db_path + '.db') + + +def first_setup(): + _create_db_schema_if_not_exists() + schedule_hash_update() + + +def _create_db_schema_if_not_exists(): + # Create folder + if not os.path.isdir(mailsrv_dir): + os.mkdir(mailsrv_dir) + # Create schema if not exists + with db_cursor() as cur: + cur.executescript(map_db_schema_script) + diff --git a/plinth/modules/email_server/aliases/models.py b/plinth/modules/email_server/aliases/models.py new file mode 100644 index 000000000..ac3277fe1 --- /dev/null +++ b/plinth/modules/email_server/aliases/models.py @@ -0,0 +1,31 @@ +import re +from dataclasses import dataclass, field, InitVar + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +email_positive_pattern = re.compile('^[a-zA-Z0-9-_\\.]+') + + +def sanitize_email_name(email_name): + email_name = email_name.strip().lower() + if len(email_name) < 2: + raise ValidationError(_('Must be at least 2 characters long')) + if not re.match('^[a-z0-9-_\\.]+$', email_name): + raise ValidationError(_('Contains illegal characters')) + if not re.match('^[a-z0-9].*[a-z0-9]$', email_name): + raise ValidationError(_('Must start and end with a-z or 0-9')) + if re.match('^[0-9]+$', email_name): + raise ValidationError(_('Cannot be a number')) + return email_name + + +@dataclass +class Alias: + uid_number: int + email_name: str + enabled: bool = field(init=False) + status: InitVar[int] + + def __post_init__(self, status): + self.enabled = (status != 0) diff --git a/plinth/modules/email_server/audit/ldap.py b/plinth/modules/email_server/audit/ldap.py index 790b8a953..f0af7a7bd 100644 --- a/plinth/modules/email_server/audit/ldap.py +++ b/plinth/modules/email_server/audit/ldap.py @@ -1,4 +1,5 @@ -"""Audit of LDAP and mail submission settings""" +"""Provides the diagnosis of SASL, mail submission, and user database lookup +configurations""" # SPDX-License-Identifier: AGPL-3.0-or-later import logging @@ -6,6 +7,7 @@ import logging from plinth import actions import plinth.modules.email_server.postconf as postconf +import plinth.modules.email_server.aliases as aliases from . import models default_config = { @@ -40,6 +42,12 @@ default_smtps_options = { 'smtpd_relay_restrictions': 'permit_sasl_authenticated,reject' } + +MAILSRV_DIR = '/var/lib/plinth/mailsrv' +ETC_ALIASES = 'hash:/etc/aliases' +BEFORE_ALIASES = 'ldap:/etc/postfix/freedombox-username-to-uid-number.cf' +AFTER_ALIASES = 'hash:' + aliases.hash_db_path + logger = logging.getLogger(__name__) @@ -50,19 +58,27 @@ def get(): GET /audit/ldap """ results = [] - with postconf.postconf_mutex.lock_all(): + with postconf.mutex.lock_all(): results.append(check_sasl()) + results.append(check_alias_maps()) + results.append(check_local_recipient_maps()) return results def repair(): - """Tries to repair LDAP and mail submission settings + """Tries to repair SASL, mail submission, and user lookup settings Recommended endpoint name: POST /audit/ldap/repair """ - actions.superuser_run('email_server', ['-i', 'ldap', 'set_sasl']) - actions.superuser_run('email_server', ['-i', 'ldap', 'set_submission']) + aliases.first_setup() + actions.superuser_run('email_server', ['-i', 'ldap', 'set_up']) + + +def action_set_up(): + action_set_sasl() + action_set_submission() + action_set_ulookup() def check_sasl(): @@ -79,13 +95,13 @@ def fix_sasl(diagnosis): def action_set_sasl(): - """Called by email_server ipc ldap set_sasl""" - with postconf.postconf_mutex.lock_all(): + """Handles email_server -i ldap set_sasl""" + with postconf.mutex.lock_all(): fix_sasl(check_sasl()) def action_set_submission(): - """Called by email_server ipc set_submission""" + """Handles email_server -i ldap set_submission""" logger.info('Set postfix service: %r', default_submission_options) postconf.set_master_cf_options(service_flags=submission_flags, options=default_submission_options) @@ -93,3 +109,96 @@ def action_set_submission(): logger.info('Set postfix service: %r', default_smtps_options) postconf.set_master_cf_options(service_flags=smtps_flags, options=default_smtps_options) + + +def check_alias_maps(): + """Check the ability to mail to usernames and user aliases""" + diagnosis = models.MainCfDiagnosis('Postfix alias maps') + + analysis = models.AliasMapsAnalysis() + analysis.parsed = postconf.parse_maps_by_key_unsafe('alias_maps') + analysis.isystem = list_find(analysis.parsed, ETC_ALIASES) + analysis.ibefore = list_find(analysis.parsed, BEFORE_ALIASES) + analysis.iafter = list_find(analysis.parsed, AFTER_ALIASES) + + if analysis.ibefore == -1 or analysis.iafter == -1: + diagnosis.flag_once('alias_maps', user=analysis) + diagnosis.critical('Required maps not in list') + if analysis.ibefore > analysis.iafter: + diagnosis.flag_once('alias_maps', user=analysis) + diagnosis.critical('Insecure map order') + + return diagnosis + + +def fix_alias_maps(diagnosis): + unresolved_issues = list(diagnosis.unresolved_issues()) + if 'alias_maps' in unresolved_issues: + analysis = diagnosis.user['alias_maps'] + # Delete *all* references to BEFORE_ALIASES and AFTER_ALIASES + for i in range(len(analysis.parsed)): + if analysis.parsed[i] in (BEFORE_ALIASES, AFTER_ALIASES): + analysis.parsed[i] = '' + # Does hash:/etc/aliases exist? + if analysis.isystem >= 0: + # Put the maps around hash:/etc/aliases + val = '%s %s %s' % (BEFORE_ALIASES, ETC_ALIASES, AFTER_ALIASES) + analysis.parsed[analysis.isystem] = val + else: + # To the end + analysis.parsed.append(BEFORE_ALIASES) + analysis.parsed.append(AFTER_ALIASES) + # List -> string + fixed = ' '.join(filter(None, analysis.parsed)) + diagnosis.advice['alias_maps'] = fixed + + diagnosis.assert_resolved() + logging.info('Setting postfix config: %r', diagnosis.advice) + postconf.set_many_unsafe(diagnosis.advice) + + +def check_local_recipient_maps(): + diagnosis = models.MainCfDiagnosis('Postfix local recipient maps') + lrcpt_maps = postconf.parse_maps_by_key_unsafe('local_recipient_maps') + list_modified = False + + # Block mails to system users + # local_recipient_maps must not contain proxy:unix:passwd.byname + ipasswd = list_find(lrcpt_maps, 'proxy:unix:passwd.byname') + if ipasswd >= 0: + diagnosis.critical('Mail to system users (/etc/passwd) possible') + # Propose a fix + lrcpt_maps[ipasswd] = '' + list_modified = True + + if list_modified: + fix = ' '.join(filter(None, lrcpt_maps)) + diagnosis.flag('local_recipient_maps', corrected_value=fix) + + return diagnosis + + +def fix_local_recipient_maps(diagnosis): + diagnosis.assert_resolved() + logging.info('Setting postfix config: %r', diagnosis.advice) + postconf.set_many_unsafe(diagnosis.advice) + + +def action_set_ulookup(): + """Handles email_server -i ldap set_ulookup""" + with postconf.mutex.lock_all(): + fix_alias_maps(check_alias_maps()) + fix_local_recipient_maps(check_local_recipient_maps()) + + +def list_find(lst, element, start=None, end=None): + if start is None: + start = 0 + if end is None: + end = len(lst) + if start < 0 or end < 0: + return -1 + try: + return lst.index(element, start, end) + except ValueError: + return -1 diff --git a/plinth/modules/email_server/audit/models.py b/plinth/modules/email_server/audit/models.py index a08efb0a2..9debf504e 100644 --- a/plinth/modules/email_server/audit/models.py +++ b/plinth/modules/email_server/audit/models.py @@ -1,6 +1,8 @@ """Audit models""" # SPDX-License-Identifier: AGPL-3.0-or-later +import dataclasses import logging +import typing logger = logging.getLogger(__name__) @@ -57,8 +59,15 @@ class MainCfDiagnosis(Diagnosis): self.user = {} def flag(self, key, corrected_value=None, user=None): - self.advice[key] = corrected_value - self.user[key] = user + if key in self.advice: + raise ValueError('Key has been flagged') + else: + self.advice[key] = corrected_value + self.user[key] = user + + def flag_once(self, key, **kwargs): + if key not in self.advice: + self.flag(key, **kwargs) def unresolved_issues(self): """Returns an interator of dictionary keys""" @@ -79,3 +88,14 @@ class MainCfDiagnosis(Diagnosis): unresolved issue""" if None in self.advice.values(): raise UnresolvedIssueError('Assertion failed') + + +@dataclasses.dataclass(init=False) +class AliasMapsAnalysis: + parsed = typing.List[str] + ibefore = int + isystem = int + iafter = int + + def __init__(self): + pass diff --git a/plinth/modules/email_server/audit/spam.py b/plinth/modules/email_server/audit/spam.py index f0ad26fe3..8a8f0c24b 100644 --- a/plinth/modules/email_server/audit/spam.py +++ b/plinth/modules/email_server/audit/spam.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) def get(): results = [] - with postconf.postconf_mutex.lock_all(): + with postconf.mutex.lock_all(): results.append(check_filter()) return results @@ -48,5 +48,5 @@ def fix_filter(diagnosis): def action_set_filter(): - with postconf.postconf_mutex.lock_all(): + with postconf.mutex.lock_all(): fix_filter(check_filter()) diff --git a/plinth/modules/email_server/data/etc/postfix/freedombox-username-to-uid-number.cf b/plinth/modules/email_server/data/etc/postfix/freedombox-username-to-uid-number.cf new file mode 100644 index 000000000..c8919becf --- /dev/null +++ b/plinth/modules/email_server/data/etc/postfix/freedombox-username-to-uid-number.cf @@ -0,0 +1,9 @@ +# This file is managed by FreedomBox + +# Map user name to UID number + +bind = no +server_host = ldap://127.0.0.1 +search_base = dc=thisbox +query_filter = (&(objectClass=posixAccount)(uid=%s)) +result_attribute = uidNumber diff --git a/plinth/modules/email_server/forms.py b/plinth/modules/email_server/forms.py index 4f6ecdac7..a38123a8a 100644 --- a/plinth/modules/email_server/forms.py +++ b/plinth/modules/email_server/forms.py @@ -1,6 +1,14 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from django import forms - +from django.utils.translation import ugettext_lazy as _ class EmailServerForm(forms.Form): - pass + domain = forms.CharField(label=_('domain'), max_length=256) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class AliasCreationForm(forms.Form): + email_name = forms.CharField(label=_('New alias (without @domain)'), + max_length=50) diff --git a/plinth/modules/email_server/manifest.py b/plinth/modules/email_server/manifest.py index 3a6bdae77..6426b50fe 100644 --- a/plinth/modules/email_server/manifest.py +++ b/plinth/modules/email_server/manifest.py @@ -50,3 +50,14 @@ clients = [{ 'url': store_url('google-play', 'eu.faircode.email') }] }] + +backup = { + 'data': { + 'directories': [ + '/var/lib/plinth/mailsrv', + '/etc/postfix', + '/etc/dovecot' + ] + }, + 'services': ['postfix', 'dovecot'] +} diff --git a/plinth/modules/email_server/postconf.py b/plinth/modules/email_server/postconf.py index caf443ea5..749e6f99d 100644 --- a/plinth/modules/email_server/postconf.py +++ b/plinth/modules/email_server/postconf.py @@ -6,7 +6,7 @@ import re import subprocess from .lock import Mutex -postconf_mutex = Mutex('email-postconf') +mutex = Mutex('email-postconf') @dataclasses.dataclass @@ -31,7 +31,7 @@ def get_many(key_list): Return a key-value map""" for key in key_list: validate_key(key) - with postconf_mutex.lock_all(): + with mutex.lock_all(): return get_many_unsafe(key_list) @@ -48,7 +48,7 @@ def set_many(kv_map): validate_key(key) validate_value(value) - with postconf_mutex.lock_all(): + with mutex.lock_all(): set_many_unsafe(kv_map) @@ -57,7 +57,7 @@ def set_many_unsafe(kv_map): set_unsafe(key, value) -def set_master_cf_options(service_flags, options): +def set_master_cf_options(service_flags, options={}): """Acquire resource lock. Set master.cf service options""" if not isinstance(service_flags, ServiceFlags): raise TypeError('service_flags') @@ -68,7 +68,7 @@ def set_master_cf_options(service_flags, options): service_slash_type = service_flags.service + '/' + service_flags.type flag_string = service_flags.serialize() - with postconf_mutex.lock_all(): + with mutex.lock_all(): # /sbin/postconf -M "service/type=flag_string" set_unsafe(service_slash_type, flag_string, '-M') for short_key, value in options.items(): @@ -93,6 +93,23 @@ def set_unsafe(key, value, flag=''): _run(['/sbin/postconf', '{}={}'.format(key, value)]) +def parse_maps(raw_value): + if '{' in raw_value or '}' in raw_value: + raise ValueError('Unsupported map list format') + + value_list = [] + for segment in raw_value.split(','): + for sub_segment in segment.strip().split(' '): + sub_segment = sub_segment.strip() + if sub_segment: + value_list.append(sub_segment) + return value_list + + +def parse_maps_by_key_unsafe(key): + return parse_maps(get_unsafe(key)) + + def _run(args): """Run process. Capture and return standard output as a string. Raise a RuntimeError on non-zero exit codes""" diff --git a/plinth/modules/email_server/templates/alias.html b/plinth/modules/email_server/templates/alias.html new file mode 100644 index 000000000..e8b072d49 --- /dev/null +++ b/plinth/modules/email_server/templates/alias.html @@ -0,0 +1,52 @@ +{% extends "app.html" %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + + {{ tabs|safe }} +

{% trans "Alias Management" %}

+ + {% if error %} + + {% endif %} + + {% if no_alias %} +

{% trans "You have no email aliases." %}

+ {% else %} +
+ {% csrf_token %} + {{ alias_boxes|safe }} + + + + + + + + +
+ {% endif %} + +

{% trans "Create a new email alias" %}

+ +
+ {% csrf_token %} + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/plinth/modules/email_server/urls.py b/plinth/modules/email_server/urls.py index 61d5844af..eae2a2d7c 100644 --- a/plinth/modules/email_server/urls.py +++ b/plinth/modules/email_server/urls.py @@ -1,8 +1,11 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from django.urls import path +from plinth.utils import non_admin_view from . import views urlpatterns = [ - path('apps/email_server/', views.EmailServerView.as_view(), name='index') + path('apps/email_server/', views.EmailServerView.as_view(), name='index'), + path('apps/email_server/my_aliases', + non_admin_view(views.AliasView.as_view()), name='my_aliases') ] diff --git a/plinth/modules/email_server/views.py b/plinth/modules/email_server/views.py index e27160939..ec8663a7b 100644 --- a/plinth/modules/email_server/views.py +++ b/plinth/modules/email_server/views.py @@ -1,18 +1,23 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import io +import itertools +import pwd import plinth.views -from django.shortcuts import render + +from django.core.exceptions import ValidationError from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ +from django.views.generic.base import TemplateView from . import forms +from . import aliases tabs = [ - ('', 'Home'), - ('alias', 'Alias'), - ('relay', 'Relay'), - ('security', 'Security') + ('', _('Home')), + ('alias', _('Alias')), + ('relay', _('Relay')), + ('security', _('Security')) ] @@ -34,6 +39,126 @@ class EmailServerView(plinth.views.AppView): return context +class AliasView(TemplateView): + class Checkboxes: + def __init__(self, post=None, initial=None): + self.models = initial + self.post = post + self.cleaned_data = {} + + def render(self): + if self.models is None: + raise RuntimeError('Uninitialized form') + sb = io.StringIO() + enabled = [a.email_name for a in self.models if a.enabled] + disabled = [a.email_name for a in self.models if not a.enabled] + + if len(enabled) > 0: + sb.write('
') + sb.write('%s' % escape(_('Enabled'))) + self._render_boxes(enabled, 'enabled', sb) + sb.write('
') + if len(disabled) > 0: + sb.write('
') + sb.write('%s' % escape(_('Disabled'))) + self._render_boxes(disabled, 'disabled', sb) + sb.write('
') + return sb.getvalue() + + @staticmethod + def _render_boxes(email_names, suffix, sb): + for i, email_name in enumerate(email_names): + input_id = 'cb_alias_%s_%d' % (suffix, i) + value = escape(email_name) + sb.write('
') + sb.write('' % (input_id, value)) + sb.write('' % (input_id, value)) + sb.write('
') + + def is_valid(self): + lst = list(filter(None, self.post.getlist('alias'))) + if not lst: + return False + else: + self.cleaned_data['alias'] = lst + return True + + template_name = 'alias.html' + form_classes = (forms.AliasCreationForm, Checkboxes) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['form'] = forms.AliasCreationForm() + + uid = pwd.getpwnam(self.request.user.username).pw_uid + models = aliases.get(uid) + if len(models) > 0: + form = AliasView.Checkboxes(initial=models) + context['alias_boxes'] = form.render() + else: + context['no_alias'] = True + return context + + def find_form(self, post): + form_name = post.get('form') + for cls in self.form_classes: + if cls.__name__ == form_name: + return cls(post) + raise ValidationError('Form was unspecified') + + def find_button(self, post): + key_filter = (k for k in post.keys() if k.startswith('btn_')) + lst = list(itertools.islice(key_filter, 2)) + if len(lst) != 1: + raise ValidationError('Bad post data') + if not isinstance(lst[0], str): + raise ValidationError('Bad post data') + return lst[0][len('btn_'):] + + def post(self, request): + try: + return self._post(request) + except ValidationError as e: + context = self.get_context_data() + context['error'] = e + return self.render_to_response(context, status=400) + + def _post(self, request): + form = self.find_form(request.POST) + button = self.find_button(request.POST) + if not form.is_valid(): + raise ValidationError('Form invalid') + + if isinstance(form, AliasView.Checkboxes): + if button not in ('delete', 'disable', 'enable'): + raise ValidationError('Bad button') + return self.alias_operation_form_valid(form, button) + + if isinstance(form, forms.AliasCreationForm): + if button != 'add': + raise ValidationError('Bad button') + return self.alias_creation_form_valid(form, button) + + raise RuntimeError('Unknown form') + + def alias_operation_form_valid(self, form, button): + uid = pwd.getpwnam(self.request.user.username).pw_uid + alias_list = form.cleaned_data['alias'] + if button == 'delete': + aliases.delete(uid, alias_list) + elif button == 'disable': + aliases.set_disabled(uid, alias_list) + elif button == 'enable': + aliases.set_enabled(uid, alias_list) + return self.render_to_response(self.get_context_data()) + + def alias_creation_form_valid(self, form, button): + uid = pwd.getpwnam(self.request.user.username).pw_uid + aliases.put(uid, form.cleaned_data['email_name']) + return self.render_to_response(self.get_context_data()) + + def render_tabs(request): sb = io.StringIO() sb.write('')