Files
Zero/apps/mail/app/mailto-handler.ts
Adam 0a92f10fa3 CSR (#1369)
# Client-Side Rendering Migration and Authentication Provider Refactoring

## Description

This PR migrates the mail app from server-side rendering to client-side rendering and refactors how authentication providers are loaded. Key changes include:

1. Renamed loader functions to `clientLoader` across multiple pages to support client-side rendering
2. Added a new `/api/public/providers` endpoint to fetch authentication provider information
3. Removed direct environment variable access from the client and replaced with API calls
4. Added loading fallback components for better UX during hydration
5. Refactored email content rendering to use direct HTML instead of iframes
6. Removed unused components and Microsoft identity association files
7. Fixed reply count calculation to exclude draft messages in threads

## Type of Change

- [x] 🐛 Bug fix (non-breaking change which fixes an issue)
- [x]  New feature (non-breaking change which adds functionality)
- [x] 💥 Breaking change (fix or feature with breaking changes)
- [x]  Performance improvement

## Areas Affected

- [x] Email Integration (Gmail, IMAP, etc.)
- [x] User Interface/Experience
- [x] Authentication/Authorization
- [x] API Endpoints
- [x] Deployment/Infrastructure

## Testing Done

- [x] Manual testing performed
- [x] Cross-browser testing (if UI changes)
- [x] Mobile responsiveness verified (if UI changes)

## Security Considerations

- [x] No sensitive data is exposed
- [x] Authentication checks are in place

## Checklist

- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] All tests pass locally

## Additional Notes

The migration to client-side rendering should improve the initial load performance and reduce server load. The new authentication provider API endpoint provides a more secure way to fetch provider information without exposing environment variables to the client.
2025-06-18 22:59:28 -07:00

285 lines
9.5 KiB
TypeScript

import { cleanEmailAddresses } from '../lib/email-utils';
import { trpcClient } from '@/providers/query-provider';
import type { Route } from './+types/mailto-handler';
import { authProxy } from '@/lib/auth-proxy';
// 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 = '';
let cc = '';
let bcc = '';
// 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 {
// If second decoding fails, use the result of the first decoding
}
} catch {
// 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') || '';
const rawCc = queryParams.get('cc') || '';
const rawBcc = queryParams.get('bcc') || '';
// Try to decode them in case they're still encoded
try {
subject = decodeURIComponent(rawSubject);
} catch {
subject = rawSubject;
}
try {
body = decodeURIComponent(rawBody);
} catch {
body = rawBody;
}
try {
cc = decodeURIComponent(rawCc);
} catch {
cc = rawCc;
}
try {
bcc = decodeURIComponent(rawBcc);
} catch {
bcc = rawBcc;
}
} catch (e) {
console.error('Error parsing query parameters:', e);
}
}
// Return the parsed data if email is valid - handle multiple recipients
if (toEmail) {
console.log('Parsed mailto data:', { to: toEmail, subject, body, cc, bcc });
return { to: toEmail, subject, body, cc, bcc };
}
} 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;
cc?: string;
bcc?: string;
}) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
// Helper function to handle Invalid To header errors by toggling format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleInvalidToHeader = (draftData: any) => {
if (Array.isArray(draftData.to)) {
// Convert array to comma-separated string
draftData.to = draftData.to.join(',');
} else if (typeof draftData.to === 'string') {
// Convert string to array
draftData.to = draftData.to.split(',').map((e: string) => e.trim().replace(/^<|>$/g, ''));
}
};
try {
// 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>`;
// For the draft creation, we need to ensure we're providing the to/cc/bcc in the proper format
const toAddresses = cleanEmailAddresses(mailtoData.to);
const ccAddresses = mailtoData.cc ? cleanEmailAddresses(mailtoData.cc) : [];
const bccAddresses = mailtoData.bcc ? cleanEmailAddresses(mailtoData.bcc) : [];
// Let's try a simpler approach for multiple recipients
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const draftData: any = {
id: null,
subject: mailtoData.subject,
message: htmlContent,
attachments: [],
};
// Add recipients - ensuring they are in the correct format
// For multiple recipients, use array format; for single recipient, use string format
if (toAddresses && toAddresses.length > 0) {
if (toAddresses.length === 1) {
draftData.to = toAddresses[0];
} else {
draftData.to = toAddresses.join(',');
}
}
// Do the same for CC
if (ccAddresses && ccAddresses.length > 0) {
if (ccAddresses.length === 1) {
draftData.cc = ccAddresses[0];
} else {
draftData.cc = ccAddresses.join(',');
}
} else {
// Always include cc in the draft data, even if empty
draftData.cc = '';
}
// And for BCC
if (bccAddresses && bccAddresses.length > 0) {
if (bccAddresses.length === 1) {
draftData.bcc = bccAddresses[0];
} else {
draftData.bcc = bccAddresses.join(',');
}
} else {
// Always include bcc in the draft data, even if empty
draftData.bcc = '';
}
console.log('Creating draft with data:', {
to: draftData.to,
cc: draftData.cc,
bcc: draftData.bcc,
subject: draftData.subject,
messageSample: htmlContent.substring(0, 100) + (htmlContent.length > 100 ? '...' : ''),
});
// Try to create the draft with retries
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`Attempt ${attempt} to create draft...`);
const result = await trpcClient.drafts.create.mutate(draftData);
if (result?.id) {
console.log('Draft created successfully with ID:', result.id);
return result.id;
} else {
console.error(
`Draft creation failed (attempt ${attempt}):`,
result?.error || 'Unknown error',
);
// If the error is related to "Invalid To header", try to fix the format for the next attempt
if (attempt < MAX_RETRIES) {
if (
typeof result === 'object' &&
result &&
'error' in result &&
String(result.error).includes('Invalid To header')
) {
handleInvalidToHeader(draftData);
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY * attempt)); // Exponential backoff
continue;
}
}
} catch (error) {
console.error(`Error creating draft (attempt ${attempt}):`, error);
console.error('Error details:', error instanceof Error ? error.message : String(error));
// If the error is related to "Invalid To header", try to fix the format for the next attempt
if (attempt < MAX_RETRIES) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Invalid To header')) {
handleInvalidToHeader(draftData);
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY * attempt)); // Exponential backoff
continue;
}
}
}
} catch (error) {
console.error('Error creating draft from mailto:', error);
}
return null;
}
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const session = await authProxy.api.getSession({ headers: request.headers });
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
const url = new URL(request.url);
// Get the mailto parameter from the URL
const mailto = url.searchParams.get('mailto');
if (!mailto) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
// Parse the mailto URL
const mailtoData = await parseMailtoUrl(mailto);
// If parsing failed, redirect to empty compose
if (!mailtoData) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
// Create a draft from the mailto data
const draftId = await createDraftFromMailto(mailtoData);
// If draft creation failed, redirect to empty compose with the parsed data as a fallback
if (!draftId) {
const fallbackUrl = new URL(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
if (mailtoData.to) fallbackUrl.searchParams.append('to', mailtoData.to);
if (mailtoData.subject) fallbackUrl.searchParams.append('subject', mailtoData.subject);
if (mailtoData.body) fallbackUrl.searchParams.append('body', mailtoData.body);
if (mailtoData.cc) fallbackUrl.searchParams.append('cc', mailtoData.cc);
if (mailtoData.bcc) fallbackUrl.searchParams.append('bcc', mailtoData.bcc);
return Response.redirect(fallbackUrl.toString());
}
// Redirect to compose with the draft ID
return Response.redirect(
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`,
);
}