Skip to content

Commit 236b418

Browse files
authored
Merge pull request #12 from posit-dev/feat-bulk-sending
feat: mailgun bulk sending
2 parents a29912e + 2863817 commit 236b418

File tree

6 files changed

+377
-10
lines changed

6 files changed

+377
-10
lines changed

emailer_lib/egress.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22
import base64
3+
import os
34

45
from email.mime.multipart import MIMEMultipart
56
from email.mime.text import MIMEText
@@ -164,24 +165,104 @@ def send_intermediate_email_with_yagmail(i_email: IntermediateEmail):
164165
raise NotImplementedError
165166

166167

167-
def send_intermediate_email_with_mailgun(i_email: IntermediateEmail):
168+
def send_intermediate_email_with_mailgun(
169+
api_key: str,
170+
domain: str,
171+
sender: str,
172+
i_email: IntermediateEmail,
173+
):
168174
"""
169175
Send an Intermediate Email object via Mailgun.
170176
171177
Parameters
172178
----------
179+
api_key
180+
Mailgun API key (found in account settings)
181+
domain
182+
Your verified Mailgun domain (e.g., "mg.yourdomain.com")
183+
sender
184+
Email address to send from (must be authorized in your domain)
173185
i_email
174186
IntermediateEmail object containing the email content and attachments
175187
176188
Returns
177189
-------
178-
None
190+
Response
191+
Response from Mailgun API
192+
193+
Raises
194+
------
195+
Exception
196+
If the Mailgun API returns an error
197+
198+
Examples
199+
--------
200+
```python
201+
email = IntermediateEmail(
202+
html="<p>Hello world</p>",
203+
subject="Test Email",
204+
recipients=["[email protected]"],
205+
)
206+
207+
response = send_intermediate_email_with_mailgun(
208+
api_key="your-api-key",
209+
domain="mg.yourdomain.com",
210+
211+
i_email=email
212+
)
213+
```
179214
180215
Notes
181216
-----
182-
This function is a placeholder and has not been implemented yet.
217+
Requires the `mailgun` package: `pip install mailgun`
183218
"""
184-
raise NotImplementedError
219+
from mailgun.client import Client
220+
221+
# Create Mailgun client
222+
client = Client(auth=("api", api_key))
223+
224+
if i_email.recipients is None:
225+
raise TypeError(
226+
"i_email must have a populated recipients attribute. Currently, i_email.recipients is None."
227+
)
228+
229+
# Prepare the basic email data
230+
data = {
231+
"from": sender,
232+
"to": i_email.recipients,
233+
"subject": i_email.subject,
234+
"html": i_email.html,
235+
}
236+
237+
# Add text content if available
238+
if i_email.text:
239+
data["text"] = i_email.text
240+
241+
# Prepare files for attachments
242+
files = []
243+
244+
# Handle inline images (embedded in HTML with cid:)
245+
for image_name, image_base64 in i_email.inline_attachments.items():
246+
img_bytes = base64.b64decode(image_base64)
247+
# Use 'inline' for images referenced in HTML with cid:
248+
files.append(("inline", (image_name, img_bytes)))
249+
250+
# Handle external attachments
251+
for filename in i_email.external_attachments:
252+
with open(filename, "rb") as f:
253+
file_data = f.read()
254+
255+
# Extract just the filename (not full path) for the attachment name
256+
basename = os.path.basename(filename)
257+
files.append(("attachment", (basename, file_data)))
258+
259+
# Send the message using Mailgun client
260+
response = client.messages.create(
261+
data=data, files=files if files else None, domain=domain
262+
)
263+
264+
# The response object has a .json() method that returns the actual data
265+
return response
185266

186267

187268
def send_intermediate_email_with_smtp(
@@ -190,7 +271,7 @@ def send_intermediate_email_with_smtp(
190271
username: str,
191272
password: str,
192273
i_email: IntermediateEmail,
193-
security: str = Literal["tls", "ssl", "smtp"]
274+
security: str = Literal["tls", "ssl", "smtp"],
194275
):
195276
"""
196277
Send an Intermediate Email object via SMTP.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# serializer version: 1
2+
# name: test_preview_email_complex_html
3+
'''
4+
<!DOCTYPE html>
5+
<html lang="en">
6+
<head>
7+
<meta charset="UTF-8">
8+
<title>Test Email</title>
9+
<style>
10+
body { font-family: Arial, sans-serif; }
11+
.header { background-color: #f0f0f0; padding: 20px; }
12+
.content { padding: 20px; }
13+
</style>
14+
</head>
15+
<body>
16+
<h2 style="padding-left:16px;">Subject: Complex Email Structure</h2>
17+
<div class="header">
18+
<h1>Welcome!</h1>
19+
</div>
20+
<div class="content">
21+
<p>This is a <strong>complex</strong> email with <em>formatting</em>.</p>
22+
<ul>
23+
<li>Item 1</li>
24+
<li>Item 2</li>
25+
<li>Item 3</li>
26+
</ul>
27+
<img src="data:image;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Test Image" />
28+
</div>
29+
</body>
30+
</html>
31+
'''
32+
# ---
33+
# name: test_preview_email_simple_html
34+
'''
35+
<html><body>
36+
<h2 style="padding-left:16px;">Subject: Simple Test Email</h2><p>Hello World!</p></body></html>
37+
'''
38+
# ---
39+
# name: test_preview_email_with_inline_attachments
40+
'''
41+
<html>
42+
<body>
43+
<h2 style="padding-left:16px;">Subject: Email with Inline Images</h2>
44+
<h1>Email with Images</h1>
45+
<img src="data:image;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="Logo" />
46+
<p>Some text content</p>
47+
<img src="data:image;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAAA=" alt="Banner" />
48+
</body>
49+
</html>
50+
'''
51+
# ---

emailer_lib/tests/test_egress.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ def test_send_intermediate_email_with_smtp_unknown_mime_type(monkeypatch):
145145

146146

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

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

206205

206+
def test_send_intermediate_email_with_mailgun(monkeypatch):
207+
email = make_basic_email()
208+
email.external_attachments = ["file.txt"]
209+
210+
# Mock the response object with .json() method
211+
mock_response = MagicMock()
212+
mock_response.json.return_value = {
213+
"id": "<20251028141836.beb7f6b3fd2be2b7@sandboxedc0eedbb2da49f39cbc02665f66556c.mailgun.org>",
214+
"message": "Queued. Thank you."
215+
}
216+
mock_response.__repr__ = lambda self: "<Response [200]>"
217+
218+
# Mock the Mailgun Client
219+
mock_client_instance = MagicMock()
220+
mock_messages = MagicMock()
221+
mock_client_instance.messages = mock_messages
222+
mock_messages.create = MagicMock(return_value=mock_response)
223+
224+
mock_client_class = MagicMock(return_value=mock_client_instance)
225+
226+
with patch("mailgun.client.Client", mock_client_class):
227+
with patch("builtins.open", mock_open(read_data=b"file content")):
228+
response = send_intermediate_email_with_mailgun(
229+
api_key="test-api-key",
230+
domain="mg.example.com",
231+
232+
i_email=email,
233+
)
234+
235+
# Verify Client was initialized with correct auth
236+
mock_client_class.assert_called_once_with(auth=("api", "test-api-key"))
237+
238+
mock_messages.create.assert_called_once()
239+
call_args = mock_messages.create.call_args
240+
241+
data = call_args.kwargs["data"]
242+
assert data["from"] == "[email protected]"
243+
assert data["to"] == ["[email protected]"]
244+
assert data["subject"] == "Test"
245+
assert data["html"] == "<p>Hi</p>"
246+
assert data["text"] == "Plain text"
247+
248+
# Check files were passed
249+
files = call_args.kwargs["files"]
250+
assert files is not None
251+
assert len(files) == 2 # 1 inline, 1 external
252+
253+
assert call_args.kwargs["domain"] == "mg.example.com"
254+
255+
assert response == mock_response
256+
assert response.json() == {
257+
"id": "<20251028141836.beb7f6b3fd2be2b7@sandboxedc0eedbb2da49f39cbc02665f66556c.mailgun.org>",
258+
"message": "Queued. Thank you."
259+
}
260+
261+
262+
def test_send_intermediate_email_with_mailgun_no_recipients():
263+
email = IntermediateEmail(
264+
html="<p>Hi</p>",
265+
subject="Test",
266+
recipients=None,
267+
)
268+
269+
with pytest.raises(TypeError, match="i_email must have a populated recipients attribute"):
270+
send_intermediate_email_with_mailgun(
271+
api_key="test-api-key",
272+
domain="mg.example.com",
273+
274+
i_email=email,
275+
)
276+
277+
207278
@pytest.mark.parametrize(
208279
"send_func",
209280
[
210281
send_intermediate_email_with_redmail,
211282
send_intermediate_email_with_yagmail,
212-
send_intermediate_email_with_mailgun,
213283
],
214284
)
215285
def test_not_implemented_functions(send_func):
216-
"""Test that unimplemented send functions raise NotImplementedError."""
217286
email = make_basic_email()
218287
with pytest.raises(NotImplementedError):
219288
send_func(email)

emailer_lib/tests/test_structs.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,84 @@ def test_not_implemented_methods(method_name):
9595
method = getattr(email, method_name)
9696
with pytest.raises(NotImplementedError):
9797
method()
98+
99+
100+
def test_preview_email_simple_html(tmp_path, snapshot):
101+
html = "<html><body><p>Hello World!</p></body></html>"
102+
email = IntermediateEmail(
103+
html=html,
104+
subject="Simple Test Email",
105+
)
106+
107+
out_file = tmp_path / "preview.html"
108+
email.write_preview_email(str(out_file))
109+
content = out_file.read_text(encoding="utf-8")
110+
111+
assert content == snapshot
112+
113+
114+
def test_preview_email_with_inline_attachments(tmp_path, snapshot):
115+
html = """<html>
116+
<body>
117+
<h1>Email with Images</h1>
118+
<img src="cid:logo.png" alt="Logo" />
119+
<p>Some text content</p>
120+
<img src="cid:banner.jpg" alt="Banner" />
121+
</body>
122+
</html>"""
123+
email = IntermediateEmail(
124+
html=html,
125+
subject="Email with Inline Images",
126+
inline_attachments={
127+
"logo.png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
128+
"banner.jpg": "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAAA="
129+
},
130+
)
131+
132+
out_file = tmp_path / "preview.html"
133+
email.write_preview_email(str(out_file))
134+
content = out_file.read_text(encoding="utf-8")
135+
136+
assert content == snapshot
137+
138+
139+
def test_preview_email_complex_html(tmp_path, snapshot):
140+
html = """<!DOCTYPE html>
141+
<html lang="en">
142+
<head>
143+
<meta charset="UTF-8">
144+
<title>Test Email</title>
145+
<style>
146+
body { font-family: Arial, sans-serif; }
147+
.header { background-color: #f0f0f0; padding: 20px; }
148+
.content { padding: 20px; }
149+
</style>
150+
</head>
151+
<body>
152+
<div class="header">
153+
<h1>Welcome!</h1>
154+
</div>
155+
<div class="content">
156+
<p>This is a <strong>complex</strong> email with <em>formatting</em>.</p>
157+
<ul>
158+
<li>Item 1</li>
159+
<li>Item 2</li>
160+
<li>Item 3</li>
161+
</ul>
162+
<img src="cid:test.png" alt="Test Image" />
163+
</div>
164+
</body>
165+
</html>"""
166+
email = IntermediateEmail(
167+
html=html,
168+
subject="Complex Email Structure",
169+
inline_attachments={
170+
"test.png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
171+
},
172+
)
173+
174+
out_file = tmp_path / "preview.html"
175+
email.write_preview_email(str(out_file))
176+
content = out_file.read_text(encoding="utf-8")
177+
178+
assert content == snapshot

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ dev = [
3030
"quartodoc",
3131
"pytest-cov",
3232
"griffe",
33+
"syrupy",
34+
]
35+
36+
mailgun = [
37+
"mailgun"
3338
]
3439

3540
docs = [

0 commit comments

Comments
 (0)