Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,10 @@
# MONGODB_URI=mongodb://localhost:27017/iiitl-alumni

# MongoDB Atlas (production example)
# MONGODB_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/iiitl
# MONGODB_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/iiitl

# Mailgun Configuration
MAILGUN_API_KEY=""
MAILGUN_DOMAIN=""
# MAILGUN_URL="https://api.eu.mailgun.net" # Uncomment if using an EU region domain
EMAIL_FROM="no-reply@yourdomain.com"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# Student-hub-ref
.student-hub-ref/*
1 change: 1 addition & 0 deletions .student-hub-ref
Submodule .student-hub-ref added at 3efe55
12 changes: 12 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import mongoose from "mongoose";

const MONGODB_URI = process.env.MONGODB_URI;

Check warning on line 3 in lib/db.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck-and-build

'MONGODB_URI' is assigned a value but never used

declare global {
var mongoose: {
Expand All @@ -16,7 +16,19 @@
cached = global.mongoose = { conn: null, promise: null };
}

/**
* Establishes a connection to the MongoDB database.
*
* This function utilizes connection caching to prevent multiple database connections
* during hot-reloading in development environments. It reads the `MONGODB_URI`
* environment variable dynamically at invocation to ensure it captures runtime environment configurations.
*
* @returns {Promise<typeof mongoose>} A promise that resolves to the Mongoose connection instance.
* @throws {Error} Throws an error if the `MONGODB_URI` environment variable is not defined.
*/
export async function connectDB() {
const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
throw new Error("Please define the MONGODB_URI environment variable");
}
Expand Down
138 changes: 138 additions & 0 deletions lib/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import FormData from 'form-data';
import Mailgun from 'mailgun.js';
import EmailLog from '../../models/EmailLog';
import { connectDB } from '../db';

/**
* Options required to send an email via Mailgun.
*/
interface EmailOptions {
/** The recipient's email address. */
to: string;
/** The subject line of the email. */
subject: string;
/** The plain text body of the email. */
text: string;
/** The HTML encoded body of the email (optional, defaults to plain text if not provided). */
html?: string;
}

/**
* Validates the presence of required Mailgun environment variables.
*
* @returns {boolean} True if all required environment variables are present, otherwise false.
*/
function validateMailgunEnvVars(): boolean {
const requiredVars = ['MAILGUN_API_KEY', 'MAILGUN_DOMAIN'];
const missingVars = requiredVars.filter((varName) => !process.env[varName]);

if (missingVars.length > 0) {
console.warn(`Missing Mailgun environment variables: ${missingVars.join(', ')}`);
return false;
}
return true;
}

let mailgunClient: ReturnType<Mailgun['client']> | null = null;

/**
* Initializes and returns a singleton instance of the Mailgun client.
*
* The client is configured using the `MAILGUN_API_KEY` and `MAILGUN_URL`
* environment variables. It caches the instance to avoid re-initialization on subsequent calls.
*
* @returns {ReturnType<Mailgun['client']> | null} The authenticated Mailgun client instance, or null if initialization fails.
*/
function getMailgunClient() {
if (!mailgunClient && validateMailgunEnvVars()) {
const mailgun = new Mailgun(FormData);
mailgunClient = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY || '',
url: process.env.MAILGUN_URL || 'https://api.mailgun.net', // allows EU domains
});
}
return mailgunClient;
}

/**
* Sends an email using the Mailgun API and logs the attempt to the database.
*
* This function ensures the Mailgun client is properly configured and constructs
* the 'From' address using environment variables. It logs both successful sends
* and failed attempts to the `EmailLog` database collection.
*
* @param {EmailOptions} options - The configuration object for the email to be sent.
* @returns {Promise<boolean>} A promise that resolves to `true` if the email was successfully sent, or `false` if it failed.
*/
export async function sendEmail({ to, subject, text, html }: EmailOptions): Promise<boolean> {
const mg = getMailgunClient();

if (!mg || !process.env.MAILGUN_DOMAIN) {
const initError = 'Mailgun client not initialized or domain missing';
console.error(initError);
void logEmail(to, subject, 'failed', undefined, initError);
return false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Ensure from address is in proper format
const fromAddress = process.env.EMAIL_FROM || `no-reply@${process.env.MAILGUN_DOMAIN}`;
function sanitizeErrorDetails(message: string, maxLen = 1000): string {
return message
.replace(/[\u0000-\u001F\u007F]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLen);
}
try {
const data = await mg.messages.create(process.env.MAILGUN_DOMAIN, {
from: fromAddress,
to: [to],
subject,
text,
html: html || text,
});

console.log('Email sent successfully via Mailgun:', data.id);
void logEmail(to, subject, 'sent', data.id);
return true;

} catch (error: unknown) {
const err = error as { message?: string };
const safeErrorDetails = sanitizeErrorDetails(err?.message ?? '');
console.error('Mailgun error details:', safeErrorDetails);
void logEmail(to, subject, 'failed', undefined, safeErrorDetails);
return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/**
* Logs an email delivery attempt to the MongoDB database.
*
* @param {string} to - The recipient's email address.
* @param {string} subject - The subject line of the email.
* @param {'sent' | 'failed'} status - The delivery outcome status.
* @param {string} [messageId] - The Mailgun message ID (applicable if successful).
* @param {string} [errorDetails] - The error message details (applicable if failed).
* @returns {Promise<void>}
*/
async function logEmail(
to: string,
subject: string,
status: 'sent' | 'failed',
messageId?: string,
errorDetails?: string
) {
try {
await connectDB();
await EmailLog.create({
to,
subject,
status,
messageId,
errorDetails,
});
} catch (dbError) {
console.error('Failed to log email to database:', dbError);
}
}
51 changes: 51 additions & 0 deletions models/EmailLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import mongoose, { Schema, Document, Model } from 'mongoose';

/**
* Represents a logged email delivery attempt in the database.
*/
export interface IEmailLog extends Document {
/** The recipient's email address. */
to: string;
/** The subject line of the email. */
subject: string;
/** The delivery status of the email. */
status: 'sent' | 'failed';
/** The unique message ID returned by Mailgun upon successful send (optional). */
messageId?: string;
/** Detailed error message if the email attempt failed (optional). */
errorDetails?: string;
/** Automatically generated timestamp of when the log was created. */
createdAt: Date;
/** Automatically generated timestamp of when the log was last updated. */
updatedAt: Date;
}

const EmailLogSchema: Schema<IEmailLog> = new Schema<IEmailLog>(
{
to: {
type: String,
required: [true, 'Recipient email is required'],
},
subject: {
type: String,
required: [true, 'Email subject is required'],
},
status: {
type: String,
enum: ['sent', 'failed'],
required: [true, 'Email status is required'],
},
messageId: {
type: String,
},
errorDetails: {
type: String,
},
},
{ timestamps: true }
);
Comment thread
DistantMyth marked this conversation as resolved.

const EmailLog: Model<IEmailLog> =
mongoose.models.EmailLog || mongoose.model<IEmailLog>('EmailLog', EmailLogSchema);

export default EmailLog;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
},
"dependencies": {
"dotenv": "^17.4.1",
"form-data": "^4.0.5",
"mailgun.js": "^12.7.1",
"mongoose": "^9.4.1",
"next": "16.2.3",
"react": "19.2.4",
Expand All @@ -24,6 +26,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
Loading
Loading