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
162 changes: 162 additions & 0 deletions apps/mail/app/(routes)/mail/compose/handle-mailto/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from 'next/server';
import { createDraft } from '@/actions/drafts';
import { auth } from '@/lib/auth';

// Function to parse mailto URLs
async function parseMailtoUrl(mailtoUrl: string) {
if (!mailtoUrl.startsWith('mailto:')) {
return null;
}

try {
// Remove mailto: prefix to get the raw email and query part
const mailtoContent = mailtoUrl.substring(7); // "mailto:".length === 7

// Split at the first ? to separate email from query params
const [emailPart, queryPart] = mailtoContent.split('?', 2);

// Decode the email address - might be double-encoded
const toEmail = decodeURIComponent(emailPart || '');

// Default values
let subject = '';
let body = '';

// Parse query parameters if they exist
if (queryPart) {
try {
// Try to decode the query part - it might be double-encoded
// (once by the browser and once by our encodeURIComponent)
let decodedQueryPart = queryPart;

// Try decoding up to twice to handle double-encoding
try {
decodedQueryPart = decodeURIComponent(decodedQueryPart);
// Try one more time in case of double encoding
try {
decodedQueryPart = decodeURIComponent(decodedQueryPart);
} catch (e) {
// If second decoding fails, use the result of the first decoding
}
} catch (e) {
// If first decoding fails, try parsing directly
decodedQueryPart = queryPart;
}

const queryParams = new URLSearchParams(decodedQueryPart);

// Get and decode parameters
const rawSubject = queryParams.get('subject') || '';
const rawBody = queryParams.get('body') || '';

// Try to decode them in case they're still encoded
try {
subject = decodeURIComponent(rawSubject);
} catch (e) {
subject = rawSubject;
}

try {
body = decodeURIComponent(rawBody);
} catch (e) {
body = rawBody;
}
} catch (e) {
console.error('Error parsing query parameters:', e);
}
}

// Return the parsed data if email is valid
if (toEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(toEmail)) {
console.log('Parsed mailto data:', { to: toEmail, subject, body });
return { to: toEmail, subject, body };
}
} catch (error) {
console.error('Failed to parse mailto URL:', error);
}

return null;
}

// Function to create a draft and get its ID
async function createDraftFromMailto(mailtoData: { to: string; subject: string; body: string }) {
try {
// The driver's parseDraft function looks for text/plain MIME type
// We need to ensure line breaks are preserved in the plain text
// The Gmail editor will handle displaying these line breaks correctly

// Ensure any non-standard line breaks are normalized to \n
const normalizedBody = mailtoData.body
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');

// Create proper HTML-encoded content by wrapping all paragraphs in <p> tags
// This is the format that will work best with the editor
const htmlContent = `<!DOCTYPE html><html><body>
${normalizedBody.split(/\n\s*\n/).map(paragraph => {
return `<p>${paragraph.replace(/\n/g, '<br />').replace(/\s{2,}/g, match => '&nbsp;'.repeat(match.length))}</p>`;
}).join('\n')}
</body></html>`;

const draftData = {
to: mailtoData.to,
subject: mailtoData.subject,
message: htmlContent,
attachments: []
};

console.log('Creating draft with body sample:', {
to: draftData.to,
subject: draftData.subject,
messageSample: htmlContent.substring(0, 100) + (htmlContent.length > 100 ? '...' : '')
});

const result = await createDraft(draftData);

if (result?.success && result.id) {
console.log('Draft created successfully with ID:', result.id);
return result.id;
} else {
console.error('Draft creation failed:', result?.error || 'Unknown error');
}
} catch (error) {
console.error('Error creating draft from mailto:', error);
}

return null;
}

export async function GET(request: NextRequest) {
// Check authentication first
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}

// Get the mailto parameter from the URL
const searchParams = request.nextUrl.searchParams;
const mailto = searchParams.get('mailto');

if (!mailto) {
return NextResponse.redirect(new URL('/mail/compose', request.url));
}

// Parse the mailto URL
const mailtoData = await parseMailtoUrl(mailto);

// If parsing failed, redirect to empty compose
if (!mailtoData) {
return NextResponse.redirect(new URL('/mail/compose', request.url));
}

// Create a draft from the mailto data
const draftId = await createDraftFromMailto(mailtoData);

// If draft creation failed, redirect to empty compose
if (!draftId) {
return NextResponse.redirect(new URL('/mail/compose', request.url));
}

// Redirect to compose with the draft ID
return NextResponse.redirect(new URL(`/mail/compose?draftId=${draftId}`, request.url));
}
46 changes: 46 additions & 0 deletions apps/mail/app/(routes)/mail/compose/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { CreateEmail } from '@/components/create/create-email';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

// Define the type for search params
interface ComposePageProps {
searchParams: Promise<{
to?: string;
subject?: string;
body?: string;
draftId?: string;
}>;
}

export default async function ComposePage({ searchParams }: ComposePageProps) {
const headersList = await headers();
const session = await auth.api.getSession({ headers: headersList });

if (!session) {
redirect('/login');
}

// Need to await searchParams in Next.js 15+
const params = await searchParams;

// Check if this is a mailto URL
const toParam = params.to || '';
if (toParam.startsWith('mailto:')) {
// Redirect to our dedicated mailto handler
redirect(`/mail/compose/handle-mailto?mailto=${encodeURIComponent(toParam)}`);
}

// Handle normal compose page (direct or with draftId)
return (
<div className="flex h-full w-full flex-col">
<div className="h-full flex-1">
<CreateEmail
initialTo={params.to || ''}
initialSubject={params.subject || ''}
initialBody={params.body || ''}
/>
</div>
</div>
);
}
115 changes: 76 additions & 39 deletions apps/mail/components/create/create-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,52 @@ import './prosemirror.css';

const MAX_VISIBLE_ATTACHMENTS = 12;

export function CreateEmail() {
const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};

const createEmptyDocContent = (): JSONContent => ({
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
});

export function CreateEmail({
initialTo = '',
initialSubject = '',
initialBody = '',
}: {
initialTo?: string;
initialSubject?: string;
initialBody?: string;
}) {
const [toInput, setToInput] = React.useState('');
const [toEmails, setToEmails] = React.useState<string[]>([]);
const [subjectInput, setSubjectInput] = React.useState('');
const [toEmails, setToEmails] = React.useState<string[]>(initialTo ? [initialTo] : []);
const [subjectInput, setSubjectInput] = React.useState(initialSubject);
const [attachments, setAttachments] = React.useState<File[]>([]);
const [resetEditorKey, setResetEditorKey] = React.useState(0);
const [isDragging, setIsDragging] = React.useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const [messageContent, setMessageContent] = React.useState('');
const [messageContent, setMessageContent] = React.useState(initialBody);
const [draftId, setDraftId] = useQueryState('draftId');
const [defaultValue, setDefaultValue] = React.useState<JSONContent | null>(null);

const [defaultValue, setDefaultValue] = React.useState<JSONContent | null>(() => {
if (initialBody) {
try {
return generateJSON(initialBody, [Document, Paragraph, Text, Bold]);
} catch (error) {
console.error('Error parsing initial body:', error);
return createEmptyDocContent();
}
}
return null;
});

const { data: session } = useSession();
const { data: connections } = useConnections();
Expand All @@ -50,31 +84,14 @@ export function CreateEmail() {

React.useEffect(() => {
if (!draftId && !defaultValue) {
// Set initial empty content
setDefaultValue({
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
});
setDefaultValue(createEmptyDocContent());
}
}, [draftId, defaultValue]);

React.useEffect(() => {
const loadDraft = async () => {
if (!draftId) {
setDefaultValue({
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
});
setDefaultValue(createEmptyDocContent());
return;
}

Expand Down Expand Up @@ -117,11 +134,6 @@ export function CreateEmail() {

const t = useTranslations();

const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};

const handleAddEmail = (email: string) => {
const trimmedEmail = email.trim().replace(/,$/, '');

Expand Down Expand Up @@ -219,16 +231,7 @@ export function CreateEmail() {
setAttachments([]);
setMessageContent('');

setDefaultValue({
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
});

setDefaultValue(createEmptyDocContent());
setResetEditorKey((prev) => prev + 1);

setHasUnsavedChanges(false);
Expand Down Expand Up @@ -278,6 +281,40 @@ export function CreateEmail() {
return () => document.removeEventListener('keydown', handleKeyPress);
}, []);

React.useEffect(() => {
if (initialTo) {
const emails = initialTo.split(',').map(email => email.trim());
const validEmails = emails.filter(email => isValidEmail(email));
if (validEmails.length > 0) {
setToEmails(validEmails);
} else {
setToInput(initialTo);
}
}

if (initialSubject) {
setSubjectInput(initialSubject);
}

if (initialBody && !defaultValue) {
setDefaultValue({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: initialBody
}
]
}
]
});
setMessageContent(initialBody);
}
}, [initialTo, initialSubject, initialBody, defaultValue]);

return (
<div
className="bg-offsetLight dark:bg-offsetDark relative flex h-full flex-col overflow-hidden shadow-inner md:rounded-2xl md:border md:shadow-sm"
Expand Down
Loading