Merge pull request #327 from simple-login/generic-subject

Generic subject for PGP-encrypted forwarded emails
This commit is contained in:
Son Nguyen Kim 2020-11-07 13:02:45 +01:00 committed by GitHub
commit 44fd80b2e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 236 additions and 4 deletions

View file

@ -6,6 +6,17 @@
Mailbox {{ mailbox.email }}
{% endblock %}
{% block head %}
<style>
div[disabled]
{
pointer-events: none;
opacity: 0.7;
}
</style>
{% endblock %}
{% block default_content %}
<div class="row">
@ -72,7 +83,7 @@
<div class="card-body">
<div class="card-title">
Pretty Good Privacy (PGP)
<div class="small-text">
<div class="small-text mt-1">
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are
<b>encrypted</b> with your key.
</div>
@ -106,6 +117,45 @@
</div>
<div class="card" {% if not mailbox.pgp_finger_print %} disabled {% endif %}>
<form method="post">
<input type="hidden" name="form-name" value="generic-subject">
<div class="card-body">
<div class="card-title">
Hide email subject when PGP is enabled
<div class="small-text mt-1">
When PGP is enabled, you can choose to use a <b>generic</b> subject for the forwarded emails.
The original subject is then added into the email body. <br>
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.
</div>
</div>
<div class="form-group">
<label class="form-label">Generic Subject</label>
<input name="generic-subject"
{% if not mailbox.pgp_finger_print %} disabled {% endif %}
class="form-control" maxlength="78"
placeholder="Generic Subject"
value="{{ mailbox.generic_subject or "" }}"
>
</div>
<button class="btn btn-primary" name="action"
{% if not mailbox.pgp_finger_print %} disabled {% endif %}
value="save">Save
</button>
{% if mailbox.generic_subject %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
{% endif %}
</div>
</form>
</div>
<hr>
<h2 class="h4">Advanced Options</h2>

View file

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

View file

@ -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"""
<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
<td style="border-bottom:1px dashed #5675E2; padding: 10px 0px">{html_header}</td>
</tr>
<tr>
<td>{payload}</td>
</tr>
</table>
"""
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

View file

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

View file

@ -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 <b>{msg["Subject"]}</b> as subject""",
)
try:
msg = prepare_pgp_message(
msg, mailbox.pgp_finger_print, mailbox.pgp_public_key, can_sign=True

View file

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

View file

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

View file

@ -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
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=us-ascii">
</head>
<body style="word-wrap: break-word;" class="">
<b class="">bold</b>
</body>
</html>
"""
)
new_msg = add_header(msg, "text header", "html header")
assert "Test-Header: Test-Value" in new_msg.as_string()
assert "<table" in new_msg.as_string()
assert "</table>" 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
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=us-ascii">
</head>
<body style="word-wrap: break-word;" class="">
<b class="">bold</b>
</body>
</html>
"""
)
new_msg = add_header(msg, "text header", "html header")
assert "Test-Header: Test-Value" in new_msg.as_string()
assert "<table" in new_msg.as_string()
assert "</table>" in new_msg.as_string()
assert "html header" in new_msg.as_string()
assert "text header" in new_msg.as_string()