How can I read and bundle a non-js or ts file from my netlify function?

I’m trying to load a mjml file as a string that’s then sent to SendGrid after being compiled but I’m having trouble deploying it along with my Netlify function.

Here’s my method:

// api/src/lib/email.js
export async function getTemplate(name) {
  const templateDir = process.env.LAMBDA_TASK_ROOT
    ? `${process.env.LAMBDA_TASK_ROOT}/emails`
    : path.join(__dirname, '../../../email/src/templates')

  return await fs.readFile(path.join(templateDir, `${name}.mjml`), 'utf8')
}

I’ve also added this command to my build process to try to copy them in the correct folder for Netlify.
cp -r ../email/src/templates dist/graphql/emails

However when running the call I get the error:

ENOENT: no such file or directory, open '/var/task/emails/welcome.mjml'

What’s the correct directory I need to copy these files into to ensure they’re deployed with my API?

1 Like

So if you have the mjml file as part of the source, a quick an easy way would be to use a js/ts file, with the template in backticks

e.g.
template.ts

export const template = `
<html>
<h1> Hello jmcmullen </h1>
</html>
`

api/src/lib/email.js

import {template} from './templates.ts'

export async function getTemplate(name) {
  return template
}

If you are fetching these files at runtime, perhaps consider using S3? Once you fetch the template, you can upload it to S3 and save the URL in your db. It’s probably a more scalable way of storing files - if you’re using lambdas or even k8s, storing files locally doesn’t guarantee its available in every instance (unless you mount some sort of shared storage)

I’m trying to avoid saving them as .js files because the source files are .mjml in the same monorepo and it would duplicate code or ruin my live-reload/dev experience when building them. I might just copy them the web/public folder and load them with the request npm module if there’s no way to save them locally.

I was under the impression if you upload files into a folder with the same name as the function it will copy them to the lambda as well. This is based off a Netlify employee’s posts here and the example here

May I ask why you want to use a function vs a service?

What will “trigger” the task (load mjml, create html email content, send mail via SendGrid email api?).

Is there a difference between how services and functions are deployed? Not sure I follow, I’m sending emails on user registration, password reset, etc performed via GraphQL mutations.

Hey @jmcmullen

From reading that post they’re saying:

Since lambda functions are self-contained, that would only work if your build pipeline copies each required file to your function folder and explicitly bundles it up. Our buildbot will not natively handle what you described, though if you bundle your function manually things should be doable.

We build your code and then hand it off to the build-bot, which zips up each Serverless Function (using zip it and ship it). Zip it and Ship It looks at the imports in each Serverless Function and only includes those, so, unless you’re importing these files they won’t be included.

The alternative is to take care of zip-it-and-ship-it yourself by doing the following:

  1. yarn rw build
  2. run zip it and ship it
  3. insert the files into the zip
  4. disable the zip it and ship it build bot step.

OR

Spitballing here: Modify require to include your mjml extension as a no-op import: Modules: CommonJS modules | Node.js v21.6.0 Documentation

The easiest would be to inline it as a template literal.

1 Like

Ah, actually reading the correct example now. You could try using

fs.readFileSync(path/to/file)
fs.readFileSync(path/to/file)
fs.readFileSync(path/to/file)

in src/functions/graphql

ZiSi might try to include those.

Update: I find it very hard to believe that the JSON example above works. Since it’s processed at runtime.

If you do put in src/functions there is an issue (I think) where redwood dev server only doesn’t like functions in directories. So a flat structure like:

image

// sendMail.js

fs.readFileSync(./template.mjml)

Might work?

Edit: Actually because of the directory issue, it may not.

/functions/
/functions/sendMail
/functions/sendMail/index.js
/functions/sendMail/template.mjml

might… but perhaps not in RW dev.

A GraphQL mutation would call a service, so I imagine you have a createUser service or signup or register that creates a User record.

In that service, you might call another service like Mailer that has sendWelcomeMail(user).

The service would then load the welcome email mjml template, generate html (combine with some suer profile info), call SendGrid.

Yes, they sort of are all serverless function underneath but the services are able to be called by graphql, functions cannot.

I would try:

  • use services for user and mailer
  • include the *.mjml in functions dir at same level as graphql.

Here’s why:

Netlify zips and ships the graphql function:

4:33:28 PM: Packaging Functions from api/dist/functions directory:
4:33:28 PM:  - graphql.js
4:33:53 PM: ​

image

The first graphql.js is just

module.exports = require('./src/api/dist/functions/graphql.js')

The next loads services.

It might help for you to look into the zipped dir that Netlify will ship … and then see maybe where best to include your file.

To do that:

  • Install the Netlify cli npm install netlify-cli -g
  • Link to your Netlify site netlify link
  • Build netlify build

You should now have:

image

Then have a peek what is in the archive(s) … or in any function you made.

2 Likes

That makes way more sense now, thanks. I’m thinking a netlify build plugin would solve all of this pretty easily right? Will report back with my findings.

Got it working with the following custom build plugin:

// plugins/copy-emails/index.js
module.exports = {
  onPostBuild: async ({ utils: { run }, constants: { FUNCTIONS_DIST } }) => {
    await run.command(
      `zip -ur ${FUNCTIONS_DIST}/graphql.zip ./email/src/templates`
    )
  },
}
// api/src/lib/email.js
import path from 'path'
import fs from 'fs-extra'
import mjml2html from 'mjml'
import Vue from 'vue'
import { createRenderer } from 'vue-server-renderer'

const templateDir = process.env.LAMBDA_TASK_ROOT
  ? `${process.env.LAMBDA_TASK_ROOT}/`
  : path.join(__dirname, '../../../')

export async function getTemplate(name) {
  const resolved = path.resolve(templateDir, `email/src/templates/${name}.mjml`)
  return await fs.readFile(resolved, 'utf8')
}

export async function compileEmail(data, template) {
  const renderer = createRenderer()
  const app = new Vue({ data, template: await getTemplate(template) })
  const render = await renderer.renderToString(app)
  const { html, errors } = mjml2html(render, {
    filePath: path.resolve(templateDir, 'email/src/templates/'),
  })
  if (errors.length) console.log('MJML Errors:', errors)
  return html
}
1 Like

Nice one! :clap:

So this way you can keep that monorepo structure right? and your email templates stay where they always are … and not have to copy?

Could a pre-build copy into … no… I bet because the template isn’t a dependency it doesn’t get into the zip bundle

Also - that Vue rendering is really interesting.

Very cool.

1 Like

Yeah. It’s much easier to just use template literals like recommended with MJML but my app is heavily focused around emails and will likely have over 50 so a nice developer experience will be absolutely vital, plus I love the challenge. This way I get to keep hot reloading without duplicating any code. Appreciate all the support!

2 Likes

This is great :dizzy:. Thank you again for sharing on the forums, I love the idea of “community docs”. I’m going to change the title to make it a little more easily searchable!

1 Like

I can’t edit my original post but the plugin now has to use onPostBuild instead of onSuccess

1 Like

I tried to edit it for you, did I get it right?

1 Like

Yep, cheers!