Merge pull request #327 from simple-login/generic-subject
Generic subject for PGP-encrypted forwarded emails
This commit is contained in:
commit
44fd80b2e1
|
@ -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>
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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
|
||||
|
|
29
migrations/versions/2020_110712_d0f197979bd9_.py
Normal file
29
migrations/versions/2020_110712_d0f197979bd9_.py
Normal 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 ###
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue