Sending an email#

This notebook shows how to create an email and send it.

import nuclio
# nuclio: start-code
%nuclio config kind = "job"
%nuclio config spec.image = "mlrun/mlrun"
from mlrun.execution import MLClientCtx
from typing import List

# Import the email modules we'll need
import smtplib
from email.message import EmailMessage
import os

# For guessing MIME type based on file name extension
import mimetypes

def send_email(
    context: MLClientCtx,
    sender: str,
    to: str,
    subject: str,
    content: str = "",
    server_addr: str = None,
    attachments: List[str] = []
) -> None:
    """Send an email.
    :param sender: Sender email address
    :param context: The function context
    :param to: Email address of mail recipient
    :param subject: Email subject
    :param content: Optional mail text
    :param server_addr: Address of SMTP server to use. Use format <addr>:<port>
    :param attachments: List of attachments to add.
    """

    # Validate inputs
    email_user = context.get_secret("SMTP_USER")
    email_pass = context.get_secret("SMTP_PASSWORD")
    if email_user is None or email_pass is None:
        context.logger.error("Missing sender email or password - cannot send email.")
        return

    if server_addr is None:
        context.logger.error("Server not specified - cannot send email.")
        return
        
    msg = EmailMessage()
    msg['From'] = sender
    msg['Subject'] = subject
    msg['To'] = to
    msg.set_content(content)   

    for filename in attachments:
        context.logger.info(f'Looking at attachment: {filename}')
        if not os.path.isfile(filename):
            context.logger.warning(f'Filename does not exist {filename}')
            continue
        # Guess the content type based on the file's extension.  Encoding
        # will be ignored, although we should check for simple things like
        # gzip'd or compressed files.
        ctype, encoding = mimetypes.guess_type(filename)
        if ctype is None or encoding is not None:
            # No guess could be made, or the file is encoded (compressed), so
            # use a generic bag-of-bits type.
            ctype = 'application/octet-stream'
        maintype, subtype = ctype.split('/', 1)
        with open(filename,'rb') as fp:
            msg.add_attachment(fp.read(),
                               maintype=maintype,
                               subtype=subtype,
                               filename=os.path.basename(filename))
            context.logger.info(f'Added attachment: Filename: {filename}, of mimetype: {maintype}, {subtype}')
    
    try:
        s = smtplib.SMTP(host=server_addr)
        s.starttls()
        s.login(email_user,email_pass)
        s.send_message(msg)
        context.logger.info('Email sent successfully.')
    except smtplib.SMTPException as exp:
        context.logger.error(f'SMTP exception caught in SMTP code: {exp}')
    except ConnectionError as ce:
        context.logger.error(f'Connection error caught in SMTP code: {ce}')
# nuclio: end-code

MLRun conf#

from mlrun import mlconf
import os

#artifact_path = mlconf.artifact_path or os.path.abspath('jobs')
artifact_path = os.path.abspath('jobs')
mlconf.dbpath = 'http://mlrun-api:8080'
mlconf.artifact_path = artifact_path
print(f'Artifacts path: {mlconf.artifact_path}\nMLRun DB path: {mlconf.dbpath}')

Save function#

from mlrun import code_to_function

# create job function object from notebook code
fn = code_to_function("send_email")
# add metadata (for templates and reuse)
fn.spec.default_handler = "send_email"
fn.spec.description = "Send Email messages through SMTP server"
fn.metadata.categories = ["notifications"]
fn.metadata.labels = {"author": "saarc"}
fn.export("function.yaml")

Test the function#

First, configure MLRun. Define project parameters that will be used for testing the function

from os import path, getenv
from mlrun import new_project

project_name = '-'.join(filter(None, ['email-sending', getenv('V3IO_USERNAME', None)]))
project_path = path.abspath('conf')
project = new_project(project_name, project_path, init_git=True)

print(f'Project path: {project_path}\nProject name: {project_name}')
from mlrun import run_local, NewTask, import_function, mount_v3io

# Target location for storing pipeline artifacts
artifact_path = path.abspath('jobs')
# MLRun DB path or API service URL
mlconf.dbpath = mlconf.dbpath or 'http://mlrun-api:8080'

print(f'Artifacts path: {artifact_path}\nMLRun DB path: {mlconf.dbpath}')

Create some artifacts#

First we’ll load the Iris dataset and use the describe function to generate some artifacts describing it.

This is only used to generate some nice artifacts so we can send them later via email. If all you want is to test the email sending functionality you can safely ignore this part (and modify the code that actually sends the email to not use attachments).

from mlrun.execution import MLClientCtx
from typing import List
from os import path
import pandas as pd

# Ingest a data set into the platform
def get_data(context, 
             source_url, 
             format='csv'):

    iris_dataset = pd.read_csv(str(source_url))

    target_path = path.join(context.artifact_path, 'data')
    # Optionally print data to your logger
    context.logger.info('Saving Iris data set to {} ...'.format(target_path))

    # Store the data set in your artifacts database
    context.log_dataset('iris_dataset', df=iris_dataset, format=format,
                        index=False, artifact_path=target_path)
    
source_url = 'http://iguazio-sample-data.s3.amazonaws.com/iris_dataset.csv'

get_data_run = run_local(name='get_data',
                         handler=get_data,
                         inputs={'source_url':source_url},
                         project=project_name, artifact_path=artifact_path)

project.set_function('hub://describe', 'describe')
describe = project.func('describe').apply(mount_v3io())

describe_run = describe.run(params={'label_column': 'label'},
                            inputs={"table":
                                    get_data_run.outputs['iris_dataset']},
                            artifact_path=artifact_path)

Sending the email#

Sending emails need to have server_addr set to confirure the SMTP address. It also needs to have secrets created with the SMTP_USER and SMTP_PASSWORD secrets set, so it can login to the server.

We’ll send an email with the artifacts generated by the describe function. Note that some of these artifacts are HTML and the last one is a CSV. The send_email function will attempt to auto-detect the attachments’ MIME types and add them to the email with their appropriate types.

Configure task parameters to be used when executing the function#

Make sure to replace placeholders with actual SMTP configuration (address/email/password)

task_params = {
    'sender' : '<sender email>',
    'to': '<recipient email>',
    'subject': 'Dataset description, sent by the send_email function',
    'content': 'Some basic analysis of the iris dataset.',
    'attachments': [describe_run.outputs['histograms'],
                    describe_run.outputs['correlation'],
                    describe_run.outputs['correlation-matrix']],
    'server_addr': '<server address>:<port>',
}

task = NewTask(name='email_task', project=project_name, handler=send_email, artifact_path=artifact_path,
              params=task_params)

task_secrets = {'SMTP_USER':'<username>',
                'SMTP_PASSWORD': '<password>'}

task.with_secrets('inline',task_secrets)

Run locally#

send_email_run = run_local(task,name='send_email')

Run remotely#

# Convert the local get_data function into an email_func project function
email_func = code_to_function(name='send_email')
email_func.apply(mount_v3io())

email_func.run(task, params=task_params,  workdir=mlconf.artifact_path)
email_func.doc()