diff --git a/CHANGELOG.md b/CHANGELOG.md index f9379d7..39fd740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [Version 1.1.0](https://github.com/dataiku/dss-plugin-sendmail/releases/tag/v1.1.0) - Feature release - 2026-01 + +- Add comma-delimited CSV option +- Add cc and bcc fields ## [Version 1.0.3](https://github.com/dataiku/dss-plugin-sendmail/releases/tag/v1.0.3) - Feature release - 2025-03 diff --git a/custom-recipes/send-mails-from-contacts-dataset/recipe.json b/custom-recipes/send-mails-from-contacts-dataset/recipe.json index e656acd..2bb81c4 100644 --- a/custom-recipes/send-mails-from-contacts-dataset/recipe.json +++ b/custom-recipes/send-mails-from-contacts-dataset/recipe.json @@ -84,15 +84,6 @@ "type": "PASSWORD", "visibilityCondition" : "(model.mail_channel == null || model.mail_channel == '__DKU__DIRECT_SMTP__') && model.smtp_use_auth" }, - - { - "name": "recipient_column", - "label" : "Recipient (column)", - "type": "COLUMN", - "columnRole" : "contacts", - "description" : "Recipient of the email (from a column)", - "mandatory": true - }, { "name": "sender_column", "label" : "Sender (column)", @@ -115,6 +106,28 @@ "type": "BOOLEAN", "visibilityCondition" : "!model.mail_channel.endsWith('__WITH_DEFINED_SENDER__')" }, + { + "name": "recipient_column", + "label" : "Recipient (column)", + "type": "COLUMN", + "columnRole" : "contacts", + "description" : "Recipient(s) of the email (comma-separated or array, from a column)", + "mandatory": true + }, + { + "name": "cc_column", + "label" : "cc (column)", + "type": "COLUMN", + "columnRole" : "contacts", + "description" : "Courtesy copy (comma-separated or array, from a column). Note: Duplicate data will cause multiple emails." + }, + { + "name": "bcc_column", + "label" : "bcc (column)", + "type": "COLUMN", + "columnRole" : "contacts", + "description" : "Blind courtesy copy (comma-separated or array, from a column). Note: Duplicate data will cause multiple emails." + }, { "name": "subject_column", "label" : "Subject (column)", @@ -194,7 +207,6 @@ "label": "Attachments", "type": "SEPARATOR" }, - { "name": "attachment_type", "label" : "Attachments format", @@ -202,7 +214,8 @@ "selectChoices" : [ {"value": "send_no_attachments", "label": "Do not send attachments"}, {"value": "excel", "label": "Excel"}, - {"value": "csv", "label": "CSV"} + {"value": "csv", "label": "CSV tab-delimited"}, + {"value": "csv_comma", "label": "CSV comma-delimited"} ], "defaultValue" : "send_no_attachments", "description" : "File format for attachments" diff --git a/custom-recipes/send-mails-from-contacts-dataset/recipe.py b/custom-recipes/send-mails-from-contacts-dataset/recipe.py index 0d54a97..2ea6303 100644 --- a/custom-recipes/send-mails-from-contacts-dataset/recipe.py +++ b/custom-recipes/send-mails-from-contacts-dataset/recipe.py @@ -34,17 +34,33 @@ def to_real_channel_id(channel_id): def does_channel_have_sender(channel_id): return channel_id is not None and channel_id.endswith(SENDER_SUFFIX) -# Takes a string and returns a list of one or more address values +# Takes a string and returns a list of address values, could be empty - any no blank/empty strings are filtered out def parse_recipients(recipients): + if not recipients: + # Handle None and empty strings quickly + return [] + json_array_found = False try: # JSON array case value = json.loads(recipients) if isinstance(value, list): - return value + recipients_list = value + json_array_found = True except json.decoder.JSONDecodeError: pass # Other cases - either a single value or comma separated string `name@place.com, name2@place.com` - return recipients.split(",") + if not json_array_found: + recipients_list = recipients.split(",") + + filter_out_blanks = lambda item: bool(item) and not (isinstance(item, (str)) and item.isspace()) + filtered_list = list(filter(filter_out_blanks, recipients_list)) + return filtered_list + +# Validates column exists in schema +def confirm_column(column_name, schema): + column = next(filter(lambda col: col['name'] == column_name, schema), None) + if not column: + raise AttributeError("The column you specified (%s) was not found." % column_name) # Get handles on datasets output_A_names = get_output_names_for_role('output') @@ -59,6 +75,9 @@ def parse_recipients(recipients): recipient_column = config.get('recipient_column', None) +cc_column = config.get('cc_column', None) +bcc_column = config.get('bcc_column', None) + sender_column = config.get('sender_column', None) sender_value = config.get('sender_value', None) use_sender_value = config.get('use_sender_value', False) @@ -86,7 +105,7 @@ def parse_recipients(recipients): attachment_type = config.get('attachment_type', "send_no_attachments") -# Validation part 1 - Check some kind of value/column exists for body, subject, sender and recipient +# Validation part 1 - Check some kind of value/column has been given for body, subject, sender and recipient is_body_present = False if use_body_value: @@ -107,22 +126,37 @@ def parse_recipients(recipients): if not is_subject_present: raise AttributeError("No value provided for the subject") +is_sender_present = False + +if channel_has_sender: + is_sender_present = True +else: + if use_sender_value: + is_sender_present = bool(sender_value) + else: + is_sender_present = bool(sender_column) +if not is_sender_present: + raise AttributeError("No value provided for the sender") + if not recipient_column: raise AttributeError("No value provided for the recipient") -# Validation part 2 - when necessary, check the column values provided are in the contacts (people) dataset -people_columns = [p['name'] for p in people.read_schema()] +# Validation part 2 - when necessary, check the columns given exist in the contacts (people) dataset +people_schema = people.read_schema() for arg in ['subject', 'body']: - if not globals()["use_" + arg + "_value"] and globals()[arg + "_column"] not in people_columns: - raise AttributeError("The column you specified for %s (%s) was not found." % (arg, globals()[arg + "_column"])) + if not globals()["use_" + arg + "_value"]: + confirm_column(globals()[arg + "_column"], people_schema) -if not channel_has_sender and not use_sender_value and sender_column not in people_columns: - raise AttributeError("The column you specified for sender (%s) was not found." % sender_column) - -if recipient_column not in people_columns: - raise AttributeError("The column you specified for recipient (%s) was not found." % recipient_column) +if not channel_has_sender and not use_sender_value: + confirm_column(sender_column, people_schema) +for arg in [recipient_column, cc_column, bcc_column]: + if not arg: + # recipient_column would have previously failed in Validation Part 1 + pass + else: + confirm_column(arg, people_schema) # Create Jinja templates if needed @@ -159,27 +193,34 @@ def parse_recipients(recipients): fail = 0 try: for contact in people.iter_rows(): - recipients_string = contact[recipient_column] + recipients_string = contact[recipient_column] + cc_string = contact[cc_column] if cc_column else None + bcc_string = contact[bcc_column] if bcc_column else None if recipients_string: logging.info("Sending to %s" % recipients_string) else: logging.info("No recipient for row - emailing will fail - row data: %s" % contact) contact_dict = dict(contact) + try: email_subject = build_email_subject(use_subject_value, subject_template, subject_column, contact_dict) email_body_text = build_email_message_text(use_body_value, body_template, attachments_templating_dict, contact_dict, body_column, use_html_body_value) recipients = parse_recipients(recipients_string) + cc_recipients = parse_recipients(cc_string) + bcc_recipients = parse_recipients(bcc_string) + # Note - if the channel has a sender configured, the sender value will be ignored by the email client here sender = sender_value if use_sender_value else contact_dict.get(sender_column, "") - email_client.send_email(sender, recipients, email_subject, email_body_text, attachment_files) + + email_client.send_email(sender, recipients, cc_recipients, bcc_recipients, email_subject, email_body_text, attachment_files) contact_dict['sendmail_status'] = 'SUCCESS' success += 1 if writer: writer.write_row_dict(contact_dict) except Exception as e: - logging.exception("Send failed") + logging.exception("Send failed: %s", str(e)) fail += 1 contact_dict['sendmail_status'] = 'FAILED' contact_dict['sendmail_error'] = str(e) diff --git a/plugin.json b/plugin.json index ee7b2ac..bfd6ea3 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "id": "sendmail", - "version": "1.0.3", + "version": "1.1.0", "meta": { "label": "Send emails", "description": "Send emails based on a dataset containing a list of contacts, with optional attachments (other datasets)", @@ -8,7 +8,8 @@ "icon": "icon-envelope-alt", "licenseInfo": "Apache Software License", "url": "https://www.dataiku.com/dss/plugins/info/sendmail.html", - "tags": ["Marketing"], - "supportLevel": "NOT_SUPPORTED" + "tags": ["Productivity","Export"], + "supportLevel": "NOT_SUPPORTED", + "category": "Digital Marketing" } } diff --git a/python-lib/dku_attachment_handling.py b/python-lib/dku_attachment_handling.py index 434ee75..6dbe53b 100644 --- a/python-lib/dku_attachment_handling.py +++ b/python-lib/dku_attachment_handling.py @@ -58,6 +58,11 @@ def build_attachment_files(attachment_datasets, attachment_type, apply_coloring_ request_fmt = "excel" if apply_coloring_excel and supports_messaging_channels_and_conditional_formatting(dataiku.api_client()): format_params = {"applyColoring": True} + elif attachment_type == "csv_comma": + request_fmt = "csv" + format_params={"style": "excel", "separator": ",", "quoteChar" : "\"", "parseHeaderRow": True} + + # We expect attachment_type == "csv" here, revisit if future types/options are added in future else: request_fmt = "tsv-excel-header" @@ -69,6 +74,7 @@ def build_attachment_files(attachment_datasets, attachment_type, apply_coloring_ if is_excel: attachment_files.append(AttachmentFile(attachment_ds.full_name + ".xlsx", "application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet", file_bytes)) + else: attachment_files.append(AttachmentFile(attachment_ds.full_name + ".csv", "text", "csv", file_bytes)) return attachment_files diff --git a/python-lib/dku_email_client.py b/python-lib/dku_email_client.py index a1aa136..6b0f6ff 100644 --- a/python-lib/dku_email_client.py +++ b/python-lib/dku_email_client.py @@ -44,11 +44,13 @@ def __init__(self, plain_text): self.plain_text = plain_text @abstractmethod - def send_email(self, sender, recipients, email_body, email_subject, attachment_files): + def send_email(self, sender, recipients, cc_recipients, bcc_recipients, email_body, email_subject, attachment_files): """ Sends a separate email to each recipient :param sender: sender email, str - is ignored if a sender configured for the channel :param recipients: recipients email addresses, list + :param cc_recipients: cc recipient emails, list + :param bcc_recipients bcc recipient emails, list :param email_subject: str :param email_body: body of either plain text or html, str :param attachment_files:attachments as list of AttachmentFile @@ -79,12 +81,24 @@ def __init__(self, plain_text, channel_id): logging.info(f"Configured channel messaging client with channel {channel_id} - type: {self.channel.type}, " f"sender: {self.channel.sender}, plain_text? {self.plain_text}") - - def send_email(self, sender, recipients, email_subject, email_body, attachment_files): + + def send_email(self, sender, recipients, cc_recipients, bcc_recipients, email_subject, email_body, attachment_files): files = [(a.file_name, a.data, f"{a.mime_type}/{a.mime_subtype}") for a in attachment_files] sender_to_use = None if self.channel.sender else sender + + if not recipients: + raise Exception("No recipients provided to send to") + for recipient in recipients: - self.channel.send(self.project_id, [recipient], email_subject, email_body, attachments=files, plain_text=self.plain_text, sender=sender_to_use) + self.channel.send(self.project_id, + [recipient], + email_subject, + email_body, + attachments=files, + plain_text=self.plain_text, + sender=sender_to_use, + cc=cc_recipients, + bcc=bcc_recipients) class SmtpEmailClient(AbstractMessageClient): @@ -131,11 +145,13 @@ def attachments_to_mime(self, attachment_files): attachment_mimes.append(mime_app) return attachment_mimes - def send_single_email(self, sender, recipients, email_subject, email_body, attachment_mimes): + def send_single_email(self, sender, recipients, cc_recipients, bcc_recipients, email_subject, email_body, attachment_mimes): """ Sends a separate email to each recipient :param sender: sender email, str - is ignored if a sender configured for the channel :param recipients: recipients email addresses, list + :param cc_recipients: cc recipient emails, list + :param bcc_recipients: bcc recipient emails, list :param email_subject: str :param email_body: body of either plain text or html, str :param attachment_mimes, list of MIMEBase @@ -143,18 +159,31 @@ def send_single_email(self, sender, recipients, email_subject, email_body, attac msg = MIMEMultipart() msg["From"] = sender msg["To"] = ",".join(recipients) + + # Note: bcc recipients are implicitly handled, by excluding from msg but including in all_recipients + # See: https://stackoverflow.com/questions/1546367/how-to-send-mail-with-to-cc-and-bcc#:~:text=2%20Comments-,Add%20a%20comment,is%20in%20textfile%20for%20reading. + msg["Cc"] = ",".join(cc_recipients) + all_recipients = recipients + cc_recipients + bcc_recipients + msg["Subject"] = email_subject body_encoding = "utf-8" text_type = 'plain' if self.plain_text else 'html' msg.attach(MIMEText(email_body, text_type, body_encoding)) for mime_app in attachment_mimes: msg.attach(mime_app) - self.smtp.sendmail(sender, recipients, msg.as_string()) - def send_email(self, sender, recipients, email_subject, email_body, attachment_files): + try: + self.smtp.sendmail(sender, all_recipients, msg.as_string()) + finally: + # Explicitly reset the session or "Error: nested MAIL command" error is possible + self.smtp.rset() + + def send_email(self, sender, recipients, cc_recipients, bcc_recipients, email_subject, email_body, attachment_files): attachment_mimes = self.attachments_to_mime(attachment_files) + if not recipients: + raise Exception("No recipients provided to send to") for recipient in recipients: - self.send_single_email(sender, [recipient], email_subject, email_body, attachment_mimes) + self.send_single_email(sender, [recipient], cc_recipients, bcc_recipients, email_subject, email_body, attachment_mimes) def quit(self): """ Do any disconnection needed"""