I have a Firebase Cloud Function (onCall) named sendTestEmail that successfully sends emails when I trigger it from my front end (a test button). However, my “real” notifications (like follow notifications or sessionRequest) aren’t sending any emails. My code for sendTestEmail adds a notification in Firestore, attempts to send an FCM push, then sends an email via Nodemailer if the user has an email address. But for real notifications, I see that they get created in Firestore (and sometimes push notifications appear), yet no email is sent. There are no errors in Cloud Functions logs that indicate an email send was triggered or failed. I want the code that handles real notifications (like new follower, new session request, etc.) to behave exactly like sendTestEmail—so that when a new notification is added, the user receives an email.
Verified that sendTestEmail calls transporter.sendMail unconditionally, whereas the code for real notifications might only do so if notificationSettings.emailNotifications is true.
Checked the Cloud Functions logs for any errors or console output from my real notifications—no logs appear, which suggests the email-sending portion might not even be executing.
Confirmed that my code to create real notifications (like when someone follows or requests a session) only sets Firestore docs and maybe triggers an onCreate function, but that onCreate code might not contain calls to transporter.sendMail.
Tried removing the gating conditions on userData.notificationSettings to see if it would force an email—still no email or logs.
Looked for differences in how test vs. real notifications call or import the Nodemailer logic. Found that sendTestEmail is in the same file with a direct transporter.sendMail call, but for real notifications I might be missing that call entirely.
notifications.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
// Create a transporter using Namecheap SMTP
const transporter = nodemailer.createTransport({
host: 'mail.privateemail',
port: 465,
secure: true,
auth: {
user: functions.config().email.user,
pass: functions.config().email.password
}
});
// Test email function
exports.sendTestEmail = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'You must be signed in to send test notifications');
}
const { userId, notificationType = 'test' } = data;
if (!userId) {
throw new functions.https.HttpsError('invalid-argument', 'User ID is required');
}
// 1. Get user info from Firestore
const userDoc = await admin.firestore().collection('userInfo').doc(userId).get();
if (!userDoc.exists) {
throw new functions.https.HttpsError('not-found', 'User not found');
}
const userData = userDoc.data();
// 2. Create a notification in Firestore
const notificationRef = admin.firestore().collection('notifications').doc();
const notificationData = {
userId,
type: notificationType,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
read: false,
title: 'Test Notification',
message: 'This is a test notification'
};
await notificationRef.set(notificationData);
// 3. Send email if userData.email exists
if (userData.email) {
const mailOptions = {
from: 'Support <[email protected]>',
to: userData.email,
subject: `Test notification: ${notificationData.title}`,
html: '<h2>This is a test email</h2>'
};
try {
const result = await transporter.sendMail(mailOptions);
console.log('Test email sent:', {
messageId: result.messageId,
response: result.response
});
} catch (error) {
console.error('Error sending test email:', error);
}
}
});
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
VStack,
HStack,
Text,
Button,
Heading,
Flex,
Badge,
IconButton,
Avatar,
Icon,
Link,
Image,
} from '@chakra-ui/react';
import { collection, query, where, onSnapshot, orderBy, updateDoc, doc, addDoc, deleteDoc, serverTimestamp, getDoc } from 'firebase/firestore';
import { db } from '../../firebase';
import { useAuth } from '../../contexts/AuthContext';
import { CheckIcon, ArrowBackIcon } from '@chakra-ui/icons';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { useToast } from '@chakra-ui/react';
import { MdAccessTime } from 'react-icons/md';
import { FaDollarSign } from 'react-icons/fa';
import { TestEmail } from '../../Components/TestEmail';
import { getFunctions, httpsCallable } from 'firebase/functions';
// Helper function to generate a random join code
const generateJoinCode = () => {
return Math.random().toString(36).substring(2, 8).toUpperCase();
};
export const Notifications = () => {
const [notifications, setNotifications] = useState([]);
const [activeTab, setActiveTab] = useState('all');
const [isLoading, setIsLoading] = useState(false);
const { currentUser } = useAuth();
const navigate = useNavigate();
const [usernames, setUsernames] = useState({}); // Cache for usernames
useEffect(() => {
if (!currentUser) return;
console.log('Fetching notifications for user:', currentUser.uid);
const notificationsRef = collection(db, 'notifications');
const q = query(
notificationsRef,
where('userId', '==', currentUser.uid),
orderBy('createdAt', 'desc')
);
const unsubscribe = onSnapshot(q, async (snapshot) => {
const newNotifications = [];
const now = new Date();
for (const doc of snapshot.docs) {
const data = doc.data();
// Check if request is expired
if (data.type === 'sessionRequest' &&
data.status === 'pending' &&
data.expiresAt &&
data.expiresAt.toDate() < now) {
// Update the request status to expired
await updateDoc(doc.ref, { status: 'expired' });
// Also update the session request document
if (data.sessionRequestId) {
await updateDoc(doc(db, 'sessionRequests', data.sessionRequestId), {
status: 'expired'
});
}
// Add to notifications with expired status
newNotifications.push({
id: doc.id,
...data,
status: 'expired'
});
} else {
newNotifications.push({ id: doc.id, ...data });
}
}
console.log('All notifications:', newNotifications);
setNotifications(newNotifications);
}, (error) => {
console.error('Error fetching notifications:', error);
});
return () => unsubscribe();
}, [currentUser]);
useEffect(() => {
if (!currentUser || notifications.length === 0) return;
const markAsRead = async () => {
const unreadNotifications = notifications.filter(n => !n.read);
console.log('Marking notifications as read:', unreadNotifications);
// Update each unread notification
const updatePromises = unreadNotifications.map(notification => {
console.log('Marking notification as read:', notification.id);
return updateDoc(doc(db, 'notifications', notification.id), {
read: true
});
});
try {
await Promise.all(updatePromises);
} catch (error) {
console.error('Error marking notifications as read:', error);
}
};
markAsRead();
}, [currentUser, notifications]);
// Fetch usernames for notifications
useEffect(() => {
const fetchUsernames = async () => {
const userIds = notifications
.map(n => n.fromUserId)
.filter((id, index, self) => id && self.indexOf(id) === index);
console.log('Fetching usernames for users:', userIds);
const newUsernames = { ...usernames };
for (const userId of userIds) {
if (!newUsernames[userId]) {
try {
const userDoc = await getDoc(doc(db, 'userInfo', userId));
if (userDoc.exists()) {
const userData = userDoc.data();
console.log('Found username for user:', userId, userData.username);
newUsernames[userId] = userData.username;
}
} catch (error) {
console.error('Error fetching username for:', userId, error);
}
}
}
setUsernames(newUsernames);
};
if (notifications.length > 0) {
fetchUsernames();
}
}, [notifications]);
const getFilteredNotifications = () => {
console.log('Filtering notifications for tab:', activeTab);
if (activeTab === 'all') return notifications;
if (activeTab === 'wishlistPurchase') {
return notifications.filter(notif =>
notif.type === 'wishlistPurchase' || notif.type === 'tip'
);
}
if (activeTab === 'sessionRequest') {
return notifications.filter(notif =>
notif.type === 'sessionRequest' || notif.type === 'sessionResponse'
);
}
return notifications.filter(notif => notif.type === activeTab);
};
const getNotificationCount = (type) => {
const unreadNotifications = notifications.filter(n => !n.read);
if (type === 'all') {
return unreadNotifications.length > 0 ? unreadNotifications.length : null;
}
if (type === 'wishlistPurchase') {
const count = unreadNotifications.filter(notif =>
notif.type === 'wishlistPurchase' || notif.type === 'tip'
).length;
return count > 0 ? count : null;
}
const count = unreadNotifications.filter(notif => notif.type === type).length;
return count > 0 ? count : null;
};
const markAllAsRead = async () => {
// Add mark all as read functionality
};
const handleSessionResponse = async (notification, response) => {
try {
setIsLoading(true);
const notificationRef = doc(db, 'notifications', notification.id);
await updateDoc(notificationRef, { status: response });
if (response === 'accepted') {
// Create chat session
const chatSessionRef = await addDoc(collection(db, 'chatSessions'), {
title: notification.sessionTitle,
description: notification.sessionDescription,
participants: [notification.fromUserId, notification.userId],
creatorId: notification.userId,
status: 'active',
createdAt: serverTimestamp(),
lastMessage: null,
lastMessageTime: null,
joinCode: generateJoinCode(),
systemMessage: {
content: 'Chat session started',
timestamp: serverTimestamp()
}
});
// Call handleNotification function
const functions = getFunctions();
const handleNotification = httpsCallable(functions, 'handleNotification');
await handleNotification({
type: 'sessionResponse',
userId: notification.fromUserId,
title: 'Session Request Accepted',
message: `${currentUser.displayName || currentUser.email} has accepted your request for the session "${notification.sessionTitle}"`,
sessionId: chatSessionRef.id,
sessionTitle: notification.sessionTitle,
fromUserId: currentUser.uid,
fromUser: currentUser.displayName,
fromUserAvatar: currentUser.photoURL,
status: 'accepted'
});
} else {
// Call handleNotification function for declined request
const functions = getFunctions();
const handleNotification = httpsCallable(functions, 'handleNotification');
await handleNotification({
type: 'sessionResponse',
userId: notification.fromUserId,
title: 'Session Request Declined',
message: `${currentUser.displayName || currentUser.email} has declined your request for the session "${notification.sessionTitle}"`,
sessionTitle: notification.sessionTitle,
fromUserId: currentUser.uid,
fromUser: currentUser.displayName,
fromUserAvatar: currentUser.photoURL,
status: 'declined'
});
}
toast({
title: `Session request ${response}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
console.error('Error handling session response:', error);
toast({
title: 'Error',
description: 'Failed to process session response.',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
const formatDuration = (duration) => {
if (!duration) return '0 minutes';
if (typeof duration === 'object') {
const { hours = 0, minutes = 0 } = duration;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes} minutes`;
}
return `${duration} minutes`;
};
const NotificationTabs = () => (
<HStack spacing={2} overflowX="auto" py={2} w="full">
<Button
variant={activeTab === 'all' ? "solid" : "ghost"}
size="sm"
onClick={() => setActiveTab('all')}
flexShrink={0}
bg={activeTab === 'all' ? "dark.700" : "transparent"}
_hover={{ bg: activeTab === 'all' ? "dark.700" : "dark.800" }}
>
All {getNotificationCount('all') && ` ${getNotificationCount('all')}`}
</Button>
<Button
variant={activeTab === 'sessionRequest' ? "solid" : "ghost"}
size="sm"
onClick={() => setActiveTab('sessionRequest')}
flexShrink={0}
bg={activeTab === 'sessionRequest' ? "dark.700" : "transparent"}
_hover={{ bg: activeTab === 'sessionRequest' ? "dark.700" : "dark.800" }}
>
Requests {getNotificationCount('sessionRequest') && ` ${getNotificationCount('sessionRequest')}`}
</Button>
<Button
variant={activeTab === 'follow' ? "solid" : "ghost"}
size="sm"
onClick={() => setActiveTab('follow')}
flexShrink={0}
bg={activeTab === 'follow' ? "dark.700" : "transparent"}
_hover={{ bg: activeTab === 'follow' ? "dark.700" : "dark.800" }}
>
Follows {getNotificationCount('follow') && ` ${getNotificationCount('follow')}`}
</Button>
<Button
variant={activeTab === 'wishlistPurchase' ? "solid" : "ghost"}
size="sm"
onClick={() => setActiveTab('wishlistPurchase')}
flexShrink={0}
bg={activeTab === 'wishlistPurchase' ? "dark.700" : "transparent"}
_hover={{ bg: activeTab === 'wishlistPurchase' ? "dark.700" : "dark.800" }}
>
Purchases {getNotificationCount('wishlistPurchase') && ` ${getNotificationCount('wishlistPurchase')}`}
</Button>
</HStack>
);
const NotificationItem = ({ notification }) => {
const username = usernames[notification.fromUserId];
const renderNotificationContent = () => {
switch (notification.type) {
case 'follow':
return (
<Flex align="center" justify="space-between" w="full">
<HStack spacing={3}>
<Avatar
size="sm"
src={notification.fromUserAvatar}
name={notification.fromUser}
/>
<Box>
<Text fontWeight="medium">
<RouterLink to={`/${username}`}>
<Text as="span" color="accent.500" _hover={{ textDecoration: "underline" }}>@{notification.fromUser}</Text>
</RouterLink> followed you
</Text>
<Text fontSize="sm" color="gray.500">{notification.timestamp}</Text>
</Box>
</HStack>
<Badge colorScheme="blue" variant="subtle" >
Follow
</Badge>
</Flex>
);
case 'sessionRequest':
return (
<Flex direction="column" w="full" gap={3}>
<HStack spacing={3}>
<Avatar size="sm" src={notification.fromUserAvatar} name={notification.fromUser} />
<Box flex={1}>
<Text fontWeight="medium">
<RouterLink to={`/${username}`}>
<Text as="span" color="accent.500" _hover={{ textDecoration: "underline" }}>@{notification.fromUser}</Text>
</RouterLink> requested a session
</Text>
<Text fontSize="sm" color="gray.500">{notification.timestamp}</Text>
<Text fontSize="sm" fontWeight="medium" mt={1}>{notification.sessionTitle}</Text>
<Text fontSize="sm" color="gray.500" mt={0.5}>
{notification.sessionDetails?.description}
</Text>
<HStack spacing={4} mt={2} color="gray.500" fontSize="sm">
<Text display="flex" alignItems="center">
<Icon as={MdAccessTime} mr={1} />
{formatDuration(notification.sessionDetails?.duration)}
</Text>
{notification.sessionDetails?.price > 0 && (
<Text display="flex" alignItems="center">
<Icon as={FaDollarSign} mr={0.5} />
{notification.sessionDetails.pricingType === 'perMinute'
? `${notification.sessionDetails.price}/min`
: notification.sessionDetails.price}
</Text>
)}
</HStack>
</Box>
</HStack>
{notification.status === 'pending' && (
<HStack spacing={2}>
<Button
variant="bezel"
size="sm"
colorScheme="accent"
onClick={() => handleSessionResponse(notification, 'accepted')}
isLoading={isLoading}
>
Accept
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleSessionResponse(notification, 'declined')}
isLoading={isLoading}
>
Decline
</Button>
</HStack>
)}
{notification.status !== 'pending' && (
<Text fontSize="sm" color="gray.500">
Request {notification.status === 'expired' ? 'expired' : notification.status}
</Text>
)}
</Flex>
);
case 'sessionResponse':
return (
<Flex align="center" justify="space-between" w="full">
<HStack spacing={3}>
<Avatar size="sm" src={notification.fromUserAvatar} name={notification.fromUser} />
<Box>
<Text fontWeight="medium">
<RouterLink to={`/${username}`}>
<Text as="span" color="accent.500" _hover={{ textDecoration: "underline" }}>@{notification.fromUser}</Text>
</RouterLink>
{notification.status === 'accepted' ? ' accepted' : ' declined'} your session request
</Text>
<Text fontSize="sm" color="gray.500">{notification.timestamp}</Text>
<Text fontSize="sm" mt={1}>Session: {notification.sessionTitle}</Text>
{notification.status === 'accepted' && notification.joinCode && (
<Button
variant="bezel"
size="sm"
colorScheme="accent"
mt={2}
onClick={() => navigate(`/chat/${notification.joinCode}`)}
>
Join Session
</Button>
)}
</Box>
</HStack>
<Badge
colorScheme={notification.status === 'accepted' ? "green" : "red"}
variant="subtle"
borderRadius="full"
>
{notification.status === 'accepted' ? 'Accepted' : 'Declined'}
</Badge>
</Flex>
);
case 'wishlistPurchase':
return (
<Flex align="center" justify="space-between" w="full">
<HStack spacing={3}>
<Avatar size="sm" src={notification.fromUserAvatar} name={notification.fromUser} />
<Box>
<Text fontWeight="medium">
<RouterLink to={`/${username}`}>
<Text as="span" color="accent.500" _hover={{ textDecoration: "underline" }}>@{notification.fromUser}</Text>
</RouterLink> purchased "{notification.itemName}"
</Text>
<Text fontSize="sm" color="gray.500">{notification.timestamp}</Text>
<HStack spacing={2} mt={1}>
<Text fontSize="sm" color="accent.500" fontWeight="bold">
${(notification.amount / 100).toFixed(2)}
</Text>
{notification.itemImage && (
<Image
src={notification.itemImage}
alt={notification.itemName}
boxSize="40px"
objectFit="cover"
borderRadius="md"
/>
)}
</HStack>
</Box>
</HStack>
<Badge colorScheme="green" variant="subtle" borderRadius="full">
Purchased
</Badge>
</Flex>
);
case 'tip':
return (
<Flex align="center" justify="space-between" w="full">
<HStack spacing={3}>
<Avatar size="sm" src={notification.fromUserAvatar} name={notification.fromUser} />
<Box>
<Text fontWeight="medium">
<RouterLink to={`/${username}`}>
<Text as="span" color="accent.500" _hover={{ textDecoration: "underline" }}>@{notification.fromUser}</Text>
</RouterLink> sent you a tip
</Text>
<Text fontSize="sm" color="gray.500">{notification.timestamp}</Text>
<Text fontSize="sm" color="accent.500" fontWeight="bold" mt={1}>
${(notification.amount / 100).toFixed(2)}
</Text>
</Box>
</HStack>
<Badge colorScheme="green" variant="subtle">
Tip
</Badge>
</Flex>
);
default:
return null;
}
};
return (
<Box
p={4}
bg="dark.800"
borderRadius="md"
_hover={{ bg: 'dark.700' }}
transition="background 0.2s"
>
{renderNotificationContent()}
</Box>
);
};
return (
<Container maxW="800px" p={4}>
<VStack spacing={6} align="stretch">
<Flex align="center" justify="space-between">
<Flex align="center">
<IconButton
icon={<ArrowBackIcon boxSize={6} />}
variant="ghost"
onClick={() => navigate(-1)}
aria-label="Go back"
size="sm"
mr={4}
/>
<Heading size="lg">Your notifications</Heading>
</Flex>
<Link
as={RouterLink}
to={`/${currentUser?.username || 'profile'}`}
_hover={{ opacity: 0.8 }}
>
<Avatar
size="sm"
src={currentUser?.photoURL}
name={currentUser?.displayName}
cursor="pointer"
/>
</Link>
</Flex>
<NotificationTabs />
<Box bg="dark.800" p={4} borderRadius="md">
<Heading size="sm" mb={4}>Test Email Notifications</Heading>
<TestEmail />
</Box>
<VStack spacing={2} align="stretch">
{getFilteredNotifications().map((notification) => (
<NotificationItem key={notification.id} notification={notification} />
))}
{getFilteredNotifications().length === 0 && (
<Text color="gray.500" textAlign="center" py={8}>
No notifications
</Text>
)}
</VStack>
</VStack>
</Container>
);
};