diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..65e434bdf2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM oven/bun:canary + +WORKDIR /app + +# Install turbo globally +RUN bun install -g next turbo + + +COPY package.json bun.lock turbo.json ./ + +RUN mkdir -p apps packages + +COPY apps/*/package.json ./apps/ +COPY packages/*/package.json ./packages/ +COPY packages/tsconfig/ ./packages/tsconfig/ + +RUN bun install + +COPY . . + +# Installing with full context. Prevent missing dependencies error. +RUN bun install + + +RUN bun run build + +ENV NODE_ENV=production + +# Resolve Nextjs TextEncoder error. +ENV NODE_OPTIONS=--no-experimental-fetch + +EXPOSE 3000 + +CMD ["bun", "run", "start", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/apps/mail/.env.example b/apps/mail/.env.example index 0c98f49032..c2dc9b3860 100644 --- a/apps/mail/.env.example +++ b/apps/mail/.env.example @@ -23,6 +23,7 @@ REDIS_TOKEN="upstash-local-token" # Resend API Key RESEND_API_KEY= +RESEND_AUDIENCE_ID= # OpenAI API Key OPENAI_API_KEY= diff --git a/apps/mail/.gitignore b/apps/mail/.gitignore index 659e76d7c6..eb56721591 100644 --- a/apps/mail/.gitignore +++ b/apps/mail/.gitignore @@ -1,5 +1,6 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +output.json # dependencies /node_modules /.pnp diff --git a/apps/mail/app/(full-width)/early-access/early-access.tsx b/apps/mail/app/(full-width)/early-access/early-access.tsx new file mode 100644 index 0000000000..bd04a289ec --- /dev/null +++ b/apps/mail/app/(full-width)/early-access/early-access.tsx @@ -0,0 +1,634 @@ +'use client'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { format } from 'date-fns'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Input } from '@/components/ui/input'; + +// Define the type for early access users (exported for use in page.tsx) +export type EarlyAccessUser = { + id: string; + email: string; + isEarlyAccess: boolean; + createdAt: Date | string; + updatedAt: Date | string; +}; + +// Client component for the confirmation dialog +function ConfirmationDialog({ + isOpen, + onClose, + selectedUsers, + onConfirm, +}: { + isOpen: boolean; + onClose: () => void; + selectedUsers: EarlyAccessUser[]; + onConfirm: () => void; +}) { + return ( + + + + Confirm Early Access Selection + + You are about to grant early access to {selectedUsers.length} users. Please review the + list below. + + +
+ {selectedUsers.map((user) => ( +
+

{user.email}

+

+ Signed up: {format(new Date(user.createdAt), 'MMM d, yyyy')} +

+
+ ))} +
+ + + + +
+
+ ); +} + +type UpdateEarlyAccessResult = { + success: boolean; + emails?: string[]; + error?: any; +}; + +type ResendApiResponse = { + success: boolean; + totalProcessed?: number; + successfulCount?: number; + failedCount?: number; + successfulEmails?: string[]; + failedEmails?: string[]; + detailedResults?: Array<{ email: string; success: boolean; response?: any; error?: any }>; + error?: any; +}; + +export function EarlyAccessClient({ + initialUsers, + updateEarlyAccessUsers, +}: { + initialUsers: EarlyAccessUser[]; + updateEarlyAccessUsers: (userIds: string[]) => Promise; +}) { + const [earlyAccessUsers, setEarlyAccessUsers] = useState(initialUsers); + const [selectedUsers, setSelectedUsers] = useState([]); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [customEmails, setCustomEmails] = useState(''); + + const earlyAccessCount = earlyAccessUsers.filter((user) => user.isEarlyAccess).length; + + const selectRandomUsers = async () => { + try { + // First get the list of already emailed users + const outputResponse = await fetch('/output.json'); + const { emails: alreadyEmailed } = await outputResponse.json(); + + // Filter out users who have already been emailed + const availableUsers = earlyAccessUsers.filter(user => !alreadyEmailed.includes(user.email)); + + if (availableUsers.length === 0) { + toast.error('All users have already been emailed'); + return; + } + + // Randomly select from remaining users + const shuffled = [...availableUsers].sort(() => 0.5 - Math.random()); + const selected = shuffled.slice(0, 500); // Select 500 users for testing + + toast.success(`Selected ${selected.length} users who haven't been emailed yet`); + setSelectedUsers(selected); + setIsDialogOpen(true); + } catch (error) { + console.error('Error selecting users:', error); + toast.error('Failed to check already emailed users'); + } + }; + + const handleCustomEmails = async () => { + try { + const emails = customEmails.split(',').map(email => email.trim()); + + // Get the list of already emailed users + const outputResponse = await fetch('/output.json'); + const { emails: alreadyEmailed } = await outputResponse.json(); + + // Filter out users who have already been emailed + const selectedUsers = earlyAccessUsers.filter(user => + emails.includes(user.email) && !alreadyEmailed.includes(user.email) + ); + + if (selectedUsers.length === 0) { + toast.error('No valid emails found or all specified emails have already been sent'); + return; + } + + if (selectedUsers.length !== emails.length) { + const notFound = emails.filter(email => !earlyAccessUsers.some(user => user.email === email)); + const alreadySent = emails.filter(email => alreadyEmailed.includes(email)); + + if (notFound.length > 0) { + toast.warning(`Some emails were not found: ${notFound.join(', ')}`); + } + if (alreadySent.length > 0) { + toast.warning(`Some emails were already sent to: ${alreadySent.join(', ')}`); + } + } + + setSelectedUsers(selectedUsers); + setIsDialogOpen(true); + setCustomEmails(''); // Clear the input after opening dialog + } catch (error) { + console.error('Error handling custom emails:', error); + toast.error('Failed to check already emailed users'); + } + }; + + const handleConfirm = async () => { + try { + setIsUpdating(true); + const selectedEmails = selectedUsers.map((user) => user.email); + + // Read the current output.json file + const outputResponse = await fetch('/output.json'); + const { emails: alreadyEmailed } = await outputResponse.json(); + + // Filter out users who have already been emailed + const newEmails = selectedEmails.filter(email => !alreadyEmailed.includes(email)); + + if (newEmails.length === 0) { + toast.info('All selected users have already been emailed'); + setIsDialogOpen(false); + setSelectedUsers([]); + setIsUpdating(false); + return; + } + + // Add users to Resend audience + toast.info(`Adding ${newEmails.length} users to Resend audience...`); + const resendResult = await addUsersToResendAudience(newEmails); + + if (!resendResult.success && (!resendResult.successfulEmails || resendResult.successfulEmails.length === 0)) { + toast.error('Failed to add any users to Resend audience. No early access granted.'); + console.error('Resend API error:', resendResult.error); + return; + } + + const successfulEmails = resendResult.successfulEmails || []; + const failedEmails = resendResult.failedEmails || []; + + const successfulUserIds = selectedUsers + .filter(user => successfulEmails.includes(user.email)) + .map(user => user.id); + + if (successfulUserIds.length === 0) { + toast.error('No users were successfully added to Resend audience. No early access granted.'); + return; + } + + // Update database + toast.info(`Updating ${successfulUserIds.length} users in the database...`); + const result = await updateEarlyAccessUsers(successfulUserIds); + + if (result.success) { + // Update local state + setEarlyAccessUsers((prev) => + prev.map((user) => + successfulUserIds.includes(user.id) + ? { ...user, isEarlyAccess: true } + : user, + ), + ); + + // Send emails + toast.info(`Sending welcome emails to ${successfulEmails.length} users...`); + await sendEmailsToUsers(successfulEmails); + toast.success(`Welcome emails sent to ${successfulEmails.length} users`); + + if (failedEmails.length > 0) { + toast.warning( +
+

{successfulEmails.length} users added to Resend audience and granted early access

+

+ {failedEmails.length} users could not be added to the audience (no early access granted) +

+ {failedEmails.length <= 5 && ( +
+

Failed emails:

+
    + {failedEmails.map((email: string, i: number) => ( +
  • {email}
  • + ))} +
+
+ )} +
, + ); + } else { + toast.success( + `All ${successfulEmails.length} users successfully added to Resend audience and granted early access`, + ); + } + } else { + toast.error('Failed to update users in the database'); + } + } catch (error) { + console.error('Error in confirmation:', error); + toast.error('An error occurred'); + } finally { + setIsDialogOpen(false); + setSelectedUsers([]); + setIsUpdating(false); + } + }; + + const addUsersToResendAudience = async (emails: string[]) => { + // Show a loading toast + const toastId = toast.loading(`Adding ${emails.length} users to Resend audience...`); + + try { + const response = await fetch('/api/resend/add-to-audience', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ emails }), + }); + + if (!response.ok) { + console.error(`API response not OK: ${response.status} ${response.statusText}`); + // Update toast to error + toast.error(`Failed to add users to Resend audience: API returned ${response.status}`, { + id: toastId, + }); + return { success: false, error: `API returned ${response.status}` }; + } + + const data = (await response.json()) as ResendApiResponse; + + // Update toast based on result + if (data.success) { + const successCount = data.successfulCount || (data.successfulEmails?.length || 0); + toast.success(`Successfully added ${successCount} users to Resend audience`, { + id: toastId, + }); + } else { + toast.error(`Error adding users to Resend audience: ${data.error || 'Unknown error'}`, { + id: toastId, + }); + } + + return data; + } catch (error) { + console.error('Error adding users to Resend audience:', error); + // Update toast to error + const errorMessage = error instanceof Error ? error.message : String(error); + toast.error(`Failed to add users to Resend audience: ${errorMessage}`, { + id: toastId, + }); + return { success: false, error }; + } + }; + + const sendEmailsToUsers = async (emails: string[]) => { + const toastId = toast.loading(`Sending emails to ${emails.length} users...`); + + try { + // Read the current output.json file + const outputResponse = await fetch('/output.json'); + const { emails: alreadyEmailed } = await outputResponse.json(); + + // Filter out users who have already been emailed + const newEmails = emails.filter(email => !alreadyEmailed.includes(email)); + + if (newEmails.length === 0) { + toast.info('All users have already been emailed', { id: toastId }); + return { success: true, alreadyEmailed: true }; + } + + const apiResponse = await fetch('/api/resend/send-early-access', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + emails: newEmails, + subject: 'Welcome to Zero Early Access!', + content: ` + + + + + + + +
+ You've been granted early access to Zero! +
+ ‌​‍‎‏ +
+
+ + + + + + +
+ + + + + + +
+ Zero Early Access +
+

+ Welcome to Zero Early Access! +

+

+ Hi there, +

+

+ We're thrilled to invite you to the exclusive early access + program for Zero! We've been working hard to create an + exceptional experience, and we're incredibly excited to have + you as one of our first users :) Click the button below to access Zero! +

+ + + + + + +
+ Access Zero Now +
+

+ Join our + Discord community + to connect with other early users and the Zero team for support, + feedback, and exclusive updates. +

+

+ Your feedback during this early access phase is invaluable to us + as we continue to refine and improve Zero. We can't wait to + hear what you think! +

+
+

+ © + 2025 + Zero Email Inc. All rights reserved. +

+
+ +`, + }), + }); + + if (!apiResponse.ok) { + console.error(`API response not OK: ${apiResponse.status} ${apiResponse.statusText}`); + toast.error(`Failed to send emails: API returned ${apiResponse.status}`, { + id: toastId, + }); + return { success: false, error: `API returned ${apiResponse.status}` }; + } + + const data = (await apiResponse.json()) as ResendApiResponse; + + if (data.success) { + const successCount = data.successfulCount || (data.successfulEmails?.length || 0); + + // Update output.json with newly emailed users + if (data.successfulEmails && data.successfulEmails.length > 0) { + const updatedEmails = [...alreadyEmailed, ...data.successfulEmails]; + await fetch('/api/update-emailed-users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ emails: updatedEmails }), + }); + } + + toast.success(`Successfully sent emails to ${successCount} users`, { + id: toastId, + }); + } else { + toast.error(`Error sending emails: ${data.error || 'Unknown error'}`, { + id: toastId, + }); + } + + return data; + } catch (error) { + console.error('Error sending emails:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + toast.error(`Failed to send emails: ${errorMessage}`, { + id: toastId, + }); + return { success: false, error }; + } + }; + + return ( + <> + + + + Confirm Early Access Selection + + You are about to grant early access to {selectedUsers.length} users. Please review the + list below. + + +
+ {selectedUsers.map((user) => ( +
+

{user.email}

+

+ Signed up: {format(new Date(user.createdAt), 'MMM d, yyyy')} +

+
+ ))} +
+ + + + +
+
+ +
+
+
+
+
+ + + + + + + +
Email + Early Access +
+
+ +
+ + + {earlyAccessUsers.length === 0 ? ( + + + + ) : ( + earlyAccessUsers.map((user: EarlyAccessUser) => ( + + + + + )) + )} + +
+ No early access signups found. +
{user.email} + + {user.isEarlyAccess ? 'Yes' : 'No'} + +
+
+ +
+ + + + + + +
+ Total: {earlyAccessUsers.length}{' '} + {earlyAccessUsers.length === 1 ? 'user' : 'users'} | Early Access:{' '} + {earlyAccessCount} {earlyAccessCount === 1 ? 'user' : 'users'} +
+
+
+
+
+ +
+ + +
+ setCustomEmails(e.target.value)} + disabled={isUpdating} + /> + +
+
+
+ + ); +} diff --git a/apps/mail/app/(full-width)/early-access/page.tsx b/apps/mail/app/(full-width)/early-access/page.tsx new file mode 100644 index 0000000000..decb39a774 --- /dev/null +++ b/apps/mail/app/(full-width)/early-access/page.tsx @@ -0,0 +1,73 @@ +import { db } from "@zero/db"; +import { earlyAccess } from "@zero/db/schema"; +import { desc, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { EarlyAccessClient } from "./early-access"; + +async function getEarlyAccessUsers() { + const users = await db.query.earlyAccess.findMany({ + orderBy: [desc(earlyAccess.createdAt)] + }); + + return users.map(user => ({ + ...user, + isEarlyAccess: user.isEarlyAccess ?? false + })); +} + +async function updateEarlyAccessUsers(userIds: string[]) { + 'use server'; + try { + if (!userIds || userIds.length === 0) { + console.error('No user IDs provided'); + return { success: false, error: 'No user IDs provided' }; + } + + const usersToUpdate = await db.query.earlyAccess.findMany({ + where: (earlyAccess, { inArray }) => inArray(earlyAccess.id, userIds) + }); + + if (usersToUpdate.length === 0) { + console.error('No users found with the provided IDs'); + return { success: false, error: 'No users found with the provided IDs' }; + } + + const emails = usersToUpdate.map(user => user.email); + + const now = new Date(); + for (const id of userIds) { + await db.update(earlyAccess) + .set({ + isEarlyAccess: true, + updatedAt: now + }) + .where(sql`${earlyAccess.id} = ${id}`); + } + + revalidatePath('/early-access'); + + return { + success: true, + emails: emails + }; + } catch (error) { + console.error('Error updating early access users:', error); + if (error instanceof Error) { + console.error('Error stack:', error.stack); + } + return { success: false, error: String(error) }; + } +} + +export default async function EarlyAccess() { + const earlyAccessUsers = await getEarlyAccessUsers(); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/mail/app/(routes)/layout.tsx b/apps/mail/app/(routes)/layout.tsx index a589635d84..0089d1ccea 100644 --- a/apps/mail/app/(routes)/layout.tsx +++ b/apps/mail/app/(routes)/layout.tsx @@ -1,24 +1,27 @@ 'use client'; import { CommandPaletteProvider } from '@/components/context/command-palette-context'; +import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; import { dexieStorageProvider } from '@/lib/idb'; import { SWRConfig } from 'swr'; export default function Layout({ children }: { children: React.ReactNode }) { return ( - -
- - {children} - -
-
+ + +
+ + {children} + +
+
+
); } diff --git a/apps/mail/app/(routes)/mail/layout.tsx b/apps/mail/app/(routes)/mail/layout.tsx index a19215dc2e..10d8b89a92 100644 --- a/apps/mail/app/(routes)/mail/layout.tsx +++ b/apps/mail/app/(routes)/mail/layout.tsx @@ -1,12 +1,13 @@ -import { KeyboardShortcuts } from '@/components/mail/keyboard-shortcuts'; import { AppSidebar } from '@/components/ui/app-sidebar'; +import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys'; +import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; export default function MailLayout({ children }: { children: React.ReactNode }) { return ( - <> + - +
{children}
- +
); } diff --git a/apps/mail/app/(routes)/settings/shortcuts/hotkey-recorder.tsx b/apps/mail/app/(routes)/settings/shortcuts/hotkey-recorder.tsx new file mode 100644 index 0000000000..f84a3a4591 --- /dev/null +++ b/apps/mail/app/(routes)/settings/shortcuts/hotkey-recorder.tsx @@ -0,0 +1,94 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import type { MessageKey } from '@/config/navigation'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +interface HotkeyRecorderProps { + isOpen: boolean; + onClose: () => void; + onHotkeyRecorded: (keys: string[]) => void; + currentKeys: string[]; +} + +export function HotkeyRecorder({ + isOpen, + onClose, + onHotkeyRecorded, + currentKeys, +}: HotkeyRecorderProps) { + const t = useTranslations(); + const [recordedKeys, setRecordedKeys] = useState([]); + const [isRecording, setIsRecording] = useState(false); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + if (!isRecording) return; + + const key = e.key === ' ' ? 'Space' : e.key; + + const formattedKey = key.length === 1 ? key.toUpperCase() : key; + + if (!recordedKeys.includes(formattedKey)) { + setRecordedKeys((prev) => [...prev, formattedKey]); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + e.preventDefault(); + if (isRecording) { + setIsRecording(false); + if (recordedKeys.length > 0) { + onHotkeyRecorded(recordedKeys); + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [isOpen, isRecording, recordedKeys, onHotkeyRecorded, onClose]); + + useEffect(() => { + if (isOpen) { + setRecordedKeys([]); + setIsRecording(true); + } + }, [isOpen]); + + return ( + + + + + {t('pages.settings.shortcuts.actions.recordHotkey' as MessageKey)} + + +
+
+ {isRecording + ? t('pages.settings.shortcuts.actions.pressKeys' as MessageKey) + : t('pages.settings.shortcuts.actions.releaseKeys' as MessageKey)} +
+
+ {(recordedKeys.length > 0 ? recordedKeys : currentKeys).map((key) => ( + + {key} + + ))} +
+
+
+
+ ); +} diff --git a/apps/mail/app/(routes)/settings/shortcuts/page.tsx b/apps/mail/app/(routes)/settings/shortcuts/page.tsx index 197bac1236..5c04c64775 100644 --- a/apps/mail/app/(routes)/settings/shortcuts/page.tsx +++ b/apps/mail/app/(routes)/settings/shortcuts/page.tsx @@ -1,16 +1,35 @@ 'use client'; import { SettingsCard } from '@/components/settings/settings-card'; -import { keyboardShortcuts } from '@/config/shortcuts'; //import the shortcuts +import { keyboardShortcuts, type Shortcut } from '@/config/shortcuts'; import type { MessageKey } from '@/config/navigation'; +import { HotkeyRecorder } from './hotkey-recorder'; +import { useState, type ReactNode, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; -import type { ReactNode } from 'react'; +import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils'; +import { hotkeysDB } from '@/lib/hotkeys/hotkeys-db'; +import { toast } from 'sonner'; export default function ShortcutsPage() { - const shortcuts = keyboardShortcuts; //now gets shortcuts from the config file + const [shortcuts, setShortcuts] = useState(keyboardShortcuts); const t = useTranslations(); + useEffect(() => { + // Load any custom shortcuts from IndexedDB + hotkeysDB.getAllHotkeys() + .then(savedShortcuts => { + if (savedShortcuts.length > 0) { + const updatedShortcuts = keyboardShortcuts.map(defaultShortcut => { + const savedShortcut = savedShortcuts.find(s => s.action === defaultShortcut.action); + return savedShortcut || defaultShortcut; + }); + setShortcuts(updatedShortcuts); + } + }) + .catch(console.error); + }, []); + return (
- - +
} >
{shortcuts.map((shortcut, index) => ( - + {t(`pages.settings.shortcuts.actions.${shortcut.action}` as MessageKey)} ))} @@ -35,20 +72,57 @@ export default function ShortcutsPage() { ); } -function Shortcut({ children, keys }: { children: ReactNode; keys: string[] }) { +function Shortcut({ children, keys, action }: { children: ReactNode; keys: string[]; action: string }) { + const [isRecording, setIsRecording] = useState(false); + const displayKeys = formatDisplayKeys(keys); + + const handleHotkeyRecorded = async (newKeys: string[]) => { + try { + // Find the original shortcut to preserve its type and description + const originalShortcut = keyboardShortcuts.find(s => s.action === action); + if (!originalShortcut) { + throw new Error('Original shortcut not found'); + } + + const updatedShortcut: Shortcut = { + ...originalShortcut, + keys: newKeys, + }; + + await hotkeysDB.saveHotkey(updatedShortcut); + toast.success('Shortcut saved successfully'); + } catch (error) { + console.error('Failed to save shortcut:', error); + toast.error('Failed to save shortcut'); + } + }; + return ( -
- {children} -
- {keys.map((key) => ( - - {key} - - ))} + <> +
setIsRecording(true)} + role="button" + tabIndex={0} + > + {children} +
+ {displayKeys.map((key) => ( + + {key} + + ))} +
-
+ setIsRecording(false)} + onHotkeyRecorded={handleHotkeyRecorded} + currentKeys={keys} + /> + ); } diff --git a/apps/mail/app/api/auth/early-access/route.ts b/apps/mail/app/api/auth/early-access/route.ts index b86b5a95db..f097065516 100644 --- a/apps/mail/app/api/auth/early-access/route.ts +++ b/apps/mail/app/api/auth/early-access/route.ts @@ -1,7 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { Ratelimit } from '@upstash/ratelimit'; import { earlyAccess } from '@zero/db/schema'; -import { redis } from '@/lib/redis'; import { db } from '@zero/db'; import { Resend } from 'resend'; @@ -10,13 +8,6 @@ type PostgresError = { message: string; }; -const ratelimit = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(2, '10m'), - analytics: true, - prefix: 'ratelimit:early-access', -}); - function isEmail(email: string): boolean { if (!email) { return false; @@ -31,34 +22,7 @@ function isEmail(email: string): boolean { export async function POST(req: NextRequest) { try { - const ip = req.headers.get('CF-Connecting-IP'); - if (!ip) { - console.log('No IP detected'); - return NextResponse.json({ error: 'No IP detected' }, { status: 400 }); - } - console.log( - 'Request from IP:', - ip, - req.headers.get('x-forwarded-for'), - req.headers.get('CF-Connecting-IP'), - ); - const { success, limit, reset, remaining } = await ratelimit.limit(ip); - - const headers = { - 'X-RateLimit-Limit': limit.toString(), - 'X-RateLimit-Remaining': remaining.toString(), - 'X-RateLimit-Reset': reset.toString(), - }; const body = await req.json(); - - if (!success) { - console.log(`Rate limit exceeded for IP ${ip}. Remaining: ${remaining}`, body.email); - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers }, - ); - } - console.log('Request body:', body); const { email: rawEmail } = body as { email: string }; @@ -103,14 +67,7 @@ export async function POST(req: NextRequest) { return NextResponse.json( { message: 'Successfully joined early access' }, - { - status: 201, - headers: { - 'X-RateLimit-Limit': limit.toString(), - 'X-RateLimit-Remaining': remaining.toString(), - 'X-RateLimit-Reset': reset.toString(), - }, - }, + { status: 201 } ); } catch (err) { const pgError = err as PostgresError; @@ -124,14 +81,7 @@ export async function POST(req: NextRequest) { // Return 200 for existing emails return NextResponse.json( { message: 'Email already registered for early access' }, - { - status: 200, - headers: { - 'X-RateLimit-Limit': limit.toString(), - 'X-RateLimit-Remaining': remaining.toString(), - 'X-RateLimit-Reset': reset.toString(), - }, - }, + { status: 200 } ); } diff --git a/apps/mail/app/api/resend/add-to-audience/route.ts b/apps/mail/app/api/resend/add-to-audience/route.ts new file mode 100644 index 0000000000..dbc76fa5ad --- /dev/null +++ b/apps/mail/app/api/resend/add-to-audience/route.ts @@ -0,0 +1,150 @@ +import { Resend } from 'resend'; +import { NextRequest, NextResponse } from 'next/server'; + +// We'll initialize the Resend client inside the handler to ensure +// environment variables are properly loaded + +export async function POST(request: NextRequest) { + // Initialize Resend client with environment variables + const resendApiKey = process.env.RESEND_API_KEY; + const resendAudienceId = process.env.RESEND_AUDIENCE_ID; + + console.log('API Key available:', !!resendApiKey); + console.log('Audience ID:', resendAudienceId); + + try { + // Validate API key and audience ID + if (!resendApiKey || !resendAudienceId) { + console.error('Resend API key or audience ID not configured'); + return NextResponse.json( + { success: false, error: 'Resend API key or audience ID not configured' }, + { status: 500 } + ); + } + + // Get emails from request body + const body = await request.json(); + console.log('Request body received:', body); + + const { emails } = body; + + if (!emails || !Array.isArray(emails) || emails.length === 0) { + console.error('No emails provided in request body:', body); + return NextResponse.json( + { success: false, error: 'No emails provided' }, + { status: 400 } + ); + } + + console.log(`Processing ${emails.length} emails for Resend audience...`); + + // Process each email one by one with careful error handling + const results = []; + const successfulEmails = []; + const failedEmails = []; + + // Initialize a fresh Resend client for each request + const resend = new Resend(resendApiKey); + + // Process each email sequentially with individual error handling + for (let i = 0; i < emails.length; i++) { + const email = emails[i]; + console.log(`[${i+1}/${emails.length}] Processing email: ${email}`); + + try { + // Verify the email is valid + if (!email || typeof email !== 'string' || !email.includes('@')) { + console.error(`Invalid email format: ${email}`); + failedEmails.push(email); + results.push({ email, success: false, error: 'Invalid email format' }); + continue; + } + + console.log(`Adding ${email} to Resend audience ${resendAudienceId}...`); + + // Make the API call with explicit error handling + try { + const response = await resend.contacts.create({ + email: email, + audienceId: resendAudienceId, + unsubscribed: false + }); + + // Verify the response + if (response && response.data && response.data.id) { + console.log(`Successfully added ${email} to audience with ID: ${response.data.id}`); + successfulEmails.push(email); + results.push({ email, success: true, response }); + } else { + console.error(`Unexpected response format for ${email}:`, response); + failedEmails.push(email); + results.push({ email, success: false, error: 'Unexpected response format' }); + } + } catch (apiErr) { + console.error(`API error adding ${email} to Resend audience:`, apiErr); + failedEmails.push(email); + results.push({ email, success: false, error: apiErr }); + } + } catch (err) { + // Catch any unexpected errors + console.error(`Unexpected error processing ${email}:`, err); + failedEmails.push(email); + results.push({ email, success: false, error: err }); + } + + // Add a small delay between requests to avoid rate limiting + if (i < emails.length - 1) { + console.log(`Waiting before processing next email...`); + await new Promise(resolve => setTimeout(resolve, 500)); // Increased delay + } + } + + console.log(`Processing complete. Success: ${successfulEmails.length}/${emails.length}, Failed: ${failedEmails.length}/${emails.length}`); + if (failedEmails.length > 0) { + console.log(`Failed emails: ${failedEmails.join(', ')}`); + } + + // Calculate final results + const allSuccessful = failedEmails.length === 0; + + // Detailed logging of results + console.log(`Resend audience update complete.`); + console.log(`Total emails: ${emails.length}`); + console.log(`Successfully added: ${successfulEmails.length}`); + console.log(`Failed to add: ${failedEmails.length}`); + + return NextResponse.json({ + success: allSuccessful, + totalProcessed: emails.length, + successfulCount: successfulEmails.length, + failedCount: failedEmails.length, + successfulEmails, + failedEmails, + detailedResults: results + }); + } catch (error) { + // Log the full error with stack trace if available + console.error('Error adding users to Resend audience:'); + console.error(error); + + if (error instanceof Error) { + console.error('Error stack:', error.stack); + } + + try { + console.error('Stringified error:', JSON.stringify(error, null, 2)); + } catch (e) { + console.error('Error could not be stringified:', e); + } + + return NextResponse.json( + { + success: false, + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString() + }, + { status: 500 } + ); + } +} diff --git a/apps/mail/app/api/resend/send-early-access/route.ts b/apps/mail/app/api/resend/send-early-access/route.ts new file mode 100644 index 0000000000..32204cd3fa --- /dev/null +++ b/apps/mail/app/api/resend/send-early-access/route.ts @@ -0,0 +1,104 @@ +import { Resend } from 'resend'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + const resendApiKey = process.env.RESEND_API_KEY; + + try { + // Validate API key + if (!resendApiKey) { + console.error('Resend API key not configured'); + return NextResponse.json( + { success: false, error: 'Resend API key not configured' }, + { status: 500 } + ); + } + + // Get emails from request body + const body = await request.json(); + const { emails, subject, content } = body; + + if (!emails || !Array.isArray(emails) || emails.length === 0) { + return NextResponse.json( + { success: false, error: 'No emails provided' }, + { status: 400 } + ); + } + + if (!subject || !content) { + return NextResponse.json( + { success: false, error: 'Subject and content are required' }, + { status: 400 } + ); + } + + // Initialize Resend client + const resend = new Resend(resendApiKey); + + // Process each email with individual error handling + const results = []; + const successfulEmails = []; + const failedEmails = []; + + for (let i = 0; i < emails.length; i++) { + const email = emails[i]; + + try { + // Verify the email is valid + if (!email || typeof email !== 'string' || !email.includes('@')) { + console.error(`Invalid email format: ${email}`); + failedEmails.push(email); + results.push({ email, success: false, error: 'Invalid email format' }); + continue; + } + + // Send the email + const response = await resend.emails.send({ + from: '0.email ', + to: email, + subject: subject, + html: content, + }); + + if (response && response.data && response.data.id) { + console.log(`Successfully sent email to ${email}`); + successfulEmails.push(email); + results.push({ email, success: true, response }); + } else { + console.error(`Unexpected response format for ${email}:`, response); + failedEmails.push(email); + results.push({ email, success: false, error: 'Unexpected response format' }); + } + } catch (err) { + console.error(`Error sending email to ${email}:`, err); + failedEmails.push(email); + results.push({ email, success: false, error: err }); + } + + // Add a small delay between requests to avoid rate limiting + if (i < emails.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + return NextResponse.json({ + success: failedEmails.length === 0, + totalProcessed: emails.length, + successfulCount: successfulEmails.length, + failedCount: failedEmails.length, + successfulEmails, + failedEmails, + detailedResults: results + }); + } catch (error) { + console.error('Error sending mass emails:', error); + return NextResponse.json( + { + success: false, + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/mail/app/api/update-emailed-users/route.ts b/apps/mail/app/api/update-emailed-users/route.ts new file mode 100644 index 0000000000..22ff2edf9b --- /dev/null +++ b/apps/mail/app/api/update-emailed-users/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { writeFile } from 'fs/promises'; +import { join } from 'path'; + +export async function POST(request: Request) { + try { + const { emails } = await request.json(); + + if (!emails || !Array.isArray(emails)) { + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 } + ); + } + + // Write the updated list to output.json in the public directory + const outputPath = join(process.cwd(), 'public', 'output.json'); + await writeFile(outputPath, JSON.stringify({ emails }, null, 2)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating emailed users:', error); + return NextResponse.json( + { error: 'Failed to update emailed users list' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/apps/mail/app/api/v1/hotkeys/route.ts b/apps/mail/app/api/v1/hotkeys/route.ts new file mode 100644 index 0000000000..b66736ef22 --- /dev/null +++ b/apps/mail/app/api/v1/hotkeys/route.ts @@ -0,0 +1,91 @@ +import type { Shortcut } from '@/config/shortcuts'; +import { userHotkeys } from '@zero/db/schema'; +import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { auth } from '@/lib/auth'; +import { eq } from 'drizzle-orm'; +import { db } from '@zero/db'; + +export async function GET() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const result = await db.select().from(userHotkeys).where(eq(userHotkeys.userId, session.user.id)); + + return NextResponse.json(result[0]?.shortcuts || []); +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const shortcuts = (await request.json()) as Shortcut[]; + const now = new Date(); + + await db + .insert(userHotkeys) + .values({ + userId: session.user.id, + shortcuts, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userHotkeys.userId], + set: { + shortcuts, + updatedAt: now, + }, + }); + + return NextResponse.json({ success: true }); +} + +export async function PUT(request: Request) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const shortcut = (await request.json()) as Shortcut; + const now = new Date(); + + const result = await db.select().from(userHotkeys).where(eq(userHotkeys.userId, session.user.id)); + + const existingShortcuts = (result[0]?.shortcuts || []) as Shortcut[]; + const updatedShortcuts = existingShortcuts.map((s: Shortcut) => + s.action === shortcut.action ? shortcut : s, + ); + + if (!existingShortcuts.some((s: Shortcut) => s.action === shortcut.action)) { + updatedShortcuts.push(shortcut); + } + + await db + .insert(userHotkeys) + .values({ + userId: session.user.id, + shortcuts: updatedShortcuts, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userHotkeys.userId], + set: { + shortcuts: updatedShortcuts, + updatedAt: now, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/mail/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx index a91396644b..80ba4024bb 100644 --- a/apps/mail/components/create/create-email.tsx +++ b/apps/mail/components/create/create-email.tsx @@ -1,10 +1,17 @@ 'use client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { UploadedFileIcon } from '@/components/create/uploaded-file-icon'; import { generateHTML, generateJSON } from '@tiptap/core'; import { useConnections } from '@/hooks/use-connections'; import { createDraft, getDraft } from '@/actions/drafts'; import { ArrowUpIcon, Paperclip, X } from 'lucide-react'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { Separator } from '@/components/ui/separator'; import { SidebarToggle } from '../ui/sidebar-toggle'; import Paragraph from '@tiptap/extension-paragraph'; @@ -23,17 +30,12 @@ import Bold from '@tiptap/extension-bold'; import { type JSONContent } from 'novel'; import { useQueryState } from 'nuqs'; import { Plus } from 'lucide-react'; +import { useEffect } from 'react'; +import posthog from 'posthog-js'; import { toast } from 'sonner'; import * as React from 'react'; import Editor from './editor'; import './prosemirror.css'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import posthog from 'posthog-js'; const MAX_VISIBLE_ATTACHMENTS = 12; @@ -62,9 +64,8 @@ const filterContacts = (contacts: any[], searchTerm: string, excludeEmails: stri const term = searchTerm.toLowerCase(); return contacts.filter( (contact) => - (contact.email?.toLowerCase().includes(term) || - contact.name?.toLowerCase().includes(term)) && - !excludeEmails.includes(contact.email) + (contact.email?.toLowerCase().includes(term) || contact.name?.toLowerCase().includes(term)) && + !excludeEmails.includes(contact.email), ); }; @@ -99,6 +100,7 @@ export function CreateEmail({ const [draftId, setDraftId] = useQueryState('draftId'); const [includeSignature, setIncludeSignature] = React.useState(true); const { settings } = useSettings(); + const { enableScope, disableScope } = useHotkeysContext(); const [isCardHovered, setIsCardHovered] = React.useState(false); const dragCounter = React.useRef(0); @@ -129,17 +131,17 @@ export function CreateEmail({ const filteredContacts = React.useMemo( () => filterContacts(contacts, toInput, toEmails), - [contacts, toInput, toEmails] + [contacts, toInput, toEmails], ); const filteredCcContacts = React.useMemo( () => filterContacts(contacts, ccInput, [...toEmails, ...ccEmails]), - [contacts, ccInput, toEmails, ccEmails] + [contacts, ccInput, toEmails, ccEmails], ); const filteredBccContacts = React.useMemo( () => filterContacts(contacts, bccInput, [...toEmails, ...ccEmails, ...bccEmails]), - [contacts, bccInput, toEmails, ccEmails, bccEmails] + [contacts, bccInput, toEmails, ccEmails, bccEmails], ); React.useEffect(() => { @@ -459,6 +461,16 @@ export function CreateEmail({ } }, [initialTo, initialSubject, initialBody, defaultValue]); + useEffect(() => { + console.log('Enabling compose scope (CreateEmail)'); + enableScope('compose'); + + return () => { + console.log('Disabling compose scope (CreateEmail)'); + disableScope('compose'); + }; + }, [enableScope, disableScope]); + const toDropdownRef = React.useRef(null); const ccDropdownRef = React.useRef(null); const bccDropdownRef = React.useRef(null); @@ -506,8 +518,13 @@ export function CreateEmail({
-
-
+
+
{isDragging && isCardHovered && (
@@ -516,7 +533,7 @@ export function CreateEmail({
)} -
+
@@ -544,55 +561,58 @@ export function CreateEmail({
))} -
- handleEmailInputChange('to', e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (filteredContacts.length > 0) { - const selectedEmail = filteredContacts[selectedContactIndex]?.email; - if (selectedEmail) handleAddEmail('to', selectedEmail); - setSelectedContactIndex(0); - } else { - handleAddEmail('to', toInput); - } - } else if (e.key === 'ArrowDown' && filteredContacts.length > 0) { - e.preventDefault(); - setSelectedContactIndex((prev) => - Math.min(prev + 1, filteredContacts.length - 1), - ); - } else if (e.key === 'ArrowUp' && filteredContacts.length > 0) { - e.preventDefault(); - setSelectedContactIndex((prev) => Math.max(prev - 1, 0)); - } - }} - /> - {toInput && filteredContacts.length > 0 && ( -
- {filteredContacts.map((contact, index) => ( - - ))} -
- )} + } else { + handleAddEmail('to', toInput); + } + } else if (e.key === 'ArrowDown' && filteredContacts.length > 0) { + e.preventDefault(); + setSelectedContactIndex((prev) => + Math.min(prev + 1, filteredContacts.length - 1), + ); + } else if (e.key === 'ArrowUp' && filteredContacts.length > 0) { + e.preventDefault(); + setSelectedContactIndex((prev) => Math.max(prev - 1, 0)); + } + }} + /> + {toInput && filteredContacts.length > 0 && ( +
+ {filteredContacts.map((contact, index) => ( + + ))} +
+ )}
))} -
+
0) { - const selectedEmail = filteredCcContacts[selectedCcContactIndex]?.email; + const selectedEmail = + filteredCcContacts[selectedCcContactIndex]?.email; if (selectedEmail) { handleAddEmail('cc', selectedEmail); setSelectedCcContactIndex(0); @@ -689,7 +710,10 @@ export function CreateEmail({ }} /> {ccInput && filteredCcContacts.length > 0 && ( -
+
{filteredCcContacts.map((contact, index) => ( ))} @@ -739,7 +765,7 @@ export function CreateEmail({
))} -
+
0) { - const selectedEmail = filteredBccContacts[selectedBccContactIndex]?.email; + const selectedEmail = + filteredBccContacts[selectedBccContactIndex]?.email; if (selectedEmail) { handleAddEmail('bcc', selectedEmail); setSelectedBccContactIndex(0); @@ -772,7 +799,10 @@ export function CreateEmail({ }} /> {bccInput && filteredBccContacts.length > 0 && ( -
+
{filteredBccContacts.map((contact, index) => ( ))} @@ -837,7 +869,7 @@ export function CreateEmail({
-
+
@@ -897,9 +929,7 @@ export function CreateEmail({ console.log('CreateEmail: Successfully applied AI content'); } catch (error) { console.error('CreateEmail: Error applying AI content', error); - toast.error( - 'Error applying AI content to your email. Please try again.', - ); + toast.error('Error applying AI content to your email. Please try again.'); } }} /> @@ -914,8 +944,8 @@ export function CreateEmail({ {attachments.length}{' '} {t('common.replyCompose.attachmentCount', { - count: attachments.length, - })} + count: attachments.length, + })} @@ -928,8 +958,8 @@ export function CreateEmail({

{attachments.length}{' '} {t('common.replyCompose.fileCount', { - count: attachments.length, - })} + count: attachments.length, + })}

@@ -982,16 +1012,14 @@ export function CreateEmail({
-
-
: null +
+
+ ) : null; - if (demo) return demoContent + if (demo) return demoContent; - const content = latestMessage && getThreadData ? ( -
+ const content = + latestMessage && getThreadData ? ( +

- + {highlightText(latestMessage.sender.name, searchValue.highlight)} {' '} {getThreadData.hasUnread && !isMailSelected ? ( @@ -332,9 +340,9 @@ const Thread = memo(

+
-
- ) : null; + ) : null; return latestMessage ? ( { const [threadId, setThreadId] = useQueryState('threadId'); const [category, setCategory] = useQueryState('category'); const [searchValue, setSearchValue] = useSearchValue(); + const { enableScope, disableScope } = useHotkeysContext(); const { data: { threads: items, nextPageToken }, isValidating, isLoading, loadMore, mutate, - isReachingEnd + isReachingEnd, } = useThreads(); const allCategories = Categories(); @@ -483,7 +492,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { } // Otherwise select all items else if (items.length > 0) { - // TODO: debug + // TODO: debug const allIds = items.map((item) => item.id); setMail((prev) => ({ ...prev, @@ -494,74 +503,6 @@ export const MailList = memo(({ isCompact }: MailListProps) => { } }, [items, setMail, mail.bulkSelected, t]); - useHotKey('Meta+Shift+u', () => { - markAsUnread({ ids: mail.bulkSelected }).then((result) => { - if (result.success) { - toast.success(t('common.mail.markedAsUnread')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsUnread')); - }); - }); - - useHotKey('Control+Shift+u', () => { - markAsUnread({ ids: mail.bulkSelected }).then((response) => { - if (response.success) { - toast.success(t('common.mail.markedAsUnread')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsUnread')); - }); - }); - - useHotKey('Meta+Shift+i', () => { - markAsRead({ ids: mail.bulkSelected }).then((data) => { - if (data.success) { - toast.success(t('common.mail.markedAsRead')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsRead')); - }); - }); - - useHotKey('Control+Shift+i', () => { - markAsRead({ ids: mail.bulkSelected }).then((response) => { - if (response.success) { - toast.success(t('common.mail.markedAsRead')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsRead')); - }); - }); - - // useHotKey('Meta+a', (event) => { - // event?.preventDefault(); - // selectAll(); - // }); - - useHotKey('Control+a', (event) => { - event?.preventDefault(); - selectAll(); - }); - - // useHotKey('Meta+n', (event) => { - // event?.preventDefault(); - // selectAll(); - // }); - - // useHotKey('Control+n', (event) => { - // event?.preventDefault(); - // selectAll(); - // }); - const getSelectMode = useCallback((): MailSelectMode => { if (isKeyPressed('Control') || isKeyPressed('Meta')) { return 'mass'; @@ -612,6 +553,14 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
{ + console.log('[MailList] Mouse Enter - Enabling scope: mail-list'); + enableScope('mail-list'); + }} + onMouseLeave={() => { + console.log('[MailList] Mouse Leave - Disabling scope: mail-list'); + disableScope('mail-list'); + }} > {items.map((data, index) => { @@ -688,7 +637,7 @@ const MailLabels = memo( {getLabelIcon(label)} - + {t('common.notes.title')} @@ -734,7 +683,7 @@ const MailLabels = memo( {getLabelIcon(label)} - + {labelContent} diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 16922e03d9..35454b8a23 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -52,7 +52,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { clearBulkSelectionAtom } from './use-mail'; import { useThreads } from '@/hooks/use-threads'; import { Button } from '@/components/ui/button'; -import { useHotKey } from '@/hooks/use-hot-key'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { useSession } from '@/lib/auth-client'; import { useStats } from '@/hooks/use-stats'; import { useRouter } from 'next/navigation'; @@ -218,6 +218,7 @@ export function MailLayout() { const { data: session, isPending } = useSession(); const t = useTranslations(); const prevFolderRef = useRef(folder); + const { enableScope, disableScope } = useHotkeysContext(); useEffect(() => { if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) { @@ -250,15 +251,27 @@ export function MailLayout() { const [threadId, setThreadId] = useQueryState('threadId'); - const handleClose = () => { - setThreadId(null); - } + useEffect(() => { + if (threadId) { + console.log('Enabling thread-display scope, disabling mail-list'); + enableScope('thread-display'); + disableScope('mail-list'); + } else { + console.log('Enabling mail-list scope, disabling thread-display'); + enableScope('mail-list'); + disableScope('thread-display'); + } - // Search bar is always visible now, no need for keyboard shortcuts to toggle it - useHotKey('Esc', (event) => { - event?.preventDefault(); - // Handle other Esc key functionality if needed - }); + return () => { + console.log('Cleaning up mail/thread scopes'); + disableScope('thread-display'); + disableScope('mail-list'); + }; + }, [threadId, enableScope, disableScope]); + + const handleClose = useCallback(() => { + setThreadId(null); + }, [setThreadId]); // Add mailto protocol handler registration useEffect(() => { diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index cb64149e48..984f956930 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -20,19 +20,13 @@ import { Forward, ReplyAll, } from 'lucide-react'; -import { - type Dispatch, - type SetStateAction, - useRef, - useState, - useEffect, - useCallback, - useReducer, -} from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useRef, useState, useEffect, useCallback, useReducer } from 'react'; import { UploadedFileIcon } from '@/components/create/uploaded-file-icon'; -import { useForm, SubmitHandler, useWatch } from 'react-hook-form'; +import { extractTextFromHTML } from '@/actions/extractText'; +import { useForm, SubmitHandler } from 'react-hook-form'; import { generateAIResponse } from '@/actions/ai-reply'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { Separator } from '@/components/ui/separator'; import { useMail } from '@/components/mail/use-mail'; import { useSettings } from '@/hooks/use-settings'; @@ -40,19 +34,17 @@ import Editor from '@/components/create/editor'; import { Button } from '@/components/ui/button'; import { useThread } from '@/hooks/use-threads'; import { useSession } from '@/lib/auth-client'; +import { createDraft } from '@/actions/drafts'; import { useTranslations } from 'next-intl'; import { sendEmail } from '@/actions/send'; import type { JSONContent } from 'novel'; import { useQueryState } from 'nuqs'; +import { Input } from '../ui/input'; +import posthog from 'posthog-js'; import { Sender } from '@/types'; import { toast } from 'sonner'; import type { z } from 'zod'; -import { createDraft } from '@/actions/drafts'; -import { extractTextFromHTML } from '@/actions/extractText'; -import { Input } from '../ui/input'; -import posthog from 'posthog-js'; - // Utility function to check if an email is a noreply address const isNoReplyAddress = (email: string): boolean => { const lowerEmail = email.toLowerCase(); @@ -166,6 +158,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const [mail, setMail] = useMail(); const { settings } = useSettings(); const [draftId, setDraftId] = useQueryState('draftId'); + const { enableScope, disableScope } = useHotkeysContext(); const [isEditingRecipients, setIsEditingRecipients] = useState(false); const [showCc, setShowCc] = useState(false); const [showBcc, setShowBcc] = useState(false); @@ -250,7 +243,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { } if (!emailData) return; try { - const originalEmail = emailData.latest + const originalEmail = emailData.latest; const userEmail = session?.activeConnection?.email?.toLowerCase(); if (!userEmail) { @@ -276,16 +269,16 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const ccRecipients: Sender[] | undefined = showCc ? ccEmails.map((email) => ({ - email, - name: email.split('@')[0] || 'User', - })) + email, + name: email.split('@')[0] || 'User', + })) : undefined; const bccRecipients: Sender[] | undefined = showBcc ? bccEmails.map((email) => ({ - email, - name: email.split('@')[0] || 'User', - })) + email, + name: email.split('@')[0] || 'User', + })) : undefined; const messageId = originalEmail.messageId; @@ -302,7 +295,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { quotedMessage, ); - const inReplyTo = messageId + const inReplyTo = messageId; const existingRefs = originalEmail.references?.split(' ') || []; const references = [...existingRefs, originalEmail?.inReplyTo, cleanEmailAddress(messageId)] .filter(Boolean) @@ -320,10 +313,9 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { References: references, 'Thread-Id': threadId ?? '', }, - threadId + threadId, }).then(() => mutate()); - - + if (ccRecipients && bccRecipients) { posthog.capture('Reply Email Sent with CC and BCC'); } else if (ccRecipients) { @@ -509,15 +501,15 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const isMessageEmpty = !getValues('messageContent') || getValues('messageContent') === - JSON.stringify({ - type: 'doc', - content: [ - { - type: 'paragraph', - content: [], - }, - ], - }); + JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }); // Check if form is valid for submission const isFormValid = !isMessageEmpty || attachments.length > 0; @@ -532,16 +524,20 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const originalSender = latestEmail?.sender?.name || 'the recipient'; // Create a summary of the thread content for context - const threadContent = (await Promise.all(emailData.messages.map(async (email) => { - const body = await extractTextFromHTML(email.decodedBody || 'No content'); - return ` + const threadContent = ( + await Promise.all( + emailData.messages.map(async (email) => { + const body = await extractTextFromHTML(email.decodedBody || 'No content'); + return ` ${email.sender?.name || 'Unknown'} <${email.sender?.email || 'unknown@email.com'}> ${email.subject || 'No Subject'} ${new Date(email.receivedOn || '').toLocaleString()} ${body} `; - }))).join('\n\n'); + }), + ) + ).join('\n\n'); const suggestion = await generateAIResponse(threadContent, originalSender); aiDispatch({ type: 'SET_SUGGESTION', payload: suggestion }); @@ -705,7 +701,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { if (isEditingRecipients || mode === 'forward') { return (
-
+
{icon} @@ -925,6 +921,21 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { } }, [mode, emailData, getValues, attachments, draftId, setDraftId]); + useEffect(() => { + if (composerIsOpen) { + console.log('Enabling compose scope (ReplyCompose)'); + enableScope('compose'); + } else { + console.log('Disabling compose scope (ReplyCompose)'); + disableScope('compose'); + } + + return () => { + console.log('Cleaning up compose scope (ReplyCompose)'); + disableScope('compose'); + }; + }, [composerIsOpen, enableScope, disableScope]); + // Simplified composer visibility check if (!composerIsOpen) { if (!emailData || emailData.messages.length === 0) return null; @@ -1038,7 +1049,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { {composerState.isDragging && } {/* Header */} -
+
{renderHeaderContent()}
diff --git a/apps/mail/components/providers/hotkey-provider-wrapper.tsx b/apps/mail/components/providers/hotkey-provider-wrapper.tsx new file mode 100644 index 0000000000..cc89ddc636 --- /dev/null +++ b/apps/mail/components/providers/hotkey-provider-wrapper.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { HotkeysProvider } from 'react-hotkeys-hook'; +import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys'; +import { MailListHotkeys } from '@/lib/hotkeys/mail-list-hotkeys'; +import { ThreadDisplayHotkeys } from '@/lib/hotkeys/thread-display-hotkeys'; +import { ComposeHotkeys } from '@/lib/hotkeys/compose-hotkeys'; +import React from 'react'; + +interface HotkeyProviderWrapperProps { + children: React.ReactNode; +} + +export function HotkeyProviderWrapper({ children }: HotkeyProviderWrapperProps) { + return ( + + + + + + {children} + + ); +} \ No newline at end of file diff --git a/apps/mail/components/ui/ai-sidebar.tsx b/apps/mail/components/ui/ai-sidebar.tsx index 126385c7cb..59771a8cff 100644 --- a/apps/mail/components/ui/ai-sidebar.tsx +++ b/apps/mail/components/ui/ai-sidebar.tsx @@ -6,15 +6,13 @@ import { X, MessageSquare } from 'lucide-react'; import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { createContext, useContext } from 'react'; interface AISidebarProps { className?: string; } -// Create a context to manage the AI sidebar state globally -import { createContext, useContext } from 'react'; -import { useHotKey } from '@/hooks/use-hot-key'; - type AISidebarContextType = { open: boolean; setOpen: (open: boolean) => void; @@ -47,14 +45,6 @@ export function AISidebarProvider({ children }: { children: React.ReactNode }) { export function AISidebar({ className }: AISidebarProps) { const { open, setOpen } = useAISidebar(); - useHotKey('Meta+0', () => { - setOpen(!open); - }); - - useHotKey('Control+0', () => { - setOpen(!open); - }); - return ( <>