diff --git a/.github/ISSUE_TEMPLATE/airflow_providers_bug_report.yml b/.github/ISSUE_TEMPLATE/airflow_providers_bug_report.yml index 756fd1cffee8d..3a7efad358925 100644 --- a/.github/ISSUE_TEMPLATE/airflow_providers_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/airflow_providers_bug_report.yml @@ -91,6 +91,7 @@ body: - sftp - singularity - slack + - smtp - snowflake - sqlite - ssh diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e5d989f61f19d..8b539430cb4bd 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -621,7 +621,7 @@ imap, influxdb, jdbc, jenkins, kerberos, kubernetes, ldap, leveldb, microsoft.az microsoft.mssql, microsoft.psrp, microsoft.winrm, mongo, mssql, mysql, neo4j, odbc, openfaas, opsgenie, oracle, pagerduty, pandas, papermill, password, pinot, plexus, postgres, presto, qds, qubole, rabbitmq, redis, s3, salesforce, samba, segment, sendgrid, sentry, sftp, singularity, slack, -snowflake, spark, sqlite, ssh, statsd, tableau, tabular, telegram, trino, vertica, virtualenv, +smtp, snowflake, spark, sqlite, ssh, statsd, tableau, tabular, telegram, trino, vertica, virtualenv, webhdfs, winrm, yandex, zendesk .. END EXTRAS HERE diff --git a/INSTALL b/INSTALL index e3848a3716ba7..87a759bc02489 100644 --- a/INSTALL +++ b/INSTALL @@ -105,7 +105,7 @@ imap, influxdb, jdbc, jenkins, kerberos, kubernetes, ldap, leveldb, microsoft.az microsoft.mssql, microsoft.psrp, microsoft.winrm, mongo, mssql, mysql, neo4j, odbc, openfaas, opsgenie, oracle, pagerduty, pandas, papermill, password, pinot, plexus, postgres, presto, qds, qubole, rabbitmq, redis, s3, salesforce, samba, segment, sendgrid, sentry, sftp, singularity, slack, -snowflake, spark, sqlite, ssh, statsd, tableau, tabular, telegram, trino, vertica, virtualenv, +smtp, snowflake, spark, sqlite, ssh, statsd, tableau, tabular, telegram, trino, vertica, virtualenv, webhdfs, winrm, yandex, zendesk # END EXTRAS HERE diff --git a/airflow/providers/smtp/CHANGELOG.rst b/airflow/providers/smtp/CHANGELOG.rst new file mode 100644 index 0000000000000..f7968bbf49c60 --- /dev/null +++ b/airflow/providers/smtp/CHANGELOG.rst @@ -0,0 +1,30 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + + +.. NOTE TO CONTRIBUTORS: + Please, only add notes to the Changelog just below the "Changelog" header when there are some breaking changes + and you want to add an explanation to the users on how they are supposed to deal with them. + The changelog is updated and maintained semi-automatically by release manager. + +Changelog +--------- + +1.0.0 +..... + +Initial version of the provider. diff --git a/airflow/providers/smtp/__init__.py b/airflow/providers/smtp/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/providers/smtp/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/providers/smtp/hooks/__init__.py b/airflow/providers/smtp/hooks/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/providers/smtp/hooks/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/providers/smtp/hooks/smtp.py b/airflow/providers/smtp/hooks/smtp.py new file mode 100644 index 0000000000000..521e47fca135f --- /dev/null +++ b/airflow/providers/smtp/hooks/smtp.py @@ -0,0 +1,362 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" +This module provides everything to be able to search in mails for a specific attachment +and also to download it. +It uses the smtplib library that is already integrated in python 3. +""" +from __future__ import annotations + +import collections.abc +import os +import re +import smtplib +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate +from typing import Any, Iterable + +from airflow.exceptions import AirflowException, AirflowNotFoundException +from airflow.hooks.base import BaseHook +from airflow.models.connection import Connection + + +class SmtpHook(BaseHook): + """ + This hook connects to a mail server by using the smtp protocol. + + .. note:: Please call this Hook as context manager via `with` + to automatically open and close the connection to the mail server. + + :param smtp_conn_id: The :ref:`smtp connection id ` + that contains the information used to authenticate the client. + """ + + conn_name_attr = "smtp_conn_id" + default_conn_name = "smtp_default" + conn_type = "smtp" + hook_name = "SMTP" + + def __init__(self, smtp_conn_id: str = default_conn_name) -> None: + super().__init__() + self.smtp_conn_id = smtp_conn_id + self.smtp_connection: Connection | None = None + self.smtp_client: smtplib.SMTP_SSL | smtplib.SMTP | None = None + + def __enter__(self) -> SmtpHook: + return self.get_conn() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.smtp_client.close() + + def get_conn(self) -> SmtpHook: + """ + Login to the smtp server. + + .. note:: Please call this Hook as context manager via `with` + to automatically open and close the connection to the smtp server. + + :return: an authorized SmtpHook object. + """ + if not self.smtp_client: + try: + self.smtp_connection = self.get_connection(self.smtp_conn_id) + except AirflowNotFoundException: + raise AirflowException("SMTP connection is not found.") + + for attempt in range(1, self.smtp_retry_limit + 1): + try: + self.smtp_client = self._build_client() + except smtplib.SMTPServerDisconnected: + if attempt < self.smtp_retry_limit: + continue + raise AirflowException("Unable to connect to smtp server") + + if self.smtp_starttls: + self.smtp_client.starttls() + if self.smtp_user and self.smtp_password: + self.smtp_client.login(self.smtp_user, self.smtp_password) + break + + return self + + def _build_client(self) -> smtplib.SMTP_SSL | smtplib.SMTP: + + SMTP: type[smtplib.SMTP_SSL] | type[smtplib.SMTP] + if self.use_ssl: + SMTP = smtplib.SMTP_SSL + else: + SMTP = smtplib.SMTP + + smtp_kwargs: dict[str, Any] = {"host": self.host} + if self.port: + smtp_kwargs["port"] = self.port + smtp_kwargs["timeout"] = self.timeout + + return SMTP(**smtp_kwargs) + + @classmethod + def get_connection_form_widgets(cls) -> dict[str, Any]: + """Returns connection widgets to add to connection form""" + from flask_appbuilder.fieldwidgets import BS3TextFieldWidget + from flask_babel import lazy_gettext + from wtforms import BooleanField, IntegerField, StringField + from wtforms.validators import NumberRange + + return { + "from_email": StringField(lazy_gettext("From email"), widget=BS3TextFieldWidget()), + "timeout": IntegerField( + lazy_gettext("Connection timeout"), + validators=[NumberRange(min=0)], + widget=BS3TextFieldWidget(), + default=30, + ), + "retry_limit": IntegerField( + lazy_gettext("Number of Retries"), + validators=[NumberRange(min=0)], + widget=BS3TextFieldWidget(), + default=5, + ), + "disable_tls": BooleanField(lazy_gettext("Disable TLS"), default=False), + "disable_ssl": BooleanField(lazy_gettext("Disable SSL"), default=False), + } + + def test_connection(self) -> tuple[bool, str]: + """Test SMTP connectivity from UI""" + try: + smtp_client = self.get_conn().smtp_client + if smtp_client: + status = smtp_client.noop()[0] + if status == 250: + return True, "Connection successfully tested" + except Exception as e: + return False, str(e) + return False, "Failed to establish connection" + + def send_email_smtp( + self, + to: str | Iterable[str], + subject: str, + html_content: str, + from_email: str | None = None, + files: list[str] | None = None, + dryrun: bool = False, + cc: str | Iterable[str] | None = None, + bcc: str | Iterable[str] | None = None, + mime_subtype: str = "mixed", + mime_charset: str = "utf-8", + custom_headers: dict[str, Any] | None = None, + **kwargs, + ) -> None: + """Send an email with html content. + + :param to: Recipient email address or list of addresses. + :param subject: Email subject. + :param html_content: Email body in HTML format. + :param from_email: Sender email address. If it's None, the hook will check if there is an email + provided in the connection, and raises an exception if not. + :param files: List of file paths to attach to the email. + :param dryrun: If True, the email will not be sent, but all other actions will be performed. + :param cc: Carbon copy recipient email address or list of addresses. + :param bcc: Blind carbon copy recipient email address or list of addresses. + :param mime_subtype: MIME subtype of the email. + :param mime_charset: MIME charset of the email. + :param custom_headers: Dictionary of custom headers to include in the email. + :param kwargs: Additional keyword arguments. + + >>> send_email_smtp( + 'test@example.com', 'foo', 'Foo bar', ['/dev/null'], dryrun=True + ) + """ + if not self.smtp_client: + raise AirflowException("The 'smtp_client' should be initialized before!") + from_email = from_email or self.from_email + if not from_email: + raise AirflowException("You should provide `from_email` or define it in the connection.") + + mime_msg, recipients = self._build_mime_message( + mail_from=from_email, + to=to, + subject=subject, + html_content=html_content, + files=files, + cc=cc, + bcc=bcc, + mime_subtype=mime_subtype, + mime_charset=mime_charset, + custom_headers=custom_headers, + ) + if not dryrun: + for attempt in range(1, self.smtp_retry_limit + 1): + try: + self.smtp_client.sendmail( + from_addr=from_email, to_addrs=recipients, msg=mime_msg.as_string() + ) + except smtplib.SMTPServerDisconnected as e: + if attempt < self.smtp_retry_limit: + continue + raise e + break + + def _build_mime_message( + self, + mail_from: str | None, + to: str | Iterable[str], + subject: str, + html_content: str, + files: list[str] | None = None, + cc: str | Iterable[str] | None = None, + bcc: str | Iterable[str] | None = None, + mime_subtype: str = "mixed", + mime_charset: str = "utf-8", + custom_headers: dict[str, Any] | None = None, + ) -> tuple[MIMEMultipart, list[str]]: + """ + Build a MIME message that can be used to send an email and returns a full list of recipients. + + :param mail_from: Email address to set as the email's "From" field. + :param to: A string or iterable of strings containing email addresses + to set as the email's "To" field. + :param subject: The subject of the email. + :param html_content: The content of the email in HTML format. + :param files: A list of paths to files to be attached to the email. + :param cc: A string or iterable of strings containing email addresses + to set as the email's "CC" field. + :param bcc: A string or iterable of strings containing email addresses + to set as the email's "BCC" field. + :param mime_subtype: The subtype of the MIME message. Default: "mixed". + :param mime_charset: The charset of the email. Default: "utf-8". + :param custom_headers: Additional headers to add to the MIME message. + No validations are run on these values, and they should be able to be encoded. + :return: A tuple containing the email as a MIMEMultipart object and + a list of recipient email addresses. + """ + to = self._get_email_address_list(to) + + msg = MIMEMultipart(mime_subtype) + msg["Subject"] = subject + msg["From"] = mail_from + msg["To"] = ", ".join(to) + recipients = to + if cc: + cc = self._get_email_address_list(cc) + msg["CC"] = ", ".join(cc) + recipients += cc + + if bcc: + # don't add bcc in header + bcc = self._get_email_address_list(bcc) + recipients += bcc + + msg["Date"] = formatdate(localtime=True) + mime_text = MIMEText(html_content, "html", mime_charset) + msg.attach(mime_text) + + for fname in files or []: + basename = os.path.basename(fname) + with open(fname, "rb") as file: + part = MIMEApplication(file.read(), Name=basename) + part["Content-Disposition"] = f'attachment; filename="{basename}"' + part["Content-ID"] = f"<{basename}>" + msg.attach(part) + + if custom_headers: + for header_key, header_value in custom_headers.items(): + msg[header_key] = header_value + + return msg, recipients + + def _get_email_address_list(self, addresses: str | Iterable[str]) -> list[str]: + """ + Returns a list of email addresses from the provided input. + + :param addresses: A string or iterable of strings containing email addresses. + :return: A list of email addresses. + :raises TypeError: If the input is not a string or iterable of strings. + """ + if isinstance(addresses, str): + return self._get_email_list_from_str(addresses) + elif isinstance(addresses, collections.abc.Iterable): + if not all(isinstance(item, str) for item in addresses): + raise TypeError("The items in your iterable must be strings.") + return list(addresses) + else: + raise TypeError(f"Unexpected argument type: Received '{type(addresses).__name__}'.") + + def _get_email_list_from_str(self, addresses: str) -> list[str]: + """ + Extract a list of email addresses from a string. The string + can contain multiple email addresses separated by + any of the following delimiters: ',' or ';'. + + :param addresses: A string containing one or more email addresses. + :return: A list of email addresses. + """ + pattern = r"\s*[,;]\s*" + return [address for address in re.split(pattern, addresses)] + + @property + def conn(self) -> Connection: + if not self.smtp_connection: + raise AirflowException("The smtp connection should be loaded before!") + return self.smtp_connection + + @property + def smtp_retry_limit(self) -> int: + return int(self.conn.extra_dejson.get("retry_limit", 5)) + + @property + def from_email(self) -> str | None: + return self.conn.extra_dejson.get("from_email") + + @property + def smtp_user(self) -> str: + return self.conn.login + + @property + def smtp_password(self) -> str: + return self.conn.password + + @property + def smtp_starttls(self) -> bool: + return not bool(self.conn.extra_dejson.get("disable_tls", False)) + + @property + def host(self) -> str: + return self.conn.host + + @property + def port(self) -> int: + return self.conn.port + + @property + def timeout(self) -> int: + return int(self.conn.extra_dejson.get("timeout", 30)) + + @property + def use_ssl(self) -> bool: + return not bool(self.conn.extra_dejson.get("disable_ssl", False)) + + @staticmethod + def get_ui_field_behaviour() -> dict[str, Any]: + """Returns custom field behaviour""" + return { + "hidden_fields": ["schema", "extra"], + "relabeling": {}, + } diff --git a/airflow/providers/smtp/operators/__init__.py b/airflow/providers/smtp/operators/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/providers/smtp/operators/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/providers/smtp/operators/smtp.py b/airflow/providers/smtp/operators/smtp.py new file mode 100644 index 0000000000000..7c18f1c39094f --- /dev/null +++ b/airflow/providers/smtp/operators/smtp.py @@ -0,0 +1,93 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from typing import Any, Sequence + +from airflow.models import BaseOperator +from airflow.providers.smtp.hooks.smtp import SmtpHook +from airflow.utils.context import Context + + +class EmailOperator(BaseOperator): + """ + Sends an email. + + :param to: list of emails to send the email to. (templated) + :param from_email: email to send from. (templated) + :param subject: subject line for the email. (templated) + :param html_content: content of the email, html markup + is allowed. (templated) + :param files: file names to attach in email (templated) + :param cc: list of recipients to be added in CC field + :param bcc: list of recipients to be added in BCC field + :param mime_subtype: MIME sub content type + :param mime_charset: character set parameter added to the Content-Type + header. + :param custom_headers: additional headers to add to the MIME message. + """ + + template_fields: Sequence[str] = ("to", "from_email", "subject", "html_content", "files") + template_fields_renderers = {"html_content": "html"} + template_ext: Sequence[str] = (".html",) + ui_color = "#e6faf9" + + def __init__( + self, + *, + to: list[str] | str, + from_email: str, + subject: str, + html_content: str, + files: list | None = None, + cc: list[str] | str | None = None, + bcc: list[str] | str | None = None, + mime_subtype: str = "mixed", + mime_charset: str = "utf-8", + conn_id: str = "smtp_default", + custom_headers: dict[str, Any] | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.to = to + self.from_email = from_email + self.subject = subject + self.html_content = html_content + self.files = files or [] + self.cc = cc + self.bcc = bcc + self.mime_subtype = mime_subtype + self.mime_charset = mime_charset + self.conn_id = conn_id + self.custom_headers = custom_headers + + def execute(self, context: Context): + with SmtpHook(smtp_conn_id=self.conn_id) as smtp_hook: + return smtp_hook.send_email_smtp( + self.to, + self.from_email, + self.subject, + self.html_content, + files=self.files, + cc=self.cc, + bcc=self.bcc, + mime_subtype=self.mime_subtype, + mime_charset=self.mime_charset, + conn_id=self.conn_id, + custom_headers=self.custom_headers, + ) diff --git a/airflow/providers/smtp/provider.yaml b/airflow/providers/smtp/provider.yaml new file mode 100644 index 0000000000000..b7b96eba23aa9 --- /dev/null +++ b/airflow/providers/smtp/provider.yaml @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +--- +package-name: apache-airflow-providers-smtp +name: Simple Mail Transfer Protocol (SMTP) + +description: | + `Simple Mail Transfer Protocol (SMTP) `__ + +versions: + - 1.0.0 + +dependencies: + - apache-airflow>=2.3.0 + +integrations: + - integration-name: Simple Mail Transfer Protocol (SMTP) + external-doc-url: https://tools.ietf.org/html/rfc5321 + logo: /integration-logos/smtp/SMTP.png + tags: [protocol] + +operators: + - integration-name: Simple Mail Transfer Protocol (SMTP) + python-modules: + - airflow.providers.smtp.operators.smtp + +hooks: + - integration-name: Simple Mail Transfer Protocol (SMTP) + python-modules: + - airflow.providers.smtp.hooks.smtp + +connection-types: + - hook-class-name: airflow.providers.smtp.hooks.smtp.SmtpHook + connection-type: smtp diff --git a/docs/apache-airflow-providers-smtp/commits.rst b/docs/apache-airflow-providers-smtp/commits.rst new file mode 100644 index 0000000000000..64dc7ca944b55 --- /dev/null +++ b/docs/apache-airflow-providers-smtp/commits.rst @@ -0,0 +1,33 @@ + + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + + +Package apache-airflow-providers-smtp +------------------------------------------------------ + +`Simple Mail Transfer Protocol (SMTP) `__ + + +This is detailed commit list of changes for versions provider package: ``smtp``. +For high-level changelog, see :doc:`package information including changelog `. + + + + +1.0.0 +..... diff --git a/docs/apache-airflow-providers-smtp/connections/smtp.rst b/docs/apache-airflow-providers-smtp/connections/smtp.rst new file mode 100644 index 0000000000000..3ad02c6e8b077 --- /dev/null +++ b/docs/apache-airflow-providers-smtp/connections/smtp.rst @@ -0,0 +1,80 @@ +.. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + + + +.. _howto/connection:smtp: + +SMTP Connection +=============== + +The SMTP connection type enables integrations with the SMTP client. + +Authenticating to SMTP +---------------------- + +Authenticate to the SMTP client with the login and password field. +Use standard `SMTP authentication +`_ + +Default Connection IDs +---------------------- + +Hooks, operators, and sensors related to SMTP use ``smtp_default`` by default. + +Configuring the Connection +-------------------------- + +Login + Specify the username used for the SMTP client. + +Password + Specify the password used for the SMTP client. + +Host + Specify the SMTP host url. + +Port + Specify the SMTP port to connect to. The default depends on the whether you use ssl or not. + +Extra (optional) + Specify the extra parameters (as json dictionary) + + * ``from_email``: The email address from which you want to send the email. + * ``disable_ssl``: If set to true, then a non-ssl connection is being used. Default is false. Also note that changing the ssl option also influences the default port being used. + * ``timeout``: The SMTP connection creation timeout in seconds. Default is 30. + * ``disable_tls``: By default the SMTP connection is created in TLS mode. Set to false to disable tls mode. + * ``retry_limit``: How many attempts to connect to the server before raising an exception. Default is 5. + +When specifying the connection in environment variable you should specify +it using URI syntax. + +Note that all components of the URI should be URL-encoded. + +For example: + +.. code-block:: bash + + export AIRFLOW_CONN_SMTP_DEFAULT='smtp://username:password@smtp.sendgrid.net:587' + +Another example for connecting via a non-SSL connection. + +.. code-block:: bash + + export AIRFLOW_CONN_SMTP_NOSSL='smtp://username:password@smtp.sendgrid.net:587?disable_ssl=true' + +Note that you can set the port regardless of whether you choose to use ssl or not. The above examples show default ports for SSL and Non-SSL connections. diff --git a/docs/apache-airflow-providers-smtp/index.rst b/docs/apache-airflow-providers-smtp/index.rst new file mode 100644 index 0000000000000..3f8ca0061d782 --- /dev/null +++ b/docs/apache-airflow-providers-smtp/index.rst @@ -0,0 +1,70 @@ + + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +``apache-airflow-providers-smtp`` +================================= + +Content +------- + +.. toctree:: + :maxdepth: 1 + :caption: References + + Connection types + Python API <_api/airflow/providers/smtp/index> + +.. toctree:: + :maxdepth: 1 + :caption: Resources + + PyPI Repository + Installing from sources + +.. THE REMAINDER OF THE FILE IS AUTOMATICALLY GENERATED. IT WILL BE OVERWRITTEN AT RELEASE TIME! + + +.. toctree:: + :maxdepth: 1 + :caption: Commits + + Detailed list of commits + + +Package apache-airflow-providers-smtp +------------------------------------------------------ + +`Simple Mail Transfer Protocol (SMTP) `__ + + +Release: 1.0.0 + +Provider package +---------------- + +This is a provider package for ``smtp`` provider. All classes for this provider package +are in ``airflow.providers.smtp`` python package. + +Installation +------------ + +You can install this package on top of an existing Airflow 2 installation (see ``Requirements`` below) +for the minimum Airflow version supported) via +``pip install apache-airflow-providers-smtp`` + +.. include:: ../../airflow/providers/smtp/CHANGELOG.rst diff --git a/docs/apache-airflow-providers-smtp/installing-providers-from-sources.rst b/docs/apache-airflow-providers-smtp/installing-providers-from-sources.rst new file mode 100644 index 0000000000000..1c90205d15b3a --- /dev/null +++ b/docs/apache-airflow-providers-smtp/installing-providers-from-sources.rst @@ -0,0 +1,18 @@ + .. Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + .. http://www.apache.org/licenses/LICENSE-2.0 + + .. Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +.. include:: ../installing-providers-from-sources.rst diff --git a/docs/apache-airflow/extra-packages-ref.rst b/docs/apache-airflow/extra-packages-ref.rst index 457a34aafa6c6..7cb7df771b623 100644 --- a/docs/apache-airflow/extra-packages-ref.rst +++ b/docs/apache-airflow/extra-packages-ref.rst @@ -294,6 +294,8 @@ These are extras that provide support for integration with external systems via +---------------------+-----------------------------------------------------+--------------------------------------+--------------+ | sftp | ``pip install 'apache-airflow[sftp]'`` | SFTP hooks, operators and sensors | | +---------------------+-----------------------------------------------------+--------------------------------------+--------------+ +| smtp | ``pip install 'apache-airflow[smtp]'`` | SMTP hooks and operators | | ++---------------------+-----------------------------------------------------+--------------------------------------+--------------+ | sqlite | ``pip install 'apache-airflow[sqlite]'`` | SQLite hooks and operators | * | +---------------------+-----------------------------------------------------+--------------------------------------+--------------+ | ssh | ``pip install 'apache-airflow[ssh]'`` | SSH hooks and operators | | diff --git a/docs/integration-logos/smtp/SMTP.png b/docs/integration-logos/smtp/SMTP.png new file mode 100644 index 0000000000000..dbdde70a8b704 Binary files /dev/null and b/docs/integration-logos/smtp/SMTP.png differ diff --git a/generated/provider_dependencies.json b/generated/provider_dependencies.json index 716156b8ad5af..625f118f1410c 100644 --- a/generated/provider_dependencies.json +++ b/generated/provider_dependencies.json @@ -683,6 +683,12 @@ "common.sql" ] }, + "smtp": { + "deps": [ + "apache-airflow>=2.3.0" + ], + "cross-providers-deps": [] + }, "snowflake": { "deps": [ "apache-airflow-providers-common-sql>=1.3.1", diff --git a/images/breeze/output-commands-hash.txt b/images/breeze/output-commands-hash.txt index 9646cac98862a..01ee85ccebb01 100644 --- a/images/breeze/output-commands-hash.txt +++ b/images/breeze/output-commands-hash.txt @@ -2,7 +2,7 @@ # Please do not solve it but run `breeze setup regenerate-command-images`. # This command should fix the conflict and regenerate help images that you have conflict with. main:83de6a9bf2b1afecd1f9ce4cd0493733 -build-docs:18235f12f85f8df82f3eb245e429f62d +build-docs:61ddf42565c6e39b2a1b228b2a0e89de ci:find-newer-dependencies:8fa2b57f5f0523c928743b235ee3ab5a ci:fix-ownership:fee2c9ec9ef19686792002ae054fecdd ci:free-space:47234aa0a60b0efd84972e6e797379f8 @@ -37,15 +37,15 @@ prod-image:verify:31bc5efada1d70a0a31990025db1a093 prod-image:eb1ef0cf6e139d01ceb26f09ca3deaaa release-management:create-minor-branch:6a01066dce15e09fb269a8385626657c release-management:generate-constraints:ae30d6ad49a1b2c15b61cb29080fd957 -release-management:generate-issue-content-providers:9b8b5b39e9310b01e6e0fb6b41483063 +release-management:generate-issue-content-providers:f3c00ba74e3afc054fe29b65156740ac release-management:prepare-airflow-package:3ac14ea6d2b09614959c0ec4fd564789 -release-management:prepare-provider-documentation:452b6165f09755d052501f6003ae3ea1 -release-management:prepare-provider-packages:c85c2997e01b7f9db95dc9a7abb9ea48 +release-management:prepare-provider-documentation:40d540fdfebf6c8ddc4cd151d52b88e6 +release-management:prepare-provider-packages:72dd7c3b19f85024bc9a4939cac5d87c release-management:release-prod-images:c9bc40938e0efad49e51ef66e83f9527 release-management:start-rc-process:6aafbaceabd7b67b9a1af4c2f59abc4c release-management:start-release:acb384d86e02ff5fde1bf971897be17c release-management:verify-provider-packages:88bd609aff6d09d52ab8d80d6e055e7b -release-management:edbaabee1315498f15a27bd853bcd1cc +release-management:926400d9c6d5c491f0182c5520bbfd68 setup:autocomplete:03343478bf1d0cf9c101d454cdb63b68 setup:check-all-params-in-groups:4d0f8c19cbdb56290055d863b08a3376 setup:config:3ffcd35dd24b486ddf1d08b797e3d017 diff --git a/images/breeze/output_build-docs.svg b/images/breeze/output_build-docs.svg index 84653d88b8b30..2a0435a831639 100644 --- a/images/breeze/output_build-docs.svg +++ b/images/breeze/output_build-docs.svg @@ -281,12 +281,12 @@ apache-airflow-providers-samba | apache-airflow-providers-segment |                         apache-airflow-providers-sendgrid | apache-airflow-providers-sftp |                         apache-airflow-providers-singularity | apache-airflow-providers-slack |                     -apache-airflow-providers-snowflake | apache-airflow-providers-sqlite |                      -apache-airflow-providers-ssh | apache-airflow-providers-tableau |                           -apache-airflow-providers-tabular | apache-airflow-providers-telegram |                      -apache-airflow-providers-trino | apache-airflow-providers-vertica |                         -apache-airflow-providers-yandex | apache-airflow-providers-zendesk | docker-stack |         -helm-chart)                                                                                 +apache-airflow-providers-smtp | apache-airflow-providers-snowflake |                        +apache-airflow-providers-sqlite | apache-airflow-providers-ssh |                            +apache-airflow-providers-tableau | apache-airflow-providers-tabular |                       +apache-airflow-providers-telegram | apache-airflow-providers-trino |                        +apache-airflow-providers-vertica | apache-airflow-providers-yandex |                        +apache-airflow-providers-zendesk | docker-stack | helm-chart)                               --github-repository-gGitHub repository used to pull, push run images.(TEXT)[default: apache/airflow] ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ diff --git a/images/breeze/output_release-management_generate-issue-content-providers.svg b/images/breeze/output_release-management_generate-issue-content-providers.svg index 044fc3b1a354c..ace464046a763 100644 --- a/images/breeze/output_release-management_generate-issue-content-providers.svg +++ b/images/breeze/output_release-management_generate-issue-content-providers.svg @@ -176,8 +176,8 @@                                                                   oracle | pagerduty | papermill | plexus | postgres |                                                                   presto | qubole | redis | salesforce | samba |                                                                   segment | sendgrid | sftp | singularity | slack | -                                                                  snowflake | sqlite | ssh | tableau | tabular | -                                                                  telegram | trino | vertica | yandex | zendesk]... +                                                                  smtp | snowflake | sqlite | ssh | tableau | tabular +                                                                  | telegram | trino | vertica | yandex | zendesk]... Generates content for issue to test the release. diff --git a/images/breeze/output_release-management_prepare-provider-documentation.svg b/images/breeze/output_release-management_prepare-provider-documentation.svg index 986426ccb13a5..807577a6ef118 100644 --- a/images/breeze/output_release-management_prepare-provider-documentation.svg +++ b/images/breeze/output_release-management_prepare-provider-documentation.svg @@ -169,9 +169,9 @@                                                                 mongo | mysql | neo4j | odbc | openfaas | opsgenie |                                                                 oracle | pagerduty | papermill | plexus | postgres |                                                                 presto | qubole | redis | salesforce | samba | segment -                                                                | sendgrid | sftp | singularity | slack | snowflake | -                                                                sqlite | ssh | tableau | tabular | telegram | trino | -                                                                vertica | yandex | zendesk]... +                                                                | sendgrid | sftp | singularity | slack | smtp | +                                                                snowflake | sqlite | ssh | tableau | tabular | +                                                                telegram | trino | vertica | yandex | zendesk]... Prepare CHANGELOGREADME and COMMITS information for providers. diff --git a/images/breeze/output_release-management_prepare-provider-packages.svg b/images/breeze/output_release-management_prepare-provider-packages.svg index 8d98428323ea4..d75838e2621eb 100644 --- a/images/breeze/output_release-management_prepare-provider-packages.svg +++ b/images/breeze/output_release-management_prepare-provider-packages.svg @@ -1,4 +1,4 @@ - +                                                            | mysql | neo4j | odbc | openfaas | opsgenie | oracle |                                                            pagerduty | papermill | plexus | postgres | presto | qubole                                                            | redis | salesforce | samba | segment | sendgrid | sftp | -                                                           singularity | slack | snowflake | sqlite | ssh | tableau | -                                                           tabular | telegram | trino | vertica | yandex | zendesk]... - -Prepare sdist/whl packages of Airflow Providers. - -╭─ Package flags ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ ---package-formatFormat of packages.(wheel | sdist | both)[default: wheel] ---version-suffix-for-pypiVersion suffix used for PyPI packages (alpha, beta, rc1, etc.).(TEXT) ---package-list-fileRead list of packages from text file (one package per line).(FILENAME) ---debugDrop user in shell instead of running the command. Useful for debugging. ---github-repository-gGitHub repository used to pull, push run images.(TEXT)[default: apache/airflow] -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ ---verbose-vPrint verbose information about performed steps. ---dry-run-DIf dry-run is set, commands are only printed, not executed. ---help-hShow this message and exit. -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +                                                           singularity | slack | smtp | snowflake | sqlite | ssh | +                                                           tableau | tabular | telegram | trino | vertica | yandex | +                                                           zendesk]... + +Prepare sdist/whl packages of Airflow Providers. + +╭─ Package flags ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ +--package-formatFormat of packages.(wheel | sdist | both)[default: wheel] +--version-suffix-for-pypiVersion suffix used for PyPI packages (alpha, beta, rc1, etc.).(TEXT) +--package-list-fileRead list of packages from text file (one package per line).(FILENAME) +--debugDrop user in shell instead of running the command. Useful for debugging. +--github-repository-gGitHub repository used to pull, push run images.(TEXT)[default: apache/airflow] +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +--verbose-vPrint verbose information about performed steps. +--dry-run-DIf dry-run is set, commands are only printed, not executed. +--help-hShow this message and exit. +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/providers/smtp/__init__.py b/tests/providers/smtp/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/tests/providers/smtp/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/providers/smtp/hooks/__init__.py b/tests/providers/smtp/hooks/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/tests/providers/smtp/hooks/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/providers/smtp/hooks/smtp.py b/tests/providers/smtp/hooks/smtp.py new file mode 100644 index 0000000000000..567f5580a29f6 --- /dev/null +++ b/tests/providers/smtp/hooks/smtp.py @@ -0,0 +1,290 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import json +import os +import smtplib +import tempfile +from email.mime.application import MIMEApplication +from unittest.mock import Mock, patch + +import pytest + +from airflow.models import Connection +from airflow.providers.smtp.hooks.smtp import SmtpHook +from airflow.utils import db +from airflow.utils.session import create_session + +smtplib_string = "airflow.providers.smtp.hooks.smtp.smtplib" + +TEST_EMAILS = ["test1@example.com", "test2@example.com"] + + +def _create_fake_smtp(mock_smtplib, use_ssl=True): + if use_ssl: + mock_conn = Mock(spec=smtplib.SMTP_SSL) + mock_smtplib.SMTP_SSL.return_value = mock_conn + else: + mock_conn = Mock(spec=smtplib.SMTP) + mock_smtplib.SMTP.return_value = mock_conn + + mock_conn.close.return_value = ("OK", []) + + return mock_conn + + +class TestSmtpHook: + def setup_method(self): + db.merge_conn( + Connection( + conn_id="smtp_default", + conn_type="smtp", + host="smtp_server_address", + login="smtp_user", + password="smtp_password", + port=465, + extra=json.dumps(dict(from_email="from")), + ) + ) + db.merge_conn( + Connection( + conn_id="smtp_nonssl", + conn_type="smtp", + host="smtp_server_address", + login="smtp_user", + password="smtp_password", + port=587, + extra=json.dumps(dict(disable_ssl=True, from_email="from")), + ) + ) + + @patch(smtplib_string) + def test_connect_and_disconnect(self, mock_smtplib): + mock_conn = _create_fake_smtp(mock_smtplib) + + with SmtpHook(): + pass + + mock_smtplib.SMTP_SSL.assert_called_once_with(host="smtp_server_address", port=465, timeout=30) + mock_conn.login.assert_called_once_with("smtp_user", "smtp_password") + assert mock_conn.close.call_count == 1 + + @patch(smtplib_string) + def test_connect_and_disconnect_via_nonssl(self, mock_smtplib): + mock_conn = _create_fake_smtp(mock_smtplib, use_ssl=False) + + with SmtpHook(smtp_conn_id="smtp_nonssl"): + pass + + mock_smtplib.SMTP.assert_called_once_with(host="smtp_server_address", port=587, timeout=30) + mock_conn.login.assert_called_once_with("smtp_user", "smtp_password") + assert mock_conn.close.call_count == 1 + + @patch(smtplib_string) + def test_get_email_address_single_email(self, mock_smtplib): + with SmtpHook() as smtp_hook: + assert smtp_hook._get_email_address_list("test1@example.com") == ["test1@example.com"] + + @patch(smtplib_string) + def test_get_email_address_comma_sep_string(self, mock_smtplib): + with SmtpHook() as smtp_hook: + assert smtp_hook._get_email_address_list("test1@example.com, test2@example.com") == TEST_EMAILS + + @patch(smtplib_string) + def test_get_email_address_colon_sep_string(self, mock_smtplib): + with SmtpHook() as smtp_hook: + assert smtp_hook._get_email_address_list("test1@example.com; test2@example.com") == TEST_EMAILS + + @patch(smtplib_string) + def test_get_email_address_list(self, mock_smtplib): + with SmtpHook() as smtp_hook: + assert ( + smtp_hook._get_email_address_list(["test1@example.com", "test2@example.com"]) == TEST_EMAILS + ) + + @patch(smtplib_string) + def test_get_email_address_tuple(self, mock_smtplib): + with SmtpHook() as smtp_hook: + assert ( + smtp_hook._get_email_address_list(("test1@example.com", "test2@example.com")) == TEST_EMAILS + ) + + @patch(smtplib_string) + def test_get_email_address_invalid_type(self, mock_smtplib): + with pytest.raises(TypeError): + with SmtpHook() as smtp_hook: + smtp_hook._get_email_address_list(1) + + @patch(smtplib_string) + def test_get_email_address_invalid_type_in_iterable(self, mock_smtplib): + with pytest.raises(TypeError): + with SmtpHook() as smtp_hook: + smtp_hook._get_email_address_list(["test1@example.com", 2]) + + @patch(smtplib_string) + def test_build_mime_message(self, mock_smtplib): + mail_from = "from@example.com" + mail_to = "to@example.com" + subject = "test subject" + html_content = "Test" + custom_headers = {"Reply-To": "reply_to@example.com"} + with SmtpHook() as smtp_hook: + msg, recipients = smtp_hook._build_mime_message( + mail_from=mail_from, + to=mail_to, + subject=subject, + html_content=html_content, + custom_headers=custom_headers, + ) + + assert "From" in msg + assert "To" in msg + assert "Subject" in msg + assert "Reply-To" in msg + assert [mail_to] == recipients + assert msg["To"] == ",".join(recipients) + + @patch(smtplib_string) + def test_send_smtp(self, mock_smtplib): + mock_send_mime = mock_smtplib.SMTP_SSL().sendmail + with SmtpHook() as smtp_hook, tempfile.NamedTemporaryFile() as attachment: + attachment.write(b"attachment") + attachment.seek(0) + smtp_hook.send_email_smtp("to", "subject", "content", files=[attachment.name]) + assert mock_send_mime.called + _, call_args = mock_send_mime.call_args + assert "from" == call_args["from_addr"] + assert ["to"] == call_args["to_addrs"] + msg = call_args["msg"] + assert "Subject: subject" in msg + assert "From: from" in msg + filename = 'attachment; filename="' + os.path.basename(attachment.name) + '"' + assert filename in msg + mimeapp = MIMEApplication("attachment") + assert mimeapp.get_payload() in msg + + @patch("airflow.providers.smtp.hooks.smtp.SmtpHook.get_connection") + @patch(smtplib_string) + def test_hook_conn(self, mock_smtplib, mock_hook_conn): + mock_conn = Mock() + mock_conn.login = "user" + mock_conn.password = "password" + mock_conn.extra_dejson = { + "disable_ssl": False, + } + mock_hook_conn.return_value = mock_conn + smtp_client_mock = mock_smtplib.SMTP_SSL() + with SmtpHook() as smtp_hook: + smtp_hook.send_email_smtp("to", "subject", "content", from_email="from") + mock_hook_conn.assert_called_with("smtp_default") + smtp_client_mock.login.assert_called_once_with("user", "password") + smtp_client_mock.sendmail.assert_called_once() + assert smtp_client_mock.close.called + + @patch("smtplib.SMTP_SSL") + @patch("smtplib.SMTP") + def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl): + mock_smtp_ssl.return_value = Mock() + with SmtpHook() as smtp_hook: + smtp_hook.send_email_smtp("to", "subject", "content", from_email="from") + assert not mock_smtp.called + mock_smtp_ssl.assert_called_once_with(host="smtp_server_address", port=465, timeout=30) + + @patch("smtplib.SMTP_SSL") + @patch("smtplib.SMTP") + def test_send_mime_nossl(self, mock_smtp, mock_smtp_ssl): + mock_smtp.return_value = Mock() + with SmtpHook(smtp_conn_id="smtp_nonssl") as smtp_hook: + smtp_hook.send_email_smtp("to", "subject", "content", from_email="from") + assert not mock_smtp_ssl.called + mock_smtp.assert_called_once_with(host="smtp_server_address", port=587, timeout=30) + + @patch("smtplib.SMTP") + def test_send_mime_noauth(self, mock_smtp): + mock_smtp.return_value = Mock() + conn = Connection( + conn_id="smtp_noauth", + conn_type="smtp", + host="smtp_server_address", + login=None, + password="None", + port=587, + extra=json.dumps(dict(disable_ssl=True, from_email="from")), + ) + db.merge_conn(conn) + with SmtpHook(smtp_conn_id="smtp_noauth") as smtp_hook: + smtp_hook.send_email_smtp("to", "subject", "content", from_email="from") + mock_smtp.assert_called_once_with(host="smtp_server_address", port=587, timeout=30) + assert not mock_smtp.login.called + with create_session() as session: + session.query(Connection).filter(Connection.id == conn.id).delete() + + @patch("smtplib.SMTP_SSL") + @patch("smtplib.SMTP") + def test_send_mime_dryrun(self, mock_smtp, mock_smtp_ssl): + with SmtpHook() as smtp_hook: + smtp_hook.send_email_smtp("to", "subject", "content", dryrun=True) + assert not mock_smtp.sendmail.called + assert not mock_smtp_ssl.sendmail.called + + @patch("smtplib.SMTP_SSL") + def test_send_mime_ssl_complete_failure(self, mock_smtp_ssl): + mock_smtp_ssl().sendmail.side_effect = smtplib.SMTPServerDisconnected() + with SmtpHook() as smtp_hook: + with pytest.raises(smtplib.SMTPServerDisconnected): + smtp_hook.send_email_smtp("to", "subject", "content") + assert mock_smtp_ssl().sendmail.call_count == 5 + + @patch("email.message.Message.as_string") + @patch("smtplib.SMTP_SSL") + def test_send_mime_partial_failure(self, mock_smtp_ssl, mime_message_mock): + mime_message_mock.return_value = "msg" + final_mock = Mock() + side_effects = [smtplib.SMTPServerDisconnected(), smtplib.SMTPServerDisconnected(), final_mock] + mock_smtp_ssl.side_effect = side_effects + with SmtpHook() as smtp_hook: + smtp_hook.send_email_smtp("to", "subject", "content") + assert mock_smtp_ssl.call_count == side_effects.index(final_mock) + 1 + assert final_mock.starttls.called + final_mock.sendmail.assert_called_once_with(from_addr="from", to_addrs=["to"], msg="msg") + assert final_mock.close.called + + @patch("airflow.models.connection.Connection") + @patch("smtplib.SMTP_SSL") + def test_send_mime_custom_timeout_retrylimit(self, mock_smtp_ssl, connection_mock): + mock_smtp_ssl().sendmail.side_effect = smtplib.SMTPServerDisconnected() + custom_retry_limit = 10 + custom_timeout = 60 + fake_conn = Connection( + conn_id="mock_conn", + conn_type="smtp", + host="smtp_server_address", + login="smtp_user", + password="smtp_password", + port=465, + extra=json.dumps(dict(from_email="from", timeout=custom_timeout, retry_limit=custom_retry_limit)), + ) + connection_mock.get_connection_from_secrets.return_value = fake_conn + with SmtpHook() as smtp_hook: + with pytest.raises(smtplib.SMTPServerDisconnected): + smtp_hook.send_email_smtp("to", "subject", "content") + mock_smtp_ssl.assert_any_call( + host=fake_conn.host, port=fake_conn.port, timeout=fake_conn.extra_dejson["timeout"] + ) + assert mock_smtp_ssl().sendmail.call_count == 10