490 lines
15 KiB
Vue
490 lines
15 KiB
Vue
<template>
|
|
<v-card>
|
|
<v-card-title class="text-h6 d-flex align-center">
|
|
<v-icon class="mr-2">mdi-email-multiple</v-icon>
|
|
Email History
|
|
<v-spacer />
|
|
<v-btn
|
|
icon
|
|
variant="text"
|
|
@click="loadEmails"
|
|
:loading="loading"
|
|
>
|
|
<v-icon size="small">mdi-refresh</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">
|
|
Refresh emails
|
|
</v-tooltip>
|
|
</v-btn>
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<div v-if="loading && threads.length === 0" class="text-center py-8">
|
|
<v-progress-circular indeterminate color="primary" />
|
|
<div class="mt-2">Loading email threads...</div>
|
|
</div>
|
|
|
|
<div v-else-if="!loading && threads.length === 0" class="text-center py-8 text-grey">
|
|
<v-icon size="48" class="mb-2">mdi-email-off</v-icon>
|
|
<div>No email conversations found with this client.</div>
|
|
</div>
|
|
|
|
<v-expansion-panels v-else variant="accordion">
|
|
<v-expansion-panel v-for="thread in threads" :key="thread.id">
|
|
<v-expansion-panel-title>
|
|
<div class="d-flex align-center" style="width: 100%">
|
|
<v-icon class="mr-2">
|
|
{{ thread.emails.length > 1 ? 'mdi-email-multiple' : 'mdi-email' }}
|
|
</v-icon>
|
|
<div class="flex-grow-1">
|
|
<div class="font-weight-medium">{{ thread.subject }}</div>
|
|
<div class="text-caption text-grey">
|
|
{{ thread.emailCount }} {{ thread.emailCount === 1 ? 'email' : 'emails' }} ·
|
|
Last: {{ formatDate(thread.latestTimestamp) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</v-expansion-panel-title>
|
|
<v-expansion-panel-text>
|
|
<v-timeline density="compact" side="end">
|
|
<v-timeline-item
|
|
v-for="email in thread.emails"
|
|
:key="email.id"
|
|
:dot-color="email.direction === 'sent' ? 'primary' : 'grey'"
|
|
size="small"
|
|
>
|
|
<template v-slot:opposite>
|
|
<div class="text-caption text-grey">
|
|
{{ formatDate(email.timestamp) }}
|
|
</div>
|
|
</template>
|
|
<v-card variant="outlined">
|
|
<v-card-title class="text-subtitle-2 d-flex align-center">
|
|
<v-icon
|
|
size="small"
|
|
class="mr-1"
|
|
:color="email.direction === 'sent' ? 'primary' : 'grey'"
|
|
>
|
|
{{ email.direction === 'sent' ? 'mdi-send' : 'mdi-email-receive' }}
|
|
</v-icon>
|
|
{{ email.direction === 'sent' ? 'You' : extractName(email.from) }}
|
|
</v-card-title>
|
|
<v-card-subtitle class="text-caption">
|
|
From: {{ email.from }}<br>
|
|
To: {{ email.to }}
|
|
</v-card-subtitle>
|
|
<v-card-text>
|
|
<div
|
|
v-if="!expandedEmails[email.id]"
|
|
class="email-preview"
|
|
@click="expandedEmails[email.id] = true"
|
|
style="cursor: pointer"
|
|
>
|
|
{{ truncateText(email.body) }}
|
|
<span v-if="email.body.length > 200" class="text-primary">
|
|
... Show more
|
|
</span>
|
|
</div>
|
|
<div v-else class="email-full">
|
|
<pre style="white-space: pre-wrap; font-family: inherit;">{{ email.body }}</pre>
|
|
<div
|
|
class="text-primary text-caption mt-2"
|
|
style="cursor: pointer"
|
|
@click="expandedEmails[email.id] = false"
|
|
>
|
|
Show less
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Attachments Section -->
|
|
<div v-if="email.attachments && email.attachments.length > 0" class="mt-3">
|
|
<v-divider class="mb-2" />
|
|
<div class="text-caption text-grey mb-2">
|
|
<v-icon size="small" class="mr-1">mdi-paperclip</v-icon>
|
|
{{ email.attachments.length }} Attachment{{ email.attachments.length > 1 ? 's' : '' }}
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<v-chip
|
|
v-for="(attachment, index) in email.attachments"
|
|
:key="index"
|
|
size="small"
|
|
variant="outlined"
|
|
:prepend-icon="getAttachmentIcon(attachment.contentType)"
|
|
@click="downloadAttachment(attachment)"
|
|
:disabled="!!attachment.error"
|
|
>
|
|
<span class="text-truncate" style="max-width: 150px">
|
|
{{ attachment.filename }}
|
|
</span>
|
|
<span class="text-caption ml-1">({{ formatFileSize(attachment.size) }})</span>
|
|
<v-tooltip v-if="attachment.error" activator="parent">
|
|
{{ attachment.error }}
|
|
</v-tooltip>
|
|
</v-chip>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn
|
|
size="small"
|
|
variant="text"
|
|
color="primary"
|
|
prepend-icon="mdi-reply"
|
|
@click="replyToEmail(email, thread)"
|
|
>
|
|
Reply
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-timeline-item>
|
|
</v-timeline>
|
|
</v-expansion-panel-text>
|
|
</v-expansion-panel>
|
|
</v-expansion-panels>
|
|
</v-card-text>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { ref, onMounted, reactive } from 'vue';
|
|
import type { Interest } from '@/utils/types';
|
|
|
|
interface Props {
|
|
interest: Interest;
|
|
}
|
|
|
|
interface EmailMessage {
|
|
id: string;
|
|
from: string;
|
|
to: string | string[];
|
|
subject: string;
|
|
body: string;
|
|
html?: string;
|
|
timestamp: string;
|
|
direction: 'sent' | 'received';
|
|
threadId?: string;
|
|
attachments?: Array<{
|
|
id?: string;
|
|
filename: string;
|
|
originalName?: string;
|
|
contentType: string;
|
|
size: number;
|
|
path?: string;
|
|
bucket?: string;
|
|
error?: string;
|
|
}>;
|
|
}
|
|
|
|
interface EmailThread {
|
|
id: string;
|
|
subject: string;
|
|
emailCount: number;
|
|
latestTimestamp: string;
|
|
emails: EmailMessage[];
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<{
|
|
'reply-to-email': [email: EmailMessage, thread: EmailThread];
|
|
}>();
|
|
|
|
const user = useDirectusUser();
|
|
const toast = useToast();
|
|
const { mobile } = useDisplay();
|
|
|
|
const loading = ref(false);
|
|
const threads = ref<EmailThread[]>([]);
|
|
const expandedEmails = reactive<Record<string, boolean>>({});
|
|
|
|
const getSessionId = () => {
|
|
return sessionStorage.getItem('emailSessionId') || '';
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (hours < 1) {
|
|
return 'Just now';
|
|
} else if (hours < 24) {
|
|
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
} else if (days < 7) {
|
|
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
} else {
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
};
|
|
|
|
const extractName = (email: string) => {
|
|
// Extract name from email format "Name <email@domain.com>"
|
|
const match = email.match(/^([^<]+)\s*</);
|
|
if (match) {
|
|
return match[1].trim();
|
|
}
|
|
// If no name, just return the email address
|
|
return email.replace(/<|>/g, '').trim();
|
|
};
|
|
|
|
const truncateText = (text: string, maxLength: number = 200) => {
|
|
if (text.length <= maxLength) return text;
|
|
return text.substring(0, maxLength).trim();
|
|
};
|
|
|
|
const loadEmails = async () => {
|
|
// Check if we have a session ID before trying to fetch emails
|
|
const sessionId = getSessionId();
|
|
if (!sessionId) {
|
|
// No credentials, don't try to fetch
|
|
threads.value = [];
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
try {
|
|
const response = await $fetch<{
|
|
success: boolean;
|
|
emails: EmailMessage[];
|
|
threads: EmailThread[]
|
|
}>('/api/email/fetch-thread', {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-tag': user.value?.email ? '094ut234' : 'pjnvü1230',
|
|
},
|
|
body: {
|
|
clientEmail: props.interest['Email Address'],
|
|
interestId: props.interest.Id,
|
|
sessionId: sessionId,
|
|
limit: 20
|
|
}
|
|
});
|
|
|
|
if (response.success) {
|
|
threads.value = response.threads || [];
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to load emails:', error);
|
|
|
|
// Handle 502 Gateway Timeout - session is likely invalid after container restart
|
|
if (error.statusCode === 502 || error.status === 502) {
|
|
console.log('Got 502 error, clearing invalid session');
|
|
// Clear the invalid session
|
|
sessionStorage.removeItem('emailSessionId');
|
|
// Don't show error toast, just reset to no emails
|
|
threads.value = [];
|
|
return;
|
|
}
|
|
|
|
if (error.data?.statusMessage?.includes('Email credentials not found')) {
|
|
// Don't show error, parent component should handle reconnection
|
|
threads.value = [];
|
|
} else {
|
|
toast.error(error.data?.statusMessage || 'Failed to load email history');
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Reply to an email
|
|
const replyToEmail = (email: EmailMessage, thread: EmailThread) => {
|
|
emit('reply-to-email', email, thread);
|
|
};
|
|
|
|
// Reload emails when an email is sent
|
|
const reloadEmails = () => {
|
|
loadEmails();
|
|
};
|
|
|
|
// Get icon for attachment based on content type
|
|
const getAttachmentIcon = (contentType: string) => {
|
|
if (!contentType) return 'mdi-file';
|
|
|
|
if (contentType.startsWith('image/')) return 'mdi-file-image';
|
|
if (contentType.startsWith('video/')) return 'mdi-file-video';
|
|
if (contentType.startsWith('audio/')) return 'mdi-file-music';
|
|
if (contentType.includes('pdf')) return 'mdi-file-pdf-box';
|
|
if (contentType.includes('word') || contentType.includes('document')) return 'mdi-file-word';
|
|
if (contentType.includes('sheet') || contentType.includes('excel')) return 'mdi-file-excel';
|
|
if (contentType.includes('powerpoint') || contentType.includes('presentation')) return 'mdi-file-powerpoint';
|
|
if (contentType.includes('zip') || contentType.includes('compressed')) return 'mdi-folder-zip';
|
|
|
|
return 'mdi-file';
|
|
};
|
|
|
|
// Format file size for display
|
|
const formatFileSize = (bytes: number) => {
|
|
if (!bytes || bytes === 0) return '0 B';
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
};
|
|
|
|
// Download attachment
|
|
const downloadAttachment = async (attachment: any) => {
|
|
if (!attachment.path || !attachment.bucket) {
|
|
toast.error('Attachment information is missing');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Use the proxy download endpoint
|
|
const downloadUrl = `/api/files/proxy-download?bucket=${attachment.bucket}&fileName=${encodeURIComponent(attachment.path)}`;
|
|
|
|
// Create a temporary link and trigger download
|
|
const link = document.createElement('a');
|
|
link.href = downloadUrl;
|
|
link.download = attachment.originalName || attachment.filename;
|
|
link.target = '_blank';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} catch (error) {
|
|
console.error('Failed to download attachment:', error);
|
|
toast.error('Failed to download attachment');
|
|
}
|
|
};
|
|
|
|
// Load emails on mount
|
|
onMounted(() => {
|
|
loadEmails();
|
|
});
|
|
|
|
// Expose reload method to parent
|
|
defineExpose({
|
|
reloadEmails
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.email-preview {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
|
|
.email-full pre {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Mobile-specific styles */
|
|
@media (max-width: 768px) {
|
|
/* Fix container width on mobile */
|
|
:deep(.v-card) {
|
|
max-width: 100vw;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Ensure card text doesn't exceed screen width */
|
|
:deep(.v-card-text) {
|
|
padding: 8px 12px !important;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Fix expansion panel width */
|
|
:deep(.v-expansion-panels) {
|
|
width: 100%;
|
|
max-width: calc(100vw - 24px);
|
|
}
|
|
|
|
/* Timeline adjustments for mobile */
|
|
:deep(.v-timeline) {
|
|
padding-left: 8px !important;
|
|
padding-right: 8px !important;
|
|
}
|
|
|
|
:deep(.v-timeline-item) {
|
|
margin-bottom: 12px !important;
|
|
}
|
|
|
|
/* Email card adjustments */
|
|
:deep(.v-timeline-item .v-card) {
|
|
max-width: calc(100vw - 80px);
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Email content width constraints */
|
|
:deep(.v-card-title) {
|
|
font-size: 0.875rem !important;
|
|
padding: 8px 12px !important;
|
|
word-break: break-word;
|
|
}
|
|
|
|
:deep(.v-card-subtitle) {
|
|
font-size: 0.75rem !important;
|
|
padding: 4px 12px !important;
|
|
word-break: break-all;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
:deep(.v-card-text) {
|
|
padding: 8px 12px !important;
|
|
font-size: 0.875rem !important;
|
|
}
|
|
|
|
/* Fix pre-formatted text width */
|
|
.email-full pre {
|
|
font-size: 0.75rem !important;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Thread title adjustments */
|
|
:deep(.v-expansion-panel-title) {
|
|
padding: 12px !important;
|
|
min-height: 48px !important;
|
|
}
|
|
|
|
:deep(.v-expansion-panel-title .font-weight-medium) {
|
|
font-size: 0.875rem !important;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
:deep(.v-expansion-panel-title .text-caption) {
|
|
font-size: 0.75rem !important;
|
|
}
|
|
|
|
/* Timeline opposite content (timestamps) */
|
|
:deep(.v-timeline-item .v-timeline-item__opposite) {
|
|
max-width: 60px;
|
|
font-size: 0.7rem !important;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
/* Compact email actions */
|
|
:deep(.v-card-actions) {
|
|
padding: 4px 8px !important;
|
|
min-height: 40px !important;
|
|
}
|
|
|
|
:deep(.v-card-actions .v-btn) {
|
|
font-size: 0.75rem !important;
|
|
height: 32px !important;
|
|
}
|
|
}
|
|
|
|
/* Ensure text wrapping for long email addresses */
|
|
:deep(.v-card-subtitle) {
|
|
white-space: normal !important;
|
|
line-height: 1.3;
|
|
}
|
|
</style>
|