diff --git a/Dockerfile b/Dockerfile index b6c6200..08890bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ -FROM python:3.8.1 +FROM python:3.8.12-slim-buster RUN apt-get -y update && apt-get -y upgrade && \ - apt-get install --no-install-recommends -y wait-for-it postgresql-client + apt-get install --no-install-recommends -y wait-for-it lsb-release wget gnupg2 gcc libpq-dev libc-dev + +# install psql 13 (11 is the default with debian) +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" |sudo tee /etc/apt/sources.list.d/pgdg.list +RUN apt-get update && apt-get --no-install-recommends -y install postgresql-client-13 && apt-get clean all WORKDIR /app diff --git a/Dockerfile.dev b/Dockerfile.dev index daf0672..36b3738 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,12 @@ -FROM python:3.8.1 +FROM python:3.8.12-slim-buster RUN apt-get -y update && apt-get -y upgrade && \ - apt-get install --no-install-recommends -y wait-for-it postgresql-client vim + apt-get install --no-install-recommends -y wait-for-it lsb-release wget gnupg2 gcc libpq-dev libc-dev vim + +# install psql 13 (11 is the default with debian) +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list +RUN apt-get update && apt-get --no-install-recommends -y install postgresql-client-13 && apt-get clean all WORKDIR /app diff --git a/Makefile b/Makefile index 60bcd90..1306623 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,17 @@ run-bash: | build-dev docker-compose -f dc.dev.yml exec -u $$(id -u $${USER}):$$(id -g $${USER}) max bash || true docker-compose -f dc.dev.yml down +run-bash-root: | build-dev + MAX_COMMAND="sleep infinity" docker-compose -f dc.dev.yml up -d + docker-compose -f dc.dev.yml exec max "./entrypoint-dev.sh" + docker-compose -f dc.dev.yml exec max bash || true + docker-compose -f dc.dev.yml down + stop-dev: docker-compose -f dc.dev.yml down run-cleanup: | build-dev - docker run -ti -v `pwd`/max:/app/max max-dev ./cleanup.sh + docker run -ti -v `pwd`/max:/app/max --entrypoint="" max-dev ./cleanup.sh # PROD diff --git a/max/db/aliases.py b/max/db/aliases.py index fca41c9..15792db 100644 --- a/max/db/aliases.py +++ b/max/db/aliases.py @@ -12,6 +12,7 @@ class Alias(NoteLinesMixin): email: str destination_user_id: int enabled: bool + can_receive: bool note: str @@ -35,8 +36,9 @@ def get_aliases(cur, user_id): def get_alias_with_user_by_id(cur, alias_id): cur.execute( """\ - SELECT a.id, a.source_email_id, ea.email, a.destination_user_id, a.enabled, a.note, - u.id, u.email_id, eu.email, u.passwordhash, u.enabled, u.is_admin, u.note + SELECT a.id, a.source_email_id, ea.email, a.destination_user_id, a.enabled, + a.can_receive, a.note, + u.id, u.email_id, eu.email, u.passwordhash, u.enabled, u.can_receive, u.is_admin, u.note FROM aliases a INNER JOIN emails ea ON a.source_email_id=ea.id INNER JOIN users u ON a.destination_user_id=u.id @@ -47,23 +49,23 @@ def get_alias_with_user_by_id(cur, alias_id): row = cur.fetchone() if not row: return None - return Alias(*row[:6]), User(*row[6:]) + return Alias(*row[:7]), User(*row[7:]) @with_cursor -def create_alias(cur, email, user, enabled, note): +def create_alias(cur, email, user, enabled, can_receive, note): email_id = create_email(cur, email) cur.execute( """ - INSERT INTO aliases (source_email_id, destination_user_id, enabled, note) - VALUES (%s, %s, %s, %s)""", - [email_id, user.id, enabled, note], + INSERT INTO aliases (source_email_id, destination_user_id, enabled, can_receive, note) + VALUES (%s, %s, %s, %s, %s)""", + [email_id, user.id, enabled, can_receive, note], ) @with_cursor def update_alias(cur, id, **fields): - """updates enabled and note""" + """updates enabled, can_receive and note""" cur.execute( "UPDATE aliases SET " + ", ".join(f"{field}=%s" for field in fields.keys()) diff --git a/max/db/common.py b/max/db/common.py index 4a14b32..a7ba54c 100644 --- a/max/db/common.py +++ b/max/db/common.py @@ -6,15 +6,17 @@ class EmailAlreadyExists(Exception): class NoteLinesMixin: def note_lines(self): - return [line.strip() for line in self.note.split("\n")] + if not self.note.strip(): + return [] + return [line.strip() for line in self.note.strip().split("\n")] def create_email(cur, email): - cur.execute("SELECT EXISTS (SELECT 1 FROM emails WHERE email=%s)", [email]) + cur.execute("SELECT EXISTS (SELECT 1 FROM emails WHERE email=lower(%s))", [email]) email_exists = cur.fetchone()[0] if email_exists: - cur.execute("SELECT id FROM emails WHERE email=%s", [email]) + cur.execute("SELECT id FROM emails WHERE email=lower(%s)", [email]) email_id = cur.fetchone()[0] # Get user? @@ -35,6 +37,8 @@ def create_email(cur, email): raise EmailAlreadyExists("Used as an user.") else: - cur.execute("INSERT INTO emails (email) VALUES (%s) RETURNING id", [email]) + cur.execute( + "INSERT INTO emails (email) VALUES (lower(%s)) RETURNING id", [email] + ) email_id = cur.fetchone()[0] return email_id diff --git a/max/db/schema.sql b/max/db/schema.sql index 3fbb7be..2da7de4 100644 --- a/max/db/schema.sql +++ b/max/db/schema.sql @@ -7,20 +7,26 @@ CREATE TABLE IF NOT EXISTS emails ( email varchar(255) NOT NULL UNIQUE ); +-- Make emails unique by lower-case comparison +CREATE UNIQUE INDEX email_unique_idx on emails (LOWER(email)); + CREATE TABLE IF NOT EXISTS users ( id serial PRIMARY KEY, email_id integer REFERENCES emails(id) ON DELETE RESTRICT NOT NULL UNIQUE, passwordhash varchar(255) NOT NULL, enabled boolean NOT NULL DEFAULT TRUE, + can_receive boolean NOT NULL DEFAULT TRUE, is_admin boolean NOT NULL DEFAULT FALSE, note text NOT NULL DEFAULT '' ); +-- source=me2@finn.st destination=me@finn.st: me2 is an alias of me CREATE TABLE IF NOT EXISTS aliases ( id serial PRIMARY KEY, source_email_id integer REFERENCES emails(id) ON DELETE RESTRICT NOT NULL, destination_user_id integer REFERENCES users(id) ON DELETE RESTRICT NOT NULL, enabled boolean NOT NULL DEFAULT TRUE, + can_receive boolean NOT NULL DEFAULT TRUE, note text, UNIQUE(source_email_id, destination_user_id) ); diff --git a/max/db/users.py b/max/db/users.py index 34538de..e68babb 100644 --- a/max/db/users.py +++ b/max/db/users.py @@ -11,6 +11,7 @@ class User(NoteLinesMixin): email: str passwordhash: str enabled: bool + can_receive: bool is_admin: bool note: str @@ -52,7 +53,7 @@ def get_users(cur): @with_cursor def update_user(cur, id, **fields): - """only pwhash, enabled, is_admin, note""" + """only pwhash, enabled, can_receive, is_admin, note""" cur.execute( "UPDATE users SET " + ", ".join(f"{field}=%s" for field in fields.keys()) @@ -62,13 +63,13 @@ def update_user(cur, id, **fields): @with_cursor -def create_user(cur, email, passwordhash, enabled, is_admin, note): +def create_user(cur, email, passwordhash, enabled, can_receive, is_admin, note): email_id = create_email(cur, email) cur.execute( """\ - INSERT INTO users (email_id, passwordhash, enabled, is_admin, note) - VALUES (%s, %s, %s, %s, %s)""", - [email_id, passwordhash, enabled, is_admin, note], + INSERT INTO users (email_id, passwordhash, enabled, can_receive, is_admin, note) + VALUES (%s, %s, %s, %s, %s, %s)""", + [email_id, passwordhash, enabled, can_receive, is_admin, note], ) @@ -92,8 +93,8 @@ def reset_or_create_user(cur, email, hash, is_admin): user_id = user_id[0] # update User cur.execute( - "UPDATE users SET passwordhash=%s, is_admin=%s, enabled=%s WHERE id=%s", - [hash, is_admin, True, user_id], + "UPDATE users SET passwordhash=%s, is_admin=%s, enabled=%s can_receive=%s WHERE id=%s", # noqa + [hash, is_admin, True, True, user_id], ) print( f"reset password for {email}. Set admin={is_admin} and enabled the user." @@ -105,8 +106,8 @@ def reset_or_create_user(cur, email, hash, is_admin): cur.execute("INSERT INTO emails (email) VALUES (%s) RETURNING id", [email]) email_id = cur.fetchone()[0] cur.execute( - "INSERT INTO users (email_id, passwordhash, enabled, is_admin, note) VALUES (%s, %s, %s, %s, %s)", # noqa - [email_id, hash, True, is_admin, "Created by commandline"], + "INSERT INTO users (email_id, passwordhash, enabled, can_receive, is_admin, note) VALUES (%s, %s, %s, %s, %s, %s)", # noqa + [email_id, hash, True, True, is_admin, "Created by commandline"], ) user_type = "admin" if is_admin else "user" print(f"Created {user_type} {email}") diff --git a/max/middlewares.py b/max/middlewares.py index 9ebbf87..5f6757f 100644 --- a/max/middlewares.py +++ b/max/middlewares.py @@ -58,7 +58,7 @@ class ExceptionMiddleware(BaseMiddleware): def __call__(self, *args, **kwargs): try: return self.handler(*args, **kwargs) - except (NotFound, PermissionDenied) as e: + except (NotFound, PermissionDenied): notFoundView = NotFoundView(**kwargs) return notFoundView.render(), 404 diff --git a/max/not_found_view.py b/max/not_found_view.py index e23b5c1..4f61a31 100644 --- a/max/not_found_view.py +++ b/max/not_found_view.py @@ -1,6 +1,6 @@ from max.permissions import AllowAnyAccess -from max.views import BaseTemplateGetView from max.translations import t +from max.views import BaseTemplateGetView class NotFoundView(AllowAnyAccess, BaseTemplateGetView): diff --git a/max/private_static/i18n/de.json b/max/private_static/i18n/de.json index 20463e1..892857e 100644 --- a/max/private_static/i18n/de.json +++ b/max/private_static/i18n/de.json @@ -19,6 +19,7 @@ "Create new user": "Neuen Nutzer erstellen", "Create": "Erstellen", "Enabled": "Aktiviert", + "Can receive emails": "Kann Emails empfangen", "Admin": "Admin", "Note": "Notiz", "Delete {user}?": "{user} löschen?", @@ -47,6 +48,7 @@ "The domain is invalid. Must be one of: {domains}": "Die Domain ist nacht valide. Dies sind die erlaubten Domains: {domains}", "Email must be given": "Die Email muss gegeben sein", "Enabled is not set": "Aktiviert ist nicht gesetzt", + "Can receive is not set": "Kann empfangen ist nicht gesetzt", "Note must be a string": "Die Notiz muss eine Zeichenkette sein", "Creation of alias {email} was successful!": "Erstellen des Alias {email} war erfolgreich!", "Alias {email} was deleted successfully": "Alias {email} wurde erfolgreich gelöscht", diff --git a/max/routes/__init__.py b/max/routes/__init__.py index bd76a20..e79e2e1 100644 --- a/max/routes/__init__.py +++ b/max/routes/__init__.py @@ -5,6 +5,7 @@ from .alias.create import AliasCreate from .alias.delete import AliasDelete from .alias.detail import AliasDetail from .alias.edit_note import AliasEditNote +from .alias.toggle_can_receive import AliasToggleCanReceive from .alias.toggle_enabled import AliasToggleEnabled from .favicon import Favicon from .robots import RobotsTXT @@ -15,6 +16,7 @@ from .user.detail import UserDetail from .user.edit_note import UserEditNote from .user.list import UserList from .user.toggle_admin import UserToggleAdmin +from .user.toggle_can_receive import UserToggleCanReceive from .user.toggle_enabled import UserToggleEnabled @@ -36,6 +38,11 @@ def init_routes(app): "user-toggle-enabled", UserToggleEnabled.as_view(), ) + app.add_url_rule( + "/user//toggle-can-receive", + "user-toggle-can-receive", + UserToggleCanReceive.as_view(), + ) app.add_url_rule( "/user//toggle-admin", "user-toggle-admin", @@ -57,6 +64,11 @@ def init_routes(app): "alias-toggle-enabled", AliasToggleEnabled.as_view(), ) + app.add_url_rule( + "/alias//toggle-can-receive", + "alias-toggle-can-receive", + AliasToggleCanReceive.as_view(), + ) app.add_url_rule( "/robots.txt", diff --git a/max/routes/alias/create.py b/max/routes/alias/create.py index c2bd87b..f87695c 100644 --- a/max/routes/alias/create.py +++ b/max/routes/alias/create.py @@ -38,6 +38,15 @@ class AliasCreate( flash(t("Enabled is not set"), category="error") error = True + can_receive = self.request.form.get("can_receive") + if can_receive == "on": + self.can_receive = True + elif can_receive is None: + self.can_receive = False + else: + flash(t("Can receive is not set"), category="error") + error = True + note = self.request.form.get("note", "") if not isinstance(note, str): flash(t("Note must be a string"), category="error") @@ -49,7 +58,9 @@ class AliasCreate( return # force re-rendering try: - create_alias(self.email, self.user, self.enabled, self.note) + create_alias( + self.email, self.user, self.enabled, self.can_receive, self.note + ) except EmailAlreadyExists as e: if self.auth_user.is_admin: flash( @@ -68,7 +79,7 @@ class AliasCreate( def get_context(self): context = super().get_context() - for key in ("email", "enabled", "note"): + for key in ("email", "enabled", "can_receive", "note"): if hasattr(self, key): context[key] = getattr(self, key) return context diff --git a/max/routes/alias/toggle_can_receive.py b/max/routes/alias/toggle_can_receive.py new file mode 100644 index 0000000..4bad750 --- /dev/null +++ b/max/routes/alias/toggle_can_receive.py @@ -0,0 +1,14 @@ +from max.db import update_alias +from max.permissions import AllowAdminOrSelf +from max.views import BaseView + +from ..base_alias_views import BackToAliasesMixin, FetchAliasMixin + + +class AliasToggleCanReceive( + AllowAdminOrSelf, FetchAliasMixin, BackToAliasesMixin, BaseView +): + def post(self): + update_alias(self.alias.id, can_receive=(not self.alias.can_receive)) + _, back_url = self.get_back_text_and_url() + return self.redirect(back_url) diff --git a/max/routes/user/create.py b/max/routes/user/create.py index ac2cce3..ac27839 100644 --- a/max/routes/user/create.py +++ b/max/routes/user/create.py @@ -48,6 +48,15 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BackToUsersMixin, BaseTemplateGetV flash(t("Enabled is not set"), category="error") error = True + can_receive = self.request.form.get("can_receive") + if can_receive == "on": + self.can_receive = True + elif can_receive is None: + self.can_receive = False + else: + flash(t("Can receive is not set"), category="error") + error = True + is_admin = self.request.form.get("is_admin") if is_admin == "on": self.is_admin = True diff --git a/max/routes/user/toggle_can_receive.py b/max/routes/user/toggle_can_receive.py new file mode 100644 index 0000000..edb0bf3 --- /dev/null +++ b/max/routes/user/toggle_can_receive.py @@ -0,0 +1,12 @@ +from max.db import update_user +from max.permissions import AllowAdmin +from max.views import BaseView + +from ..base_user_views import BackToUsersMixin, FetchUserMixin + + +class UserToggleCanReceive(AllowAdmin, FetchUserMixin, BackToUsersMixin, BaseView): + def post(self): + update_user(self.user.id, can_receive=(not self.user.can_receive)) + _, back_url = self.get_back_text_and_url() + return self.redirect(back_url) diff --git a/max/static/base.css b/max/static/base.css index 60fe724..704ee09 100644 --- a/max/static/base.css +++ b/max/static/base.css @@ -144,14 +144,21 @@ form { margin-bottom: 8px; } .detail b { - width: 4.5em; display: inline-block; } +.detail .note a { + margin-right: 1em; + margin-left: 1em; + vertical-align: top; +} .detail .note b { + margin-right: 1em; + margin-top: 0.5em; vertical-align: top; } .detail .note div { display: inline-block; + margin-top: 0.5em; } .detail form { margin: 0; @@ -174,6 +181,9 @@ form { margin-top: 0; margin-bottom: 0.5em; } +.connection-properties .indent { + margin-left: 1.5em; +} .checkbox { padding: 8px; diff --git a/max/templates/alias/create.html b/max/templates/alias/create.html index 5945607..5800897 100644 --- a/max/templates/alias/create.html +++ b/max/templates/alias/create.html @@ -17,6 +17,13 @@ +
+ +
+
diff --git a/max/templates/alias/detail.html b/max/templates/alias/detail.html index 6b45485..7640707 100644 --- a/max/templates/alias/detail.html +++ b/max/templates/alias/detail.html @@ -8,13 +8,19 @@
{{ t("Enabled") }}: {{ macros.checkbox(alias, "enabled", url_for('alias-toggle-enabled', alias_id=alias.id, return='detail')) }} -
+
+
+ {{ t("Can receive emails") }}: + {{ macros.checkbox(alias, "can_receive", url_for('alias-toggle-can-receive', alias_id=alias.id, return='detail')) }} +
+
{{ t("Note") }}:
{{ macros.format_note(alias) }}
{{ t("Edit") }} -
+
+
{{ t("Delete") }} diff --git a/max/templates/user/create.html b/max/templates/user/create.html index bf3262d..271f24d 100644 --- a/max/templates/user/create.html +++ b/max/templates/user/create.html @@ -27,6 +27,13 @@
+
+ +
+
+
+ {{ t("Can receive emails") }}: + {{ macros.checkbox(user, "can_receive", url_for('user-toggle-can-receive', user_id=user.id, return='detail')) }} +
{{ t("Admin") }}: {{ macros.checkbox(user, "is_admin", url_for('user-toggle-admin', user_id=user.id, return='detail')) }} @@ -53,10 +57,14 @@
{{ t("Email connection properties") }} -

IMAP: mail.finn.st, Port: 993

-

username: {{ user.email }}

-

Connection security: SSL/TLS

-

Authentication method: password

+

IMAP: finn.st, Port 993

+

Username: {{ user.email }}

+

Connection security: SSL/TLS

+

Authentication method: password

+

SMTP: finn.st, Port 587

+

Username: {{ user.email }}

+

Connection security: STARTTLS

+

Authentication method: password

{% if aliases|length > 0 %} @@ -66,6 +74,7 @@ {{ t("Email") }} {{ t("Enabled") }} + {{ t("Can receive emails") }} {{ t("Note") }} {{ t("Actions") }} @@ -80,6 +89,9 @@ {{ macros.checkbox(alias, "enabled", url_for('alias-toggle-enabled', alias_id=alias.id)) }} + + {{ macros.checkbox(alias, "can_receive", url_for('alias-toggle-can-receive', alias_id=alias.id)) }} + {{ macros.format_note(alias) }}
diff --git a/max/templates/user/list.html b/max/templates/user/list.html index 64bf1dc..cd748e7 100644 --- a/max/templates/user/list.html +++ b/max/templates/user/list.html @@ -20,6 +20,7 @@ {{ t("Email") }} {{ t("Enabled") }} + {{ t("Can receive emails") }} {{ t("Admin") }} {{ t("Note") }} {{ t("Actions") }} @@ -35,6 +36,9 @@ {{ macros.checkbox(user, "enabled", url_for('user-toggle-enabled', user_id=user.id)) }} + + {{ macros.checkbox(user, "can_receive", url_for('user-toggle-can-receive', user_id=user.id)) }} + {{ macros.checkbox(user, "is_admin", url_for('user-toggle-admin', user_id=user.id)) }} diff --git a/scripts/psql.sh b/scripts/psql.sh new file mode 100755 index 0000000..738d8fc --- /dev/null +++ b/scripts/psql.sh @@ -0,0 +1,2 @@ +#!/bin/bash +PGPASSWORD="$PGSQL_PASSWORD" psql -h "$PGSQL_HOST" -p "$PGSQL_PORT" -U "$PGSQL_USER" -d "$PGSQL_NAME"