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

create resend.com plugin template #852

Open
wants to merge 1 commit into
base: 0.8.2-dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions tracardi/domain/resources/resend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel


class ResendResource(BaseModel):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to reuse existing model for ApiKey. It is at: tracardi.resources.api_key

api_key: str
45 changes: 45 additions & 0 deletions tracardi/process_engine/action/v1/connectors/resend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## send email api

url: POST https://api.resend.com/emails

```shell
curl -X POST 'https://api.resend.com/emails' \
-H 'Authorization: Bearer re_123456789' \
-H 'Content-Type: application/json' \
-d $'{
"from": "Acme <[email protected]>",
"to": ["[email protected]"],
"subject": "hello world",
"text": "it works!",
"headers": {
"X-Entity-Ref-ID": "123"
},
"attachments": [
{
"filename": 'invoice.pdf',
"content": invoiceBuffer,
},
]
}'
```

| params | type | required |
|---------------------|------------------|----------|
| from | str | True |
| to | str or str[] | True |
| subject | str | True |
| bcc | str or str[] | False |
| cc | str or str[] | False |
| reply_to | str or str[] | False |
| html | str | False |
| text | str | False |
| react | str | False |
| headers | dict | False |
| attachments | list[attachment] | False |
| attachment.content | buffer or str | False |
| attachment.filename | str | False |
| attachment.path | str | False |
| tags | list[tag] | False |
| tag.name | str | True |
| value | str | False |

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import aiohttp
from aiohttp import ContentTypeError
from json import JSONDecodeError
from tracardi.domain.named_entity import NamedEntity
from tracardi.domain.resources.resend import ResendResource
from tracardi.service.tracardi_http_client import HttpClient
from tracardi.service.storage.driver.elastic import resource as resource_db
from tracardi.service.plugin.domain.config import PluginConfig
from tracardi.service.plugin.domain.result import Result
from tracardi.service.plugin.runner import ActionRunner
from typing import Any, Union, Optional
from pydantic import BaseModel


class SendEmailParams(BaseModel):
sender: str
to: Union[str, list[str]]
subject: str
bcc: Optional[Union[str, list[str]]] = None
cc: Optional[Union[str, list[str]]] = None
reply_to: Optional[Union[str, list[str]]] = None
message: dict
headers: Optional[dict[str, Any]] = None
attachments: Optional[Any] = None
tags: Optional[Any] = None


class Config(PluginConfig):
resource: NamedEntity
params: SendEmailParams


def validate(config: dict) -> Config:
return Config(**config)


class ResendSendEmailAction(ActionRunner):
credentials: ResendResource
config: SendEmailParams

async def set_up(self, init):
config = validate(init)
resource = await resource_db.load(config.resource.id)

self.config = config.params
self.credentials: "ResendResource" = resource.credentials.get_credentials(self, output=ResendResource)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please reuse: tracardi.resource.api_key instead of ResendResource.


async def run(self, payload: dict, in_edge=None) -> Result:
url = f"https://api.resend.com/emails"
params = {
"from": self.config.sender,
"to": self.config.to,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You use dotPath for sender, to, etc which is good but the params.sender are not dun through dotAccessor to get the value.

to get the right value when the user use dotPath like [email protected] do the following:

dot = self._get_dot_accessor(payload) # This gets the class that can evaluate any dot notation.

self.config.sender = dot[self.config.sender]

this will convert the dot notation if found to the actual value. If not found it will leave the value as it is. THis should be done for all dotPaths.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be also good to check if after converting the sender, to, etc. if the values are real emails.

"subject": self.config.subject,
"bcc": self.config.bcc,
"cc": self.config.cc,
"reply_to": self.config.reply_to,
"headers": self.config.headers
}
if self.config.message.get("type", None) == "text/html":
params["html"] = self.config.message.get("content", "")
else:
params["text"] = self.config.message.get("content", "")

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no content we'd rather not send anything and return a message on error port.

timeout = aiohttp.ClientTimeout(total=2)
async with HttpClient(0, [200, 201, 202, 203], timeout=timeout) as client:
async with client.post(
url=url,
json=params,
headers={"Authorization": f"Bearer {self.credentials.api_key}"},
) as response:
try:
content = await response.json()
except ContentTypeError:
content = await response.text()
except JSONDecodeError:
content = await response.text()

result = {
"status": response.status,
"content": content
}

if response.status in [200, 201, 202, 203]:
return Result(port="response", value=result)
else:
return Result(port="error", value=result)
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from tracardi.process_engine.action.v1.connectors.resend.send_email.plugin import ResendSendEmailAction
from tracardi.service.plugin.domain.register import Plugin, Spec, Form, FormGroup, FormField, FormComponent, MetaData, \
Documentation, PortDoc


def register() -> Plugin:
return Plugin(
start=False,
spec=Spec(
module='tracardi.process_engine.action.v1.connectors.resend.send_email.plugin',
className=ResendSendEmailAction.__name__,
inputs=['payload'],
outputs=['response', 'error'],
version="0.8.2",
license="MIT",
author="RyomaHan([email protected])",
init={
"resource": {
"id": "",
"name": ""
},
"params": {},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a good practive to set the default values for params:

init={
     "resource": {
            "id": "",
            "name": ""
      },
      "params": {
            "from": "",
            "to": "[email protected]",
            ...
          }
}

},
form=Form(
groups=[
FormGroup(
fields=[
FormField(
id="resource",
name="Resend Resource",
required=True,
description="Select Resend Resource.",
component=FormComponent(type="resource", props={"label": "Resend Resource", "tag": "resend"})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When yo reuse existing ApiKeythen set tag to: api_key

),
]
),
FormGroup(
name="Resend Send Email API Params",
fields=[
FormField(
id="params.sender",
name="Sender Email Address",
required=True,
description="To include a friendly name, use the format \"Your Name <[email protected]>\".",
component=FormComponent(type="dotPath", props={"label": "Resend"})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You use dotPath for sender which is good but the params.sender are not dun through dotAccessor to get the value.

),
FormField(
id="params.to",
name="Recipient Email Address(es)",
required=True,
description="Recipient email address. For multiple addresses, send as an array of strings, such as: [[email protected],...]. Max 50.",
component=FormComponent(type="dotPath", props={"label": "Resend"})
),
FormField(
id="params.subject",
name="Email Subject",
required=True,
component=FormComponent(type="dotPath", props={"label": "Resend"})
),
FormField(
id="params.bcc",
name="Bcc Recipient Email Address",
description="Bcc recipient email address. For multiple addresses, send as an array of strings, such as: [[email protected],...].",
component=FormComponent(type="dotPath", props={"label": "Resend"})
),
FormField(
id="params.cc",
name="Cc Recipient Email Address",
description="Cc recipient email address. For multiple addresses, send as an array of strings, such as: [[email protected],...].",
component=FormComponent(type="dotPath", props={"label": "Resend"})
),
FormField(
id="params.reply_to",
name="Reply-to Email Address",
description="Reply-to email address. For multiple addresses, send as an array of strings, such as: [[email protected],...].",
component=FormComponent(type="dotPath", props={"label": "Resend"})
),
FormField(
id="params.message",
name="Message",
description="The HTML version of the message or the plain text version of the message.",
component=FormComponent(
type="contentInput",
props={
"rows": 13,
"label": "Message body",
"allowedTypes": ["text/plain", "text/html"]
})
),
FormField(
id="params.headers",
name="Custom Headers",
description="Custom headers to add to the email.",
component=FormComponent(type="dotPath", props={"label": "Resend"})
),
]
),
]
)
),
metadata=MetaData(
name="Resend: Send Email",
desc="Send email(s) by Resend.",
brand="Resend",
icon="resend",
group=["Resend"],
documentation=Documentation(
inputs={
"payload": PortDoc(desc="This port takes payload object.")
},
outputs={
"response": PortDoc(desc="This port returns response status and content."),
"error": PortDoc(desc="This port returns error if request will fail ")}
)
)
)
9 changes: 9 additions & 0 deletions tracardi/service/setup/setup_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
test=PluginTest(init=None, resource=None),
),

"tracardi.process_engine.action.v1.connectors.resend.send_email.plugin": PluginMetadata(
test=PluginTest(
init={'resource': {'id': 'id', 'name': 'name'}, 'message': 'test'},
resource={
"api_key": "api_key",
}),
plugin_registry="tracardi.process_engine.action.v1.connectors.resend.send_email.registry"
),

"tracardi.process_engine.action.v1.connectors.telegram.post.plugin": PluginMetadata(
test=PluginTest(
init={'resource': {'id': 'id', 'name': 'name'}, 'message': 'test'},
Expand Down
8 changes: 8 additions & 0 deletions tracardi/service/setup/setup_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ def get_resource_types() -> List[ResourceSettings]:
"password": "<password>"
}
),
ResourceSettings(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YOu could reuse the existing api_key resource then there is no need for new ResourceSetting.

this is the one:

ResourceSettings(
            id="api-key",
            name="Api Key",
            icon="hash",
            tags=["api", "key", "token", "api_key"],
            config={
                "api_key": ""
            }
        ),

It should be first on the list.

id="resend",
name="Resend",
tags=["resend"],
config={
"api_key": "<api-key>",
},
),
ResourceSettings(
id="telegram",
name="Telegram",
Expand Down