mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-09-19 04:59:01 +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
|
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,
|
||||||
|
|||||||
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
|
# 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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
|
# 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)
|
||||||
|
|||||||
@ -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']
|
||||||
|
}
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
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
|
# 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')
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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>')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user