cunybpl / sendgrid_client

A simple client/email backend wrapper for bulk emailing with sendgrid
0 stars 0 forks source link

Emailing Backend Implementation #3

Closed kgasiorowski closed 1 year ago

kgasiorowski commented 1 year ago

We need to write some code which will allow us to send emails.

The django.core.mail is a very good starting point in where to look for how to implement this. They include multiple backends, including: a dummy one, one that prints to the console, one that writes to files, and one which actually sends emails. Plenty of useful code there to work with. The dummy one is probably most important, since we need a way to test this stuff without reaching our sendgrid API limits.

We'll be using sendgrid to actually send the emails. It seems like sendgrid has a built-in way of handling connections and post requests and such, so hopefully that will work to simplify the code we're working with from django. We will need API keys for that, which is probably a @bsnacks000 thing

We should also bring in jinja2 and create a local email template to keep things nice and clean.

kgasiorowski commented 1 year ago

django: https://github.com/django/django/tree/main/django/core/mail sendgrid: https://docs.sendgrid.com/for-developers/sending-email/quickstart-python jinja2: https://frankcorso.dev/email-html-templates-jinja-python.html (not the official documentation, but seems like a good starting point)

bsnacks000 commented 1 year ago

^^ yup I have an API key for when we are ready to test but I think first we should come up with a design that lets us swap backends so we can substitute a dummy backend that prints to console. That will lets us test the templating ...

It can be a very simple interface that we can override... probably just a single method abstract that sends a rendered html response. This will let us pass config into the constructor.

We'll also need an object to hold the email credentials we want to send... to avoid extra deps we can use plain class or dataclass for this...

Lastly we'll need some kind of payload object with the data we want to send. It should also be an interface with maybe a method like render that returns a string. This way we have flexibility and we can create different templates in the applications.

Finally just a stand a lot method send_email or something that will take the email addresses to send + a payload + backend. So like a controller method.

bsnacks000 commented 1 year ago

Super simplfied view of how this should work in an app ...

# in config 
...

ADMIN_EMAILS = [
        "me@gmail.com", 
        "you@gmail.com"
    ]

admin_emails = Emails(ADMIN_EMAILS)  # <--- see below

if config.ENVIRONMENT == 'production': 
    sg_key = os.environ.get('SENGRID_API_KEY')
    email_backend  = SengridBackend(api_key=sg_key)

if config.ENVIRONMENT == 'local': 
    email_backend = DummyBackend()
...

# in the app ... ex. in a fastapi exception handler 
try: 
   .... 
except Exception as err: 

    content = render_content(request=req, exc=err) # <---- would produce html template with the exc detail and request object 
     send_emails(admin_emails,  backend=email_backend, from_email: str='bpldev@host.com', subject="some error occurred",content=content) 

send_emails controller would need to be run in a threadpool since it blocks, but this is the basic idea.

If we were running this locally the DummyBackend if configured would simply print the email messages to the console...

I'm thinking we might not even need jinja honestly it seems a bit overkill now that i'm looking at this... we can just use just regular html formatted as a multiline string with placeholder {variables} to inject data in the application. The render_content method above we can do in fastapi so that we can change up what is needed in the application... it really is just a way to get data into a string.. there is not need to make it fancier then that i think

The key thing is that the content is dynamic at runtime but other parts are lazily initialized. This library actually should not configure any environment variables to make it more flexible for us in a downstream app.

The way I would do this is instead of having a BaseBackend we'd have a mixin called EmailMessager. Then "backends" are types that only inherit the action but their constructors vary.

class SendgridBackend(EmailMessager): 
    def __init__(self, api_key: str ): 
         self.client = sendgrid.SendGridAPIClient(api_key=api_key) 
    def send_message(self, messages: Emails) -> int: 
         ... #do stuff with self.client 

The Emails class essentially can just become a container for emails to be sent. We can maybe just have it do some validation and hold onto the strings. For the purposes of what we need this to do (send admin emails) this should suffice.

class Emails: 
    def __init__(self, addresses: List[str] ): 
        self.emails = [_validate(a) for a in addresses]   # <--- some regex or whatever... raise ValueError if fails... 

This would just hang around the app in memory like the backend waiting to be used.

send_emails would then take this method sig:

def send_emails(emails: Emails, backend: EmailMessager, from_email: str, subject: str, content: str) -> None: 
    ... 

And we have a bulk emailer more or less...

kgasiorowski commented 1 year ago

So, I tried that approach you showed in the first code snippet above, but I ran into problems. Firstly, I'm getting a circular import error because the app uses config, but config also makes references back to the backend classes we defined in the app when it checks the environment. How can we get around this?

Secondarily, I'm confused about the ENVIRONMENT thing. Since this is a standalone library, and its gonna be used in any number of projects, how are we going to get the environment from the .env? Every project I've checked has its own name for ENVIRONMENT a la BEIST_ENVIRONMENT or BPLRPC_ENVIRONMENT so how can we access that in sendgrid_api? Will the projects need to add a SENDGRID_ENVIRONMENT env variable or something like that?

@bsnacks000

bsnacks000 commented 1 year ago

I had some comments i think in the PR... we want to do away with config.py here and just focus on the api... this is more a very small library package rather then an application. I tried to illustrate this above ^^ but maybe its unclear...

In bemadb for example we'd have: BEMADB_SENDGRID_API_KEY under its config module and then it would imported around that way. This lib does not need to read anything from the env... just expose its API for our applications to use as needed.

So here we can basically just ignore it and test by passing stuff into objects at runtime.