diff --git a/src/resources/filters/quarto-post/email.lua b/src/resources/filters/quarto-post/email.lua index 836e037d47d..d1d680d2e53 100644 --- a/src/resources/filters/quarto-post/email.lua +++ b/src/resources/filters/quarto-post/email.lua @@ -185,12 +185,22 @@ function generate_html_email_from_template( return html_str end +-- v2: Collections for multiple emails +local emails = {} +local current_email = nil + +-- v1 fallback: Document-level metadata local subject = "" +local email_text = "" local email_images = {} local image_tbl = {} local suppress_scheduled_email = false local found_email_div = false +-- Track whether we detected v1-style top-level metadata +local has_top_level_metadata = false +local email_count = 0 + function process_meta(meta) if not found_email_div then return @@ -211,9 +221,11 @@ function process_meta(meta) end -- Function to check whether a div with the 'email' class is present in the document +-- and count them for version detection function find_email_div(div) if div.classes:includes("email") then found_email_div = true + email_count = email_count + 1 end end @@ -223,81 +235,100 @@ function process_div(div) return nil end - if div.classes:includes("subject") then - - subject = pandoc.utils.stringify(div) - return {} - - elseif div.classes:includes("email-text") then - - email_text = pandoc.write(pandoc.Pandoc({ div }), "plain") - return {} - - elseif div.classes:includes("email-scheduled") then - - local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(div)), 10) - local scheduled_email = str_truthy_falsy(email_scheduled_str) - - suppress_scheduled_email = not scheduled_email - - return {} - - elseif div.classes:includes("email") then - - --[[ - Render of HTML email message body for Connect: `email_html` + -- V2 mode: email div contains subject/email-text/email-scheduled + if div.classes:includes("email") then + + -- Start a new email object + current_email = { + subject = "", + email_text = "", + email_html = "", + email_html_preview = "", + image_tbl = {}, + email_images = {}, + suppress_scheduled_email = false, + attachments = {} + } - For each of the tags found we need to modify the tag so that it contains a - reference to the Base64 string; this reference is that of the Content-ID (or CID) tag; - the basic form is `` (the `image-id` will be written as - `img.`, incrementing from 1) - ]] + -- Extract nested metadata from immediate children + local remaining_content = {} + quarto.log.debug("Processing email div. Total children: " .. tostring(#div.content)) + + for i, child in ipairs(div.content) do + quarto.log.debug("Child " .. tostring(i) .. ": type=" .. tostring(child.t)) + if child.t == "Div" then + quarto.log.debug(" - Is Div, classes: " .. table.concat(child.classes, ",")) + if child.classes:includes("subject") then + quarto.log.debug("FOUND nested subject!") + quarto.log.debug(" Subject content: " .. pandoc.utils.stringify(child)) + current_email.subject = pandoc.utils.stringify(child) + elseif child.classes:includes("email-text") then + current_email.email_text = pandoc.write(pandoc.Pandoc({ child }), "plain") + elseif child.classes:includes("email-scheduled") then + local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(child)), 10) + local scheduled_email = str_truthy_falsy(email_scheduled_str) + current_email.suppress_scheduled_email = not scheduled_email + else + table.insert(remaining_content, child) + end + else + table.insert(remaining_content, child) + end + end + + -- Create a modified div without metadata for processing + local email_without_metadata = pandoc.Div(remaining_content, div.attr) + -- Process images with CID tags local count = 1 + local render_div_cid = quarto._quarto.ast.walk(email_without_metadata, { + Image = function(img_el) + local file_extension = get_file_extension(img_el.src) + local cid = "img" .. tostring(count) .. "." .. file_extension + current_email.image_tbl[cid] = img_el.src + img_el.src = "cid:" .. cid + count = count + 1 + return img_el + end + }) - local render_div_cid = quarto._quarto.ast.walk(div, {Image = function(img_el) - - local file_extension = get_file_extension(img_el.src) - local cid = "img" .. tostring(count) .. "." .. file_extension - image_tbl[cid] = img_el.src - img_el.src = "cid:" .. cid - count = count + 1 - return img_el - - end}) - - email_html = extract_email_div_str(render_div_cid) - - --[[ - Render of HTML email message body for Connect: `email_html_preview` - - We are keeping a render of the email HTML for previewing purposes (if the option - is taken to do so); here, the HTML is self-contained where the image tags contain - base64-encoded data - ]] - - local render_div_base64 = quarto._quarto.ast.walk(div, {Image = function(img_el) + current_email.email_html = extract_email_div_str(render_div_cid) + + -- Process images with base64 for preview + local render_div_base64 = quarto._quarto.ast.walk(email_without_metadata, { + Image = function(img_el) + local image_file = io.open(img_el.src, "rb") + if type(image_file) == "userdata" then + local image_data = image_file:read("*all") + image_file:close() + local encoded_data = quarto.base64.encode(image_data) + local file_extension = get_file_extension(img_el.src) + local base64_str = "data:image/" .. file_extension .. ";base64," .. encoded_data + img_el.src = base64_str + end + return img_el + end + }) - local image_file = io.open(img_el.src, "rb") + current_email.email_html_preview = extract_email_div_str(render_div_base64) + -- Encode base64 images for JSON + for cid, img in pairs(current_email.image_tbl) do + local image_file = io.open(img, "rb") if type(image_file) == "userdata" then - image_data = image_file:read("*all") + local image_data = image_file:read("*all") image_file:close() + local encoded_data = quarto.base64.encode(image_data) + current_email.email_images[cid] = encoded_data end + end - local encoded_data = quarto.base64.encode(image_data) - local file_extension = get_file_extension(img_el.src) - local base64_str = "data:image/" .. file_extension .. ";base64," .. encoded_data - img_el.src = base64_str - return img_el - - end}) - - email_html_preview = extract_email_div_str(render_div_base64) + -- Add current email to collection + table.insert(emails, current_email) + current_email = nil - -- Remove the the `.email` div so it doesn't appear in the main report document + -- Remove the email div from output return {} - end end @@ -312,6 +343,41 @@ function process_document(doc) return doc end + -- V1 fallback: Process document-level metadata divs (not nested in email) + doc = quarto._quarto.ast.walk(doc, { + Div = function(div) + if div.classes:includes("subject") then + quarto.log.debug("found top-level subject") + subject = pandoc.utils.stringify(div) + has_top_level_metadata = true + return {} + elseif div.classes:includes("email-text") then + email_text = pandoc.write(pandoc.Pandoc({ div }), "plain") + has_top_level_metadata = true + return {} + elseif div.classes:includes("email-scheduled") then + local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(div)), 10) + local scheduled_email = str_truthy_falsy(email_scheduled_str) + suppress_scheduled_email = not scheduled_email + has_top_level_metadata = true + return {} + end + return div + end + }) + + -- Warn if old v1 input format detected + if has_top_level_metadata then + quarto.log.warning("Old v1 email format detected (top-level subject/email-text). Outputting as v2 with single email for forward compatibility.") + end + + -- In v1 mode (document-level metadata), only keep the first email + if has_top_level_metadata and #emails > 1 then + quarto.log.warning("V1 format with document-level metadata should have only one email. Keeping first email only.") + emails = { emails[1] } + email_count = 1 + end + -- Get the current date and time local connect_date_time = os.date("%Y-%m-%d %H:%M:%S") @@ -321,117 +387,119 @@ function process_document(doc) local connect_report_url = os.getenv("RSC_REPORT_URL") local connect_report_subscription_url = os.getenv("RSC_REPORT_SUBSCRIPTION_URL") - -- The following regexes remove the surrounding
from the HTML text - email_html = string.gsub(email_html, "^
", '') - email_html = string.gsub(email_html, "
$", '') + -- Determine the location of the Quarto project directory + local project_output_directory = quarto.project.output_directory + local dir + if (project_output_directory ~= nil) then + dir = project_output_directory + else + local file = quarto.doc.input_file + dir = pandoc.path.directory(file) + end - -- Use the Connect email template components along with the `email_html` and - -- `email_html_preview` objects to generate the email message body for Connect - -- and the email HTML file (as a local preview) + quarto.log.warning("Generating V2 multi-email output format with " .. tostring(email_count) .. " email(s).") - html_email_body = generate_html_email_from_template( - email_html, - connect_date_time, - connect_report_rendering_url, - connect_report_url, - connect_report_subscription_url - ) - - html_preview_body = generate_html_email_from_template( - email_html_preview, - connect_date_time, - connect_report_rendering_url, - connect_report_url, - connect_report_subscription_url - ) - - -- Right after the tag in `html_preview_body` we need to insert a subject line HTML string; - -- this is the string to be inserted: - subject_html_preview = "
subject: " .. subject .. "
" - - -- insert `subject_html_preview` into `html_preview_body` at the aforementioned location - html_preview_body = string.gsub(html_preview_body, "", "\n" .. subject_html_preview) - - -- For each of the tags we need to create a Base64-encoded representation - -- of the image and place that into the table `email_images` (keyed by `cid`) - - local image_data = nil - - for cid, img in pairs(image_tbl) do - - local image_file = io.open(img, "rb") - - if type(image_file) == "userdata" then - image_data = image_file:read("*all") - image_file:close() + -- Process all emails and generate their previews + local emails_for_json = {} + + for idx, email_obj in ipairs(emails) do + + -- Apply document-level fallbacks with warnings + if email_obj.subject == "" and subject ~= "" then + quarto.log.warning("Email #" .. tostring(idx) .. " has no subject. Using document-level subject.") + email_obj.subject = subject + end + + if email_obj.email_text == "" and email_text ~= "" then + quarto.log.warning("Email #" .. tostring(idx) .. " has no email-text. Using document-level email-text.") + email_obj.email_text = email_text end - local encoded_data = quarto.base64.encode(image_data) - - -- Insert `encoded_data` into `email_images` table with prepared key - email_images[cid] = encoded_data - end + if not email_obj.suppress_scheduled_email and suppress_scheduled_email then + quarto.log.warning("Email #" .. tostring(idx) .. " has no suppress-scheduled setting. Using document-level setting.") + email_obj.suppress_scheduled_email = suppress_scheduled_email + end - -- Encode all of the strings and tables of strings into the JSON file - -- (`.output_metadata.json`) that's needed for Connect's email feature + if is_empty_table(email_obj.attachments) and not is_empty_table(attachments) then + email_obj.attachments = attachments + end - if (is_empty_table(email_images)) then + -- Clean up HTML + local email_html_clean = string.gsub(email_obj.email_html, "^
", '') + email_html_clean = string.gsub(email_html_clean, "
$", '') + + -- Generate HTML bodies + local html_email_body = generate_html_email_from_template( + email_html_clean, + connect_date_time, + connect_report_rendering_url, + connect_report_url, + connect_report_subscription_url + ) + + local html_preview_body = generate_html_email_from_template( + email_obj.email_html_preview, + connect_date_time, + connect_report_rendering_url, + connect_report_url, + connect_report_subscription_url + ) + + -- Add subject to preview + local subject_html_preview = "
subject: " .. email_obj.subject .. "
" + html_preview_body = string.gsub(html_preview_body, "", "\n" .. subject_html_preview) + + -- Build email object for JSON + + -- rsc_email_suppress_report_attachment (now inverted to send_report_as_attachment) referred to + -- the attachment of the rendered report to each connect email. + -- This is always true for all emails unless overridden by blastula (as is the case in v1) + local email_json_obj = { + email_id = idx, + subject = email_obj.subject, + body_html = html_email_body, + body_text = email_obj.email_text, + attachments = email_obj.attachments, + suppress_scheduled = email_obj.suppress_scheduled_email, + send_report_as_attachment = false + } - metadata_str = quarto.json.encode({ - rsc_email_subject = subject, - rsc_email_attachments = attachments, - rsc_email_body_html = html_email_body, - rsc_email_body_text = email_text, - rsc_email_suppress_report_attachment = true, - rsc_email_suppress_scheduled = suppress_scheduled_email - }) + -- Only add images if present + if not is_empty_table(email_obj.email_images) then + email_json_obj.images = email_obj.email_images + end - else + table.insert(emails_for_json, email_json_obj) - metadata_str = quarto.json.encode({ - rsc_email_subject = subject, - rsc_email_attachments = attachments, - rsc_email_body_html = html_email_body, - rsc_email_body_text = email_text, - rsc_email_images = email_images, - rsc_email_suppress_report_attachment = true, - rsc_email_suppress_scheduled = suppress_scheduled_email - }) + -- Write individual preview file + if meta_email_preview ~= false then + local preview_filename = "email-preview/email_id-" .. tostring(idx) .. ".html" + quarto._quarto.file.write(pandoc.path.join({dir, preview_filename}), html_preview_body) + end end - -- Determine the location of the Quarto project directory; if not defined - -- by the user then set to the location of the input file - local project_output_directory = quarto.project.output_directory + -- Generate V2 JSON with version field + local metadata_str = quarto.json.encode({ + rsc_email_version = 2, + emails = emails_for_json + }) - if (project_output_directory ~= nil) then - dir = project_output_directory - else - local file = quarto.doc.input_file - dir = pandoc.path.directory(file) - end + -- Write V2 metadata file + local metadata_path_file = pandoc.path.join({dir, ".output_metadata.json"}) + io.open(metadata_path_file, "w"):write(metadata_str):close() - -- For all file attachments declared by the user, ensure they copied over - -- to the project directory (`dir`) + -- Copy attachments to project directory for _, v in pairs(attachments) do - local source_attachment_file = pandoc.utils.stringify(v) local dest_attachment_path_file = pandoc.path.join({dir, pandoc.utils.stringify(v)}) - -- Only if the file exists should it be copied into the project directory if (file_exists(source_attachment_file)) then local attachment_text = io.open(source_attachment_file):read("*a") io.open(dest_attachment_path_file, "w"):write(attachment_text):close() end end - - -- Write the `.output_metadata.json` file to the project directory - local metadata_path_file = pandoc.path.join({dir, ".output_metadata.json"}) - io.open(metadata_path_file, "w"):write(metadata_str):close() - -- Write the `email-preview/index.html` file unless meta_email_preview is false - if meta_email_preview ~= false then - quarto._quarto.file.write(pandoc.path.join({dir, "email-preview/index.html"}), html_preview_body) - end + return doc end function render_email()