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); } |