diff --git a/app/dashboard/templates/dashboard/mailbox_detail.html b/app/dashboard/templates/dashboard/mailbox_detail.html index 0c763d5f..648e1df4 100644 --- a/app/dashboard/templates/dashboard/mailbox_detail.html +++ b/app/dashboard/templates/dashboard/mailbox_detail.html @@ -6,6 +6,17 @@ Mailbox {{ mailbox.email }} {% endblock %} +{% block head %} + +{% endblock %} + + {% block default_content %}
@@ -72,7 +83,7 @@
Pretty Good Privacy (PGP) -
+
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are encrypted with your key.
@@ -106,6 +117,45 @@
+
+
+ + +
+
+ Hide email subject when PGP is enabled +
+ When PGP is enabled, you can choose to use a generic subject for the forwarded emails. + The original subject is then added into the email body.
+ As PGP does not encrypt the email subject and the email subject might contain sensitive information, + this option will allow a further protection of your email content. +
+
+ +
+ + + +
+ + + {% if mailbox.generic_subject %} + + {% endif %} + +
+
+ +
+

Advanced Options

diff --git a/app/dashboard/views/mailbox_detail.py b/app/dashboard/views/mailbox_detail.py index 2df0e4fb..4eaa527e 100644 --- a/app/dashboard/views/mailbox_detail.py +++ b/app/dashboard/views/mailbox_detail.py @@ -151,6 +151,30 @@ def mailbox_detail_route(mailbox_id): return redirect( url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) ) + elif request.form.get("form-name") == "generic-subject": + if request.form.get("action") == "save": + if not mailbox.pgp_finger_print: + flash( + "Generic subject can only be used on PGP-enabled mailbox", + "error", + ) + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + + mailbox.generic_subject = request.form.get("generic-subject") + db.session.commit() + flash("Generic subject for PGP-encrypted email is enabled", "success") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + elif request.form.get("action") == "remove": + mailbox.generic_subject = None + db.session.commit() + flash("Generic subject for PGP-encrypted email is disabled", "success") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) spf_available = ENFORCE_SPF return render_template("dashboard/mailbox_detail.html", **locals()) diff --git a/app/email_utils.py b/app/email_utils.py index 0b98cb73..9010dfba 100644 --- a/app/email_utils.py +++ b/app/email_utils.py @@ -653,3 +653,40 @@ def is_valid_email(email_address: str) -> bool: return validate_email( email_address=email_address, check_mx=False, use_blacklist=False ) + + +def add_header(msg: Message, text_header, html_header) -> Message: + if msg.get_content_type() == "text/plain": + payload = msg.get_payload() + if type(payload) is str: + clone_msg = copy(msg) + payload = f"{text_header}\n---\n{payload}" + clone_msg.set_payload(payload) + return clone_msg + elif msg.get_content_type() == "text/html": + payload = msg.get_payload() + if type(payload) is str: + + new_payload = f""" + + + + + + + +
{html_header}
{payload}
+ """ + clone_msg = copy(msg) + clone_msg.set_payload(new_payload) + return clone_msg + elif msg.get_content_type() in ("multipart/alternative", "multipart/related"): + new_parts = [] + for part in msg.get_payload(): + new_parts.append(add_header(part, text_header, html_header)) + clone_msg = copy(msg) + clone_msg.set_payload(new_parts) + return clone_msg + + LOG.d("No header added for %s", msg.get_content_type()) + return msg diff --git a/app/models.py b/app/models.py index fe2a397b..ae358644 100644 --- a/app/models.py +++ b/app/models.py @@ -1642,6 +1642,8 @@ class Mailbox(db.Model, ModelMixin): # a mailbox can be disabled if it can't be reached disabled = db.Column(db.Boolean, default=False, nullable=False, server_default="0") + generic_subject = db.Column(db.String(78), nullable=True) + __table_args__ = (db.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),) user = db.relationship(User, foreign_keys=[user_id]) diff --git a/email_handler.py b/email_handler.py index aa6b4f40..4ac21f41 100644 --- a/email_handler.py +++ b/email_handler.py @@ -99,6 +99,7 @@ from app.email_utils import ( send_email_at_most_times, is_valid_alias_address_domain, should_add_dkim_signature, + add_header, ) from app.extensions import db from app.greylisting import greylisting_needed @@ -397,7 +398,7 @@ def should_append_alias(msg: Message, address: str): def prepare_pgp_message( orig_msg: Message, pgp_fingerprint: str, public_key: str, can_sign: bool = False -): +) -> Message: msg = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") # clone orig message to avoid modifying it @@ -687,6 +688,15 @@ def forward_email_to_mailbox( # create PGP email if needed if mailbox.pgp_finger_print and user.is_premium() and not alias.disable_pgp: LOG.d("Encrypt message using mailbox %s", mailbox) + if mailbox.generic_subject: + LOG.d("Use a generic subject for %s", mailbox) + add_or_replace_header(msg, "Subject", mailbox.generic_subject) + msg = add_header( + msg, + f"""Forwarded by SimpleLogin to {alias.email} with "{msg["Subject"]}" as subject""", + f"""Forwarded by SimpleLogin to {alias.email} with {msg["Subject"]} as subject""", + ) + try: msg = prepare_pgp_message( msg, mailbox.pgp_finger_print, mailbox.pgp_public_key, can_sign=True diff --git a/migrations/versions/2020_110712_d0f197979bd9_.py b/migrations/versions/2020_110712_d0f197979bd9_.py new file mode 100644 index 00000000..319b8346 --- /dev/null +++ b/migrations/versions/2020_110712_d0f197979bd9_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: d0f197979bd9 +Revises: 84dec6c29c48 +Create Date: 2020-11-07 12:47:44.131900 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd0f197979bd9' +down_revision = '84dec6c29c48' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mailbox', sa.Column('generic_subject', sa.String(length=78), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mailbox', 'generic_subject') + # ### end Alembic commands ### diff --git a/server.py b/server.py index b4c2e9ee..aa7c700c 100644 --- a/server.py +++ b/server.py @@ -49,6 +49,7 @@ from app.config import ( LANDING_PAGE_URL, STATUS_PAGE_URL, SUPPORT_EMAIL, + get_abs_path, ) from app.dashboard.base import dashboard_bp from app.developer.base import developer_bp @@ -77,6 +78,7 @@ from app.models import ( ) from app.monitor.base import monitor_bp from app.oauth.base import oauth_bp +from app.pgp_utils import load_public_key if SENTRY_DSN: LOG.d("enable sentry") @@ -213,12 +215,14 @@ def fake_data(): api_key = ApiKey.create(user_id=user.id, name="Firefox") api_key.code = "codeFF" + pgp_public_key = open(get_abs_path("local_data/public-pgp.asc")).read() m1 = Mailbox.create( user_id=user.id, - email="m1@cd.ef", + email="pgp@example.org", verified=True, - pgp_finger_print="fake fingerprint", + pgp_public_key=pgp_public_key, ) + m1.pgp_finger_print = load_public_key(pgp_public_key) db.session.commit() for i in range(3): diff --git a/tests/test_email_utils.py b/tests/test_email_utils.py index fea50210..15478c02 100644 --- a/tests/test_email_utils.py +++ b/tests/test_email_utils.py @@ -14,6 +14,7 @@ from app.email_utils import ( get_spam_from_header, get_header_from_bounce, is_valid_email, + add_header, ) from app.extensions import db from app.models import User, CustomDomain @@ -293,3 +294,78 @@ def test_is_valid_email(): assert not is_valid_email("with space@gmail.com") assert not is_valid_email("strange char !ç@gmail.com") assert not is_valid_email("emoji👌@gmail.com") + + +def test_add_header_plain_text(): + msg = email.message_from_string( + """Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit +Test-Header: Test-Value + +coucou +""" + ) + new_msg = add_header(msg, "text header", "html header") + assert "text header" in new_msg.as_string() + assert "html header" not in new_msg.as_string() + + +def test_add_header_html(): + msg = email.message_from_string( + """Content-Type: text/html; charset=us-ascii +Content-Transfer-Encoding: 7bit +Test-Header: Test-Value + + + + + + +bold + + +""" + ) + new_msg = add_header(msg, "text header", "html header") + assert "Test-Header: Test-Value" in new_msg.as_string() + assert "" in new_msg.as_string() + assert "html header" in new_msg.as_string() + assert "text header" not in new_msg.as_string() + + +def test_add_header_multipart_alternative(): + msg = email.message_from_string( + """Content-Type: multipart/alternative; + boundary="foo" +Content-Transfer-Encoding: 7bit +Test-Header: Test-Value + +--foo +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +bold + +--foo +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + + + + + + +bold + + +""" + ) + new_msg = add_header(msg, "text header", "html header") + assert "Test-Header: Test-Value" in new_msg.as_string() + assert "" in new_msg.as_string() + assert "html header" in new_msg.as_string() + assert "text header" in new_msg.as_string()