Skip to content

Commit

Permalink
T5282 Add Mailgun (#202)
Browse files Browse the repository at this point in the history
* add mailgun support and a unit test, update test workflow
  • Loading branch information
wleightond committed May 31, 2023
1 parent 45ddba9 commit aeb267c
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 48 deletions.
13 changes: 8 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches:
- "T4627_py3_main"
- "T5212_webhook_post_args"
- "T5282_add_mailgun"

jobs:
tests:
Expand Down Expand Up @@ -95,17 +95,20 @@ jobs:
- name: Run unit tests
env:
CANARY_ALERT_EMAIL_FROM_ADDRESS: tester@jingwei.in
CANARY_ALERT_EMAIL_FROM_DISPLAY: testing-display@jingwei.in
CANARY_ALERT_EMAIL_SUBJECT: Canarytokens Alert
CANARY_MAILGUN_DOMAIN_NAME: syruppdfs.com
CANARY_MAILGUN_BASE_URL: https://api.eu.mailgun.net
CANARY_ALERT_EMAIL_FROM_ADDRESS: noreply@syruppdfs.com
CANARY_ALERT_EMAIL_FROM_DISPLAY: Canarytoken Mail
CANARY_ALERT_EMAIL_SUBJECT: Your Canarytoken was Triggered
# Here we gather coverage info on all tests.
run: |
mv frontend/frontend.env.dist frontend/frontend.env
mv switchboard/switchboard.env.dist switchboard/switchboard.env
export CANARY_AWSID_URL=$(aws ssm get-parameter --name "/staging/awsid_url" --with-decryption --region eu-west-1 | jq -r '.Parameter.Value')
export CANARY_TESTING_AWS_ACCESS_KEY_ID=${{ secrets.TESTING_AWS_ACCESS_KEY_ID }}
export CANARY_TESTING_AWS_SECRET_ACCESS_KEY=${{ secrets.TESTING_AWS_SECRET_ACCESS_KEY }}
export CANARY_SENDGRID_API_KEY=$(aws ssm get-parameter --name "/staging/sendgrid_api_key" --with-decryption --region eu-west-1 | jq -r '.Parameter.Value')
export CANARY_SENDGRID_API_KEY=${{ secrets.TESTING_SENDGRID_API_KEY }}
export CANARY_MAILGUN_API_KEY=${{ secrets.TESTING_MAILGUN_API_KEY }}
export CANARY_SENTRY_ENVIRONMENT=ci
cd tests
poetry run coverage run --source=../canarytokens --omit="integration/test_custom_binary.py,integration/test_sql_server_token.py" -m pytest units --runv3
Expand Down
136 changes: 97 additions & 39 deletions canarytokens/channel_output_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
"""
from pathlib import Path
from textwrap import dedent
import textwrap
from typing import Optional

import minify_html
import requests
import sendgrid
from jinja2 import Template
from pydantic import EmailStr, SecretStr
from pydantic import EmailStr, HttpUrl, SecretStr
from python_http_client.exceptions import HTTPError
from sendgrid.helpers.mail import Content, From, Mail, MailSettings, SandBoxMode, To
from twisted.logger import Logger
Expand Down Expand Up @@ -40,7 +42,7 @@ def __init__(
):
self.settings = settings
self.from_email = settings.ALERT_EMAIL_FROM_ADDRESS
self.from_email_display = settings.ALERT_EMAIL_FROM_DISPLAY
self.from_display = settings.ALERT_EMAIL_FROM_DISPLAY
self.email_subject = settings.ALERT_EMAIL_SUBJECT
super().__init__(
switchboard,
Expand Down Expand Up @@ -88,6 +90,33 @@ def format_report_html(
)
return minify_html.minify(rendered_html)

@staticmethod
def format_report_text(details: TokenAlertDetails, body_length: int = 999999999):
"""Returns a string containing an incident report in text,
suitable for emailing"""

if body_length <= 140:
body = """Canarydrop@{time} via {channel_name}: """.format(
channel_name=details.channel, time=details.time
)
capacity = 140 - len(body)
body += details.memo[:capacity]
else:
additional_data = "\n" + "\n".join(
f"{k}: {v}" for k, v in details.additional_data.items()
)
body = textwrap.dedent(
f"""
One of your canarydrops was triggered.
Channel: {details.channel}
Time : {details.time}
Memo : {details.memo}{additional_data}
Manage your settings for this Canarydrop:
{details.manage_url}
"""
).strip()
return body

@staticmethod
def format_report_intro(details: TokenAlertDetails):
details.channel
Expand Down Expand Up @@ -131,17 +160,32 @@ def do_send_alert(
recipient=canarydrop.alert_email_recipient,
details=alert_details,
)
if self.settings.SENDGRID_API_KEY:
if self.settings.MAILGUN_API_KEY:
sent_successfully, message_id = EmailOutputChannel.mailgun_send(
email_address=canarydrop.alert_email_recipient,
email_subject=self.email_subject,
email_content_html=EmailOutputChannel.format_report_html(
alert_details,
Path(f"{self.settings.TEMPLATES_PATH}/emails/notification.html"),
),
email_content_text=EmailOutputChannel.format_report_text(alert_details),
from_email=EmailStr(self.from_email),
from_display=self.from_display,
api_key=self.settings.MAILGUN_API_KEY,
base_url=self.settings.MAILGUN_BASE_URL,
mailgun_domain=self.settings.MAILGUN_DOMAIN_NAME,
)
elif self.settings.SENDGRID_API_KEY:
sent_successfully, message_id = EmailOutputChannel.sendgrid_send(
api_key=self.settings.SENDGRID_API_KEY,
email_address=canarydrop.alert_email_recipient,
email_content=EmailOutputChannel.format_report_html(
email_content_html=EmailOutputChannel.format_report_html(
alert_details,
Path(f"{self.settings.TEMPLATES_PATH}/emails/notification.html"),
),
from_email=EmailStr(self.from_email),
email_subject=self.email_subject,
from_email_display=self.from_email_display,
from_display=self.from_display,
sandbox_mode=False,
# self.settings.SENDGRID_SANDBOX_MODE,
)
Expand Down Expand Up @@ -175,10 +219,10 @@ def sendgrid_send(
*,
api_key: SecretStr,
email_address: EmailStr,
email_content: str,
email_content_html: str,
from_email: EmailStr,
from_email_display: EmailStr,
email_subject=str,
from_display: EmailStr,
email_subject: str,
sandbox_mode: bool = False,
) -> tuple[bool, str]:

Expand All @@ -188,15 +232,15 @@ def sendgrid_send(

from_email = From(
email=from_email,
name=from_email_display,
name=from_display,
subject=email_subject,
)
to_emails = [
To(
email=email_address,
)
]
content = Content("text/html", email_content)
content = Content("text/html", email_content_html)
mail = Mail(
from_email=from_email,
to_emails=to_emails,
Expand Down Expand Up @@ -249,6 +293,49 @@ def check_sendgrid_mail_status(api_key: str) -> bool:
# queries.put_mail_on_sent_queue(mail_key=mail_key, details=alert_details)
# return mail_key is not None

@staticmethod
def should_retry_mailgun(success: bool, message_id: str) -> bool:
if not success:
log.error("Failed to send mail via mailgun.")
return not success

@staticmethod
@retry_on_returned_error(retry_if=should_retry_mailgun)
def mailgun_send(
*,
email_address: EmailStr,
email_content_html: str,
email_content_text: str,
email_subject: str,
from_email: EmailStr,
from_display: str,
api_key: SecretStr,
base_url: HttpUrl,
mailgun_domain: str,
) -> tuple[bool, str]:
sent_successfully = False
message_id = ""
try:
url = "{}/v3/{}/messages".format(base_url, mailgun_domain)
auth = ("api", api_key.get_secret_value().strip())
data = {
"from": f"{from_display} <{from_email}>",
"to": email_address,
"subject": email_subject,
"text": email_content_text,
"html": email_content_html,
}
response = requests.post(url, auth=auth, data=data)
# Raise an error if the returned status is 4xx or 5xx
response.raise_for_status()
except requests.exceptions.HTTPError as e:
log.error("A mailgun error occurred: %s - %s" % (e.__class__, e))
else:
sent_successfully = True
message_id = response.json().get("id")
finally:
return sent_successfully, message_id

# def get_basic_details(self,):

# vars = { 'Description' : self.data['description'],
Expand Down Expand Up @@ -281,35 +368,6 @@ def check_sendgrid_mail_status(api_key: str) -> bool:

# return vars

# def mailgun_send(self, msg=None, canarydrop=None):
# try:
# base_url = 'https://api.mailgun.net'
# if settings.MAILGUN_BASE_URL:
# base_url = settings.MAILGUN_BASE_URL
# url = '{}/v3/{}/messages'.format(base_url, settings.MAILGUN_DOMAIN_NAME)
# auth = ('api', settings.MAILGUN_API_KEY)
# data = {
# 'from': '{name} <{address}>'.format(name=msg['from_display'],address=msg['from_address']),
# 'to': canarydrop['alert_email_recipient'],
# 'subject': msg['subject'],
# 'text': msg['body'],
# 'html': self.format_report_html()
# }

# if settings.DEBUG:
# pprint.pprint(data)
# else:
# result = requests.post(url, auth=auth, data=data)
# #Raise an error if the returned status is 4xx or 5xx
# result.raise_for_status()

# log.info('Sent alert to {recipient} for token {token}'\
# .format(recipient=canarydrop['alert_email_recipient'],
# token=canarydrop.canarytoken.value()))

# except requests.exceptions.HTTPError as e:
# log.error('A mailgun error occurred: %s - %s' % (e.__class__, e))

# def mandrill_send(self, msg=None, canarydrop=None):
# try:
# mandrill_client = mandrill.Mandrill(settings.MANDRILL_API_KEY)
Expand Down
10 changes: 8 additions & 2 deletions canarytokens/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,16 @@ class Settings(BaseSettings):
ALERT_EMAIL_FROM_ADDRESS: EmailStr = EmailStr("illegal@email.com")
ALERT_EMAIL_FROM_DISPLAY: str = "Canarytokens-Test"
ALERT_EMAIL_SUBJECT: str = "Canarytokens Alert"
SENDGRID_API_KEY: SecretStr = SecretStr("NoSendgridAPIKeyFound")
SENDGRID_SANDBOX_MODE: bool = True
MAX_ALERTS_PER_MINUTE: int = 1000

MAILGUN_API_KEY: Optional[SecretStr] = SecretStr("NoSendgridAPIKeyFound")
MAILGUN_BASE_URL: Optional[HttpUrl] = HttpUrl(
"https://api.mailgun.net", scheme="https"
)
MAILGUN_DOMAIN_NAME: Optional[str]
SENDGRID_API_KEY: Optional[SecretStr] = SecretStr("NoSendgridAPIKeyFound")
SENDGRID_SANDBOX_MODE: bool = True

SENTRY_DSN: HttpUrl
SENTRY_ENVIRONMENT: Literal["prod", "staging", "dev", "ci", "local"] = "local"

Expand Down
42 changes: 40 additions & 2 deletions tests/units/test_channel_output_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,54 @@ def test_sendgrid_send(settings: Settings, frontend_settings: FrontendSettings):

success, message_id = email_output_channel.sendgrid_send(
api_key=settings.SENDGRID_API_KEY,
email_content=EmailOutputChannel.format_report_html(
email_content_html=EmailOutputChannel.format_report_html(
details, Path(f"{settings.TEMPLATES_PATH}/emails/notification.html")
),
email_address=EmailStr("benjamin+token-tester@thinkst.com"),
from_email=settings.ALERT_EMAIL_FROM_ADDRESS,
email_subject=settings.ALERT_EMAIL_SUBJECT,
from_email_display=settings.ALERT_EMAIL_FROM_DISPLAY,
from_display=settings.ALERT_EMAIL_FROM_DISPLAY,
sandbox_mode=True,
)
assert success
assert len(message_id) > 0


def test_mailgun_send(settings: Settings, frontend_settings: FrontendSettings):
sb = Switchboard()
details = TokenAlertDetails(
channel="DNS",
token=Canarytoken().value(),
token_type=TokenTypes.DNS,
src_ip="127.0.0.1",
time=datetime.datetime.now(),
memo="This is a test Memo",
manage_url="https://some.link/manage/here",
additional_data={},
)

email_output_channel = EmailOutputChannel(
frontend_settings=frontend_settings,
settings=settings,
switchboard=sb,
)
success, message_id = email_output_channel.mailgun_send(
email_content_html=EmailOutputChannel.format_report_html(
details, Path(f"{settings.TEMPLATES_PATH}/emails/notification.html")
),
email_content_text=EmailOutputChannel.format_report_text(details),
email_address=EmailStr("benjamin+token-tester@thinkst.com"),
from_email=settings.ALERT_EMAIL_FROM_ADDRESS,
email_subject=settings.ALERT_EMAIL_SUBJECT,
from_display=settings.ALERT_EMAIL_FROM_DISPLAY,
api_key=settings.MAILGUN_API_KEY,
base_url=settings.MAILGUN_BASE_URL,
mailgun_domain=settings.MAILGUN_DOMAIN_NAME,
)
assert success
assert len(message_id) > 0


def test_do_send_alert(
frontend_settings: FrontendSettings, settings: Settings, setup_db
):
Expand Down Expand Up @@ -169,6 +204,9 @@ def test_do_send_alert_retries(
settings.__dict__["ALERT_EMAIL_FROM_ADDRESS"] = "illegal@address.com"
# Ensure we not hitting the sandbox which accepts all.
settings.__dict__["SENDGRID_SANDBOX_MODE"] = False
# We can't trigger a failure this way with mailgun
settings.__dict__["MAILGUN_API_KEY"] = None

email_channel = EmailOutputChannel(
frontend_settings=frontend_settings,
settings=settings,
Expand Down

0 comments on commit aeb267c

Please sign in to comment.