Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mailchimp Transactional Email Messaging Service #2742

Merged
merged 27 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""adds MAILCHIMP_TRANSACTIONAL enum

Revision ID: 39b209861471
Revises: eb1e6ec39b83
Create Date: 2023-03-03 21:57:14.368385

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "39b209861471"
down_revision = "9f38dad37628"
branch_labels = None
depends_on = None


def upgrade():
op.execute("alter type messagingservicetype rename to messagingservicetype_old")
op.execute(
"create type messagingservicetype as enum('MAILCHIMP_TRANSACTIONAL', 'MAILGUN', 'TWILIO_TEXT', 'TWILIO_EMAIL')"
)
op.execute(
(
"alter table messagingconfig alter column service_type type messagingservicetype using "
"service_type::text::messagingservicetype"
)
)
op.execute("drop type messagingservicetype_old")


def downgrade():
# return
op.execute(
"delete from messagingconfig where service_type in ('MAILCHIMP_TRANSACTIONAL')"
)
op.execute("alter type messagingservicetype rename to messagingservicetype_old")
op.execute(
"create type messagingservicetype as enum('MAILGUN', 'TWILIO_TEXT', 'TWILIO_EMAIL')"
)
op.execute(
(
"alter table messagingconfig alter column service_type type messagingservicetype using "
"service_type::text::messagingservicetype"
)
)
op.execute("drop type messagingservicetype_old")
2 changes: 2 additions & 0 deletions src/fides/api/ops/models/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
SMS_MESSAGING_SERVICES,
SUPPORTED_MESSAGING_SERVICE_SECRETS,
MessagingMethod,
MessagingServiceSecretsMailchimpTransactional,
MessagingServiceSecretsMailgun,
MessagingServiceSecretsTwilioEmail,
MessagingServiceSecretsTwilioSMS,
Expand Down Expand Up @@ -55,6 +56,7 @@ def get_schema_for_secrets(
"""
try:
schema = {
MessagingServiceType.MAILCHIMP_TRANSACTIONAL: MessagingServiceSecretsMailchimpTransactional,
MessagingServiceType.MAILGUN: MessagingServiceSecretsMailgun,
MessagingServiceType.TWILIO_TEXT: MessagingServiceSecretsTwilioSMS,
MessagingServiceType.TWILIO_EMAIL: MessagingServiceSecretsTwilioEmail,
Expand Down
43 changes: 39 additions & 4 deletions src/fides/api/ops/schemas/messaging/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class MessagingMethod(Enum):
class MessagingServiceType(Enum):
"""Enum for messaging service type. Upper-cased in the database"""

MAILCHIMP_TRANSACTIONAL = "MAILCHIMP_TRANSACTIONAL"

MAILGUN = "MAILGUN"

TWILIO_TEXT = "TWILIO_TEXT"
Expand All @@ -40,6 +42,7 @@ def _missing_(


EMAIL_MESSAGING_SERVICES: Tuple[str, ...] = (
MessagingServiceType.MAILCHIMP_TRANSACTIONAL.value,
MessagingServiceType.MAILGUN.value,
MessagingServiceType.TWILIO_EMAIL.value,
)
Expand Down Expand Up @@ -167,15 +170,29 @@ class EmailForActionType(BaseModel):
class MessagingServiceDetails(Enum):
"""Enum for messaging service details"""

# Generic
DOMAIN = "domain"
EMAIL_FROM = "email_from"

# Mailgun
IS_EU_DOMAIN = "is_eu_domain"
API_VERSION = "api_version"
DOMAIN = "domain"

# Twilio Email
TWILIO_EMAIL_FROM = "twilio_email_from"
seanpreston marked this conversation as resolved.
Show resolved Hide resolved


class MessagingServiceDetailsMailchimpTransactional(BaseModel):
"""The details required to represent a Mailchimp Transactional email configuration."""

email_from: str

class Config:
"""Restrict adding other fields through this schema."""

extra = Extra.forbid


class MessagingServiceDetailsMailgun(BaseModel):
"""The details required to represent a Mailgun email configuration."""

Expand Down Expand Up @@ -203,6 +220,9 @@ class Config:
class MessagingServiceSecrets(Enum):
"""Enum for message service secrets"""

# Mailchimp Transactional
MAILCHIMP_TRANSACTIONAL_API_KEY = "mailchimp_transactional_api_key"

# Mailgun
MAILGUN_API_KEY = "mailgun_api_key"

Expand All @@ -216,8 +236,19 @@ class MessagingServiceSecrets(Enum):
TWILIO_API_KEY = "twilio_api_key"


class MessagingServiceSecretsMailchimpTransactional(BaseModel):
seanpreston marked this conversation as resolved.
Show resolved Hide resolved
"""The secrets required to connect to Mailchimp Transactional."""

mailchimp_transactional_api_key: str

class Config:
"""Restrict adding other fields through this schema."""

extra = Extra.forbid


class MessagingServiceSecretsMailgun(BaseModel):
"""The secrets required to connect to mailgun."""
"""The secrets required to connect to Mailgun."""

mailgun_api_key: str

Expand All @@ -228,7 +259,7 @@ class Config:


class MessagingServiceSecretsTwilioSMS(BaseModel):
"""The secrets required to connect to twilio SMS."""
"""The secrets required to connect to Twilio SMS."""

twilio_account_sid: str
twilio_auth_token: str
Expand Down Expand Up @@ -272,7 +303,11 @@ class MessagingConfigBase(BaseModel):

service_type: MessagingServiceType
details: Optional[
Union[MessagingServiceDetailsMailgun, MessagingServiceDetailsTwilioEmail]
Union[
MessagingServiceDetailsMailchimpTransactional,
MessagingServiceDetailsMailgun,
MessagingServiceDetailsTwilioEmail,
]
]

class Config:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@

from fides.api.ops.schemas.base_class import NoValidationSchema
from fides.api.ops.schemas.messaging.messaging import (
MessagingServiceSecretsMailchimpTransactional,
MessagingServiceSecretsMailgun,
MessagingServiceSecretsTwilioEmail,
MessagingServiceSecretsTwilioSMS,
)


class MessagingServiceSecretsMailchimpTransactionalDocs(
MessagingServiceSecretsMailchimpTransactional,
NoValidationSchema,
):
"""The secrets required to connect Mailchimp Transactional, for documentation"""


class MessagingSecretsMailgunDocs(MessagingServiceSecretsMailgun, NoValidationSchema):
"""The secrets required to connect to Mailgun, for documentation"""

Expand All @@ -25,6 +33,7 @@ class MessagingSecretsTwilioEmailDocs(


possible_messaging_secrets = Union[
MessagingServiceSecretsMailchimpTransactionalDocs,
MessagingSecretsMailgunDocs,
MessagingSecretsTwilioSMSDocs,
MessagingSecretsTwilioEmailDocs,
Expand Down
22 changes: 19 additions & 3 deletions src/fides/api/ops/service/connectors/email_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ def _get_email_messaging_config_service_type(db: Session) -> Optional[str]:
if not messaging_configs:
# let messaging dispatch service handle non-existent service
return None

twilio_email_config = next(
(
config
Expand All @@ -245,6 +246,10 @@ def _get_email_messaging_config_service_type(db: Session) -> Optional[str]:
),
None,
)
if twilio_email_config:
# First choice: use Twilio
return MessagingServiceType.TWILIO_EMAIL.value

mailgun_config = next(
(
config
Expand All @@ -253,9 +258,20 @@ def _get_email_messaging_config_service_type(db: Session) -> Optional[str]:
),
None,
)
if twilio_email_config:
# we prefer twilio over mailgun
return MessagingServiceType.TWILIO_EMAIL.value
if mailgun_config:
# Second choice: use Mailgun
return MessagingServiceType.MAILGUN.value

mailchimp_transactional_config = next(
(
config
for config in messaging_configs
if config.service_type == MessagingServiceType.MAILCHIMP_TRANSACTIONAL
),
None,
)
if mailchimp_transactional_config:
# Third choice: use Mailchimp Transactional
return MessagingServiceType.MAILCHIMP_TRANSACTIONAL.value

return None
86 changes: 73 additions & 13 deletions src/fides/api/ops/service/messaging/message_dispatch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,9 @@ def dispatch_message(
logger.info(
"Retrieving appropriate dispatcher for email service: {}", messaging_service
)
dispatcher: Optional[
Callable[[MessagingConfig, Any, Optional[str]], None]
] = _get_dispatcher_from_config_type(message_service_type=messaging_service)
dispatcher: Optional[Callable] = _get_dispatcher_from_config_type(
message_service_type=messaging_service
)
if not dispatcher:
logger.error(
"Dispatcher has not been implemented for message service type: {}",
Expand Down Expand Up @@ -343,31 +343,92 @@ def _build_email( # pylint: disable=too-many-return-statements

def _get_dispatcher_from_config_type(
message_service_type: MessagingServiceType,
) -> Optional[Callable[[MessagingConfig, Any, Optional[str]], None]]:
) -> Optional[Callable]:
"""Determines which dispatcher to use based on message service type"""
if message_service_type == MessagingServiceType.MAILGUN:
return _mailgun_dispatcher
if message_service_type == MessagingServiceType.TWILIO_TEXT:
return _twilio_sms_dispatcher
if message_service_type == MessagingServiceType.TWILIO_EMAIL:
return _twilio_email_dispatcher
return None
handler = {
MessagingServiceType.MAILGUN: _mailgun_dispatcher,
MessagingServiceType.MAILCHIMP_TRANSACTIONAL: _mailchimp_transactional_dispatcher,
MessagingServiceType.TWILIO_TEXT: _twilio_sms_dispatcher,
MessagingServiceType.TWILIO_EMAIL: _twilio_email_dispatcher,
}
return handler.get(message_service_type) # type: ignore


def _mailchimp_transactional_dispatcher(
messaging_config: MessagingConfig,
message: EmailForActionType,
to: Optional[str],
) -> None:
"""Dispatches email using Mailchimp Transactional"""
if not to:
logger.error("Message failed to send. No email identity supplied.")
raise MessageDispatchException("No email identity supplied.")

if not messaging_config.details or not messaging_config.secrets:
logger.error(
"Message failed to send. No mailgun config details or secrets supplied."
seanpreston marked this conversation as resolved.
Show resolved Hide resolved
)
raise MessageDispatchException("No mailgun config details or secrets supplied.")
seanpreston marked this conversation as resolved.
Show resolved Hide resolved

from_email = messaging_config.details[MessagingServiceDetails.EMAIL_FROM.value]
data = json.dumps(
{
"key": messaging_config.secrets[
MessagingServiceSecrets.MAILCHIMP_TRANSACTIONAL_API_KEY.value
],
"message": {
"from_email": from_email,
"subject": message.subject,
"text": message.body,
# On Mailchimp Transactional's free plan `to` must be an email of the same
# domain as `from_email`
"to": [{"email": to.strip(), "type": "to"}],
},
}
)

response = requests.post(
"https://mandrillapp.com/api/1.0/messages/send",
headers={"Content-Type": "application/json"},
data=data,
)
if not response.ok:
logger.error("Email failed to send with status code: %s" % response.status_code)
raise MessageDispatchException(
f"Email failed to send with status code {response.status_code}"
)

send_data = response.json()[0]
email_rejected = send_data.get("status", "rejected") == "rejected"
if email_rejected:
reason = send_data.get("reject_reason", "Fides Error")
explanations = {
"soft-bounce": "A temporary error occured with the target inbox. For example, this inbox could be full. See https://mailchimp.com/developer/transactional/docs/reputation-rejections/#bounces for more info.",
"hard-bounce": "A permanent error occured with the target inbox. See https://mailchimp.com/developer/transactional/docs/reputation-rejections/#bounces for more info.",
"recipient-domain-mismatch": f"You are not authorised to send email to this domain from {from_email}.",
}
explanation = explanations.get(reason, "")
raise MessageDispatchException(
f"Verification email unable to send due to reason: {reason}. {explanation}"
)


def _mailgun_dispatcher(
messaging_config: MessagingConfig,
message: EmailForActionType,
to: Optional[str],
) -> None:
"""Dispatches email using mailgun"""
"""Dispatches email using Mailgun"""
if not to:
logger.error("Message failed to send. No email identity supplied.")
raise MessageDispatchException("No email identity supplied.")

if not messaging_config.details or not messaging_config.secrets:
logger.error(
"Message failed to send. No mailgun config details or secrets supplied."
)
raise MessageDispatchException("No mailgun config details or secrets supplied.")

base_url = (
"https://api.mailgun.net"
if messaging_config.details[MessagingServiceDetails.IS_EU_DOMAIN.value] is False
Expand Down Expand Up @@ -457,7 +518,6 @@ def _twilio_email_dispatcher(
)

try:

sg = sendgrid.SendGridAPIClient(
api_key=messaging_config.secrets[
MessagingServiceSecrets.TWILIO_API_KEY.value
Expand Down
9 changes: 7 additions & 2 deletions src/fides/core/config/notification_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class NotificationSettings(FidesSettings):

notification_service_type: Optional[str] = Field(
default=None,
description="Sets the notification service type used to send notifications. Accepts mailgun, twilio_sms, or twilio_email.",
description="Sets the notification service type used to send notifications. Accepts mailchimp_transactional, mailgun, twilio_sms, or twilio_email.",
)
send_request_completion_notification: bool = Field(
default=False,
Expand All @@ -32,7 +32,12 @@ class NotificationSettings(FidesSettings):
def validate_notification_service_type(cls, value: Optional[str]) -> Optional[str]:
"""Ensure the provided type is a valid value."""
if value:
valid_values = ["MAILGUN", "TWILIO_TEXT", "TWILIO_EMAIL"]
valid_values = [
"MAILCHIMP_TRANSACTIONAL",
"MAILGUN",
"TWILIO_TEXT",
"TWILIO_EMAIL",
]
value = value.upper() # force uppercase for safety

if value not in valid_values:
Expand Down
Loading