Skip to content
Open
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
91 changes: 86 additions & 5 deletions emailer_lib/egress.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
import base64
import os

from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
Expand Down Expand Up @@ -164,24 +165,104 @@ def send_intermediate_email_with_yagmail(i_email: IntermediateEmail):
raise NotImplementedError


def send_intermediate_email_with_mailgun(i_email: IntermediateEmail):
def send_intermediate_email_with_mailgun(
api_key: str,
domain: str,
sender: str,
i_email: IntermediateEmail,
):
"""
Send an Intermediate Email object via Mailgun.

Parameters
----------
api_key
Mailgun API key (found in account settings)
domain
Your verified Mailgun domain (e.g., "mg.yourdomain.com")
sender
Email address to send from (must be authorized in your domain)
i_email
IntermediateEmail object containing the email content and attachments

Returns
-------
None
Response
Response from Mailgun API

Raises
------
Exception
If the Mailgun API returns an error

Examples
--------
```python
email = IntermediateEmail(
html="<p>Hello world</p>",
subject="Test Email",
recipients=["[email protected]"],
)

response = send_intermediate_email_with_mailgun(
api_key="your-api-key",
domain="mg.yourdomain.com",
sender="[email protected]",
i_email=email
)
```

Notes
-----
This function is a placeholder and has not been implemented yet.
Requires the `mailgun` package: `pip install mailgun`
"""
raise NotImplementedError
from mailgun.client import Client

# Create Mailgun client
client = Client(auth=("api", api_key))

if i_email.recipients is None:
raise TypeError(
"i_email must have a populated recipients attribute. Currently, i_email.recipients is None."
)

# Prepare the basic email data
data = {
"from": sender,
"to": i_email.recipients,
"subject": i_email.subject,
"html": i_email.html,
}

# Add text content if available
if i_email.text:
data["text"] = i_email.text

# Prepare files for attachments
files = []

# Handle inline images (embedded in HTML with cid:)
for image_name, image_base64 in i_email.inline_attachments.items():
img_bytes = base64.b64decode(image_base64)
# Use 'inline' for images referenced in HTML with cid:
files.append(("inline", (image_name, img_bytes)))

# Handle external attachments
for filename in i_email.external_attachments:
with open(filename, "rb") as f:
file_data = f.read()

# Extract just the filename (not full path) for the attachment name
basename = os.path.basename(filename)
files.append(("attachment", (basename, file_data)))

# Send the message using Mailgun client
response = client.messages.create(
data=data, files=files if files else None, domain=domain
)

# The response object has a .json() method that returns the actual data
return response


def send_intermediate_email_with_smtp(
Expand All @@ -190,7 +271,7 @@ def send_intermediate_email_with_smtp(
username: str,
password: str,
i_email: IntermediateEmail,
security: str = Literal["tls", "ssl", "smtp"]
security: str = Literal["tls", "ssl", "smtp"],
):
"""
Send an Intermediate Email object via SMTP.
Expand Down
51 changes: 51 additions & 0 deletions emailer_lib/tests/__snapshots__/test_structs.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# serializer version: 1
# name: test_preview_email_complex_html
'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test Email</title>
<style>
body { font-family: Arial, sans-serif; }
.header { background-color: #f0f0f0; padding: 20px; }
.content { padding: 20px; }
</style>
</head>
<body>
<h2 style="padding-left:16px;">Subject: Complex Email Structure</h2>
<div class="header">
<h1>Welcome!</h1>
</div>
<div class="content">
<p>This is a <strong>complex</strong> email with <em>formatting</em>.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<img src="data:image;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Test Image" />
</div>
</body>
</html>
'''
# ---
# name: test_preview_email_simple_html
'''
<html><body>
<h2 style="padding-left:16px;">Subject: Simple Test Email</h2><p>Hello World!</p></body></html>
'''
# ---
# name: test_preview_email_with_inline_attachments
'''
<html>
<body>
<h2 style="padding-left:16px;">Subject: Email with Inline Images</h2>
<h1>Email with Images</h1>
<img src="data:image;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Logo" />
<p>Some text content</p>
<img src="data:image;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAAA=" alt="Banner" />
</body>
</html>
'''
# ---
75 changes: 72 additions & 3 deletions emailer_lib/tests/test_egress.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ def test_send_intermediate_email_with_smtp_unknown_mime_type(monkeypatch):


def test_send_intermediate_email_with_smtp_sendmail_args(monkeypatch):
"""Test that sendmail is called with correct sender, recipients, and message format."""
email = make_basic_email()
mock_smtp, mock_smtp_ssl, context = setup_smtp_mocks(monkeypatch)

Expand Down Expand Up @@ -204,16 +203,86 @@ def test_send_quarto_email_with_gmail(monkeypatch):
assert i_email.recipients == ["[email protected]"]


def test_send_intermediate_email_with_mailgun(monkeypatch):
email = make_basic_email()
email.external_attachments = ["file.txt"]

# Mock the response object with .json() method
mock_response = MagicMock()
mock_response.json.return_value = {
"id": "<20251028141836.beb7f6b3fd2be2b7@sandboxedc0eedbb2da49f39cbc02665f66556c.mailgun.org>",
"message": "Queued. Thank you."
}
mock_response.__repr__ = lambda self: "<Response [200]>"

# Mock the Mailgun Client
mock_client_instance = MagicMock()
mock_messages = MagicMock()
mock_client_instance.messages = mock_messages
mock_messages.create = MagicMock(return_value=mock_response)

mock_client_class = MagicMock(return_value=mock_client_instance)

with patch("mailgun.client.Client", mock_client_class):
with patch("builtins.open", mock_open(read_data=b"file content")):
response = send_intermediate_email_with_mailgun(
api_key="test-api-key",
domain="mg.example.com",
sender="[email protected]",
i_email=email,
)

# Verify Client was initialized with correct auth
mock_client_class.assert_called_once_with(auth=("api", "test-api-key"))

mock_messages.create.assert_called_once()
call_args = mock_messages.create.call_args

data = call_args.kwargs["data"]
assert data["from"] == "[email protected]"
assert data["to"] == ["[email protected]"]
assert data["subject"] == "Test"
assert data["html"] == "<p>Hi</p>"
assert data["text"] == "Plain text"

# Check files were passed
files = call_args.kwargs["files"]
assert files is not None
assert len(files) == 2 # 1 inline, 1 external

assert call_args.kwargs["domain"] == "mg.example.com"

assert response == mock_response
assert response.json() == {
"id": "<20251028141836.beb7f6b3fd2be2b7@sandboxedc0eedbb2da49f39cbc02665f66556c.mailgun.org>",
"message": "Queued. Thank you."
}


def test_send_intermediate_email_with_mailgun_no_recipients():
email = IntermediateEmail(
html="<p>Hi</p>",
subject="Test",
recipients=None,
)

with pytest.raises(TypeError, match="i_email must have a populated recipients attribute"):
send_intermediate_email_with_mailgun(
api_key="test-api-key",
domain="mg.example.com",
sender="[email protected]",
i_email=email,
)


@pytest.mark.parametrize(
"send_func",
[
send_intermediate_email_with_redmail,
send_intermediate_email_with_yagmail,
send_intermediate_email_with_mailgun,
],
)
def test_not_implemented_functions(send_func):
"""Test that unimplemented send functions raise NotImplementedError."""
email = make_basic_email()
with pytest.raises(NotImplementedError):
send_func(email)
81 changes: 81 additions & 0 deletions emailer_lib/tests/test_structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,84 @@ def test_not_implemented_methods(method_name):
method = getattr(email, method_name)
with pytest.raises(NotImplementedError):
method()


def test_preview_email_simple_html(tmp_path, snapshot):
html = "<html><body><p>Hello World!</p></body></html>"
email = IntermediateEmail(
html=html,
subject="Simple Test Email",
)

out_file = tmp_path / "preview.html"
email.write_preview_email(str(out_file))
content = out_file.read_text(encoding="utf-8")

assert content == snapshot


def test_preview_email_with_inline_attachments(tmp_path, snapshot):
html = """<html>
<body>
<h1>Email with Images</h1>
<img src="cid:logo.png" alt="Logo" />
<p>Some text content</p>
<img src="cid:banner.jpg" alt="Banner" />
</body>
</html>"""
email = IntermediateEmail(
html=html,
subject="Email with Inline Images",
inline_attachments={
"logo.png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"banner.jpg": "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAAA="
},
)

out_file = tmp_path / "preview.html"
email.write_preview_email(str(out_file))
content = out_file.read_text(encoding="utf-8")

assert content == snapshot


def test_preview_email_complex_html(tmp_path, snapshot):
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test Email</title>
<style>
body { font-family: Arial, sans-serif; }
.header { background-color: #f0f0f0; padding: 20px; }
.content { padding: 20px; }
</style>
</head>
<body>
<div class="header">
<h1>Welcome!</h1>
</div>
<div class="content">
<p>This is a <strong>complex</strong> email with <em>formatting</em>.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<img src="cid:test.png" alt="Test Image" />
</div>
</body>
</html>"""
email = IntermediateEmail(
html=html,
subject="Complex Email Structure",
inline_attachments={
"test.png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
},
)

out_file = tmp_path / "preview.html"
email.write_preview_email(str(out_file))
content = out_file.read_text(encoding="utf-8")

assert content == snapshot
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ dev = [
"quartodoc",
"pytest-cov",
"griffe",
"syrupy",
]

mailgun = [
"mailgun"
]

docs = [
Expand Down
Loading