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 15 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 = "eb1e6ec39b83"
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
44 changes: 40 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,30 @@ 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."""

domain: str
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 +221,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 +237,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 +260,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 +304,11 @@ class MessagingConfigBase(BaseModel):

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

class Config:
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
80 changes: 72 additions & 8 deletions src/fides/api/ops/service/messaging/message_dispatch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,29 +344,93 @@ def _get_dispatcher_from_config_type(
message_service_type: MessagingServiceType,
) -> Optional[Callable[[MessagingConfig, Any, Optional[str]], None]]:
"""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,
}
func: Optional[Callable[[MessagingConfig, Any, Optional[str]], None]] = handler.get(
message_service_type
)
return func


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
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
26 changes: 24 additions & 2 deletions tests/fixtures/application_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,30 @@ def messaging_config_twilio_sms(db: Session) -> Generator:
messaging_config.delete(db)


@pytest.fixture(scope="function")
def messaging_config_mailchimp_transactional(db: Session) -> Generator:
messaging_config = MessagingConfig.create(
db=db,
data={
"name": str(uuid4()),
"key": "my_mailchimp_transactional_messaging_config",
"service_type": MessagingServiceType.MAILCHIMP_TRANSACTIONAL,
"details": {
MessagingServiceDetails.DOMAIN.value: "some.domain",
MessagingServiceDetails.EMAIL_FROM.value: "[email protected]",
},
},
)
messaging_config.set_secrets(
db=db,
messaging_secrets={
MessagingServiceSecrets.MAILCHIMP_TRANSACTIONAL_API_KEY.value: "12984r70298r"
},
)
yield messaging_config
messaging_config.delete(db)


@pytest.fixture(scope="function")
def https_connection_config(db: Session) -> Generator:
name = str(uuid4())
Expand Down Expand Up @@ -653,7 +677,6 @@ def erasure_policy_string_rewrite_long(
def erasure_policy_two_rules(
db: Session, oauth_client: ClientDetail, erasure_policy: Policy
) -> Generator:

second_erasure_rule = Rule.create(
db=db,
data={
Expand Down Expand Up @@ -1618,7 +1641,6 @@ def authenticated_fides_client(

@pytest.fixture(scope="function")
def system(db: Session) -> System:

system = System.create(
db=db,
data={
Expand Down
2 changes: 1 addition & 1 deletion tests/ops/api/v1/endpoints/test_messaging_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def test_post_email_config_with_not_supported_service_type(
assert 422 == response.status_code
assert (
json.loads(response.text)["detail"][0]["msg"]
== "value is not a valid enumeration member; permitted: 'MAILGUN', 'TWILIO_TEXT', 'TWILIO_EMAIL'"
== "value is not a valid enumeration member; permitted: 'MAILCHIMP_TRANSACTIONAL', 'MAILGUN', 'TWILIO_TEXT', 'TWILIO_EMAIL'"
)

def test_post_email_config_with_no_key(
Expand Down