diff --git a/viewer-frontend/.env.example b/viewer-frontend/.env.example index 15aac4c..6873d58 100644 --- a/viewer-frontend/.env.example +++ b/viewer-frontend/.env.example @@ -14,6 +14,25 @@ DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag" NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32" NEXTAUTH_URL="http://localhost:3001" +# Email provider (smtp or resend) +EMAIL_PROVIDER="smtp" + +# SMTP (primary) +SMTP_HOST="mail.your-domain.com" +SMTP_PORT="465" +SMTP_SECURE="true" +SMTP_USER="mailer@your-domain.com" +SMTP_PASS="your-mailbox-password" +SMTP_FROM_EMAIL="noreply@your-domain.com" +SMTP_FROM_NAME="PunimTag Viewer" +SMTP_REPLY_TO="support@your-domain.com" + +# Resend (fallback) +RESEND_API_KEY="re_xxx" +RESEND_FROM_EMAIL="onboarding@resend.dev" +RESEND_FROM_NAME="PunimTag Viewer" +RESEND_REPLY_TO="support@your-domain.com" + # Site Configuration NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer" NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery" diff --git a/viewer-frontend/.env_example b/viewer-frontend/.env_example index 73e83a3..89f82ca 100644 --- a/viewer-frontend/.env_example +++ b/viewer-frontend/.env_example @@ -10,6 +10,22 @@ NEXTAUTH_URL=http://127.0.0.1:3001 NEXTAUTH_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_STRING AUTH_URL=http://127.0.0.1:3001 +# Email provider (smtp or resend) +EMAIL_PROVIDER=smtp + +# SMTP (primary) +SMTP_HOST=mail.your-domain.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=mailer@your-domain.com +SMTP_PASS=CHANGE_ME +SMTP_FROM_EMAIL=noreply@your-domain.com +SMTP_FROM_NAME=PunimTag +SMTP_REPLY_TO=support@your-domain.com + +# Resend (fallback) RESEND_API_KEY=CHANGE_ME_secret-key RESEND_FROM_EMAIL="onboarding@resend.dev" +RESEND_FROM_NAME=PunimTag +RESEND_REPLY_TO=support@your-domain.com UPLOAD_DIR="/mnt/db-server-uploads/pending-photos" diff --git a/viewer-frontend/lib/email.ts b/viewer-frontend/lib/email.ts index 048905b..84984e8 100644 --- a/viewer-frontend/lib/email.ts +++ b/viewer-frontend/lib/email.ts @@ -1,8 +1,117 @@ -import { Resend } from 'resend'; import crypto from 'crypto'; +import nodemailer from 'nodemailer'; +import { Resend } from 'resend'; + +type EmailPayload = { + to: string; + subject: string; + html: string; + text?: string; +}; const resend = new Resend(process.env.RESEND_API_KEY); +function getBaseUrl(): string { + return process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001'; +} + +function formatFromAddress(fromEmail: string, fromName?: string): string { + if (!fromName) { + return fromEmail; + } + return `${fromName} <${fromEmail}>`; +} + +function getSmtpPort(): number { + const port = Number(process.env.SMTP_PORT || '465'); + return Number.isNaN(port) ? 465 : port; +} + +function getSmtpSecure(): boolean { + return (process.env.SMTP_SECURE || 'true').toLowerCase() === 'true'; +} + +function hasSmtpConfig(): boolean { + return Boolean( + process.env.SMTP_HOST && + process.env.SMTP_USER && + process.env.SMTP_PASS + ); +} + +async function sendViaSmtp(payload: EmailPayload): Promise { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_PASS; + + if (!smtpHost || !smtpUser || !smtpPass) { + throw new Error('Missing SMTP configuration (SMTP_HOST, SMTP_USER, SMTP_PASS)'); + } + + const smtpFromEmail = process.env.SMTP_FROM_EMAIL || smtpUser; + const smtpFromName = process.env.SMTP_FROM_NAME; + const smtpReplyTo = process.env.SMTP_REPLY_TO || smtpFromEmail; + + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: getSmtpPort(), + secure: getSmtpSecure(), + auth: { + user: smtpUser, + pass: smtpPass, + }, + }); + + await transporter.sendMail({ + from: formatFromAddress(smtpFromEmail, smtpFromName), + to: payload.to, + replyTo: smtpReplyTo, + subject: payload.subject, + text: payload.text, + html: payload.html, + }); +} + +async function sendViaResend(payload: EmailPayload): Promise { + const fromEmail = process.env.RESEND_FROM_EMAIL || process.env.SMTP_FROM_EMAIL || 'onboarding@resend.dev'; + const fromName = process.env.RESEND_FROM_NAME || process.env.SMTP_FROM_NAME; + const replyTo = process.env.RESEND_REPLY_TO || process.env.SMTP_REPLY_TO || fromEmail; + + const result = await resend.emails.send({ + from: formatFromAddress(fromEmail, fromName), + to: payload.to, + replyTo, + subject: payload.subject, + text: payload.text, + html: payload.html, + }); + + if (result.error) { + throw new Error(`Resend API error: ${result.error.message || 'Unknown error'}`); + } +} + +async function sendEmailWithFallback(payload: EmailPayload): Promise { + const provider = (process.env.EMAIL_PROVIDER || 'smtp').toLowerCase(); + + if (provider == 'resend') { + await sendViaResend(payload); + return; + } + + if (!hasSmtpConfig()) { + await sendViaResend(payload); + return; + } + + try { + await sendViaSmtp(payload); + } catch (smtpError) { + console.error('[EMAIL] SMTP send failed, trying Resend fallback:', smtpError); + await sendViaResend(payload); + } +} + export function generateEmailConfirmationToken(): string { return crypto.randomBytes(32).toString('hex'); } @@ -12,44 +121,47 @@ export async function sendEmailConfirmation( name: string, token: string ): Promise { - const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001'; - const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`; + const confirmationUrl = `${getBaseUrl()}/api/auth/verify-email?token=${token}`; - try { - await resend.emails.send({ - from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev', - to: email, - subject: 'Confirm your email address', - html: ` - - - - - - Confirm your email - - -
-

Confirm your email address

-

Hi ${name},

-

Thank you for signing up! Please confirm your email address by clicking the button below:

- -

Or copy and paste this link into your browser:

-

${confirmationUrl}

-

- If you didn't create an account, you can safely ignore this email. -

-
- - - `, - }); - } catch (error) { - console.error('Error sending confirmation email:', error); - throw new Error('Failed to send confirmation email'); - } + const html = ` + + + + + + Confirm your email + + +
+

Confirm your email address

+

Hi ${name},

+

Thank you for signing up! Please confirm your email address by clicking the button below:

+ +

Or copy and paste this link into your browser:

+

${confirmationUrl}

+

+ If you didn't create an account, you can safely ignore this email. +

+
+ + + `; + + const text = `Hi ${name}, + +Thank you for signing up. Please confirm your email address: +${confirmationUrl} + +If you did not create this account, you can safely ignore this email.`; + + await sendEmailWithFallback({ + to: email, + subject: 'Confirm your email address', + html, + text, + }); } export async function sendEmailConfirmationResend( @@ -57,44 +169,47 @@ export async function sendEmailConfirmationResend( name: string, token: string ): Promise { - const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001'; - const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`; + const confirmationUrl = `${getBaseUrl()}/api/auth/verify-email?token=${token}`; - try { - await resend.emails.send({ - from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev', - to: email, - subject: 'Confirm your email address', - html: ` - - - - - - Confirm your email - - -
-

Confirm your email address

-

Hi ${name},

-

You requested a new confirmation email. Please confirm your email address by clicking the button below:

- -

Or copy and paste this link into your browser:

-

${confirmationUrl}

-

- This link will expire in 24 hours. If you didn't request this email, you can safely ignore it. -

-
- - - `, - }); - } catch (error) { - console.error('Error sending confirmation email:', error); - throw new Error('Failed to send confirmation email'); - } + const html = ` + + + + + + Confirm your email + + +
+

Confirm your email address

+

Hi ${name},

+

You requested a new confirmation email. Please confirm your email address by clicking the button below:

+ +

Or copy and paste this link into your browser:

+

${confirmationUrl}

+

+ This link will expire in 24 hours. If you didn't request this email, you can safely ignore it. +

+
+ + + `; + + const text = `Hi ${name}, + +You requested a new confirmation email. Please confirm your email address: +${confirmationUrl} + +This link expires in 24 hours. If you did not request this, you can ignore this email.`; + + await sendEmailWithFallback({ + to: email, + subject: 'Confirm your email address', + html, + text, + }); } export function generatePasswordResetToken(): string { @@ -106,12 +221,8 @@ export async function sendPasswordResetEmail( name: string, token: string ): Promise { - const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001'; - const resetUrl = `${baseUrl}/reset-password?token=${token}`; - const fromEmail = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'; - const replyTo = process.env.RESEND_REPLY_TO || fromEmail; + const resetUrl = `${getBaseUrl()}/reset-password?token=${token}`; - // Plain text version for better deliverability const text = `Hi ${name}, You requested to reset your password. Click the link below to create a new password: @@ -125,72 +236,43 @@ If you didn't request a password reset, you can safely ignore this email. Best regards, PunimTag Viewer Team`; - try { - console.log('[EMAIL] Sending password reset email:', { - from: fromEmail, - to: email, - replyTo: replyTo, - }); - - const result = await resend.emails.send({ - from: fromEmail, - to: email, - replyTo: replyTo, - subject: 'Reset your password - PunimTag Viewer', - text: text, - html: ` - - - - - - - Reset your password - - -
-

Reset your password

-

Hi ${name},

-

You requested to reset your password for your PunimTag Viewer account. Click the button below to create a new password:

- -

Or copy and paste this link into your browser:

-

${resetUrl}

-

- Important: This link will expire in 1 hour for security reasons. -

-

- If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. -

-

- This is an automated message from PunimTag Viewer. Please do not reply to this email. -

-
- - - `, - }); - - if (result.error) { - console.error('[EMAIL] Resend API error:', result.error); - throw new Error(`Resend API error: ${result.error.message || 'Unknown error'}`); - } - - console.log('[EMAIL] Password reset email sent successfully:', { - emailId: result.data?.id, - to: email, - }); - } catch (error: any) { - console.error('[EMAIL] Error sending password reset email:', error); - console.error('[EMAIL] Error details:', { - message: error?.message, - name: error?.name, - response: error?.response, - statusCode: error?.statusCode, - }); - throw error; - } + const html = ` + + + + + + + Reset your password + + +
+

Reset your password

+

Hi ${name},

+

You requested to reset your password for your PunimTag Viewer account. Click the button below to create a new password:

+ +

Or copy and paste this link into your browser:

+

${resetUrl}

+

+ Important: This link will expire in 1 hour for security reasons. +

+

+ If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. +

+

+ This is an automated message from PunimTag Viewer. Please do not reply to this email. +

+
+ + + `; + + await sendEmailWithFallback({ + to: email, + subject: 'Reset your password - PunimTag Viewer', + text, + html, + }); } - - diff --git a/viewer-frontend/package-lock.json b/viewer-frontend/package-lock.json index 3f9432f..e63ca0f 100644 --- a/viewer-frontend/package-lock.json +++ b/viewer-frontend/package-lock.json @@ -27,6 +27,7 @@ "lucide-react": "^0.553.0", "next": "^16.1.1", "next-auth": "^5.0.0-beta.30", + "nodemailer": "^7.0.13", "prisma": "^6.19.0", "react": "19.2.0", "react-day-picker": "^9.11.1", @@ -40,6 +41,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.2.3", @@ -2021,6 +2023,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.7", "devOptional": true, @@ -6116,6 +6128,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", diff --git a/viewer-frontend/package.json b/viewer-frontend/package.json index 4c98fed..90d0d80 100644 --- a/viewer-frontend/package.json +++ b/viewer-frontend/package.json @@ -38,6 +38,7 @@ "lucide-react": "^0.553.0", "next": "^16.1.1", "next-auth": "^5.0.0-beta.30", + "nodemailer": "^7.0.13", "prisma": "^6.19.0", "react": "19.2.0", "react-day-picker": "^9.11.1", @@ -51,6 +52,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.2.3", diff --git a/viewer-frontend/scripts/test-email-sending.ts b/viewer-frontend/scripts/test-email-sending.ts index 82a93bf..a17aea5 100644 --- a/viewer-frontend/scripts/test-email-sending.ts +++ b/viewer-frontend/scripts/test-email-sending.ts @@ -1,4 +1,5 @@ import { Resend } from 'resend'; +import nodemailer from 'nodemailer'; import * as dotenv from 'dotenv'; // Load environment variables @@ -6,63 +7,96 @@ dotenv.config(); const resend = new Resend(process.env.RESEND_API_KEY); +function smtpEnabled(): boolean { + return Boolean( + process.env.SMTP_HOST && + process.env.SMTP_USER && + process.env.SMTP_PASS + ); +} + async function testEmailSending() { console.log('๐Ÿงช Testing email sending configuration...\n'); // Check environment variables console.log('๐Ÿ“‹ Environment Variables:'); + console.log(' EMAIL_PROVIDER:', process.env.EMAIL_PROVIDER || 'smtp (default)'); + console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'โŒ NOT SET'); + console.log(' SMTP_PORT:', process.env.SMTP_PORT || '465 (default)'); + console.log(' SMTP_SECURE:', process.env.SMTP_SECURE || 'true (default)'); + console.log(' SMTP_USER:', process.env.SMTP_USER || 'โŒ NOT SET'); + console.log(' SMTP_FROM_EMAIL:', process.env.SMTP_FROM_EMAIL || 'โŒ NOT SET'); console.log(' RESEND_API_KEY:', process.env.RESEND_API_KEY ? `${process.env.RESEND_API_KEY.substring(0, 10)}...` : 'โŒ NOT SET'); console.log(' RESEND_FROM_EMAIL:', process.env.RESEND_FROM_EMAIL || 'โŒ NOT SET'); console.log(' NEXTAUTH_URL:', process.env.NEXTAUTH_URL || 'โŒ NOT SET'); console.log(''); - - if (!process.env.RESEND_API_KEY) { - console.error('โŒ RESEND_API_KEY is not set in .env file'); + + const toEmail = process.env.TEST_EMAIL_TO || process.env.SMTP_USER || process.env.RESEND_FROM_EMAIL; + if (!toEmail) { + console.error('โŒ Set TEST_EMAIL_TO or SMTP_USER in .env to run this test.'); process.exit(1); } - - if (!process.env.RESEND_FROM_EMAIL) { - console.error('โŒ RESEND_FROM_EMAIL is not set in .env file'); + + if (smtpEnabled()) { + console.log('๐Ÿ“ค Attempting SMTP test first...'); + try { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT || 465), + secure: (process.env.SMTP_SECURE || 'true').toLowerCase() === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + const smtpFromEmail = process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER!; + const smtpFromName = process.env.SMTP_FROM_NAME; + const smtpReplyTo = process.env.SMTP_REPLY_TO || smtpFromEmail; + const smtpFrom = smtpFromName + ? `${smtpFromName} <${smtpFromEmail}>` + : smtpFromEmail; + + const info = await transporter.sendMail({ + from: smtpFrom, + to: toEmail, + replyTo: smtpReplyTo, + subject: 'SMTP test from PunimTag', + text: 'SMTP test email from PunimTag viewer frontend.', + }); + + console.log('โœ… SMTP test sent successfully'); + console.log(' Message ID:', info.messageId); + process.exit(0); + } catch (smtpError: any) { + console.error('โš ๏ธ SMTP test failed, will try Resend fallback.'); + console.error(' SMTP error:', smtpError.message); + } + } else { + console.log('โ„น๏ธ SMTP config not set, skipping SMTP test.'); + } + + if (!process.env.RESEND_API_KEY || !process.env.RESEND_FROM_EMAIL) { + console.error('โŒ Resend fallback not configured (RESEND_API_KEY/RESEND_FROM_EMAIL missing).'); process.exit(1); } - - // Clean up the from email (remove quotes and spaces) - const fromEmail = process.env.RESEND_FROM_EMAIL.trim().replace(/^["']|["']$/g, ''); - console.log('๐Ÿ“ง Using FROM email:', fromEmail); - console.log(''); - - // Test email sending - console.log('๐Ÿ“ค Attempting to send test email...'); + + const resendFrom = process.env.RESEND_FROM_EMAIL.trim().replace(/^["']|["']$/g, ''); + console.log('๐Ÿ“ค Attempting Resend fallback test...'); try { const result = await resend.emails.send({ - from: fromEmail, - to: 'test@example.com', // This will fail but we'll see the error - subject: 'Test Email', - html: '

This is a test email

', + from: resendFrom, + to: toEmail, + subject: 'Resend fallback test from PunimTag', + html: '

Resend fallback test email from PunimTag viewer frontend.

', }); - - console.log('โœ… Email API call successful!'); - console.log('Response:', JSON.stringify(result, null, 2)); + if (result.error) { + throw new Error(result.error.message || 'Unknown Resend API error'); + } + console.log('โœ… Resend fallback test sent successfully'); + console.log(' Email ID:', result.data?.id); } catch (error: any) { - console.error('โŒ Error sending email:'); - console.error(' Message:', error.message); - if (error.response) { - console.error(' Response:', JSON.stringify(error.response, null, 2)); - } - - // Check for common errors - if (error.message?.includes('domain')) { - console.error('\nโš ๏ธ Domain verification issue:'); - console.error(' The email domain needs to be verified in Resend dashboard'); - console.error(' For testing, use: onboarding@resend.dev'); - } - - if (error.message?.includes('unauthorized') || error.message?.includes('Invalid API key')) { - console.error('\nโš ๏ธ API Key issue:'); - console.error(' Check that your RESEND_API_KEY is correct'); - console.error(' Get a new key from: https://resend.com/api-keys'); - } - + console.error('โŒ Resend fallback failed:', error.message); process.exit(1); } }