All files / src/lib/email tokens.ts

0% Statements 0/127
100% Branches 0/0
0% Functions 0/1
0% Lines 0/127

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128                                                                                                                                                                                                                                                               
/**
 * Email Token Utilities
 *
 * Generates and verifies tokens for email unsubscribe links.
 * Uses HMAC-SHA256 for token generation and verification.
 */

import crypto from 'crypto';

// Secret for signing tokens (use email service API key or a dedicated secret)
const TOKEN_SECRET = process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.RESEND_API_KEY || 'dev-secret';

/**
 * Unsubscribe token payload
 */
interface UnsubscribePayload {
  userId: number;
  type: 'all' | 'promotions' | 'newsletters' | 'productAlerts';
  timestamp: number;
}

/**
 * Generate an unsubscribe token for a user
 *
 * The token contains the user ID, unsubscribe type, and timestamp,
 * signed with HMAC-SHA256 to prevent tampering.
 *
 * @param userId - User ID
 * @param type - Type of unsubscribe (all, promotions, newsletters, productAlerts)
 * @returns Base64-encoded token
 */
export function generateUnsubscribeToken(
  userId: number,
  type: UnsubscribePayload['type'] = 'all'
): string {
  const payload: UnsubscribePayload = {
    userId,
    type,
    timestamp: Date.now()
  };

  const payloadStr = JSON.stringify(payload);
  const payloadBase64 = Buffer.from(payloadStr).toString('base64url');

  // Create HMAC signature
  const signature = crypto
    .createHmac('sha256', TOKEN_SECRET)
    .update(payloadBase64)
    .digest('base64url');

  // Combine payload and signature
  return `${payloadBase64}.${signature}`;
}

/**
 * Verify and decode an unsubscribe token
 *
 * @param token - The unsubscribe token
 * @returns Decoded payload if valid, null if invalid
 */
export function verifyUnsubscribeToken(token: string): UnsubscribePayload | null {
  try {
    const [payloadBase64, signature] = token.split('.');

    if (!payloadBase64 || !signature) {
      return null;
    }

    // Verify signature
    const expectedSignature = crypto
      .createHmac('sha256', TOKEN_SECRET)
      .update(payloadBase64)
      .digest('base64url');

    if (signature !== expectedSignature) {
      return null;
    }

    // Decode payload
    const payloadStr = Buffer.from(payloadBase64, 'base64url').toString();
    const payload: UnsubscribePayload = JSON.parse(payloadStr);

    // Validate payload structure
    if (
      typeof payload.userId !== 'number' ||
      !['all', 'promotions', 'newsletters', 'productAlerts'].includes(payload.type) ||
      typeof payload.timestamp !== 'number'
    ) {
      return null;
    }

    // Tokens are valid for 30 days
    const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
    if (Date.now() - payload.timestamp > thirtyDaysMs) {
      return null;
    }

    return payload;
  } catch {
    return null;
  }
}

/**
 * Generate an unsubscribe URL for a user
 *
 * @param userId - User ID
 * @param type - Type of unsubscribe
 * @returns Full unsubscribe URL
 */
export function generateUnsubscribeUrl(
  userId: number,
  type: UnsubscribePayload['type'] = 'all'
): string {
  const token = generateUnsubscribeToken(userId, type);
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000';
  return `${baseUrl}/api/email/unsubscribe?token=${encodeURIComponent(token)}`;
}

/**
 * Type guard to check if a string is a valid unsubscribe type
 */
export function isValidUnsubscribeType(
  type: string
): type is UnsubscribePayload['type'] {
  return ['all', 'promotions', 'newsletters', 'productAlerts'].includes(type);
}