Loading...

Postulate is the best way to take and share notes for classes, research, and other learning.

More info

Implementing Email Subscriptions Using Sendinblue

Profile picture of Samson ZhangSamson Zhang
May 18, 2021Last updated May 18, 20216 min read

v0.18.0 of Postulate added email subscriptions to projects, where visitors could sign up to receive an email whenever a post is published in a project, enabling email newsletter functionality a la Substack. To implement this system, I used Sendinblue's transactional email functionality and API.

Overview of design

Here's the overall flow for email subscriptions that we need to implement:

  1. Visitor enters their email in a sign-up form on Postulate

  2. Sendinblue sends an email to the provided address with a link to confirm the subscription

  3. The link goes to a Postulate page where a subscription object is created

  4. When a user publishes a post and indicates that an email should be sent, Postulate asks Sendinblue to send an email notification to all addresses with subscriptions to the project

  5. This notification email contains a link to a page where subscribers can manage their subscriptions

For authenticated users, steps 1, 2, and 5 are made much simpler because of instead of a secure link, the appropriate pages can simply use the email of the logged-in session.

To allow any user, with an account or not, to subscribe, though, our first challenge is to come up with a way to generate secure links.

Generating secure links

A simple way to generate a secure link where subscriptions can be managed for a certain email is to encrypt the email string using a secret key. With the secret key, stored safely as an environment variable on Postulate's server, it's easy to encrypt and decrypt emails. Without the secret key, though, it's impossible to generate the correct encrypted string for a given email, so as long as generated links are kept private, access to subscription management will be secure.

For Postulate, I decided to use AES encryption with a 256-bit key. I used a library called Crypto-JS to handle encryption and decryption. To encrypt an email, when sending a link in an email, for example, I use the following code:

const encryptedEmail = AES.encrypt(email, process.env.SUBSCRIBE_SECRET_KEY).toString();

And to decrypt, like when processing the URL of a link with an encrypted string in it, I use the following code:

const decryptedEmail = AES.decrypt(req.query.encryptedEmail, process.env.SUBSCRIBE_SECRET_KEY).toString(CryptoJS.enc.Utf8);

Sending subscription confirmation emails

Moving on to step 2 of the master plan, once a visitor submits the sign-up form with their email, Postulate makes the following request to Sendinblue to send a confirmation email:

await axios.post("https://api.sendinblue.com/v3/smtp/email", {

to: [{email: req.query.email}],

templateId: 11,

params: {

PROJECTNAME: thisProject.name,

CONFIRMLINK: `${process.env.NEXTAUTH_URL}/subscribe/${encodeURIComponent(emailHash)}/${req.query.projectId}`,

},

}, {

headers: { "api-key": process.env.SENDINBLUE_API_KEY },

});

Here, templateId refers to the following template I made in Sendinblue:



It takes two parameters, PROJECTNAME and CONFIRMLINK, which you'll see in the API request above as well. Here's what the final product looks like in my inbox:



Storing confirmed subscriptions

To store confirmed subscriptions, I made a new MongoDB collection subscriptions:

// schema model



const SubscriptionSchema = new mongoose.Schema({

targetType: { required: true, type: String },

targetId: { required: true, type: mongoose.Schema.Types.ObjectId },

email: { required: true, type: String },

}, {

timestamps: true,

});



// types.ts



export interface SubscriptionObj {

targetType: "project" | "user",

targetId: string,

email: string,

}

The confirmation link goes to a page that decrypts the encrypted email and creates a subscription object if one doesn't exist already (while being server-side rendered, so no keys are ever exposed to the client):

export const getServerSideProps: GetServerSideProps = async (context) => {

const decryptedEmail = AES.decrypt(context.query.emailHash, process.env.SUBSCRIBE_SECRET_KEY).toString(CryptoJS.enc.Utf8);



try {

await dbConnect();



const thisSub = await SubscriptionModel.findOne({email: decryptedEmail, targetId: context.query.projectId});



const thisProject = await ProjectModel.aggregate([

{$match: {_id: mongoose.Types.ObjectId(context.query.projectId)}},

{$lookup: {from: "users", localField: "userId", foreignField: "_id", as: "ownerArr"}},

]);



if (!thisProject) return {notFound: true};



// subscription already exists

if (thisSub) return {props: {email: decryptedEmail, emailHash: context.query.emailHash, projectData: cleanForJSON(thisProject[0]), exists: true}};



// otherwise make the subscription

await SubscriptionModel.create({

targetType: "project",

targetId: context.query.projectId,

email: decryptedEmail,

});



return {props: {email: decryptedEmail, emailHash: context.query.emailHash, projectData: cleanForJSON(thisProject[0]), exists: false}};

} catch (e) {

return {notFound: true};

}

}

The page itself looks like this:



Sending email notifications when posts are published

I created another MongoDB collection, emails, to track emails that have been sent. The schema is very simple:

// schema model

const EmailSchema = new mongoose.Schema({

targetId: mongoose.Schema.Types.ObjectId,

recipients: [{ required: true, type: String }],

}, {

timestamps: true,

});



// types.ts

export interface EmailObj {

recipients: string[],

targetId: string,

}

To send the emails themselves, I make a Sendinblue template like before, this time with a lot more parameters:



Because the subscription management link is different for each recipient (more on the management page itself later), the parameters have to be different for each recipient. This is what Sendinblue's batch send feature allows us to do. Here's my entire function:

async function sendEmails(thisProject: DatedObj<ProjectObj>, session: any, thisPost: DatedObj<PostObj> | PostObj, postId?: string) {

const recipients = await SubscriptionModel.find({targetId: thisPost.projectId});

if (recipients.length) {

const recipientEmails = recipients.map(d => d.email);

const sendVersions = recipientEmails.map(d => {

const emailHash = AES.encrypt(d, process.env.SUBSCRIBE_SECRET_KEY).toString();



return {

to: [{email: d}],

params: {

MANAGELINK: `${process.env.HOSTNAME}/subscribe/${encodeURIComponent(emailHash)}`,

},

};

});



const thisAuthor = await UserModel.findOne({_id: session.userId});



const postData = {

messageVersions: sendVersions,

templateId: 13,

params: {

TITLE: thisPost.title,

POSTLINK: `${process.env.HOSTNAME}/@${session.username}/p/${thisPost.urlName}`,

PROJECTNAME: thisProject.name,

PROJECTLINK: `${process.env.HOSTNAME}/@${session.username}/${thisProject.urlName}`,

AUTHOR: thisAuthor.name,

AUTHORLINK: `${process.env.HOSTNAME}/@${session.username}`,

SHORTPREVIEW: ellipsize(thisPost.body, 50),

LONGPREVIEW: ellipsize(thisPost.body, 500),

DATE: format("createdAt" in thisPost ? new Date(thisPost.createdAt) : new Date(), "MMMM d, yyyy 'at' h:mm a"),

},

};



await axios.post("https://api.sendinblue.com/v3/smtp/email", postData, {

headers: { "api-key": process.env.SENDINBLUE_API_KEY },

});



await EmailModel.create({

recipients: recipientEmails,

targetId: "_id" in thisPost ? thisPost._id : postId,

});

}



return true;

}

With everything plugged in, here's what the final notification email looks like:



Pretty nice!

Subscription management screen

Finally, it's fairly straightforward to implement a "manage your subscriptions" page at the same encrypted URL:



And our email subscription system is complete!


Comments (loading...)

Sign in to comment

Postulate

Founder and dev notes from building Postulate