Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions apps/mail/actions/ai-reply.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,41 @@
'use server';

import { getUserSettings } from '@/actions/settings';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';

// Function to truncate email thread content to fit within token limits
function truncateThreadContent(threadContent: string, maxTokens: number = 12000): string {
// Split the thread into individual emails
const emails = threadContent.split('\n---\n');

// Start with the most recent email (last in the array)
let truncatedContent = emails[emails.length - 1];

// Add previous emails until we reach the token limit
for (let i = emails.length - 2; i >= 0; i--) {
const newContent = `${emails[i]}\n---\n${truncatedContent}`;

// Rough estimation of tokens (1 token ≈ 4 characters)
const estimatedTokens = newContent.length / 4;

if (estimatedTokens > maxTokens) {
break;
}

truncatedContent = newContent;
}

return truncatedContent;
}

export async function generateAIResponse(threadContent: string, originalSender: string): Promise<string> {
export async function generateAIResponse(
threadContent: string,
originalSender: string,
): Promise<string> {
const headersList = await headers();
const session = await auth.api.getSession({ headers: headersList });

if (!session?.user) {
throw new Error('Unauthorized');
}
Expand All @@ -40,12 +44,18 @@ export async function generateAIResponse(threadContent: string, originalSender:
throw new Error('OpenAI API key is not configured');
}

// Get user settings to check for custom prompt
const userSettings = await getUserSettings();
const customPrompt = userSettings?.customPrompt || '';

// Truncate the thread content to fit within token limits
const truncatedThreadContent = truncateThreadContent(threadContent);

// Create the prompt for OpenAI
const prompt = `
You are ${session.user.name}, writing an email reply.
${process.env.AI_SYSTEM_PROMPT}

You should write as if your name is ${session.user.name}, who is the user writing an email reply.

Here's the context of the email thread:
${truncatedThreadContent}
Expand All @@ -54,14 +64,18 @@ export async function generateAIResponse(threadContent: string, originalSender:

Requirements:
- Be concise but thorough (2-3 paragraphs maximum)
- Maintain a professional and friendly tone
- Base your reply on the context provided. sometimes there will be an email that needs to be replied in an orderly manner while other times you will want a casual reply.
- Address the key points from the original email
- Close with an appropriate sign-off
- Don't use placeholder text or mention that you're an AI
- Write as if you are (${session.user.name})
- Don't include the subject line in the reply
- Double space paragraphs (2 newlines)
- Add two spaces bellow the sign-off

Here is some additional information about the user:
${customPrompt}

`;

try {
Expand All @@ -70,14 +84,15 @@ export async function generateAIResponse(threadContent: string, originalSender:
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'You are a helpful email assistant that generates concise, professional replies.',
content:
'You are a helpful email assistant that generates concise, professional replies.',
},
{ role: 'user', content: prompt },
],
Expand All @@ -97,4 +112,4 @@ export async function generateAIResponse(threadContent: string, originalSender:
console.error('OpenAI API Error:', error);
throw new Error(`OpenAI API Error: ${error.message || 'Unknown error'}`);
}
}
}
22 changes: 22 additions & 0 deletions apps/mail/app/(routes)/settings/general/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ import * as z from 'zod';
import { useSettings } from '@/hooks/use-settings';
import { getBrowserTimezone } from '@/lib/timezones';
import { saveUserSettings } from '@/actions/settings';
import { Textarea } from '@/components/ui/textarea';

const formSchema = z.object({
language: z.enum(locales as [string, ...string[]]),
timezone: z.string(),
dynamicContent: z.boolean(),
externalImages: z.boolean(),
customPrompt: z.string(),
});

export default function GeneralPage() {
Expand All @@ -51,6 +53,7 @@ export default function GeneralPage() {
timezone: getBrowserTimezone(),
dynamicContent: false,
externalImages: true,
customPrompt: "",
},
});

Expand Down Expand Up @@ -189,6 +192,25 @@ export default function GeneralPage() {
)}
/>
</div>
<FormField
control={form.control}
name="customPrompt"
render={({ field }) => (
<FormItem>
<FormLabel>{t('pages.settings.general.customPrompt')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('pages.settings.general.customPromptPlaceholder')}
className="min-h-[350px]"
{...field}
/>
</FormControl>
<FormDescription>
{t('pages.settings.general.customPromptDescription')}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsCard>
Expand Down
20 changes: 19 additions & 1 deletion apps/mail/components/create/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface EditorProps {
className?: string;
onCommandEnter?: () => void;
onAttachmentsChange?: (attachments: File[]) => void;
onTab?: () => boolean;
}

interface EditorState {
Expand Down Expand Up @@ -378,6 +379,7 @@ export default function Editor({
onBlur,
className,
onCommandEnter,
onTab,
onAttachmentsChange,
}: EditorProps) {
const [state, dispatch] = useReducer(editorReducer, {
Expand Down Expand Up @@ -468,7 +470,15 @@ export default function Editor({
className={`relative w-full max-w-[450px] sm:max-w-[600px] ${className || ''}`}
onClick={focusEditor}
onKeyDown={(e) => {
// Prevent form submission on Enter key
// Handle tab key
if (e.key === 'Tab' && !e.shiftKey) {
if (onTab && onTab()) {
e.preventDefault();
e.stopPropagation();
return;
}
}

if (e.key === 'Enter' && !e.shiftKey) {
e.stopPropagation();
}
Expand All @@ -491,6 +501,14 @@ export default function Editor({
editorProps={{
handleDOMEvents: {
keydown: (view, event) => {
// Handle tab key
if (event.key === 'Tab' && !event.shiftKey) {
if (onTab && onTab()) {
event.preventDefault();
return true;
}
}

// Handle Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
Expand Down
20 changes: 17 additions & 3 deletions apps/mail/components/mail/mail-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
ref={parentRef}
className={cn('h-full w-full', getSelectMode() === 'range' && 'select-none')}
>
<ScrollArea className="hide-scrollbar h-full overflow-auto pb-4">
<ScrollArea className="hide-scrollbar h-full overflow-auto">
{items.map((data, index) => {
return (
<Thread
Expand All @@ -510,8 +510,22 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
/>
);
})}
<Button variant={'ghost'} className="w-full" onClick={handleScroll}>
Load more <ChevronDown />
<Button
variant={'ghost'}
className="w-full rounded-none"
onClick={handleScroll}
disabled={isLoading || isValidating}
>
{isLoading || isValidating ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
Loading...
</div>
) : (
<>
Load more <ChevronDown />
</>
)}
</Button>
</ScrollArea>
</div>
Expand Down
Loading