Add email communication system with encrypted credentials
- Add email components for composing, viewing threads, and credential setup - Implement server API endpoints for sending emails and fetching threads - Add encryption utilities for secure credential storage - Configure email settings in environment variables - Integrate email functionality into interest details modal
This commit is contained in:
99
components/EmailCommunication.vue
Normal file
99
components/EmailCommunication.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<v-card variant="flat" class="mb-6">
|
||||
<v-card-title class="text-h6 d-flex align-center pb-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-email</v-icon>
|
||||
Email Communication
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-2">
|
||||
<!-- Show credentials setup if not connected -->
|
||||
<EmailCredentialsSetup
|
||||
v-if="!isConnected"
|
||||
@connected="onEmailConnected"
|
||||
/>
|
||||
|
||||
<!-- Show email interface if connected -->
|
||||
<template v-else>
|
||||
<div class="mb-4">
|
||||
<v-chip color="success" variant="tonal" size="small">
|
||||
<v-icon start>mdi-check-circle</v-icon>
|
||||
Connected as: {{ connectedEmail }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
@click="disconnect"
|
||||
>
|
||||
Disconnect
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Email Composer -->
|
||||
<EmailComposer
|
||||
:interest="interest"
|
||||
@sent="onEmailSent"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Email Thread View -->
|
||||
<EmailThreadView
|
||||
ref="threadView"
|
||||
:interest="interest"
|
||||
/>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import type { Interest } from '@/utils/types';
|
||||
import EmailCredentialsSetup from './EmailCredentialsSetup.vue';
|
||||
import EmailComposer from './EmailComposer.vue';
|
||||
import EmailThreadView from './EmailThreadView.vue';
|
||||
|
||||
interface Props {
|
||||
interest: Interest;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const isConnected = ref(false);
|
||||
const connectedEmail = ref('');
|
||||
const threadView = ref<InstanceType<typeof EmailThreadView>>();
|
||||
|
||||
const checkConnection = () => {
|
||||
// Check if we have a session and connected email
|
||||
const sessionId = sessionStorage.getItem('emailSessionId');
|
||||
const email = sessionStorage.getItem('connectedEmail');
|
||||
|
||||
if (sessionId && email) {
|
||||
isConnected.value = true;
|
||||
connectedEmail.value = email;
|
||||
}
|
||||
};
|
||||
|
||||
const onEmailConnected = (email: string) => {
|
||||
isConnected.value = true;
|
||||
connectedEmail.value = email;
|
||||
};
|
||||
|
||||
const onEmailSent = (messageId: string) => {
|
||||
// Reload email threads after sending
|
||||
threadView.value?.reloadEmails();
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
// Clear session storage
|
||||
sessionStorage.removeItem('emailSessionId');
|
||||
sessionStorage.removeItem('connectedEmail');
|
||||
|
||||
// Reset state
|
||||
isConnected.value = false;
|
||||
connectedEmail.value = '';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkConnection();
|
||||
});
|
||||
</script>
|
||||
301
components/EmailComposer.vue
Normal file
301
components/EmailComposer.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-email-edit</v-icon>
|
||||
Compose Email
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="sendEmail" ref="form">
|
||||
<v-text-field
|
||||
v-model="email.to"
|
||||
label="To"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
:rules="[rules.required, rules.email]"
|
||||
:disabled="sending"
|
||||
readonly
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="email.subject"
|
||||
label="Subject"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-format-title"
|
||||
:rules="[rules.required]"
|
||||
:disabled="sending"
|
||||
/>
|
||||
|
||||
<v-textarea
|
||||
v-model="email.body"
|
||||
label="Message"
|
||||
variant="outlined"
|
||||
rows="8"
|
||||
:disabled="sending"
|
||||
placeholder="Type your message here..."
|
||||
/>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="d-flex ga-2 mb-4">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="insertEOILink"
|
||||
:disabled="sending"
|
||||
>
|
||||
<v-icon start>mdi-link</v-icon>
|
||||
Insert EOI Link
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="insertFormLink"
|
||||
:disabled="sending"
|
||||
>
|
||||
<v-icon start>mdi-form-select</v-icon>
|
||||
Insert Form Link
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="showSignatureSettings = !showSignatureSettings"
|
||||
:disabled="sending"
|
||||
>
|
||||
<v-icon start>mdi-card-account-details</v-icon>
|
||||
Signature Settings
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Signature Settings -->
|
||||
<v-expand-transition>
|
||||
<v-card v-if="showSignatureSettings" variant="outlined" class="mb-4">
|
||||
<v-card-title class="text-subtitle-1">
|
||||
Customize Email Signature
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="signatureConfig.name"
|
||||
label="Your Name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="signatureConfig.title"
|
||||
label="Job Title"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="signatureConfig.email"
|
||||
label="Email (if different)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="signatureConfig.company"
|
||||
label="Company Name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<div class="text-subtitle-2 mb-2">Contact Information</div>
|
||||
<v-textarea
|
||||
v-model="signatureConfig.contactInfo"
|
||||
label="Contact Details (one per line)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="4"
|
||||
class="mb-3"
|
||||
placeholder="UK: +44 7 557 959 690 (WhatsApp) FR: +33 7 81 25 66 22 your.email@portnimara.com"
|
||||
/>
|
||||
|
||||
<div class="text-subtitle-2 mb-2">Signature Preview</div>
|
||||
<v-card variant="outlined" class="pa-3 mb-3">
|
||||
<div v-html="getSignaturePreview()" style="font-family: Arial, sans-serif; font-size: 14px;"></div>
|
||||
</v-card>
|
||||
|
||||
<v-checkbox
|
||||
v-model="includeSignature"
|
||||
label="Include signature in this email"
|
||||
density="compact"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="sending"
|
||||
:disabled="sending"
|
||||
>
|
||||
<v-icon start>mdi-send</v-icon>
|
||||
Send Email
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import type { Interest } from '@/utils/types';
|
||||
|
||||
interface Props {
|
||||
interest: Interest;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'sent', messageId: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const user = useDirectusUser();
|
||||
const toast = useToast();
|
||||
|
||||
const form = ref();
|
||||
const sending = ref(false);
|
||||
const showSignatureSettings = ref(false);
|
||||
const includeSignature = ref(true);
|
||||
|
||||
const email = ref({
|
||||
to: '',
|
||||
subject: '',
|
||||
body: ''
|
||||
});
|
||||
|
||||
const signatureConfig = ref({
|
||||
name: '',
|
||||
title: 'Sales & Marketing Director',
|
||||
email: '',
|
||||
company: 'Port Nimara',
|
||||
contactInfo: 'UK: +44 7 557 959 690 (WhatsApp)\nFR: +33 7 81 25 66 22'
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (v: string) => !!v || 'Required',
|
||||
email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email'
|
||||
};
|
||||
|
||||
const getSessionId = () => {
|
||||
return sessionStorage.getItem('emailSessionId') || '';
|
||||
};
|
||||
|
||||
const insertEOILink = () => {
|
||||
// Generate EOI link similar to the existing EOI Send to Sales functionality
|
||||
const eoiLink = `https://portnimara.com/eoi/${props.interest.Id}`;
|
||||
email.value.body += `\n\nPlease click here to complete your Expression of Interest: ${eoiLink}\n`;
|
||||
toast.success('EOI link inserted');
|
||||
};
|
||||
|
||||
const insertFormLink = () => {
|
||||
// Generate form link with pre-filled data
|
||||
const formData = {
|
||||
name: props.interest['Full Name'],
|
||||
email: props.interest['Email Address'],
|
||||
phone: props.interest['Phone Number'],
|
||||
interestId: props.interest.Id
|
||||
};
|
||||
const encodedData = btoa(JSON.stringify(formData));
|
||||
const formLink = `https://portnimara.com/interest-form?data=${encodedData}`;
|
||||
email.value.body += `\n\nPlease click here to access your personalized form: ${formLink}\n`;
|
||||
toast.success('Form link inserted');
|
||||
};
|
||||
|
||||
const getSignaturePreview = () => {
|
||||
const sig = signatureConfig.value;
|
||||
const userEmail = sig.email || email.value.to;
|
||||
const contactLines = sig.contactInfo.split('\n').filter(line => line.trim()).join('<br>');
|
||||
|
||||
return `
|
||||
<div style="margin-top: 20px;">
|
||||
<div style="font-weight: bold;">${sig.name || 'Your Name'}</div>
|
||||
<div style="color: #666;">${sig.title || 'Your Title'}</div>
|
||||
<br>
|
||||
<div style="font-weight: bold;">${sig.company || 'Company Name'}</div>
|
||||
<br>
|
||||
${contactLines ? contactLines + '<br>' : ''}
|
||||
<a href="mailto:${userEmail}" style="color: #0066cc;">${userEmail}</a>
|
||||
<br><br>
|
||||
<img src="${process.env.NUXT_EMAIL_LOGO_URL || 'https://portnimara.com/logo.png'}" alt="Logo" style="height: 80px;">
|
||||
<br>
|
||||
<div style="color: #666; font-size: 12px; margin-top: 10px;">
|
||||
The information in this message is confidential and may be privileged.<br>
|
||||
It is intended for the addressee alone.<br>
|
||||
If you are not the intended recipient it is prohibited to disclose, use or copy this information.<br>
|
||||
Please contact the Sender immediately should this message have been transmitted incorrectly.
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const sendEmail = async () => {
|
||||
const { valid } = await form.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
sending.value = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; message: string; messageId: string }>('/api/email/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': user.value?.email ? '094ut234' : 'pjnvü1230',
|
||||
},
|
||||
body: {
|
||||
to: email.value.to,
|
||||
subject: email.value.subject,
|
||||
body: email.value.body,
|
||||
interestId: props.interest.Id,
|
||||
sessionId: getSessionId(),
|
||||
includeSignature: includeSignature.value,
|
||||
signatureConfig: includeSignature.value ? signatureConfig.value : undefined
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success('Email sent successfully!');
|
||||
// Clear form
|
||||
email.value.subject = '';
|
||||
email.value.body = '';
|
||||
emit('sent', response.messageId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(error.data?.statusMessage || 'Failed to send email');
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(() => {
|
||||
// Set recipient email
|
||||
email.value.to = props.interest['Email Address'];
|
||||
|
||||
// Load saved signature config from localStorage
|
||||
const savedSignature = localStorage.getItem('emailSignatureConfig');
|
||||
if (savedSignature) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedSignature);
|
||||
signatureConfig.value = { ...signatureConfig.value, ...parsed };
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved signature:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save signature config when it changes
|
||||
watch(signatureConfig, (newConfig) => {
|
||||
localStorage.setItem('emailSignatureConfig', JSON.stringify(newConfig));
|
||||
}, { deep: true });
|
||||
});
|
||||
</script>
|
||||
159
components/EmailCredentialsSetup.vue
Normal file
159
components/EmailCredentialsSetup.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-email-lock</v-icon>
|
||||
Email Account Setup
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
Enter your email credentials to send and receive emails. Your password is encrypted and stored only for this session.
|
||||
</v-alert>
|
||||
|
||||
<v-form @submit.prevent="testConnection" ref="form">
|
||||
<v-text-field
|
||||
v-model="credentials.email"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-email"
|
||||
:rules="[rules.required, rules.email]"
|
||||
:disabled="testing"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="credentials.password"
|
||||
label="Password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
:rules="[rules.required]"
|
||||
:disabled="testing"
|
||||
/>
|
||||
|
||||
<v-expansion-panels variant="accordion" class="mb-4">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon class="mr-2">mdi-cog</v-icon>
|
||||
Advanced Settings
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-text-field
|
||||
v-model="credentials.imapHost"
|
||||
label="IMAP Host"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
placeholder="mail.portnimara.com"
|
||||
:disabled="testing"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="credentials.smtpHost"
|
||||
label="SMTP Host"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
placeholder="mail.portnimara.com"
|
||||
:disabled="testing"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="testing"
|
||||
:disabled="testing"
|
||||
>
|
||||
<v-icon start>mdi-connection</v-icon>
|
||||
Test Connection & Continue
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
interface Emits {
|
||||
(e: 'connected', email: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const user = useDirectusUser();
|
||||
const toast = useToast();
|
||||
|
||||
const form = ref();
|
||||
const testing = ref(false);
|
||||
const showPassword = ref(false);
|
||||
|
||||
const credentials = ref({
|
||||
email: '',
|
||||
password: '',
|
||||
imapHost: '',
|
||||
smtpHost: ''
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (v: string) => !!v || 'Required',
|
||||
email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email'
|
||||
};
|
||||
|
||||
// Generate or get session ID
|
||||
const getSessionId = () => {
|
||||
let sessionId = sessionStorage.getItem('emailSessionId');
|
||||
if (!sessionId) {
|
||||
sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
||||
sessionStorage.setItem('emailSessionId', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
const { valid } = await form.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
testing.value = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; message: string; email: string }>('/api/email/test-connection', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': user.value?.email ? '094ut234' : 'pjnvü1230',
|
||||
},
|
||||
body: {
|
||||
email: credentials.value.email,
|
||||
password: credentials.value.password,
|
||||
imapHost: credentials.value.imapHost || undefined,
|
||||
smtpHost: credentials.value.smtpHost || undefined,
|
||||
sessionId: getSessionId()
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success('Email connection successful!');
|
||||
// Store email in session for later use
|
||||
sessionStorage.setItem('connectedEmail', credentials.value.email);
|
||||
emit('connected', credentials.value.email);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Connection test failed:', error);
|
||||
toast.error(error.data?.statusMessage || 'Failed to connect to email server');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-fill email from user if available
|
||||
onMounted(() => {
|
||||
if (user.value?.email) {
|
||||
credentials.value.email = user.value.email;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
249
components/EmailThreadView.vue
Normal file
249
components/EmailThreadView.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<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="mdi-refresh"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="loadEmails"
|
||||
:loading="loading"
|
||||
/>
|
||||
</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>
|
||||
</v-card-text>
|
||||
</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;
|
||||
}
|
||||
|
||||
interface EmailThread {
|
||||
id: string;
|
||||
subject: string;
|
||||
emailCount: number;
|
||||
latestTimestamp: string;
|
||||
emails: EmailMessage[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const user = useDirectusUser();
|
||||
const toast = useToast();
|
||||
|
||||
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 () => {
|
||||
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: getSessionId(),
|
||||
limit: 50
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
threads.value = response.threads;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load emails:', error);
|
||||
if (error.data?.statusMessage?.includes('Email credentials not found')) {
|
||||
// Don't show error, parent component should handle reconnection
|
||||
} else {
|
||||
toast.error(error.data?.statusMessage || 'Failed to load email history');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Reload emails when an email is sent
|
||||
const reloadEmails = () => {
|
||||
loadEmails();
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
</style>
|
||||
@@ -625,6 +625,12 @@
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Email Communication Section -->
|
||||
<EmailCommunication
|
||||
v-if="interest"
|
||||
:interest="interest"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -635,6 +641,7 @@
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import type { Interest, Berth } from "@/utils/types";
|
||||
import PhoneInput from "./PhoneInput.vue";
|
||||
import EmailCommunication from "./EmailCommunication.vue";
|
||||
import {
|
||||
InterestSalesProcessLevelFlow,
|
||||
InterestLeadCategoryFlow,
|
||||
|
||||
Reference in New Issue
Block a user