Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
35 changes: 24 additions & 11 deletions custom-recipes/send-mails-from-contacts-dataset/recipe.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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)",
Expand Down Expand Up @@ -194,15 +207,15 @@
"label": "Attachments",
"type": "SEPARATOR"
},

{
"name": "attachment_type",
"label" : "Attachments format",
"type": "SELECT",
"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"
Expand Down
73 changes: 57 additions & 16 deletions custom-recipes/send-mails-from-contacts-dataset/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
liamlynch-data marked this conversation as resolved.
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')
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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")

Comment thread
liamlynch-data marked this conversation as resolved.
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

Expand Down Expand Up @@ -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))
Comment thread
liamlynch-data marked this conversation as resolved.
fail += 1
contact_dict['sendmail_status'] = 'FAILED'
contact_dict['sendmail_error'] = str(e)
Expand Down
7 changes: 4 additions & 3 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"id": "sendmail",
"version": "1.0.3",
"version": "1.1.0",
"meta": {
"label": "Send emails",
Comment thread
holstbone marked this conversation as resolved.
"description": "Send emails based on a dataset containing a list of contacts, with optional attachments (other datasets)",
"author": "Dataiku",
"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"
}
}
6 changes: 6 additions & 0 deletions python-lib/dku_attachment_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
holstbone marked this conversation as resolved.
request_fmt = "tsv-excel-header"

Expand All @@ -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
45 changes: 37 additions & 8 deletions python-lib/dku_email_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Comment thread
liamlynch-data marked this conversation as resolved.
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):
Expand Down Expand Up @@ -131,30 +145,45 @@ 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
"""
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

Comment thread
holstbone marked this conversation as resolved.
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()
Comment thread
liamlynch-data marked this conversation as resolved.

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")
Comment thread
liamlynch-data marked this conversation as resolved.
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"""
Expand Down