Skip to content
Open
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
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Prerequisites
- Node.js 20+ and pnpm or npm
- MongoDB connection string (MongoDB Atlas or local via Docker Compose)
- Finnhub API key (free tier supported; real-time may require paid)
- Gmail account for email (or update Nodemailer transport)
- Optional: Gmail account for email (or update Nodemailer transport) if you want welcome and news summary emails
- Optional: Google Gemini API key (for AI-generated welcome intros)

Clone and install
Expand Down Expand Up @@ -208,6 +208,7 @@ Notes
- The app service depends_on the mongodb service.
- Credentials are defined in Compose for the MongoDB root user; authSource=admin is required on the connection string for root.
- Data persists across restarts via the docker volume.
- `NODEMAILER_EMAIL` and `NODEMAILER_PASSWORD` are optional for local Docker runs. If they are omitted, the app still starts but email features stay disabled.

Optional: Example MongoDB service definition used in this project:
```yaml
Expand Down Expand Up @@ -273,9 +274,9 @@ GEMINI_API_KEY=your_gemini_api_key
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key

# Email (Nodemailer via Gmail; consider App Passwords if 2FA)
NODEMAILER_EMAIL=youraddress@gmail.com
NODEMAILER_PASSWORD=your_gmail_app_password
# Email (optional; Nodemailer via Gmail, consider App Passwords if 2FA)
# NODEMAILER_EMAIL=youraddress@gmail.com
# NODEMAILER_PASSWORD=your_gmail_app_password
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Local (Docker Compose) MongoDB:
Expand Down Expand Up @@ -314,15 +315,16 @@ GEMINI_API_KEY=your_gemini_api_key
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key

# Email (Nodemailer via Gmail; consider App Passwords if 2FA)
NODEMAILER_EMAIL=youraddress@gmail.com
NODEMAILER_PASSWORD=your_gmail_app_password
# Email (optional; Nodemailer via Gmail, consider App Passwords if 2FA)
# NODEMAILER_EMAIL=youraddress@gmail.com
# NODEMAILER_PASSWORD=your_gmail_app_password
```

Notes
- Keep private keys server-side whenever possible.
- If using `NEXT_PUBLIC_` variables, remember they are exposed to the browser.
- In production, prefer a dedicated SMTP provider over a personal Gmail.
- If the Nodemailer credentials are omitted, the app still runs but welcome and news summary emails are disabled.
- Do not hardcode secrets in the Dockerfile; use `.env` and Compose.

## 🧱 Project Structure <a name="project-structure"></a>
Expand Down
15 changes: 9 additions & 6 deletions lib/inngest/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,18 @@ export const sendSignUpEmail = inngest.createFunction(
}
});

await step.run('send-welcome-email', async () => {
const emailResult = await step.run('send-welcome-email', async () => {
try {

const { data: { email, name } } = event;
// introText is already a plain string from the AI provider

console.log(`📧 Attempting to send welcome email to: ${email}`);
const result = await sendWelcomeEmail({ email, name, intro: introText });
if (result.status !== 'sent') {
console.log(`Welcome email skipped for: ${email}`);
return result;
}
console.log(`✅ Welcome email sent successfully to: ${email}`);
return result;
} catch (error) {
Expand All @@ -45,10 +49,9 @@ export const sendSignUpEmail = inngest.createFunction(
}
})

return {
success: true,
message: 'Welcome email sent successfully'
}
return emailResult.status === 'sent'
? { success: true, message: 'Welcome email sent successfully' }
: { success: true, message: 'Welcome email skipped because email credentials are not configured' };
}
)

Expand Down Expand Up @@ -463,4 +466,4 @@ export const checkInactiveUsers = inngest.createFunction(

return { processed: inactiveUsers.length, sent: results };
}
);
);
72 changes: 41 additions & 31 deletions lib/nodemailer/index.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
import nodemailer from 'nodemailer';
import {WELCOME_EMAIL_TEMPLATE, NEWS_SUMMARY_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates";
import { WELCOME_EMAIL_TEMPLATE, NEWS_SUMMARY_EMAIL_TEMPLATE } from "@/lib/nodemailer/templates";

// Verify transporter configuration
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
console.warn('⚠️ NODEMAILER_EMAIL or NODEMAILER_PASSWORD is not set. Email functionality will not work.');
type EmailSendResult =
| { status: 'skipped' }
| { status: 'sent'; messageId: string };

const hasEmailConfig = Boolean(process.env.NODEMAILER_EMAIL && process.env.NODEMAILER_PASSWORD);

if (!hasEmailConfig) {
console.warn('⚠️ Email credentials are not configured. Welcome and news summary emails are disabled until NODEMAILER_EMAIL and NODEMAILER_PASSWORD are set.');
}

export const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.NODEMAILER_EMAIL!,
pass: process.env.NODEMAILER_PASSWORD!,
},
// Add connection timeout and retry options
pool: true,
maxConnections: 1,
maxMessages: 3,
})
export const transporter = hasEmailConfig
? nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.NODEMAILER_EMAIL!,
pass: process.env.NODEMAILER_PASSWORD!,
},
// Keep the pool small because email volume is low in this app.
pool: true,
maxConnections: 1,
maxMessages: 3,
})
: null;

// Verify connection on startup
transporter.verify((error, success) => {
if (error) {
console.error('❌ Nodemailer transporter verification failed:', error);
} else {
console.log('✅ Nodemailer transporter is ready to send emails');
}
});
if (transporter) {
transporter.verify((error) => {
if (error) {
console.error('❌ Nodemailer transporter verification failed:', error);
} else {
console.log('✅ Nodemailer transporter is ready to send emails');
}
});
}

export const sendWelcomeEmail = async ({ email, name, intro }: WelcomeEmailData) => {
try {
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
throw new Error('Email credentials not configured');
if (!transporter) {
console.warn('⚠️ Welcome email skipped: email credentials are not configured.');
return { status: 'skipped' } satisfies EmailSendResult;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const htmlTemplate = WELCOME_EMAIL_TEMPLATE
Expand All @@ -43,11 +52,11 @@ export const sendWelcomeEmail = async ({ email, name, intro }: WelcomeEmailData)
subject: `Welcome to Openstock - your open-source stock market toolkit!`,
text: 'Thanks for joining Openstock, an initiative by open dev society',
html: htmlTemplate,
}
};

const info = await transporter.sendMail(mailOptions);
console.log('✅ Welcome email sent successfully:', info.messageId);
return info;
return { status: 'sent', messageId: info.messageId } satisfies EmailSendResult;
} catch (error) {
console.error('❌ Failed to send welcome email:', error);
throw error;
Expand All @@ -58,8 +67,9 @@ export const sendNewsSummaryEmail = async (
{ email, date, newsContent }: { email: string; date: string; newsContent: string }
) => {
try {
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
throw new Error('Email credentials not configured');
if (!transporter) {
console.warn('⚠️ News summary email skipped: email credentials are not configured.');
return { status: 'skipped' } satisfies EmailSendResult;
}

const htmlTemplate = NEWS_SUMMARY_EMAIL_TEMPLATE
Expand All @@ -76,9 +86,9 @@ export const sendNewsSummaryEmail = async (

const info = await transporter.sendMail(mailOptions);
console.log('✅ News summary email sent successfully:', info.messageId);
return info;
return { status: 'sent', messageId: info.messageId } satisfies EmailSendResult;
} catch (error) {
console.error('❌ Failed to send news summary email:', error);
throw error;
}
};
};