Docs
Email System

Email System

Complete guide to the email system implementation using Resend, React Email, and NextAuth.js for authentication and notifications.

The YouTube Analyzer uses a comprehensive email system for user authentication, notifications, and analysis result delivery powered by Resend and React Email.

Overview

The email system handles:

  • Magic link authentication for passwordless login
  • Account activation for new user registration
  • Analysis result notifications when YouTube analysis completes
  • Subscription management email updates
  • Transactional emails for billing and account changes

Architecture

graph TD
    A[Email Trigger] --> B[Email Service]
    B --> C[React Email Template]
    C --> D[Resend API]
    D --> E[Email Delivery]
    
    F[NextAuth.js] --> B
    G[Analysis Complete] --> B
    H[Subscription Change] --> B

Configuration

Environment Variables

# Resend API Configuration
RESEND_API_KEY=re_your_resend_api_key
 
# Email sender address
EMAIL_FROM=noreply@yourdomain.com
 
# Development email override
NODE_ENV=development  # Routes all emails to delivered@resend.dev

NextAuth.js Integration

// auth.config.ts
import Resend from "next-auth/providers/resend";
import { sendVerificationRequest } from "@/lib/email";
 
export default {
  providers: [
    Resend({
      apiKey: env.RESEND_API_KEY,
      from: env.EMAIL_FROM,
      sendVerificationRequest, // Custom email handler
    }),
  ],
} satisfies NextAuthConfig;

Email Service

Core Email Service (lib/email.ts)

import { MagicLinkEmail } from "@/emails/magic-link-email";
import { Resend } from "resend";
import { getUserByEmail } from "./user";
 
export const resend = new Resend(env.RESEND_API_KEY);
 
export const sendVerificationRequest: EmailConfig["sendVerificationRequest"] =
  async ({ identifier, url, provider }) => {
    const user = await getUserByEmail(identifier);
    if (!user || !user.name) return;
 
    const userVerified = user?.emailVerified ? true : false;
    const authSubject = userVerified
      ? `Sign-in link for ${siteConfig.name}`
      : "Activate your account";
 
    try {
      const { data, error } = await resend.emails.send({
        from: provider.from,
        to: process.env.NODE_ENV === "development" 
          ? "delivered@resend.dev" 
          : identifier,
        subject: authSubject,
        react: MagicLinkEmail({
          firstName: user?.name as string,
          actionUrl: url,
          mailType: userVerified ? "login" : "register",
          siteName: siteConfig.name,
        }),
        headers: {
          "X-Entity-Ref-ID": new Date().getTime() + "",
        },
      });
 
      if (error || !data) {
        throw new Error(error?.message);
      }
    } catch (error) {
      throw new Error("Failed to send verification email.");
    }
  };

Analysis Result Email Service

// lib/email.ts (extended)
export async function sendAnalysisResultEmail(
  userId: string,
  analysisId: string,
  channelName: string,
  videoCount: number
) {
  const user = await getUserById(userId);
  if (!user?.email) return;
 
  try {
    const { data, error } = await resend.emails.send({
      from: env.EMAIL_FROM,
      to: user.email,
      subject: `Your YouTube Analysis is Ready - ${channelName}`,
      react: AnalysisResultEmail({
        userName: user.name || "there",
        channelName,
        videoCount,
        analysisUrl: `${env.NEXT_PUBLIC_APP_URL}/dashboard/results/${analysisId}`,
        dashboardUrl: `${env.NEXT_PUBLIC_APP_URL}/dashboard`,
      }),
    });
 
    if (error) {
      console.error("Failed to send analysis result email:", error);
    }
  } catch (error) {
    console.error("Email sending error:", error);
  }
}

Email Templates

// emails/magic-link-email.tsx
import {
  Body, Button, Container, Head, Hr, Html,
  Preview, Section, Tailwind, Text,
} from "@react-email/components";
import { Icons } from "../components/shared/icons";
 
type MagicLinkEmailProps = {
  actionUrl: string;
  firstName: string;
  mailType: "login" | "register";
  siteName: string;
};
 
export const MagicLinkEmail = ({
  firstName = "",
  actionUrl,
  mailType,
  siteName,
}: MagicLinkEmailProps) => (
  <Html>
    <Head />
    <Preview>
      {mailType === "login" 
        ? `Sign in to ${siteName}` 
        : `Welcome to ${siteName}! Activate your account.`}
    </Preview>
    <Tailwind>
      <Body className="bg-white font-sans">
        <Container className="mx-auto py-5 pb-12">
          <Icons.logo className="m-auto block size-10" />
          
          <Text className="text-base">Hi {firstName},</Text>
          
          <Text className="text-base">
            Welcome to {siteName}! Click the link below to{" "}
            {mailType === "login" ? "sign in to" : "activate"} your account.
          </Text>
          
          <Section className="my-5 text-center">
            <Button
              className="inline-block rounded-md bg-zinc-900 px-4 py-2 text-base text-white no-underline"
              href={actionUrl}
            >
              {mailType === "login" ? "Sign in" : "Activate Account"}
            </Button>
          </Section>
          
          <Text className="text-base">
            This link expires in 24 hours and can only be used once.
          </Text>
          
          {mailType === "login" ? (
            <Text className="text-base">
              If you did not try to log into your account, you can safely ignore it.
            </Text>
          ) : null}
          
          <Hr className="my-4 border-t-2 border-gray-300" />
          <Text className="text-sm text-gray-600">
            123 Code Street, Suite 404, Devtown, CA 98765
          </Text>
        </Container>
      </Body>
    </Tailwind>
  </Html>
);

Analysis Result Email Template

// emails/analysis-result-email.tsx
import {
  Body, Button, Container, Head, Hr, Html,
  Preview, Section, Tailwind, Text, Link,
} from "@react-email/components";
import { Icons } from "../components/shared/icons";
 
type AnalysisResultEmailProps = {
  userName: string;
  channelName: string;
  videoCount: number;
  analysisUrl: string;
  dashboardUrl: string;
};
 
export const AnalysisResultEmail = ({
  userName,
  channelName,
  videoCount,
  analysisUrl,
  dashboardUrl,
}: AnalysisResultEmailProps) => (
  <Html>
    <Head />
    <Preview>
      Your YouTube analysis for {channelName} is ready! View {videoCount} video insights.
    </Preview>
    <Tailwind>
      <Body className="bg-white font-sans">
        <Container className="mx-auto py-5 pb-12">
          <Icons.logo className="m-auto block size-10" />
          
          <Text className="text-base">Hi {userName},</Text>
          
          <Text className="text-base">
            Great news! Your YouTube analysis for <strong>{channelName}</strong> is now complete.
          </Text>
          
          <Text className="text-base">
            We've analyzed <strong>{videoCount} videos</strong> and generated comprehensive insights including:
          </Text>
          
          <ul className="ml-4 list-disc">
            <li>Content themes and trends</li>
            <li>Performance metrics analysis</li>
            <li>Engagement patterns</li>
            <li>Audience insights</li>
            <li>Optimization recommendations</li>
          </ul>
          
          <Section className="my-5 text-center">
            <Button
              className="inline-block rounded-md bg-zinc-900 px-4 py-2 text-base text-white no-underline"
              href={analysisUrl}
            >
              View Analysis Results
            </Button>
          </Section>
          
          <Text className="text-base">
            You can also access all your analyses from your{" "}
            <Link href={dashboardUrl} className="text-blue-600 underline">
              dashboard
            </Link>.
          </Text>
          
          <Hr className="my-4 border-t-2 border-gray-300" />
          <Text className="text-sm text-gray-600">
            YouTube Analyzer - Unlock insights from any YouTube channel
          </Text>
        </Container>
      </Body>
    </Tailwind>
  </Html>
);

Email Triggers

Authentication Flow

The email system is automatically triggered during authentication:

// When user attempts to sign in
const result = await signIn("resend", {
  email: userEmail,
  callbackUrl: "/dashboard",
  redirect: false,
});
 
// This triggers sendVerificationRequest in auth.config.ts
// Which sends magic link email via lib/email.ts

Analysis Completion

// After analysis completes in API route
export async function POST(request: Request) {
  // ... analysis processing ...
  
  // Send completion email
  await sendAnalysisResultEmail(
    userId,
    analysis.id,
    channelData.snippet.title,
    videoCount
  );
  
  return Response.json({ status: "complete" });
}

Subscription Changes

// Stripe webhook handler
export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  
  if (event.type === "customer.subscription.created") {
    // Send welcome email
    await sendSubscriptionWelcomeEmail(user.email, subscription);
  }
  
  if (event.type === "customer.subscription.updated") {
    // Send plan change confirmation
    await sendSubscriptionUpdateEmail(user.email, subscription);
  }
}

Development Workflow

Local Development

In development mode, all emails are redirected to delivered@resend.dev to prevent accidental sending:

const recipient = process.env.NODE_ENV === "development" 
  ? "delivered@resend.dev" 
  : actualEmailAddress;

Email Testing

  1. Resend Dashboard: View sent emails in the Resend dashboard
  2. React Email Preview: Use npm run email to preview templates
  3. Development Emails: All dev emails go to delivered@resend.dev
# Preview email templates locally
npm run email
 
# This starts React Email preview server
# Access at http://localhost:3000

Template Development

// Create new email template
export const NewEmailTemplate = (props: NewEmailProps) => (
  <Html>
    <Head />
    <Preview>Email preview text</Preview>
    <Tailwind>
      <Body>
        {/* Email content using Tailwind classes */}
      </Body>
    </Tailwind>
  </Html>
);

Email Delivery Features

Headers and Threading

headers: {
  // Prevent Gmail threading
  "X-Entity-Ref-ID": new Date().getTime() + "",
  
  // Custom tracking headers
  "X-Email-Type": "magic-link",
  "X-User-ID": userId,
}

Unsubscribe Handling

// Add unsubscribe link to marketing emails
headers: {
  "List-Unsubscribe": `<${unsubscribeUrl}>`,
  "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}

Email Analytics

// Track email opens and clicks
const trackingPixel = `${env.NEXT_PUBLIC_APP_URL}/api/email/track?email=${emailId}&event=open`;
 
// In email template
<img src={trackingPixel} width="1" height="1" style={{ display: 'none' }} />

Error Handling

Retry Logic

async function sendEmailWithRetry(emailData: EmailData, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const result = await resend.emails.send(emailData);
      return result;
    } catch (error) {
      if (attempt === maxRetries) {
        // Log final failure
        console.error(`Email failed after ${maxRetries} attempts:`, error);
        throw error;
      }
      
      // Wait before retry
      await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
    }
  }
}

Validation

const emailSchema = z.object({
  to: z.string().email(),
  subject: z.string().min(1),
  html: z.string().optional(),
  react: z.any().optional(),
});
 
export async function sendEmail(data: z.infer<typeof emailSchema>) {
  const validated = emailSchema.parse(data);
  return await resend.emails.send(validated);
}

Security Considerations

Email Verification

  • Magic links expire after 24 hours
  • Links can only be used once
  • Email addresses are verified before account activation

Rate Limiting

// Implement rate limiting for email sending
const emailRateLimit = new Ratelimit({
  redis: kv,
  limiter: Ratelimit.slidingWindow(5, "1 h"), // 5 emails per hour
});
 
export async function sendRateLimitedEmail(email: string, emailData: EmailData) {
  const { success } = await emailRateLimit.limit(email);
  
  if (!success) {
    throw new Error("Email rate limit exceeded");
  }
  
  return await resend.emails.send(emailData);
}

Content Security

// Sanitize email content
import DOMPurify from 'isomorphic-dompurify';
 
const sanitizedContent = DOMPurify.sanitize(userContent);

Monitoring & Analytics

Email Metrics

  • Delivery Rate: Percentage of emails successfully delivered
  • Open Rate: Percentage of emails opened by recipients
  • Click Rate: Percentage of email links clicked
  • Bounce Rate: Percentage of emails that bounced

Logging

// Email event logging
await prisma.emailLog.create({
  data: {
    userId,
    emailType: "magic-link",
    recipient: email,
    status: "sent",
    resendId: result.id,
    sentAt: new Date(),
  },
});

Troubleshooting

Common Issues

  1. Emails not sending: Check Resend API key and sender domain verification
  2. Templates not rendering: Verify React Email component syntax
  3. Development email issues: Ensure delivered@resend.dev is receiving emails
  4. Production delivery: Verify sender domain DNS records

Debug Commands

# Test email template rendering
npm run email
 
# Check Resend API status
curl -H "Authorization: Bearer re_your_api_key" https://api.resend.com/emails
 
# Verify domain configuration
dig TXT your-domain.com

Auto-Analysis Email Notification System

The auto-analysis notification system batches analysis outputs into either Daily or Weekly digest emails (no more per-analysis immediate sends) to reduce noise and improve deliverability. Manual "Run Now" analyses are intentionally excluded from digests.

Architecture Overview

graph TD
  A[Auto-Analysis Cron Job] --> B{Check Notification Settings}
  B -->|Daily| C[Queue for Daily Digest]
  B -->|Weekly| D[Queue for Weekly Digest]
  B -->|Disabled| E[No Notification]
 
  F[Daily Digest Cron] --> C2[Process Daily Digests]
  G[Weekly Digest Cron] --> H[Process Weekly Digests]
  H --> I[Send Unified Digest]

Notification Preferences

  1. Global / Per-Subscription
  • notificationFrequency: daily | weekly | none
  • (Deprecated) combineAllResults: Ignored; all digests are unified now.
  1. Deprecation
  • Legacy immediate migrated to daily (script + runtime guard)
  • combineAllResults soft-deprecated (accepted, ignored, will be removed)

Data Model (Excerpt)

model AutoAnalysis {
  // ... existing fields ...
  emailNotificationsEnabled Boolean @default(true)
  notificationFrequency    String  @default("daily")
  notificationDayOfWeek    String  @default("monday")
  combineAllResults        Boolean @default(false) // DEPRECATED: unified digests
}

Core Flow

On analysis completion we enqueue for the appropriate digest (no immediate send):

const result = await sendAnalysisNotification({ analysisId, autoAnalysisId });
// result.reason will be 'queued_for_daily' | 'queued_for_weekly'

Daily job calls processDailyDigests() (24h lookback). Weekly job calls processWeeklyDigests(day) (7d lookback). Queries exclude triggerType === 'manual'.

Email Rendering

Unified HTML assembly builds a single email per cadence. Subjects simplified:

Daily Digest
Weekly Digest

Counts + sources rendered in body (removed from subject for brevity / deliverability). Topic analyses use condensed blocks; channels retain structured sections.

Cron Integration

Unified digests: POST /api/cron/digests (protected by x-cron-secret) – processes both daily (24h window) and weekly (if today matches user weekly day) in one call.

Manual Runs

Manual dashboard "Run Now" analyses are excluded (noise reduction and to prevent duplicate visibility). Only scheduled (automated) analyses are digested.

Migration Steps

  1. Deploy code with dual support (runtime normalization of immediate)
  2. Run tsx scripts/migrate-immediate-to-daily.ts
  3. Remove any stale references (docs/scripts) – complete in this change set.

Deduplication & Credit Integrity

Early in /api/youtube/process multi-video submissions:

  1. Detect duplicate YouTube video IDs already analyzed by the same user
  2. If all duplicates → return 409, no credits reserved
  3. Filter duplicates, recalc cost, then reserve credits for remaining unique videos
  4. Proceed with processing & final credit settlement

Ensures no double-charging and accurate reservedCredits usage.

Future Enhancements

  • Idempotency metadata (lastDailyDigestSentAt) for double-send protection
  • Quiet hours / user timezone batching
  • In-app notification center integration
  • Final schema migration removing combineAllResults

Logging

[AutoAnalysis Cron] Queued analysis <id> for digest processing
[Daily Digest Cron] Sent X of Y daily digests
[Weekly Digest Cron] Sent X of Y weekly digests

Legacy log lines mentioning "Sending immediate" have been removed.


API Endpoints for User Preferences

Notification settings are managed through the auto-analysis API endpoints:

  1. Create with notification settings: POST /api/auto-analysis
  2. Update notification settings: PATCH /api/auto-analysis/[id]

Example payload for updating notification settings (combineAllResults omitted / ignored):

{
  "emailNotificationsEnabled": true,
  "notificationFrequency": "weekly",
  "notificationDayOfWeek": "monday",
  // combineAllResults deprecated – always unified
}

Testing the Notification System

Several test scripts are available for validating the notification system:

  1. Manual testing script: scripts/test-notification-system.ts
  2. Simplified notification test: scripts/simple-notification-test.ts
  3. End-to-end test: scripts/e2e-notification-test.ts

Example test command:

npx tsx scripts/test-notification-system.ts

Troubleshooting

Common issues with the notification system:

  1. No emails being sent: Check emailNotificationsEnabled and notificationFrequency in the database
  2. Immediate emails working but no weekly digests: Verify the weekly cron job is running and check logs

Operations: Notification Jobs & One-Off Runs

These operational commands are not exposed as npm scripts to keep package.json lean. Run them manually when needed.

Data Normalization (Legacy immediate -> daily)

npx ts-node scripts/migrate-immediate-to-daily.ts

Safe to re-run; updates any remaining legacy rows.

Force Daily Digest Send (Dev/Test)

npx ts-node scripts/run-daily-digest-once.ts

Produces a JSON summary; in dev will send to delivered@resend.dev.

Force Weekly Digest Logic (Any Day)

npx ts-node -e "import('./lib/cron/weekly-digest').then(m=>m.runWeeklyDigestCron())"

Invoke API Cron Endpoints (Post-Deploy)

curl -X POST -H "x-cron-secret: $CRON_SECRET" https://<your-domain>/api/cron/digests

Ensure CRON_SECRET matches the environment variable in deployment.

  1. Wrong day of week: Ensure notificationDayOfWeek matches the cron schedule

Debug logging:

console.log(`[AutoAnalysis Cron] Notification result:`, notificationResult);
console.log(`[Weekly Digest Cron] Processed ${results.length} weekly digests`);

Environment Configuration

The notification system uses the same email configuration as the main email system, with development emails sent to delivered@resend.dev:

to: process.env.NODE_ENV === "development" ? "delivered@resend.dev" : user.email

Future Enhancements

  1. Complete combined digest template: The current implementation needs a custom template for combined digests
  2. In-app notifications: Integration with an in-app notification center
  3. Unsubscribe links: Per-subscription and global unsubscribe options

Production Monitoring

The notification system includes comprehensive logging for production monitoring:

console.log(`[AutoAnalysis Cron] Sending immediate notification for analysis ${processData.id}`);
console.log(`[Weekly Digest Cron] Processed ${results.length} weekly digests`);

These logs can be used to track the performance and reliability of the notification system in production.

The email system provides a robust foundation for all user communications, from authentication to result delivery, ensuring reliable and professional email delivery throughout the application.