mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-04-22 10:01:45 +00:00
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:
parent
a9ac51eb7b
commit
4375828703
@ -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,
|
||||
|
||||
151
plinth/modules/email_server/aliases/__init__.py
Normal file
151
plinth/modules/email_server/aliases/__init__.py
Normal 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)
|
||||
|
||||
31
plinth/modules/email_server/aliases/models.py
Normal file
31
plinth/modules/email_server/aliases/models.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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']
|
||||
}
|
||||
|
||||
@ -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"""
|
||||
|
||||
52
plinth/modules/email_server/templates/alias.html
Normal file
52
plinth/modules/email_server/templates/alias.html
Normal 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 %}
|
||||
@ -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')
|
||||
]
|
||||
|
||||
@ -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('<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):
|
||||
sb = io.StringIO()
|
||||
sb.write('<ul class="nav nav-tabs">')
|
||||
@ -49,7 +174,7 @@ def render_tabs(request):
|
||||
|
||||
sb.write('<li class="nav-item">')
|
||||
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('</ul>')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user