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

Feat/nodemailer #3

Merged
merged 15 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ export const {
ARCJET_KEY,
QSTASH_URL,
QSTASH_TOKEN,
EMAIL_PASSWORD,
EMAIL_USER,

} = process.env;
14 changes: 14 additions & 0 deletions config/nodemailer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import nodemailer from 'nodemailer';
import { EMAIL_USER, EMAIL_PASSWORD } from './env.js';

export const accountEmail = EMAIL_USER;

const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: accountEmail,
pass: EMAIL_PASSWORD
}
})

export default transporter;
5 changes: 2 additions & 3 deletions controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ export const signUp = async (req, res, next) => {

const user = await User.create([{ name, email, password: hashedPassword }], { session });

const token = jwt.sign({ userId: user[0]._id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN });
const token = jwt.sign({ userId: user[0]._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });

await session.commitTransaction();
session.endSession();

res.status(201).json({ success: true, data: { user: user[0], token } });
res.status(201).json({ success: true, message: 'User signed up successfully' ,data: { user: user[0], token } });
} catch (error) {
await session.abortTransaction();
session.endSession();
Expand Down Expand Up @@ -66,4 +66,3 @@ export const signIn = async (req, res, next) => {
}
}

export const signOut = async (req, res, next) => {}
1 change: 1 addition & 0 deletions controllers/subscription.controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Subscription from '../models/subscription.model.js';
import { SERVER_URL } from '../config/env.js';
import { workflowClient } from '../config/upstash.js';

export const createSubscription = async (req, res, next) => {
try {
const subscription = await Subscription.create({ ...req.body, user: req.user._id });
Expand Down
26 changes: 17 additions & 9 deletions controllers/workflow.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { serve } = require('@upstash/workflow/express');

const REMAINDERS = [7, 5, 2, 1];
const REMINDERS = [7, 5, 2, 1];

import Subscription from '../models/subscription.model.js';
import { sendReminderEmail } from '../utils/send-email.js';


export const sendRemainders = serve(async (context) => {
export const sendReminders = serve(async (context) => {
const { subscriptionId } = context.requestPayload;
const subscription = await fetchSubscription(subscriptionId);
const subscription = await fetchSubscription(context, subscriptionId);

if (!subscription || subscription.status !== 'active' ) return;
const renewalDate = dayjs(subscription.renewalDate);
Expand All @@ -22,12 +22,14 @@ export const sendRemainders = serve(async (context) => {
}


for (const daysBefore of REMAINDERS) {
for (const daysBefore of REMINDERS) {
const reminderDate = renewalDate.subtract(daysBefore, 'day');
if (reminderDate.isAfter(dayjs())) {
await sleepUntilReminder(context, `Reminder ${daysBefore}-days-before`, reminderDate);
await sleepUntilReminder(context, `Reminder ${daysBefore} -days-before`, reminderDate);
}
if (dayjs().isSame(reminderDate, 'day')) {
await triggerReminder(context, `${daysBefore} days before reminder`, subscription);
}
await triggerReminder(context, `Reminder ${daysBefore}-days-before`);
}
});

Expand All @@ -42,8 +44,14 @@ const sleepUntilReminder = async (context, label, date) => {
await context.sleepUntil(label, date.toDate());
}

const triggerReminder = async (context, label) => {
return await context.run(label, () => {
const triggerReminder = async (context, label, subscription) => {
return await context.run(label,async () => {
console.log(`Triggering ${label} reminder`);

await sendReminderEmail({
to: subscription.user.email,
type: label,
subscription,
})
});
}
2 changes: 1 addition & 1 deletion middlewares/arcjet.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import aj from '../config/arcjet.js';

const arcjectMiddleware = async (req, res, next) => {
try {
const decision = await aj.protect(req);
const decision = await aj.protect(req, {requested: 1 });
if (decision.isDenied()) {
if(decision.reason.isRateLimit()) return res.status(429).json({ success: false, message: 'Rate limit exceeded' });
if(decision.reason.isBot()) return res.status(403).json({ success: false, message: 'Bot detected' });
Expand Down
5 changes: 3 additions & 2 deletions middlewares/auth.middleware.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import jwt from 'jsonwebtoken';
import User from '../models/user.model.js';
import { JWT_SECRET } from '../config/env.js';

const authorize = async (req, res, next) => {
try {
let token;

if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}

if (!token) return res.status(401).json({ success: false, message: 'Not authorized to access this route' });

const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, JWT_SECRET);

const user = await User.findById(decoded.userId);

Expand Down
4 changes: 2 additions & 2 deletions middlewares/error.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const errorMiddleware = (err, req, res, next) => {
try {
let error = { ...err };
error.message = err.message;
console.log(err);
console.error(err);

if (err.name === 'CastError') {
const message = `Resource not found with id of ${err.value}`;
Expand All @@ -17,7 +17,7 @@ const errorMiddleware = (err, req, res, next) => {
}

if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map((val) => val.message);
const message = Object.values(err.errors).map(val => val.message);
error = new Error(message);
error.statusCode = 400;
}
Expand Down
4 changes: 2 additions & 2 deletions models/subscription.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import mongoose from 'mongoose';
const subscriptionSchema = new mongoose.Schema({
name: { type: String, required: [true, "Name is required"], trim: true, minLenght:2, maxLenght:50 },
price: { type: Number, required: [true, "Price is required"], min: [0, "Price must be at least 0"] },
currency: { type: String, required: [true, "Currency is required"], trim: true, enum: ["USD", "EUR", "GBP"] },
currency: { type: String, enum: ["USD", "EUR", "GBP"], default: "USD" },
frequency: { type: String, enum: ["daily", "weekly", "monthly", "yearly"], default: "monthly" },
category: { type: String, enum: ["business", "entertainment", "health", "science", "sports", "technology"], required: [true, "Category is required"] },
paymentMethod: { type: String, required: [true, "Payment method is required"], trim: true },
status: { type: String, enum: ["active", "trial", "past_due", "canceled"], default: "active" },
status: { type: String, enum: ["active", "expired", "canceled"], default: "active" },
startDate: { type: Date, required: [true, "Start date is required"], validate: { validator: function (value) { return value <= new Date(); }, message: "Start date must be in the past" } },
renewalDate: { type: Date, validate: { validator: function (value) { return value > this.startDate; }, message: "Renewal date must be in the future" } },
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: [true, "User is required"], index: true }
Expand Down
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.13.0",
"mongoose": "^8.10.0",
"morgan": "~1.9.1"
"morgan": "~1.9.1",
"nodemailer": "^6.10.0"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
Expand Down
3 changes: 1 addition & 2 deletions routes/auth.routes.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Router } from 'express';
import { signIn, signOut, signUp } from '../controllers/auth.controller.js';
import { signIn, signUp } from '../controllers/auth.controller.js';

const authRouter = Router();

authRouter.post('/sign-up', signUp);
authRouter.post('/sign-in', signIn);
authRouter.post('/sign-out', signOut);

export default authRouter;
4 changes: 2 additions & 2 deletions routes/workflow.routes.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Router } from "express";
import { sendRemainders } from "../controllers/workflow.controller.js";
import { sendReminders } from "../controllers/workflow.controller.js";

const workflowRouter = Router();

workflowRouter.post("/", sendRemainders);
workflowRouter.post("/subscription/reminder", sendReminders);

export default workflowRouter;
94 changes: 94 additions & 0 deletions utils/email-template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
export const generateEmailTemplate = ({
userName,
subscriptionName,
renewalDate,
planName,
price,
paymentMethod,
accountSettingsLink,
supportLink,
daysLeft,
}) => `
<div style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 0; background-color: #f4f7fa;">
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<tr>
<td style="background-color: #4a90e2; text-align: center;">
<p style="font-size: 54px; line-height: 54px; font-weight: 800;">SubDub</p>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="font-size: 16px; margin-bottom: 25px;">Hello <strong style="color: #4a90e2;">${userName}</strong>,</p>

<p style="font-size: 16px; margin-bottom: 25px;">Your <strong>${subscriptionName}</strong> subscription is set to renew on <strong style="color: #4a90e2;">${renewalDate}</strong> (${daysLeft} days from today).</p>

<table cellpadding="15" cellspacing="0" border="0" width="100%" style="background-color: #f0f7ff; border-radius: 10px; margin-bottom: 25px;">
<tr>
<td style="font-size: 16px; border-bottom: 1px solid #d0e3ff;">
<strong>Plan:</strong> ${planName}
</td>
</tr>
<tr>
<td style="font-size: 16px; border-bottom: 1px solid #d0e3ff;">
<strong>Price:</strong> ${price}
</td>
</tr>
<tr>
<td style="font-size: 16px;">
<strong>Payment Method:</strong> ${paymentMethod}
</td>
</tr>
</table>

<p style="font-size: 16px; margin-bottom: 25px;">If you'd like to make changes or cancel your subscription, please visit your <a href="${accountSettingsLink}" style="color: #4a90e2; text-decoration: none;">account settings</a> before the renewal date.</p>

<p style="font-size: 16px; margin-top: 30px;">Need help? <a href="${supportLink}" style="color: #4a90e2; text-decoration: none;">Contact our support team</a> anytime.</p>

<p style="font-size: 16px; margin-top: 30px;">
Best regards,<br>
<strong>The SubDub Team</strong>
</p>
</td>
</tr>
<tr>
<td style="background-color: #f0f7ff; padding: 20px; text-align: center; font-size: 14px;">
<p style="margin: 0 0 10px;">
SubDub Inc. | 123 Main St, Anytown, AN 12345
</p>
<p style="margin: 0;">
<a href="#" style="color: #4a90e2; text-decoration: none; margin: 0 10px;">Unsubscribe</a> |
<a href="#" style="color: #4a90e2; text-decoration: none; margin: 0 10px;">Privacy Policy</a> |
<a href="#" style="color: #4a90e2; text-decoration: none; margin: 0 10px;">Terms of Service</a>
</p>
</td>
</tr>
</table>
</div>
`;

export const emailTemplates = [
{
label: "7 days before reminder",
generateSubject: (data) =>
`📅 Reminder: Your ${data.subscriptionName} Subscription Renews in 7 Days!`,
generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 7 }),
},
{
label: "5 days before reminder",
generateSubject: (data) =>
`⏳ ${data.subscriptionName} Renews in 5 Days – Stay Subscribed!`,
generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 5 }),
},
{
label: "2 days before reminder",
generateSubject: (data) =>
`🚀 2 Days Left! ${data.subscriptionName} Subscription Renewal`,
generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 2 }),
},
{
label: "1 days before reminder",
generateSubject: (data) =>
`⚡ Final Reminder: ${data.subscriptionName} Renews Tomorrow!`,
generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 1 }),
},
];
36 changes: 36 additions & 0 deletions utils/send-email.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { emailTemplates } from './email-template.js'
import dayjs from 'dayjs'
import transporter, { accountEmail } from '../config/nodemailer.js'

export const sendReminderEmail = async ({ to, type, subscription }) => {
if(!to || !type) throw new Error('Missing required parameters');

const template = emailTemplates.find((t) => t.label === type);

if(!template) throw new Error('Invalid email type');

const mailInfo = {
userName: subscription.user.name,
subscriptionName: subscription.name,
renewalDate: dayjs(subscription.renewalDate).format('MMM D, YYYY'),
planName: subscription.name,
price: `${subscription.currency} ${subscription.price} (${subscription.frequency})`,
paymentMethod: subscription.paymentMethod,
}

const message = template.generateBody(mailInfo);
const subject = template.generateSubject(mailInfo);

const mailOptions = {
from: accountEmail,
to: to,
subject: subject,
html: message,
}

transporter.sendMail(mailOptions, (error, info) => {
if(error) return console.log(error, 'Error sending email');

console.log('Email sent: ' + info.response);
})
}