From d050fb7cfef70da20289be9a46b82ab4db6dba92 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 6 Dec 2025 13:49:58 -0500 Subject: [PATCH] Resend plugin improvements adding improved support for from=, name=, cc=, reply=, and to= (#1465) --- apprise/plugins/resend.py | 241 +++++++++++++++++++++++++++--------- apprise/utils/parse.py | 4 +- tests/helpers/rest.py | 14 ++- tests/test_plugin_resend.py | 102 ++++++++++++--- 4 files changed, 280 insertions(+), 81 deletions(-) diff --git a/apprise/plugins/resend.py b/apprise/plugins/resend.py index a14279af..f5b5e97e 100644 --- a/apprise/plugins/resend.py +++ b/apprise/plugins/resend.py @@ -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 diff --git a/apprise/utils/parse.py b/apprise/utils/parse.py index 92fc9a52..66077ac5 100644 --- a/apprise/utils/parse.py +++ b/apprise/utils/parse.py @@ -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. diff --git a/tests/helpers/rest.py b/tests/helpers/rest.py index 20b6f9ce..5ba62dc3 100644 --- a/tests/helpers/rest.py +++ b/tests/helpers/rest.py @@ -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 diff --git a/tests/test_plugin_resend.py b/tests/test_plugin_resend.py index b2147f83..8be73747 100644 --- a/tests/test_plugin_resend.py +++ b/tests/test_plugin_resend.py @@ -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" + "&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" + "&reply=John" + ), + { + # A good email + "instance": NotifyResend, + "privacy_url": \ + "resend://a...d:user@example.com", + "url_matches": r"reply=John", + }, + ), + ( + ( + "resend://?apikey=abcd&from=Joe" + "&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" + ), + { + # 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"), ),