11## TODO: work in progress
2-
2+ from __future__ import annotations
33from dataclasses import dataclass
44import base64
55import json
6+ import re
7+
68
79from email .mime .multipart import MIMEMultipart
810from email .mime .text import MIMEText
1517@dataclass
1618class 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)
3155def 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-
4771def 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+
95121def 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(
115141def 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
150185def send_struct_email_with_redmail (email_struct : IntermediateDataStruct ):
151-
152186 pass
153187
188+
154189def send_struct_email_with_yagmail (email_struct : IntermediateDataStruct ):
155190 pass
156191
192+
157193def 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