Skip to content

Commit 0474c95

Browse files
committed
playing with data types and email previewing
1 parent db8cf3d commit 0474c95

File tree

2 files changed

+217
-24
lines changed

2 files changed

+217
-24
lines changed

send-quarto-emails/main.py

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,105 @@
1-
from methods import send_quarto_email_with_gmail
1+
from methods import send_quarto_email_with_gmail, write_email_message_to_file, _read_quarto_email_json
22
import os
33
from dotenv import load_dotenv
44

5+
from great_tables import GT
6+
import os
7+
from plotnine import ggplot, aes, geom_point, labs
8+
import pandas as pd
9+
import base64
10+
from io import BytesIO
11+
512
os.chdir(os.path.dirname(os.path.abspath(__file__)))
613

714
load_dotenv()
815

16+
password = os.environ["POSIT_APP_PASSWORD"]
17+
username = os.environ["POSIT_ADDRESS"]
18+
19+
# send_quarto_email_with_gmail(
20+
# username=username,
21+
# password=password,
22+
# json_path=".output_metadata.json",
23+
# recipients=[username, "jules.walzergoldfeld@gmail.com"]
24+
# )
25+
26+
27+
28+
29+
# Simple text
30+
body_text = "<p>Hello, this is a test email with text, a plot, an image, and a table.</p>"
31+
32+
# Simple plot with plotnine
33+
df = pd.DataFrame({'x': [1, 2, 3], 'y': [3, 2, 1]})
34+
p = ggplot(df, aes('x', 'y')) + geom_point() + labs(title="A Simple Plot")
35+
36+
buf = BytesIO()
37+
p.save(buf, format='png')
38+
buf.seek(0)
39+
plot_bytes = buf.read()
40+
41+
plot_html = '<img src="cid:plot-image" alt="plotnine plot"/><br>'
42+
43+
# Simple image (embed as base64)
44+
image_path = "../examples/newspapers.jpg"
45+
46+
47+
with open(image_path, "rb") as img_file:
48+
image_bytes = img_file.read()
49+
50+
image_html = '<img src="cid:photo-image" alt="placeholder image"/><br>'
51+
52+
image_html
53+
54+
# Simple table with GT
55+
table_df = pd.DataFrame({'A': [1, 2], 'B': [3, 4]})
56+
gt = GT(table_df)
57+
table_html = gt.as_raw_html()
58+
59+
result_head = """
60+
<meta charset="utf-8">
61+
<title>Email Preview</title>
62+
"""
63+
64+
# Wrap the email body in the HTML structure
65+
66+
# Modified for redmail, but there are certainly other ways to do this without the ugly jinja syntax in python
67+
email_body = f"""<!doctype html>
68+
<html>
69+
<head>
70+
{result_head}
71+
</head>
72+
<body>
73+
{body_text} {{{{ plot_image }}}} {{{{ photo_image }}}} {table_html}
74+
</body>
75+
</html>"""
76+
77+
from dotenv import load_dotenv
78+
load_dotenv()
79+
80+
from redmail import gmail
81+
982
password = os.environ["GMAIL_APP_PASSWORD"]
10-
username = os.environ["GMAIL_ADDRESS"]
83+
username = "jules.walzergoldfeld@gmail.com"
1184

12-
send_quarto_email_with_gmail(
13-
username=username,
14-
password=password,
15-
json_path=".output_metadata.json",
16-
recipients=[username, "jules.walzergoldfeld@posit.co"]
85+
gmail.username = username
86+
gmail.password = password
87+
88+
89+
msg = gmail.get_message(
90+
subject="An example email",
91+
receivers=[username],
92+
body_images={
93+
'plot_image': plot_bytes,
94+
'photo_image': image_path,
95+
},
96+
html=email_body,
1797
)
98+
99+
100+
write_email_message_to_file(msg)
101+
102+
# struct = _read_quarto_email_json(".output_metadata.json")
103+
# struct.write_preview_email("struct_preview.html")
104+
105+

send-quarto-emails/methods.py

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
## TODO: work in progress
2-
2+
from __future__ import annotations
33
from dataclasses import dataclass
44
import base64
55
import json
6+
import re
7+
68

79
from email.mime.multipart import MIMEMultipart
810
from email.mime.text import MIMEText
@@ -15,13 +17,35 @@
1517
@dataclass
1618
class IntermediateDataStruct:
1719
html: str
18-
attachments: dict[str, str]
1920
subject: str
2021
rsc_email_supress_report_attachment: bool
2122
rsc_email_supress_scheduled: bool
2223

23-
text: str = None # sometimes present in quarto
24-
recipients: list[str] = None # not present in quarto
24+
external_attachments: dict[str, str] | None = None
25+
inline_attachments: dict[str, str] | None = None
26+
27+
text: str | None = None # sometimes present in quarto
28+
recipients: list[str] | None = None # not present in quarto
29+
30+
def write_preview_email(self, out_file: str = "preview_email.html") -> None:
31+
html_with_inline = re.sub(
32+
r'src="cid:([^"\s]+)"',
33+
_add_base_64_to_inline_attachments(self.inline_attachments),
34+
self.html,
35+
)
36+
37+
with open(out_file, "w", encoding="utf-8") as f:
38+
f.write(html_with_inline)
39+
40+
if self.external_attachments:
41+
raise ValueError("Preview does not yet support external attachments.")
42+
43+
def write_email_message(self) -> EmailMessage:
44+
pass
45+
46+
# sends just to some preview recipient?
47+
def preview_send_email():
48+
pass
2549

2650

2751
# You will have to call redmail get_message, and pass that EmailMessage object to this
@@ -30,20 +54,20 @@ class IntermediateDataStruct:
3054
# Or make the intermediate struct hold that payload (the EmailMessage class)
3155
def redmail_to_intermediate_struct(msg: EmailMessage) -> IntermediateDataStruct:
3256
email_body = msg.get_body()
57+
raise NotImplementedError
58+
# TODO incomplete
3359
attachments = {}
3460
# maybe do walk
3561
for elem in msg.iter_attachments():
3662
if elem.is_attachment():
3763
# This can fail if there's no associated filename?
3864
attachments[elem.get_filename()] = elem.get_content()
3965

40-
4166
iStruct = IntermediateDataStruct(html=email_body)
4267

4368
return iStruct
4469

4570

46-
4771
def yagmail_to_intermediate_struct():
4872
pass
4973

@@ -81,26 +105,28 @@ def _read_quarto_email_json(path: str) -> IntermediateDataStruct:
81105
iStruct = IntermediateDataStruct(
82106
html=email_html,
83107
text=email_text,
84-
attachments=email_images,
108+
inline_attachments=email_images,
85109
subject=email_subject,
86110
rsc_email_supress_report_attachment=supress_report_attachment,
87111
rsc_email_supress_scheduled=supress_scheduled,
88112
)
89113

90114
return iStruct
91115

116+
92117
# what to return?
93118
# consider malformed request?
94119

120+
95121
def send_quarto_email_with_gmail(
96122
username: str,
97123
password: str,
98124
json_path: str,
99125
recipients: list[str],
100126
):
101-
'''
127+
"""
102128
End to end sending of quarto meta data
103-
'''
129+
"""
104130
email_struct: IntermediateDataStruct = _read_quarto_email_json(json_path)
105131
email_struct.recipients = recipients
106132
send_struct_email_with_gmail(
@@ -115,9 +141,9 @@ def send_quarto_email_with_gmail(
115141
def send_struct_email_with_gmail(
116142
username: str, password: str, email_struct: IntermediateDataStruct
117143
):
118-
'''
144+
"""
119145
Send the email struct content via gmail with smptlib
120-
'''
146+
"""
121147
# Compose the email
122148
msg = MIMEMultipart("related")
123149
msg["Subject"] = email_struct.subject
@@ -132,13 +158,22 @@ def send_struct_email_with_gmail(
132158
if email_struct.text:
133159
msg_alt.attach(MIMEText(email_struct.text, "plain"))
134160

135-
# Attach images
136-
for image_name, image_base64 in email_struct.attachments.items():
161+
# Attach inline images
162+
for image_name, image_base64 in email_struct.inline_attachments.items():
163+
img_bytes = base64.b64decode(image_base64)
164+
img = MIMEImage(img_bytes, _subtype="png", name=f"{image_name}")
165+
166+
img.add_header("Content-ID", f"<{image_name}>")
167+
img.add_header("Content-Disposition", "inline", filename=f"{image_name}")
168+
169+
msg.attach(img)
170+
171+
for image_name, image_base64 in email_struct.external_attachments.items():
137172
img_bytes = base64.b64decode(image_base64)
138173
img = MIMEImage(img_bytes, _subtype="png", name=f"{image_name}")
139174

140-
img.add_header('Content-ID', f'<{image_name}>')
141-
img.add_header("Content-Disposition", "inline", filename=f"{image_name}")
175+
img.add_header("Content-ID", f"<{image_name}>")
176+
img.add_header("Content-Disposition", "attachment", filename=f"{image_name}")
142177

143178
msg.attach(img)
144179

@@ -148,11 +183,81 @@ def send_struct_email_with_gmail(
148183

149184

150185
def send_struct_email_with_redmail(email_struct: IntermediateDataStruct):
151-
152186
pass
153187

188+
154189
def send_struct_email_with_yagmail(email_struct: IntermediateDataStruct):
155190
pass
156191

192+
157193
def send_struct_email_with_mailgun(email_struct: IntermediateDataStruct):
158-
pass
194+
pass
195+
196+
197+
def send_struct_email_with_smtp(email_struct: IntermediateDataStruct):
198+
pass
199+
200+
201+
def write_email_message_to_file(
202+
msg: EmailMessage, out_file: str = "preview_email.html"
203+
):
204+
"""
205+
Writes the HTML content of an email message to a file, inlining any images referenced by Content-ID (cid).
206+
207+
This function extracts all attachments referenced by Content-ID from the given EmailMessage,
208+
replaces any `src="cid:..."` references in the HTML body with base64-encoded image data,
209+
and writes the resulting HTML to the specified output file.
210+
211+
Params:
212+
msg (EmailMessage): The email message object containing the HTML body and attachments.
213+
out_file (str, optional): The path to the output HTML file. Defaults to "preview_email.html".
214+
215+
Returns:
216+
None
217+
"""
218+
inline_attachments = {}
219+
220+
for part in msg.walk():
221+
content_id = part.get("Content-ID")
222+
if content_id:
223+
cid = content_id.strip("<>")
224+
225+
payload = part.get_payload(decode=True)
226+
inline_attachments[cid] = payload
227+
228+
html = msg.get_body(preferencelist=("html")).get_content()
229+
230+
# Replace each cid reference with base64 data
231+
html_inline = re.sub(
232+
r'src="cid:([^"]+)"',
233+
_add_base_64_to_inline_attachments(inline_attachments),
234+
html,
235+
)
236+
237+
# Write to file
238+
with open(out_file, "w", encoding="utf-8") as f:
239+
f.write(html_inline)
240+
241+
242+
# TODO: make sure this is not losing other attributes of the inline attachments
243+
def _add_base_64_to_inline_attachments(inline_attachments: dict[str, str]):
244+
# Replace all src="cid:..." in the HTML
245+
def replace_cid(match):
246+
cid = match.group(1)
247+
img_data = inline_attachments.get(cid)
248+
if img_data:
249+
# TODO: this is kinda hacky
250+
# If it's a string, decode from base64 to bytes first
251+
if isinstance(img_data, str):
252+
try:
253+
img_bytes = base64.b64decode(img_data)
254+
except Exception:
255+
# If not base64, treat as raw bytes
256+
img_bytes = img_data.encode("utf-8")
257+
else:
258+
img_bytes = img_data
259+
b64 = base64.b64encode(img_bytes).decode("utf-8")
260+
return f'src="data:image;base64,{b64}"'
261+
return match.group(0)
262+
263+
return replace_cid

0 commit comments

Comments
 (0)