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:
parent
fad7426ed8
commit
618c888b85
|
|
@ -5,3 +5,11 @@ NUXT_MINIO_SECRET_KEY=your-minio-secret-key
|
||||||
# NocoDB Configuration (existing)
|
# NocoDB Configuration (existing)
|
||||||
NUXT_NOCODB_URL=your-nocodb-url
|
NUXT_NOCODB_URL=your-nocodb-url
|
||||||
NUXT_NOCODB_TOKEN=your-nocodb-token
|
NUXT_NOCODB_TOKEN=your-nocodb-token
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
NUXT_EMAIL_ENCRYPTION_KEY=your-32-character-encryption-key
|
||||||
|
NUXT_EMAIL_IMAP_HOST=mail.portnimara.com
|
||||||
|
NUXT_EMAIL_IMAP_PORT=993
|
||||||
|
NUXT_EMAIL_SMTP_HOST=mail.portnimara.com
|
||||||
|
NUXT_EMAIL_SMTP_PORT=587
|
||||||
|
NUXT_EMAIL_LOGO_URL=https://portnimara.com/logo.png
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Email Communication Section -->
|
||||||
|
<EmailCommunication
|
||||||
|
v-if="interest"
|
||||||
|
:interest="interest"
|
||||||
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
@ -635,6 +641,7 @@
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
import type { Interest, Berth } from "@/utils/types";
|
import type { Interest, Berth } from "@/utils/types";
|
||||||
import PhoneInput from "./PhoneInput.vue";
|
import PhoneInput from "./PhoneInput.vue";
|
||||||
|
import EmailCommunication from "./EmailCommunication.vue";
|
||||||
import {
|
import {
|
||||||
InterestSalesProcessLevelFlow,
|
InterestSalesProcessLevelFlow,
|
||||||
InterestLeadCategoryFlow,
|
InterestLeadCategoryFlow,
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,22 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
|
"imap": "^0.8.19",
|
||||||
|
"mailparser": "^3.7.3",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
"vuetify-nuxt-module": "^0.18.3"
|
"vuetify-nuxt-module": "^0.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/imap": "^0.8.42",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
|
"@types/nodemailer": "^6.4.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
|
|
@ -3622,6 +3630,19 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"selderee": "^0.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/merge-streams": {
|
"node_modules/@sindresorhus/merge-streams": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||||
|
|
@ -3679,6 +3700,27 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/imap": {
|
||||||
|
"version": "0.8.42",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
|
||||||
|
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mailparser": {
|
||||||
|
"version": "3.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
|
||||||
|
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"iconv-lite": "^0.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.12.0",
|
"version": "22.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
|
||||||
|
|
@ -3688,6 +3730,16 @@
|
||||||
"undici-types": "~6.20.0"
|
"undici-types": "~6.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "6.4.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||||
|
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/parse-path": {
|
"node_modules/@types/parse-path": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||||
|
|
@ -5975,6 +6027,15 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-japanese": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.0",
|
"version": "5.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz",
|
||||||
|
|
@ -7065,6 +7126,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/he": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"he": "bin/he"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hookable": {
|
"node_modules/hookable": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
|
@ -7083,6 +7153,41 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-text": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"htmlparser2": "^8.0.2",
|
||||||
|
"selderee": "^0.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
|
@ -7137,6 +7242,18 @@
|
||||||
"node": ">=14.18.0"
|
"node": ">=14.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/idb": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
|
|
@ -7178,6 +7295,42 @@
|
||||||
"integrity": "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==",
|
"integrity": "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/imap": {
|
||||||
|
"version": "0.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||||
|
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "1.1.x",
|
||||||
|
"utf7": ">=1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/imap/node_modules/isarray": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/imap/node_modules/readable-stream": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||||
|
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.1",
|
||||||
|
"isarray": "0.0.1",
|
||||||
|
"string_decoder": "~0.10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/imap/node_modules/string_decoder": {
|
||||||
|
"version": "0.10.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||||
|
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/importx": {
|
"node_modules/importx": {
|
||||||
"version": "0.4.4",
|
"version": "0.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/importx/-/importx-0.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/importx/-/importx-0.4.4.tgz",
|
||||||
|
|
@ -8538,6 +8691,15 @@
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leac": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
|
|
@ -8547,6 +8709,30 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libbase64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/libmime": {
|
||||||
|
"version": "5.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.6.tgz",
|
||||||
|
"integrity": "sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.6.3",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libqp": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|
@ -8559,6 +8745,15 @@
|
||||||
"url": "https://github.com/sponsors/antonk52"
|
"url": "https://github.com/sponsors/antonk52"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/listhen": {
|
"node_modules/listhen": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
||||||
|
|
@ -8703,6 +8898,35 @@
|
||||||
"source-map-js": "^1.2.0"
|
"source-map-js": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mailparser": {
|
||||||
|
"version": "3.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.3.tgz",
|
||||||
|
"integrity": "sha512-0RM14cZF0gO1y2Q/82hhWranispZOUSYHwvQ21h12x90NwD6+D5q59S5nOLqCtCdYitHN58LJXWEHa4RWm7BYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"he": "1.2.0",
|
||||||
|
"html-to-text": "9.0.5",
|
||||||
|
"iconv-lite": "0.6.3",
|
||||||
|
"libmime": "5.3.6",
|
||||||
|
"linkify-it": "5.0.0",
|
||||||
|
"mailsplit": "5.4.3",
|
||||||
|
"nodemailer": "7.0.3",
|
||||||
|
"punycode.js": "2.3.1",
|
||||||
|
"tlds": "1.259.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mailsplit": {
|
||||||
|
"version": "5.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.3.tgz",
|
||||||
|
"integrity": "sha512-PFV0BBh4Tv7Omui5FtXXVtN4ExAxIi8Yvmb9JgBz+J6Hnnrv/YYXLlKKudLhXwd3/qWEATOslRsnzVCWDeCnmQ==",
|
||||||
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
|
"dependencies": {
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.3.6",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -9251,6 +9475,15 @@
|
||||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||||
|
|
@ -9697,6 +9930,19 @@
|
||||||
"node": ">=14.13.0"
|
"node": ">=14.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parseley": {
|
||||||
|
"version": "0.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||||
|
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"leac": "^0.6.0",
|
||||||
|
"peberminta": "^0.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|
@ -9770,6 +10016,15 @@
|
||||||
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
|
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/peberminta": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
|
@ -10406,6 +10661,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/query-string": {
|
"node_modules/query-string": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
|
||||||
|
|
@ -11007,6 +11271,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||||
|
|
@ -11019,6 +11289,18 @@
|
||||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/selderee": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parseley": "^0.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.0",
|
"version": "7.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz",
|
||||||
|
|
@ -11953,6 +12235,15 @@
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tlds": {
|
||||||
|
"version": "1.259.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
||||||
|
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tlds": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|
@ -12531,6 +12822,12 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ufo": {
|
"node_modules/ufo": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
||||||
|
|
@ -13142,6 +13439,23 @@
|
||||||
"integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==",
|
"integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/utf7": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "~5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utf7/node_modules/semver": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util": {
|
"node_modules/util": {
|
||||||
"version": "0.12.5",
|
"version": "0.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,21 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
|
"imap": "^0.8.19",
|
||||||
|
"mailparser": "^3.7.3",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
"vuetify-nuxt-module": "^0.18.3"
|
"vuetify-nuxt-module": "^0.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/imap": "^0.8.42",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
|
"@types/nodemailer": "^6.4.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
import Imap from 'imap';
|
||||||
|
import { simpleParser } from 'mailparser';
|
||||||
|
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
||||||
|
import { listFiles, getFileStats } from '~/server/utils/minio';
|
||||||
|
|
||||||
|
interface EmailMessage {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
html?: string;
|
||||||
|
timestamp: string;
|
||||||
|
direction: 'sent' | 'received';
|
||||||
|
threadId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||||
|
|
||||||
|
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { clientEmail, interestId, sessionId, limit = 50 } = body;
|
||||||
|
|
||||||
|
if (!clientEmail || !sessionId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Client email and sessionId are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get encrypted credentials from session
|
||||||
|
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
||||||
|
if (!encryptedCredentials) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Email credentials not found. Please reconnect."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt credentials
|
||||||
|
const { email: userEmail, password } = decryptCredentials(encryptedCredentials);
|
||||||
|
|
||||||
|
// First, get emails from MinIO cache if available
|
||||||
|
const cachedEmails: EmailMessage[] = [];
|
||||||
|
if (interestId) {
|
||||||
|
try {
|
||||||
|
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.name.endsWith('.json') && !file.isFolder) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NUXT_MINIO_ENDPOINT || 'http://localhost:9000'}/${useRuntimeConfig().minio.bucketName}/${file.name}`);
|
||||||
|
const emailData = await response.json();
|
||||||
|
cachedEmails.push(emailData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read cached email:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to list cached emails:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure IMAP
|
||||||
|
const imapConfig = {
|
||||||
|
user: userEmail,
|
||||||
|
password: password,
|
||||||
|
host: process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com',
|
||||||
|
port: parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'),
|
||||||
|
tls: true,
|
||||||
|
tlsOptions: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch emails from IMAP
|
||||||
|
const imapEmails: EmailMessage[] = await new Promise((resolve, reject) => {
|
||||||
|
const emails: EmailMessage[] = [];
|
||||||
|
const imap = new Imap(imapConfig);
|
||||||
|
|
||||||
|
imap.once('ready', () => {
|
||||||
|
// Search for emails to/from the client
|
||||||
|
imap.openBox('INBOX', true, (err, box) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchCriteria = [
|
||||||
|
'OR',
|
||||||
|
['FROM', clientEmail],
|
||||||
|
['TO', clientEmail]
|
||||||
|
];
|
||||||
|
|
||||||
|
imap.search(searchCriteria, (err, results) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
imap.end();
|
||||||
|
resolve(emails);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
const messagesToFetch = results.slice(-limit);
|
||||||
|
|
||||||
|
const fetch = imap.fetch(messagesToFetch, {
|
||||||
|
bodies: '',
|
||||||
|
struct: true,
|
||||||
|
envelope: true
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.on('message', (msg, seqno) => {
|
||||||
|
msg.on('body', (stream, info) => {
|
||||||
|
simpleParser(stream as any, async (err: any, parsed: any) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Parse error:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email: EmailMessage = {
|
||||||
|
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
||||||
|
from: parsed.from?.text || '',
|
||||||
|
to: Array.isArray(parsed.to)
|
||||||
|
? parsed.to.map((addr: any) => addr.text).join(', ')
|
||||||
|
: parsed.to?.text || '',
|
||||||
|
subject: parsed.subject || '',
|
||||||
|
body: parsed.text || '',
|
||||||
|
html: parsed.html || undefined,
|
||||||
|
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
||||||
|
direction: parsed.from?.text.includes(userEmail) ? 'sent' : 'received'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract thread ID from headers if available
|
||||||
|
if (parsed.headers.has('in-reply-to')) {
|
||||||
|
email.threadId = parsed.headers.get('in-reply-to') as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
emails.push(email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.once('error', (err) => {
|
||||||
|
console.error('Fetch error:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.once('end', () => {
|
||||||
|
imap.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also check Sent folder
|
||||||
|
imap.openBox('[Gmail]/Sent Mail', true, (err, box) => {
|
||||||
|
if (err) {
|
||||||
|
// Try common sent folder names
|
||||||
|
['Sent', 'Sent Items', 'Sent Messages'].forEach(folderName => {
|
||||||
|
imap.openBox(folderName, true, (err, box) => {
|
||||||
|
if (!err) {
|
||||||
|
// Search in sent folder
|
||||||
|
imap.search([['TO', clientEmail]], (err, results) => {
|
||||||
|
if (!err && results && results.length > 0) {
|
||||||
|
// Process sent emails similarly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.once('error', (err: any) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.once('end', () => {
|
||||||
|
resolve(emails);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine cached and IMAP emails, remove duplicates
|
||||||
|
const allEmails = [...cachedEmails, ...imapEmails];
|
||||||
|
const uniqueEmails = Array.from(
|
||||||
|
new Map(allEmails.map(email => [email.id, email])).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by timestamp
|
||||||
|
uniqueEmails.sort((a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group into threads
|
||||||
|
const threads = groupIntoThreads(uniqueEmails);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
emails: uniqueEmails,
|
||||||
|
threads: threads
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch email thread:', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to fetch emails: ${error.message}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "An unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group emails into threads based on subject and references
|
||||||
|
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
||||||
|
const threads = new Map<string, EmailMessage[]>();
|
||||||
|
|
||||||
|
emails.forEach(email => {
|
||||||
|
// Normalize subject by removing Re:, Fwd:, etc.
|
||||||
|
const normalizedSubject = email.subject
|
||||||
|
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Find existing thread or create new one
|
||||||
|
let threadFound = false;
|
||||||
|
for (const [threadId, threadEmails] of threads.entries()) {
|
||||||
|
const threadSubject = threadEmails[0].subject
|
||||||
|
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (threadSubject === normalizedSubject) {
|
||||||
|
threadEmails.push(email);
|
||||||
|
threadFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadFound) {
|
||||||
|
threads.set(email.id, [email]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array format
|
||||||
|
return Array.from(threads.entries()).map(([threadId, emails]) => ({
|
||||||
|
id: threadId,
|
||||||
|
subject: emails[0].subject,
|
||||||
|
emailCount: emails.length,
|
||||||
|
latestTimestamp: emails[emails.length - 1].timestamp,
|
||||||
|
emails: emails
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
||||||
|
import { uploadFile } from '~/server/utils/minio';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||||
|
|
||||||
|
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const {
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body: emailBody,
|
||||||
|
interestId,
|
||||||
|
sessionId,
|
||||||
|
includeSignature = true,
|
||||||
|
signatureConfig
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!to || !subject || !emailBody || !sessionId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "To, subject, body, and sessionId are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get encrypted credentials from session
|
||||||
|
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
||||||
|
if (!encryptedCredentials) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Email credentials not found. Please reconnect."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt credentials
|
||||||
|
const { email, password } = decryptCredentials(encryptedCredentials);
|
||||||
|
|
||||||
|
// Get user info for signature
|
||||||
|
const defaultName = email.split('@')[0].replace('.', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
|
||||||
|
// Build email signature with customizable fields
|
||||||
|
const sig = signatureConfig || {};
|
||||||
|
const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('<br>') : '';
|
||||||
|
const signature = includeSignature ? `
|
||||||
|
<div style="margin-top: 20px; font-family: Arial, sans-serif;">
|
||||||
|
<div style="font-weight: bold;">${sig.name || defaultName}</div>
|
||||||
|
<div style="color: #666;">${sig.title || 'Sales & Marketing Director'}</div>
|
||||||
|
<br>
|
||||||
|
<div style="font-weight: bold;">${sig.company || 'Port Nimara'}</div>
|
||||||
|
<br>
|
||||||
|
${contactLines ? contactLines + '<br>' : ''}
|
||||||
|
<a href="mailto:${sig.email || email}" style="color: #0066cc;">${sig.email || email}</a>
|
||||||
|
<br><br>
|
||||||
|
<img src="${process.env.NUXT_EMAIL_LOGO_URL || 'https://portnimara.com/logo.png'}" alt="Port Nimara" 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>` : '';
|
||||||
|
|
||||||
|
// Convert plain text body to HTML with line breaks
|
||||||
|
const htmlBody = emailBody.replace(/\n/g, '<br>') + signature;
|
||||||
|
|
||||||
|
// Configure SMTP transport
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.NUXT_EMAIL_SMTP_HOST || 'mail.portnimara.com',
|
||||||
|
port: parseInt(process.env.NUXT_EMAIL_SMTP_PORT || '587'),
|
||||||
|
secure: false, // false for STARTTLS
|
||||||
|
auth: {
|
||||||
|
user: email,
|
||||||
|
pass: password
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false // Allow self-signed certificates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
const fromName = sig.name || defaultName;
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${fromName}" <${email}>`,
|
||||||
|
to: to,
|
||||||
|
subject: subject,
|
||||||
|
text: emailBody, // Plain text version
|
||||||
|
html: htmlBody // HTML version with signature
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store email in MinIO for thread history
|
||||||
|
if (interestId) {
|
||||||
|
try {
|
||||||
|
const emailData = {
|
||||||
|
id: info.messageId,
|
||||||
|
from: email,
|
||||||
|
to: to,
|
||||||
|
subject: subject,
|
||||||
|
body: emailBody,
|
||||||
|
html: htmlBody,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
direction: 'sent',
|
||||||
|
interestId: interestId
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectName = `client-emails/interest-${interestId}/${Date.now()}-sent.json`;
|
||||||
|
const buffer = Buffer.from(JSON.stringify(emailData, null, 2));
|
||||||
|
|
||||||
|
await uploadFile(
|
||||||
|
objectName,
|
||||||
|
buffer,
|
||||||
|
'application/json'
|
||||||
|
);
|
||||||
|
} catch (storageError) {
|
||||||
|
console.error('Failed to store email in MinIO:', storageError);
|
||||||
|
// Continue even if storage fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Email sent successfully",
|
||||||
|
messageId: info.messageId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email:', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to send email: ${error.message}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "An unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import Imap from 'imap';
|
||||||
|
import { encryptCredentials, storeCredentialsInSession } from '~/server/utils/encryption';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||||
|
|
||||||
|
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { email, password, imapHost, smtpHost, sessionId } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !sessionId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Email, password, and sessionId are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provided hosts or defaults from environment
|
||||||
|
const imapHostToUse = imapHost || process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com';
|
||||||
|
const smtpHostToUse = smtpHost || process.env.NUXT_EMAIL_SMTP_HOST || 'mail.portnimara.com';
|
||||||
|
const imapPort = parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993');
|
||||||
|
const smtpPort = parseInt(process.env.NUXT_EMAIL_SMTP_PORT || '587');
|
||||||
|
|
||||||
|
// Test SMTP connection
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: smtpHostToUse,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: false, // false for STARTTLS
|
||||||
|
auth: {
|
||||||
|
user: email,
|
||||||
|
pass: password
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false // Allow self-signed certificates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.verify();
|
||||||
|
|
||||||
|
// Test IMAP connection
|
||||||
|
const imapConfig = {
|
||||||
|
user: email,
|
||||||
|
password: password,
|
||||||
|
host: imapHostToUse,
|
||||||
|
port: imapPort,
|
||||||
|
tls: true,
|
||||||
|
tlsOptions: {
|
||||||
|
rejectUnauthorized: false // Allow self-signed certificates
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testImapConnection = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const imap = new Imap(imapConfig);
|
||||||
|
|
||||||
|
imap.once('ready', () => {
|
||||||
|
imap.end();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.once('error', (err: Error) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.connect();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await testImapConnection();
|
||||||
|
|
||||||
|
// If both connections successful, encrypt and store credentials
|
||||||
|
const encryptedCredentials = encryptCredentials(email, password);
|
||||||
|
storeCredentialsInSession(sessionId, encryptedCredentials);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Email connection tested successfully",
|
||||||
|
email: email
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email connection test failed:', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Check for common authentication errors
|
||||||
|
if (error.message.includes('Authentication') || error.message.includes('AUTHENTICATIONFAILED')) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Invalid email or password"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Connection failed: ${error.message}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "An unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const algorithm = 'aes-256-gcm';
|
||||||
|
const saltLength = 64;
|
||||||
|
const tagLength = 16;
|
||||||
|
const ivLength = 16;
|
||||||
|
const iterations = 100000;
|
||||||
|
const keyLength = 32;
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const key = process.env.NUXT_EMAIL_ENCRYPTION_KEY;
|
||||||
|
if (!key || key.length < 32) {
|
||||||
|
throw new Error('NUXT_EMAIL_ENCRYPTION_KEY must be at least 32 characters long');
|
||||||
|
}
|
||||||
|
// Ensure key is exactly 32 bytes
|
||||||
|
return Buffer.from(key.substring(0, 32).padEnd(32, '0'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptCredentials(email: string, password: string): string {
|
||||||
|
try {
|
||||||
|
const key = getKey();
|
||||||
|
const iv = crypto.randomBytes(ivLength);
|
||||||
|
const salt = crypto.randomBytes(saltLength);
|
||||||
|
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(key, salt, iterations, keyLength, 'sha256');
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, derivedKey, iv);
|
||||||
|
|
||||||
|
const data = JSON.stringify({ email, password });
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(data, 'utf8'),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Combine salt, iv, tag, and encrypted data
|
||||||
|
const combined = Buffer.concat([salt, iv, tag, encrypted]);
|
||||||
|
|
||||||
|
return combined.toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to encrypt credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptCredentials(encryptedData: string): { email: string; password: string } {
|
||||||
|
try {
|
||||||
|
const key = getKey();
|
||||||
|
const combined = Buffer.from(encryptedData, 'base64');
|
||||||
|
|
||||||
|
// Extract components
|
||||||
|
const salt = combined.slice(0, saltLength);
|
||||||
|
const iv = combined.slice(saltLength, saltLength + ivLength);
|
||||||
|
const tag = combined.slice(saltLength + ivLength, saltLength + ivLength + tagLength);
|
||||||
|
const encrypted = combined.slice(saltLength + ivLength + tagLength);
|
||||||
|
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(key, salt, iterations, keyLength, 'sha256');
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, derivedKey, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(encrypted),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return JSON.parse(decrypted.toString('utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to decrypt credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory session storage for credentials (cleared on server restart)
|
||||||
|
const credentialCache = new Map<string, { credentials: string; timestamp: number }>();
|
||||||
|
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
export function storeCredentialsInSession(sessionId: string, encryptedCredentials: string): void {
|
||||||
|
credentialCache.set(sessionId, {
|
||||||
|
credentials: encryptedCredentials,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up expired sessions
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCredentialsFromSession(sessionId: string): string | null {
|
||||||
|
const session = credentialCache.get(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session is expired
|
||||||
|
if (Date.now() - session.timestamp > CACHE_TTL) {
|
||||||
|
credentialCache.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timestamp on access
|
||||||
|
session.timestamp = Date.now();
|
||||||
|
return session.credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCredentialsFromSession(sessionId: string): void {
|
||||||
|
credentialCache.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredSessions(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, session] of credentialCache.entries()) {
|
||||||
|
if (now - session.timestamp > CACHE_TTL) {
|
||||||
|
credentialCache.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup expired sessions every 5 minutes
|
||||||
|
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
|
||||||
Loading…
Reference in New Issue