mirror of
https://github.com/caronc/apprise.git
synced 2025-12-15 10:04:06 +08:00
Resend plugin improvements adding improved support for from=, name=, cc=, reply=, and to= (#1465)
This commit is contained in:
@@ -31,14 +31,15 @@
|
||||
# to work.
|
||||
#
|
||||
# The schema to use the plugin looks like this:
|
||||
# {schema}://{apikey}:{from_email}
|
||||
# {schema}://{apikey}:{from_addr}
|
||||
#
|
||||
# Your {from_email} must be comprissed of your Resend Authenticated
|
||||
# Your {from_addr} must be comprissed of your Resend Authenticated
|
||||
# Domain.
|
||||
|
||||
# Simple API Reference:
|
||||
# - https://resend.com/onboarding
|
||||
|
||||
from email.utils import formataddr
|
||||
from json import dumps
|
||||
|
||||
import requests
|
||||
@@ -46,7 +47,7 @@ import requests
|
||||
from .. import exception
|
||||
from ..common import NotifyFormat, NotifyType
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..utils.parse import is_email, parse_list, validate_regex
|
||||
from ..utils.parse import is_email, parse_emails, validate_regex
|
||||
from .base import NotifyBase
|
||||
|
||||
RESEND_HTTP_ERROR_MAP = {
|
||||
@@ -92,8 +93,8 @@ class NotifyResend(NotifyBase):
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
"{schema}://{apikey}:{from_email}",
|
||||
"{schema}://{apikey}:{from_email}/{targets}",
|
||||
"{schema}://{apikey}:{from_addr}",
|
||||
"{schema}://{apikey}:{from_addr}/{targets}",
|
||||
)
|
||||
|
||||
# Define our template arguments
|
||||
@@ -107,7 +108,7 @@ class NotifyResend(NotifyBase):
|
||||
"required": True,
|
||||
"regex": (r"^[A-Z0-9._-]+$", "i"),
|
||||
},
|
||||
"from_email": {
|
||||
"from_addr": {
|
||||
"name": _("Source Email"),
|
||||
"type": "string",
|
||||
"required": True,
|
||||
@@ -139,12 +140,27 @@ class NotifyResend(NotifyBase):
|
||||
"name": _("Blind Carbon Copy"),
|
||||
"type": "list:string",
|
||||
},
|
||||
"reply": {
|
||||
"name": _("Reply To"),
|
||||
"type": "list:string",
|
||||
"map_to": "reply_to",
|
||||
},
|
||||
"from": {
|
||||
"map_to": "from_addr",
|
||||
},
|
||||
"name": {
|
||||
"name": _("From Name"),
|
||||
"map_to": "from_addr",
|
||||
},
|
||||
"apikey": {
|
||||
"map_to": "apikey",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, apikey, from_email, targets=None, cc=None, bcc=None, **kwargs
|
||||
):
|
||||
self, apikey, from_addr, targets=None, cc=None, bcc=None,
|
||||
reply_to=None, **kwargs):
|
||||
"""Initialize Notify Resend Object."""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -157,15 +173,6 @@ class NotifyResend(NotifyBase):
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
result = is_email(from_email)
|
||||
if not result:
|
||||
msg = f"Invalid ~From~ email specified: {from_email}"
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Store email address
|
||||
self.from_email = result["full_email"]
|
||||
|
||||
# Acquire Targets (To Emails)
|
||||
self.targets = []
|
||||
|
||||
@@ -175,24 +182,59 @@ class NotifyResend(NotifyBase):
|
||||
# Acquire Blind Carbon Copies
|
||||
self.bcc = set()
|
||||
|
||||
# Validate recipients (to:) and drop bad ones:
|
||||
for recipient in parse_list(targets):
|
||||
# Acquire Reply To
|
||||
self.reply_to = set()
|
||||
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.targets.append(result["full_email"])
|
||||
continue
|
||||
# For tracking our email -> name lookups
|
||||
self.names = {}
|
||||
|
||||
self.logger.warning(
|
||||
f"Dropped invalid email ({recipient}) specified.",
|
||||
)
|
||||
result = is_email(from_addr)
|
||||
if not result:
|
||||
# Invalid from
|
||||
msg = "Invalid ~From~ email specified: {}".format(from_addr)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# initialize our from address
|
||||
self.from_addr = (
|
||||
result["name"] if result["name"] is not None else False,
|
||||
result["full_email"],
|
||||
)
|
||||
|
||||
# Update our Name if specified
|
||||
self.names[self.from_addr[1]] = (
|
||||
result["name"] if result["name"] else False
|
||||
)
|
||||
|
||||
# Acquire our targets
|
||||
targets = parse_emails(targets)
|
||||
if targets:
|
||||
# Validate recipients (to:) and drop bad ones:
|
||||
for recipient in targets:
|
||||
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.targets.append(result["full_email"])
|
||||
continue
|
||||
|
||||
self.logger.warning(
|
||||
f"Dropped invalid email ({recipient}) specified.",
|
||||
)
|
||||
else:
|
||||
# If our target email list is empty we want to add ourselves to it
|
||||
self.targets.append(self.from_addr[1])
|
||||
|
||||
# Validate recipients (cc:) and drop bad ones:
|
||||
for recipient in parse_list(cc):
|
||||
for recipient in parse_emails(cc):
|
||||
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.cc.add(result["full_email"])
|
||||
|
||||
# Index our name (if one exists)
|
||||
self.names[result["full_email"]] = (
|
||||
result["name"] if result["name"] else False
|
||||
)
|
||||
continue
|
||||
|
||||
self.logger.warning(
|
||||
@@ -200,7 +242,7 @@ class NotifyResend(NotifyBase):
|
||||
)
|
||||
|
||||
# Validate recipients (bcc:) and drop bad ones:
|
||||
for recipient in parse_list(bcc):
|
||||
for recipient in parse_emails(bcc):
|
||||
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
@@ -212,9 +254,23 @@ class NotifyResend(NotifyBase):
|
||||
f"({recipient}) specified.",
|
||||
)
|
||||
|
||||
if len(self.targets) == 0:
|
||||
# Notify ourselves
|
||||
self.targets.append(self.from_email)
|
||||
# Validate recipients (reply-to:) and drop bad ones:
|
||||
for recipient in parse_emails(reply_to):
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.reply_to.add(result["full_email"])
|
||||
|
||||
# Index our name (if one exists)
|
||||
self.names[result["full_email"]] = (
|
||||
result["name"] if result["name"] else False
|
||||
)
|
||||
continue
|
||||
|
||||
self.logger.warning(
|
||||
"Dropped invalid Reply To email ({}) specified.".format(
|
||||
recipient
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -225,7 +281,7 @@ class NotifyResend(NotifyBase):
|
||||
|
||||
Targets or end points should never be identified here.
|
||||
"""
|
||||
return (self.secure_protocol, self.apikey, self.from_email)
|
||||
return (self.secure_protocol, self.apikey, self.from_addr)
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""Returns the URL built dynamically based on specified arguments."""
|
||||
@@ -233,30 +289,53 @@ class NotifyResend(NotifyBase):
|
||||
# Our URL parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
|
||||
if len(self.cc) > 0:
|
||||
if self.cc:
|
||||
# Handle our Carbon Copy Addresses
|
||||
params["cc"] = ",".join(self.cc)
|
||||
params["cc"] = ",".join([
|
||||
formataddr(
|
||||
(self.names.get(e, False), e),
|
||||
# Swap comma for it's escaped url code (if detected) since
|
||||
# we're using that as a delimiter
|
||||
charset="utf-8",
|
||||
).replace(",", "%2C")
|
||||
for e in self.cc
|
||||
])
|
||||
|
||||
if len(self.bcc) > 0:
|
||||
# Handle our Blind Carbon Copy Addresses
|
||||
params["bcc"] = ",".join(self.bcc)
|
||||
|
||||
if self.reply_to:
|
||||
# Handle our Reply-To Addresses
|
||||
params["reply"] = ",".join([
|
||||
formataddr(
|
||||
(self.names.get(e, False), e),
|
||||
# Swap comma for its escaped url code (if detected) since
|
||||
# we're using that as a delimiter
|
||||
charset="utf-8",
|
||||
).replace(",", "%2C")
|
||||
for e in self.reply_to
|
||||
])
|
||||
|
||||
# a simple boolean check as to whether we display our target emails
|
||||
# or not
|
||||
has_targets = not (
|
||||
len(self.targets) == 1 and self.targets[0] == self.from_email
|
||||
)
|
||||
len(self.targets) == 1 and self.targets[0] == self.from_addr[1])
|
||||
|
||||
return "{schema}://{apikey}:{from_email}/{targets}?{params}".format(
|
||||
if self.from_addr[0] and self.from_addr[0] != self.app_id:
|
||||
# A custom name was provided
|
||||
params["name"] = self.from_addr[0]
|
||||
|
||||
return "{schema}://{apikey}:{from_addr}/{targets}?{params}".format(
|
||||
schema=self.secure_protocol,
|
||||
apikey=self.pprint(self.apikey, privacy, safe=""),
|
||||
# never encode email since it plays a huge role in our hostname
|
||||
from_email=self.from_email,
|
||||
from_addr=self.from_addr[1],
|
||||
targets=(
|
||||
""
|
||||
if not has_targets
|
||||
else "/".join(
|
||||
[NotifyResend.quote(x, safe="") for x in self.targets]
|
||||
[NotifyResend.quote(x, safe="@") for x in self.targets]
|
||||
)
|
||||
),
|
||||
params=NotifyResend.urlencode(params),
|
||||
@@ -285,8 +364,12 @@ class NotifyResend(NotifyBase):
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
# Prepare our from_name
|
||||
self.from_addr[0] \
|
||||
if self.from_addr[0] is not False else self.app_id
|
||||
|
||||
_payload = {
|
||||
"from": self.from_email,
|
||||
"from": formataddr(self.from_addr, charset="utf-8"),
|
||||
# A subject is a requirement, so if none is specified we must
|
||||
# set a default with at least 1 character or Resend will deny
|
||||
# our request
|
||||
@@ -351,15 +434,35 @@ class NotifyResend(NotifyBase):
|
||||
cc = self.cc - self.bcc - {target}
|
||||
bcc = self.bcc - {target}
|
||||
|
||||
# handle our reply to
|
||||
reply_to = self.reply_to - {target}
|
||||
|
||||
# Format our cc addresses to support the Name field
|
||||
cc = [
|
||||
formataddr((self.names.get(addr, False), addr),
|
||||
charset="utf-8")
|
||||
for addr in cc
|
||||
]
|
||||
|
||||
# Format our reply-to addresses to support the Name field
|
||||
reply_to = [
|
||||
formataddr((self.names.get(addr, False), addr),
|
||||
charset="utf-8")
|
||||
for addr in reply_to
|
||||
]
|
||||
|
||||
# Set our target
|
||||
payload["to"] = target
|
||||
|
||||
if len(cc):
|
||||
payload["cc"] = list(cc)
|
||||
if cc:
|
||||
payload["cc"] = cc
|
||||
|
||||
if len(bcc):
|
||||
payload["bcc"] = list(bcc)
|
||||
|
||||
if reply_to:
|
||||
payload["reply_to"] = reply_to
|
||||
|
||||
self.logger.debug(
|
||||
"Resend POST URL:"
|
||||
f" {self.notify_url} (cert_verify={self.verify_certificate!r})"
|
||||
@@ -422,13 +525,13 @@ class NotifyResend(NotifyBase):
|
||||
"""Parses the URL and returns enough arguments that can allow us to re-
|
||||
instantiate this object."""
|
||||
|
||||
results = NotifyBase.parse_url(url)
|
||||
results = NotifyBase.parse_url(url, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# Our URL looks like this:
|
||||
# {schema}://{apikey}:{from_email}/{targets}
|
||||
# {schema}://{apikey}:{from_addr}/{targets}
|
||||
#
|
||||
# which actually equates to:
|
||||
# {schema}://{user}:{password}@{host}/{email1}/{email2}/etc..
|
||||
@@ -436,25 +539,41 @@ class NotifyResend(NotifyBase):
|
||||
# | | |
|
||||
# apikey -from addr-
|
||||
|
||||
if not results.get("user"):
|
||||
# An API Key as not properly specified
|
||||
return None
|
||||
|
||||
if not results.get("password"):
|
||||
# A From Email was not correctly specified
|
||||
return None
|
||||
|
||||
# Prepare our API Key
|
||||
results["apikey"] = NotifyResend.unquote(results["user"])
|
||||
if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]):
|
||||
results["apikey"] = \
|
||||
NotifyResend.unquote(results["qsd"]["apikey"])
|
||||
|
||||
# Prepare our From Email Address
|
||||
results["from_email"] = "{}@{}".format(
|
||||
NotifyResend.unquote(results["password"]),
|
||||
NotifyResend.unquote(results["host"]),
|
||||
)
|
||||
else:
|
||||
results["apikey"] = NotifyResend.unquote(results["user"])
|
||||
|
||||
# Our Targets
|
||||
results["targets"] = []
|
||||
|
||||
# Attempt to detect 'from' email address
|
||||
if "from" in results["qsd"] and len(results["qsd"]["from"]):
|
||||
results["from_addr"] = NotifyResend.unquote(results["qsd"]["from"])
|
||||
|
||||
if results.get("host"):
|
||||
results["targets"].append(
|
||||
NotifyResend.unquote(results["host"]))
|
||||
|
||||
else:
|
||||
# Prepare our From Email Address
|
||||
results["from_addr"] = "{}@{}".format(
|
||||
NotifyResend.unquote(
|
||||
results["password"]
|
||||
if results["password"] else results["user"]),
|
||||
NotifyResend.unquote(results["host"]),
|
||||
)
|
||||
|
||||
if "name" in results["qsd"] and len(results["qsd"]["name"]):
|
||||
results["from_addr"] = formataddr((
|
||||
NotifyResend.unquote(results["qsd"]["name"]),
|
||||
results["from_addr"]), charset="utf-8")
|
||||
|
||||
# Acquire our targets
|
||||
results["targets"] = NotifyResend.split_path(results["fullpath"])
|
||||
results["targets"].extend(NotifyResend.split_path(results["fullpath"]))
|
||||
|
||||
# The 'to' makes it easier to use yaml configuration
|
||||
if "to" in results["qsd"] and len(results["qsd"]["to"]):
|
||||
@@ -468,4 +587,8 @@ class NotifyResend(NotifyBase):
|
||||
if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]):
|
||||
results["bcc"] = NotifyResend.parse_list(results["qsd"]["bcc"])
|
||||
|
||||
# Handle Reply To Addresses
|
||||
if "reply" in results["qsd"] and len(results["qsd"]["reply"]):
|
||||
results["reply_to"] = results["qsd"]["reply"]
|
||||
|
||||
return results
|
||||
|
||||
@@ -414,7 +414,7 @@ def is_email(address):
|
||||
"name": (
|
||||
""
|
||||
if match.group("name") is None
|
||||
else match.group("name").strip()
|
||||
else match.group("name").rstrip(" \t\r\n\x0b\x0c+")
|
||||
),
|
||||
# The email address
|
||||
"email": match.group("email"),
|
||||
@@ -424,7 +424,7 @@ def is_email(address):
|
||||
"label": (
|
||||
""
|
||||
if match.group("label") is None
|
||||
else match.group("label").strip()
|
||||
else match.group("label").strip(" \t\r\n\x0b\x0c+")
|
||||
),
|
||||
# The user (which does not include the label) from the email
|
||||
# parsed.
|
||||
|
||||
@@ -261,9 +261,13 @@ class AppriseURLTester:
|
||||
f" expected '{privacy_url}'"
|
||||
)
|
||||
|
||||
if url_matches:
|
||||
# Assess that our URL matches a set regex
|
||||
assert re.search(url_matches, obj.url())
|
||||
# Assess that our URL matches a set regex
|
||||
if url_matches and not re.search(url_matches, obj.url()):
|
||||
raise AssertionError(
|
||||
f"URL: {url} generated an reloadable "
|
||||
f"url() of {obj.url()} that does not match "
|
||||
f"'{url_matches}'"
|
||||
)
|
||||
|
||||
# Instantiate the exact same object again using the URL
|
||||
# from the one that was already created properly
|
||||
@@ -293,7 +297,9 @@ class AppriseURLTester:
|
||||
# Verify there is no change from the old and the new
|
||||
if len(obj) != len(obj_cmp):
|
||||
raise AssertionError(
|
||||
f"URL: {url} target miscount {len(obj)} != {len(obj_cmp)}"
|
||||
f"URL: {url} generated an reloadable "
|
||||
f"url() of {obj.url()} produced target miscount "
|
||||
f"{len(obj)} != {len(obj_cmp)}"
|
||||
)
|
||||
|
||||
# Tidy our object
|
||||
|
||||
@@ -50,27 +50,27 @@ apprise_url_tests = (
|
||||
(
|
||||
"resend://",
|
||||
{
|
||||
"instance": None,
|
||||
"instance": TypeError,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://:@/",
|
||||
{
|
||||
"instance": None,
|
||||
"instance": TypeError,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd",
|
||||
{
|
||||
# Just an broken email (no api key or email)
|
||||
"instance": None,
|
||||
"instance": TypeError,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd@host",
|
||||
{
|
||||
# Just an Email specified, no API Key
|
||||
"instance": None,
|
||||
"instance": TypeError,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -89,15 +89,74 @@ apprise_url_tests = (
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser@example.com",
|
||||
"resend://abcd:user@example.com/newuser1@example.com",
|
||||
{
|
||||
# A good email
|
||||
"instance": NotifyResend,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser2@example.com?name=Jessica",
|
||||
{
|
||||
# A good email
|
||||
"instance": NotifyResend,
|
||||
"privacy_url": \
|
||||
"resend://a...d:user@example.com/newuser2@example.com",
|
||||
"url_matches": r"name=Jessica",
|
||||
},
|
||||
),
|
||||
(
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser@example.com"
|
||||
"resend://abcd@newuser4%40example.com?name=Ralph"
|
||||
"&from=user2@example.ca"
|
||||
),
|
||||
{
|
||||
# A good email
|
||||
"instance": NotifyResend,
|
||||
"privacy_url": \
|
||||
"resend://a...d:user2@example.ca/",
|
||||
"url_matches": r"name=Ralph",
|
||||
},
|
||||
),
|
||||
(
|
||||
(
|
||||
"resend://?apikey=abcd&from=Joe<user@example.com>"
|
||||
"&to=newuser5@example.com"
|
||||
),
|
||||
{
|
||||
# A good email
|
||||
"instance": NotifyResend,
|
||||
"privacy_url": \
|
||||
"resend://a...d:user@example.com/newuser5@example.com",
|
||||
"url_matches": r"name=Joe",
|
||||
},
|
||||
),
|
||||
(
|
||||
(
|
||||
"resend://?apikey=abcd&from=Joe<user@example.com>"
|
||||
"&reply=John<newuser6@example.com>"
|
||||
),
|
||||
{
|
||||
# A good email
|
||||
"instance": NotifyResend,
|
||||
"privacy_url": \
|
||||
"resend://a...d:user@example.com",
|
||||
"url_matches": r"reply=John",
|
||||
},
|
||||
),
|
||||
(
|
||||
(
|
||||
"resend://?apikey=abcd&from=Joe<user@example.com>"
|
||||
"&reply=garbage%"
|
||||
),
|
||||
{
|
||||
# A good email but has a garbage reply-to value
|
||||
"instance": NotifyResend,
|
||||
},
|
||||
),
|
||||
(
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser7@example.com"
|
||||
"?bcc=l2g@nuxref.com"
|
||||
),
|
||||
{
|
||||
@@ -106,21 +165,32 @@ apprise_url_tests = (
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser@example.com?cc=l2g@nuxref.com",
|
||||
"resend://abcd:user@example.com/newuser8@example.com?cc=l2g@nuxref.com",
|
||||
{
|
||||
# A good email with Carbon Copy
|
||||
"instance": NotifyResend,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser@example.com?to=l2g@nuxref.com",
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser8@example.com?"
|
||||
"cc=Chris<l2g@nuxref.com>"
|
||||
),
|
||||
{
|
||||
# A good email with Carbon Copy + Name
|
||||
"instance": NotifyResend,
|
||||
},
|
||||
),
|
||||
|
||||
(
|
||||
"resend://abcd:user@example.com/newuser9@example.com?to=l2g@nuxref.com",
|
||||
{
|
||||
# A good email with Carbon Copy
|
||||
"instance": NotifyResend,
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.ca/newuser@example.ca",
|
||||
"resend://abcd:user@example.ca/newuser0@example.ca",
|
||||
{
|
||||
"instance": NotifyResend,
|
||||
# force a failure
|
||||
@@ -129,7 +199,7 @@ apprise_url_tests = (
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.uk/newuser@example.uk",
|
||||
"resend://abcd:user@example.uk/newuser01@example.uk",
|
||||
{
|
||||
"instance": NotifyResend,
|
||||
# throw a bizarre code forcing us to fail to look it up
|
||||
@@ -138,7 +208,7 @@ apprise_url_tests = (
|
||||
},
|
||||
),
|
||||
(
|
||||
"resend://abcd:user@example.au/newuser@example.au",
|
||||
"resend://abcd:user@example.au/newuser02@example.au",
|
||||
{
|
||||
"instance": NotifyResend,
|
||||
# Throws a series of i/o exceptions with this flag
|
||||
@@ -163,26 +233,26 @@ def test_plugin_resend_edge_cases(mock_post, mock_get):
|
||||
|
||||
# no apikey
|
||||
with pytest.raises(TypeError):
|
||||
NotifyResend(apikey=None, from_email="user@example.com")
|
||||
NotifyResend(apikey=None, from_addr="user@example.com")
|
||||
|
||||
# invalid from email
|
||||
with pytest.raises(TypeError):
|
||||
NotifyResend(apikey="abcd", from_email="!invalid")
|
||||
NotifyResend(apikey="abcd", from_addr="!invalid")
|
||||
|
||||
# no email
|
||||
with pytest.raises(TypeError):
|
||||
NotifyResend(apikey="abcd", from_email=None)
|
||||
NotifyResend(apikey="abcd", from_addr=None)
|
||||
|
||||
# Invalid To email address
|
||||
NotifyResend(
|
||||
apikey="abcd", from_email="user@example.com", targets="!invalid"
|
||||
apikey="abcd", from_addr="user@example.com", targets="!invalid"
|
||||
)
|
||||
|
||||
# Test invalid bcc/cc entries mixed with good ones
|
||||
assert isinstance(
|
||||
NotifyResend(
|
||||
apikey="abcd",
|
||||
from_email="l2g@example.com",
|
||||
from_addr="l2g@example.com",
|
||||
bcc=("abc@def.com", "!invalid"),
|
||||
cc=("abc@test.org", "!invalid"),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user