Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ class Config(object):
AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
AWS_ROUTE53_ZONE = os.getenv("AWS_ROUTE53_ZONE", "Z2OW036USASMAK")
AWS_SES_REGION = os.getenv("AWS_SES_REGION", "us-east-1")
AWS_SES_SMTP = os.getenv("AWS_SES_SMTP", "email-smtp.ca-central-1.amazonaws.com")
AWS_SES_ACCESS_KEY = os.getenv("AWS_SES_ACCESS_KEY")
AWS_SES_SECRET_KEY = os.getenv("AWS_SES_SECRET_KEY")
AWS_PINPOINT_REGION = os.getenv("AWS_PINPOINT_REGION", "us-west-2")
Expand Down
1 change: 1 addition & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ class Service(BaseModel, Versioned):
sms_annual_limit = db.Column(db.BigInteger, nullable=False, default=DEFAULT_SMS_ANNUAL_LIMIT)
suspended_by_id = db.Column(UUID(as_uuid=True), db.ForeignKey("users.id"), nullable=True)
suspended_at = db.Column(db.DateTime, nullable=True)
smtp_user = db.Column(db.String(255), nullable=True, unique=False)

email_branding = db.relationship(
"EmailBranding",
Expand Down
50 changes: 50 additions & 0 deletions app/service/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@
from app.user.users_schema import post_set_permissions_schema
from app.utils import pagination_links

from app.smtp.aws import (smtp_add, smtp_get_user_key, smtp_remove)
from nanoid import generate

service_blueprint = Blueprint("service", __name__)

register_errors(service_blueprint)
Expand Down Expand Up @@ -1170,6 +1173,53 @@ def get_monthly_notification_data_by_service():
return jsonify(result)


@service_blueprint.route('/<uuid:service_id>/smtp', methods=['GET'])
def get_smtp_relay(service_id):
service = dao_fetch_service_by_id(service_id)
if service.smtp_user is not None:
credentials = {
"domain": service.smtp_user.split("-")[0],
"name": current_app.config["AWS_SES_SMTP"],
"port": "465",
"tls": "Yes",
"username": smtp_get_user_key(service.smtp_user),
}
return jsonify(credentials), 200
else:
return jsonify({}), 200


@service_blueprint.route('/<uuid:service_id>/smtp', methods=['POST'])
def create_smtp_relay(service_id):
service = dao_fetch_service_by_id(service_id)

if service.smtp_user is None:
user_id = generate(size=6)
credentials = smtp_add(user_id)
service.smtp_user = credentials["iam"]
dao_update_service(service)
return jsonify(credentials), 201
else:
raise InvalidRequest(
message="SMTP user already exists",
status_code=500)


@service_blueprint.route('/<uuid:service_id>/smtp', methods=['DELETE'])
def delete_smtp_relay(service_id):
service = dao_fetch_service_by_id(service_id)

if service.smtp_user is not None:
smtp_remove(service.smtp_user)
service.smtp_user = None
dao_update_service(service)
return jsonify(True), 201
else:
raise InvalidRequest(
message="SMTP user does not exist",
status_code=500)


def check_unique_name_request_args(request):
service_id = request.args.get("service_id")
name = request.args.get("name", None)
Expand Down
Empty file added app/smtp/__init__.py
Empty file.
236 changes: 236 additions & 0 deletions app/smtp/aws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import base64 # required to encode the computed key
import hashlib # required to create a SHA256 hash
import hmac # required to compute the HMAC key
import time

import boto3
from flask import current_app


def smtp_add(name):
ses_client = boto3.client(
'ses',
aws_access_key_id=current_app.config["AWS_SES_ACCESS_KEY"],
aws_secret_access_key=current_app.config["AWS_SES_SECRET_KEY"],
region_name=current_app.config["AWS_SES_REGION"])
r53_client = boto3.client(
'route53',
aws_access_key_id=current_app.config["AWS_SES_ACCESS_KEY"],
aws_secret_access_key=current_app.config["AWS_SES_SECRET_KEY"],
region_name=current_app.config["AWS_SES_REGION"])
iam_client = boto3.client(
'iam',
aws_access_key_id=current_app.config["AWS_SES_ACCESS_KEY"],
aws_secret_access_key=current_app.config["AWS_SES_SECRET_KEY"],
region_name=current_app.config["AWS_SES_REGION"])

notify_email_domain = current_app.config["NOTIFY_EMAIL_DOMAIN"]
current_app.logger.info(f"The current notify email domain is {notify_email_domain}")
name = name + '.m.' + current_app.config["NOTIFY_EMAIL_DOMAIN"]

token = create_domain_identity(ses_client, name)
add_record(r53_client, '_amazonses.' + name, "\"%s\"" % token, "TXT")

tokens = get_dkim(ses_client, name)
for token in tokens:
add_record(
r53_client,
token + "._domainkey." + name,
token + ".dkim." + name,
)

add_record(r53_client, name, "\"v=spf1 include:amazonses.com ~all\"", "TXT")
add_record(
r53_client,
"_dmarc." + name,
"\"v=DMARC1; p=none; sp=none; rua=mailto:[email protected]; ruf=mailto:[email protected]\"",
"TXT")

credentials = add_user(iam_client, name)
return credentials


def smtp_get_user_key(name):
try:
iam_client = boto3.client('iam', region_name=current_app.config["AWS_SES_REGION"])
return iam_client.list_access_keys(
UserName=name,
)["AccessKeyMetadata"][0]["AccessKeyId"]
except Exception as e:
raise e


def smtp_remove(name):
try:
ses_client = boto3.client(
'ses',
aws_access_key_id=current_app.config["AWS_SES_ACCESS_KEY"],
aws_secret_access_key=current_app.config["AWS_SES_SECRET_KEY"],
region_name=current_app.config["AWS_SES_REGION"])
r53_client = boto3.client(
'route53',
aws_access_key_id=current_app.config["AWS_SES_ACCESS_KEY"],
aws_secret_access_key=current_app.config["AWS_SES_SECRET_KEY"],
region_name=current_app.config["AWS_SES_REGION"])
iam_client = boto3.client(
'iam',
aws_access_key_id=current_app.config["AWS_SES_ACCESS_KEY"],
aws_secret_access_key=current_app.config["AWS_SES_SECRET_KEY"],
region_name=current_app.config["AWS_SES_REGION"])

[domain, _] = name.split("-")

policies = iam_client.list_user_policies(
UserName=name,
)["PolicyNames"]

for policy in policies:
iam_client.delete_user_policy(
UserName=name,
PolicyName=policy
)

keys = iam_client.list_access_keys(
UserName=name,
)["AccessKeyMetadata"]

for key in keys:
iam_client.delete_access_key(
UserName=name,
AccessKeyId=key["AccessKeyId"]
)

iam_client.delete_user(UserName=name)
ses_client.delete_identity(Identity=domain)
records = r53_client.list_resource_record_sets(
HostedZoneId=current_app.config["AWS_ROUTE53_ZONE"],
StartRecordName=domain
)["ResourceRecordSets"]

for record in records:
delete_record(r53_client, record)

return True

except Exception as e:
raise e


def create_domain_identity(client, name):
try:
return client.verify_domain_identity(
Domain=name
)['VerificationToken']
except Exception as e:
raise e


def get_dkim(client, name):
try:
return client.verify_domain_dkim(
Domain=name
)['DkimTokens']
except Exception as e:
raise e


def add_user(iam_client, name):
try:
user_name = name + "-" + str(int(time.time()))
iam_client.create_user(
Path='/notification-smtp/',
UserName=user_name,
Tags=[
{
'Key': 'SMTP-USER',
'Value': name
},
]
)

iam_client.put_user_policy(
PolicyDocument=generate_user_policy(name),
PolicyName="SP-" + user_name,
UserName=user_name
)

response = iam_client.create_access_key(
UserName=user_name
)

credentials = {
"iam": user_name,
"domain": name,
"name": current_app.config["AWS_SES_SMTP"],
"port": "465",
"tls": "Yes",
"username": response["AccessKey"]["AccessKeyId"],
"password": munge(response["AccessKey"]["SecretAccessKey"])
}

return credentials
except Exception as e:
raise e


def add_record(client, name, value, record_type="CNAME"):
try:
client.change_resource_record_sets(
HostedZoneId=current_app.config["AWS_ROUTE53_ZONE"],
ChangeBatch={
'Comment': 'add %s -> %s' % (name, value),
'Changes': [
{
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': name,
'Type': record_type,
'TTL': 300,
'ResourceRecords': [{'Value': value}]
}
}]
})
except Exception as e:
raise e


def delete_record(client, record):
try:
client.change_resource_record_sets(
HostedZoneId=current_app.config["AWS_ROUTE53_ZONE"],
ChangeBatch={
'Comment': 'Deleted',
'Changes': [
{
'Action': 'DELETE',
'ResourceRecordSet': record
}
]
}
)
except Exception as e:
raise e


def generate_user_policy(name):
policy = (
'{"Version":"2012-10-17","Statement":'
'[{"Effect":"Allow","Action":["ses:SendRawEmail"],"Resource":"*",'
'"Condition":{"StringLike":{"ses:FromAddress":"*@%s"}}}]}' % name)
return policy

# Taken from https://docs.aws.amazon.com/ses/latest/DeveloperGuide/example-create-smtp-credentials.html


def munge(secret):
message = 'SendRawEmail'
version = '\x02'

# Compute an HMAC-SHA256 key from the AWS secret access key.
signatureInBytes = hmac.new(secret.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).digest()
# Prepend the version number to the signature.
signatureAndVersion = version.encode('utf-8') + signatureInBytes
# Base64-encode the string that contains the version number and signature.
smtpPassword = base64.b64encode(signatureAndVersion)
# Decode the string and print it to the console.
return smtpPassword.decode('utf-8')
22 changes: 22 additions & 0 deletions migrations/versions/0493_smtp_columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""

Revision ID: 0493_smtp_columns
Revises: 0492_add_service_del_template
Create Date: 2025-11-07 17:08:21.019759

"""
import sqlalchemy as sa
from alembic import op

revision = "0493_smtp_columns"
down_revision = "0492_add_service_del_template"


def upgrade():
op.add_column("services", sa.Column("smtp_user", sa.Text(), nullable=True))
op.add_column("services_history", sa.Column("smtp_user", sa.Text(), nullable=True))


def downgrade():
op.drop_column("services", "smtp_user")
op.drop_column("services_history", "smtp_user")
Loading
Loading