email: Implement alias management

- Separate alias database from system
- Block mail to system users, without backscatter
- Alias management UI for non-admin users
- Enabling/Disabling aliases (mails to /dev/null)

Misc. changes

- Daemon management
- Backup information
- Postconf diagnostics interface
This commit is contained in:
fliu 2021-07-14 00:27:07 +00:00 committed by Sunil Mohan Adapa
parent a9ac51eb7b
commit 4375828703
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
13 changed files with 570 additions and 30 deletions

View File

@ -17,9 +17,10 @@ from . import manifest
version = 1 version = 1
managed_packages = ['postfix', 'dovecot-pop3d', 'dovecot-imapd', managed_packages = ['postfix', 'dovecot-pop3d', 'dovecot-imapd',
'dovecot-lmtpd', 'dovecot-ldap', 'dovecot-managesieved', 'dovecot-ldap', 'dovecot-lmtpd', 'dovecot-managesieved',
'rspamd'] 'rspamd', 'clamav', 'clamav-daemon']
managed_services = ['postfix', 'dovecot'] managed_services = ['postfix', 'dovecot', 'rspamd', 'redis', 'clamav-daemon',
'clamav-freshclam']
app = None app = None
@ -62,7 +63,7 @@ class EmailServerApp(plinth.app.App):
name=info.name, name=info.name,
short_description=info.short_description, short_description=info.short_description,
icon='roundcube', icon='roundcube',
configure_url=reverse_lazy('email_server'), url=reverse_lazy('email_server:my_aliases'),
clients=manifest.clients, clients=manifest.clients,
login_required=True login_required=True
) )
@ -85,6 +86,9 @@ class EmailServerApp(plinth.app.App):
listen_ports=dovecot_ports) listen_ports=dovecot_ports)
self.add(postfixd) self.add(postfixd)
self.add(dovecotd) self.add(dovecotd)
for name in ('rspamd', 'redis', 'clamav-daemon', 'clamav-freshclam'):
daemon = plinth.daemon.Daemon('daemon-' + name, name)
self.add(daemon)
# Ports # Ports
firewall = Firewall('firewall-email', info.name, firewall = Firewall('firewall-email', info.name,

View File

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

View File

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

View File

@ -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 # SPDX-License-Identifier: AGPL-3.0-or-later
import logging import logging
@ -6,6 +7,7 @@ import logging
from plinth import actions from plinth import actions
import plinth.modules.email_server.postconf as postconf import plinth.modules.email_server.postconf as postconf
import plinth.modules.email_server.aliases as aliases
from . import models from . import models
default_config = { default_config = {
@ -40,6 +42,12 @@ default_smtps_options = {
'smtpd_relay_restrictions': 'permit_sasl_authenticated,reject' '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__) logger = logging.getLogger(__name__)
@ -50,19 +58,27 @@ def get():
GET /audit/ldap GET /audit/ldap
""" """
results = [] results = []
with postconf.postconf_mutex.lock_all(): with postconf.mutex.lock_all():
results.append(check_sasl()) results.append(check_sasl())
results.append(check_alias_maps())
results.append(check_local_recipient_maps())
return results return results
def repair(): def repair():
"""Tries to repair LDAP and mail submission settings """Tries to repair SASL, mail submission, and user lookup settings
Recommended endpoint name: Recommended endpoint name:
POST /audit/ldap/repair POST /audit/ldap/repair
""" """
actions.superuser_run('email_server', ['-i', 'ldap', 'set_sasl']) aliases.first_setup()
actions.superuser_run('email_server', ['-i', 'ldap', 'set_submission']) 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(): def check_sasl():
@ -79,13 +95,13 @@ def fix_sasl(diagnosis):
def action_set_sasl(): def action_set_sasl():
"""Called by email_server ipc ldap set_sasl""" """Handles email_server -i ldap set_sasl"""
with postconf.postconf_mutex.lock_all(): with postconf.mutex.lock_all():
fix_sasl(check_sasl()) fix_sasl(check_sasl())
def action_set_submission(): 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) logger.info('Set postfix service: %r', default_submission_options)
postconf.set_master_cf_options(service_flags=submission_flags, postconf.set_master_cf_options(service_flags=submission_flags,
options=default_submission_options) options=default_submission_options)
@ -93,3 +109,96 @@ def action_set_submission():
logger.info('Set postfix service: %r', default_smtps_options) logger.info('Set postfix service: %r', default_smtps_options)
postconf.set_master_cf_options(service_flags=smtps_flags, postconf.set_master_cf_options(service_flags=smtps_flags,
options=default_smtps_options) 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

View File

@ -1,6 +1,8 @@
"""Audit models""" """Audit models"""
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import dataclasses
import logging import logging
import typing
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -57,8 +59,15 @@ class MainCfDiagnosis(Diagnosis):
self.user = {} self.user = {}
def flag(self, key, corrected_value=None, user=None): def flag(self, key, corrected_value=None, user=None):
self.advice[key] = corrected_value if key in self.advice:
self.user[key] = user 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): def unresolved_issues(self):
"""Returns an interator of dictionary keys""" """Returns an interator of dictionary keys"""
@ -79,3 +88,14 @@ class MainCfDiagnosis(Diagnosis):
unresolved issue""" unresolved issue"""
if None in self.advice.values(): if None in self.advice.values():
raise UnresolvedIssueError('Assertion failed') raise UnresolvedIssueError('Assertion failed')
@dataclasses.dataclass(init=False)
class AliasMapsAnalysis:
parsed = typing.List[str]
ibefore = int
isystem = int
iafter = int
def __init__(self):
pass

View File

@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
def get(): def get():
results = [] results = []
with postconf.postconf_mutex.lock_all(): with postconf.mutex.lock_all():
results.append(check_filter()) results.append(check_filter())
return results return results
@ -48,5 +48,5 @@ def fix_filter(diagnosis):
def action_set_filter(): def action_set_filter():
with postconf.postconf_mutex.lock_all(): with postconf.mutex.lock_all():
fix_filter(check_filter()) fix_filter(check_filter())

View File

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

View File

@ -1,6 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _
class EmailServerForm(forms.Form): 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)

View File

@ -50,3 +50,14 @@ clients = [{
'url': store_url('google-play', 'eu.faircode.email') 'url': store_url('google-play', 'eu.faircode.email')
}] }]
}] }]
backup = {
'data': {
'directories': [
'/var/lib/plinth/mailsrv',
'/etc/postfix',
'/etc/dovecot'
]
},
'services': ['postfix', 'dovecot']
}

View File

@ -6,7 +6,7 @@ import re
import subprocess import subprocess
from .lock import Mutex from .lock import Mutex
postconf_mutex = Mutex('email-postconf') mutex = Mutex('email-postconf')
@dataclasses.dataclass @dataclasses.dataclass
@ -31,7 +31,7 @@ def get_many(key_list):
Return a key-value map""" Return a key-value map"""
for key in key_list: for key in key_list:
validate_key(key) validate_key(key)
with postconf_mutex.lock_all(): with mutex.lock_all():
return get_many_unsafe(key_list) return get_many_unsafe(key_list)
@ -48,7 +48,7 @@ def set_many(kv_map):
validate_key(key) validate_key(key)
validate_value(value) validate_value(value)
with postconf_mutex.lock_all(): with mutex.lock_all():
set_many_unsafe(kv_map) set_many_unsafe(kv_map)
@ -57,7 +57,7 @@ def set_many_unsafe(kv_map):
set_unsafe(key, value) 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""" """Acquire resource lock. Set master.cf service options"""
if not isinstance(service_flags, ServiceFlags): if not isinstance(service_flags, ServiceFlags):
raise TypeError('service_flags') 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 service_slash_type = service_flags.service + '/' + service_flags.type
flag_string = service_flags.serialize() flag_string = service_flags.serialize()
with postconf_mutex.lock_all(): with mutex.lock_all():
# /sbin/postconf -M "service/type=flag_string" # /sbin/postconf -M "service/type=flag_string"
set_unsafe(service_slash_type, flag_string, '-M') set_unsafe(service_slash_type, flag_string, '-M')
for short_key, value in options.items(): for short_key, value in options.items():
@ -93,6 +93,23 @@ def set_unsafe(key, value, flag=''):
_run(['/sbin/postconf', '{}={}'.format(key, value)]) _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): def _run(args):
"""Run process. Capture and return standard output as a string. Raise a """Run process. Capture and return standard output as a string. Raise a
RuntimeError on non-zero exit codes""" RuntimeError on non-zero exit codes"""

View File

@ -0,0 +1,52 @@
{% extends "app.html" %}
{% load bootstrap %}
{% load i18n %}
{% block configuration %}
{{ tabs|safe }}
<h3>{% trans "Alias Management" %}</h3>
{% if error %}
<div class="alert alert-danger" role="alert">
<p>
{% trans "There was a problem with your request. Please try again." %}
</p>
{% for message in error %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% if no_alias %}
<p>{% trans "You have no email aliases." %}</p>
{% else %}
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ alias_boxes|safe }}
<input type="hidden" name="form" value="Checkboxes">
<input class="btn btn-secondary" type="submit" name="btn_disable"
value="{% trans 'Disable selected' %}">
<input class="btn btn-secondary" type="submit" name="btn_enable"
value="{% trans 'Enable selected' %}">
<input class="btn btn-danger" type="submit" name="btn_delete"
value="{% trans 'Delete selected' %}">
</form>
{% endif %}
<h4>{% trans "Create a new email alias" %}</h4>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="hidden" name="form" value="AliasCreationForm">
<input class="btn btn-primary" type="submit" name="btn_add"
value="{% trans 'Add' %}">
</form>
{% endblock %}

View File

@ -1,8 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
from django.urls import path from django.urls import path
from plinth.utils import non_admin_view
from . import views from . import views
urlpatterns = [ 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')
] ]

View File

@ -1,18 +1,23 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import io import io
import itertools
import pwd
import plinth.views import plinth.views
from django.shortcuts import render
from django.core.exceptions import ValidationError
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic.base import TemplateView
from . import forms from . import forms
from . import aliases
tabs = [ tabs = [
('', 'Home'), ('', _('Home')),
('alias', 'Alias'), ('alias', _('Alias')),
('relay', 'Relay'), ('relay', _('Relay')),
('security', 'Security') ('security', _('Security'))
] ]
@ -34,6 +39,126 @@ class EmailServerView(plinth.views.AppView):
return context 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('<fieldset>')
sb.write('<legend>%s</legend>' % escape(_('Enabled')))
self._render_boxes(enabled, 'enabled', sb)
sb.write('</fieldset>')
if len(disabled) > 0:
sb.write('<fieldset>')
sb.write('<legend>%s</legend>' % escape(_('Disabled')))
self._render_boxes(disabled, 'disabled', sb)
sb.write('</fieldset>')
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('<div>')
sb.write('<input type="checkbox" name="alias" ')
sb.write('id="%s" value="%s">' % (input_id, value))
sb.write('<label for="%s">%s</label>' % (input_id, value))
sb.write('</div>')
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): def render_tabs(request):
sb = io.StringIO() sb = io.StringIO()
sb.write('<ul class="nav nav-tabs">') sb.write('<ul class="nav nav-tabs">')
@ -49,7 +174,7 @@ def render_tabs(request):
sb.write('<li class="nav-item">') sb.write('<li class="nav-item">')
sb.write('<a class="nav-link {cls}" href="{href}">{text}</a>'.format( sb.write('<a class="nav-link {cls}" href="{href}">{text}</a>'.format(
cls=cls, href=href, text=escape(_(link_text)) cls=cls, href=href, text=escape(link_text)
)) ))
sb.write('</li>') sb.write('</li>')
sb.write('</ul>') sb.write('</ul>')