Add file attachment support to email composer
- Add file browser integration for selecting attachments - Display attached files with remove functionality in composer - Include attachments in email sending process - Show attachment info in email thread display - Update server endpoints to handle file attachments - Minor UI improvements to EOI status badge layout
This commit is contained in:
parent
bb2f5d37c8
commit
b332f913a6
|
|
@ -20,16 +20,15 @@
|
|||
</div>
|
||||
|
||||
<!-- EOI Status Badge -->
|
||||
<div v-if="hasEOI" class="mb-4">
|
||||
<div v-if="hasEOI" class="mb-4 d-flex align-center">
|
||||
<v-chip
|
||||
:color="getStatusColor(interest['EOI Status'])"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-file-document-check"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ interest['EOI Status'] }}
|
||||
</v-chip>
|
||||
<span v-if="interest['EOI Time Sent']" class="text-caption text-grey-darken-1">
|
||||
<span v-if="interest['EOI Time Sent']" class="text-caption text-grey-darken-1 ml-3">
|
||||
Sent: {{ formatDate(interest['EOI Time Sent']) }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,21 @@
|
|||
placeholder="Type your message here..."
|
||||
/>
|
||||
|
||||
<!-- File Attachments -->
|
||||
<div v-if="attachments.length > 0" class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">Attachments:</div>
|
||||
<v-chip
|
||||
v-for="(file, index) in attachments"
|
||||
:key="index"
|
||||
closable
|
||||
@click:close="removeAttachment(index)"
|
||||
class="mr-2 mb-2"
|
||||
>
|
||||
<v-icon start>mdi-paperclip</v-icon>
|
||||
{{ file.name }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="d-flex ga-2 mb-4">
|
||||
<v-btn
|
||||
|
|
@ -57,6 +72,15 @@
|
|||
<v-icon start>mdi-form-select</v-icon>
|
||||
Insert Form Link
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="showFileBrowser = true"
|
||||
:disabled="sending"
|
||||
>
|
||||
<v-icon start>mdi-paperclip</v-icon>
|
||||
Attach File
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
|
|
@ -145,11 +169,42 @@
|
|||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- File Browser Dialog -->
|
||||
<v-dialog v-model="showFileBrowser" max-width="800">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
Select Files to Attach
|
||||
<v-spacer />
|
||||
<v-btn icon @click="showFileBrowser = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text style="height: 500px; overflow-y: auto;">
|
||||
<file-browser-component
|
||||
v-if="showFileBrowser"
|
||||
:selection-mode="true"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showFileBrowser = false">Cancel</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import type { Interest } from '@/utils/types';
|
||||
import FileBrowserComponent from '~/pages/dashboard/file-browser.vue';
|
||||
|
||||
interface AttachedFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
interest: Interest;
|
||||
|
|
@ -171,6 +226,8 @@ const sending = ref(false);
|
|||
const generatingEOI = ref(false);
|
||||
const showSignatureSettings = ref(false);
|
||||
const includeSignature = ref(true);
|
||||
const showFileBrowser = ref(false);
|
||||
const attachments = ref<AttachedFile[]>([]);
|
||||
|
||||
const email = ref({
|
||||
to: '',
|
||||
|
|
@ -269,6 +326,29 @@ const getSignaturePreview = () => {
|
|||
</div>`;
|
||||
};
|
||||
|
||||
const onFileSelected = (file: any) => {
|
||||
// Add the selected file to attachments
|
||||
const attachment: AttachedFile = {
|
||||
name: file.name,
|
||||
path: file.path || file.name,
|
||||
size: file.size || 0
|
||||
};
|
||||
|
||||
// Check if file is already attached
|
||||
if (!attachments.value.some(a => a.path === attachment.path)) {
|
||||
attachments.value.push(attachment);
|
||||
toast.success(`${file.name} attached`);
|
||||
} else {
|
||||
toast.info(`${file.name} is already attached`);
|
||||
}
|
||||
|
||||
showFileBrowser.value = false;
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
attachments.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const sendEmail = async () => {
|
||||
const { valid } = await form.value.validate();
|
||||
if (!valid) return;
|
||||
|
|
@ -288,7 +368,8 @@ const sendEmail = async () => {
|
|||
interestId: props.interest.Id,
|
||||
sessionId: getSessionId(),
|
||||
includeSignature: includeSignature.value,
|
||||
signatureConfig: includeSignature.value ? signatureConfig.value : undefined
|
||||
signatureConfig: includeSignature.value ? signatureConfig.value : undefined,
|
||||
attachments: attachments.value
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -297,6 +378,7 @@ const sendEmail = async () => {
|
|||
// Clear form
|
||||
email.value.subject = '';
|
||||
email.value.body = '';
|
||||
attachments.value = [];
|
||||
emit('sent', response.messageId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -44,20 +44,7 @@
|
|||
<v-icon start>mdi-email-outline</v-icon>
|
||||
Request Info
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="eoiSendToSales"
|
||||
variant="text"
|
||||
:loading="isSendingEOI"
|
||||
:disabled="
|
||||
isRequestingMoreInfo ||
|
||||
isRequestingMoreInformation ||
|
||||
isSendingEOI ||
|
||||
isDeleting
|
||||
"
|
||||
>
|
||||
<v-icon start>mdi-send</v-icon>
|
||||
EOI to Sales
|
||||
</v-btn>
|
||||
<!-- EOI to Sales button hidden -->
|
||||
<v-btn
|
||||
@click="confirmDelete"
|
||||
variant="flat"
|
||||
|
|
@ -206,26 +193,7 @@
|
|||
<span class="text-caption">Request Info</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
@click="eoiSendToSales"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
block
|
||||
:loading="isSendingEOI"
|
||||
:disabled="
|
||||
isRequestingMoreInfo ||
|
||||
isRequestingMoreInformation ||
|
||||
isSendingEOI
|
||||
"
|
||||
class="mb-2 d-flex flex-column"
|
||||
size="large"
|
||||
>
|
||||
<v-icon size="large" class="mb-1">mdi-send</v-icon>
|
||||
<span class="text-caption">EOI to Sales</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
@click="saveInterest"
|
||||
variant="flat"
|
||||
|
|
|
|||
|
|
@ -157,9 +157,9 @@ export default defineEventHandler(async (event) => {
|
|||
new Map(allEmails.map(email => [email.id, email])).values()
|
||||
);
|
||||
|
||||
// Sort by timestamp
|
||||
// Sort by timestamp (newest first)
|
||||
uniqueEmails.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Group into threads
|
||||
|
|
@ -414,12 +414,14 @@ function groupIntoThreads(emails: EmailMessage[]): any[] {
|
|||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
}));
|
||||
// Convert to array format and sort threads by latest timestamp (newest first)
|
||||
return Array.from(threads.entries())
|
||||
.map(([threadId, emails]) => ({
|
||||
id: threadId,
|
||||
subject: emails[0].subject,
|
||||
emailCount: emails.length,
|
||||
latestTimestamp: emails[0].timestamp, // First email is newest since we sorted desc
|
||||
emails: emails
|
||||
}))
|
||||
.sort((a, b) => new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
||||
import { uploadFile } from '~/server/utils/minio';
|
||||
import { uploadFile, getMinioClient } from '~/server/utils/minio';
|
||||
import { updateInterest } from '~/server/utils/nocodb';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -19,7 +19,8 @@ export default defineEventHandler(async (event) => {
|
|||
interestId,
|
||||
sessionId,
|
||||
includeSignature = true,
|
||||
signatureConfig
|
||||
signatureConfig,
|
||||
attachments = []
|
||||
} = body;
|
||||
|
||||
if (!to || !subject || !emailBody || !sessionId) {
|
||||
|
|
@ -82,6 +83,36 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Prepare email attachments
|
||||
const emailAttachments = [];
|
||||
if (attachments && attachments.length > 0) {
|
||||
const client = getMinioClient();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
// Download file from MinIO
|
||||
const stream = await client.getObject('portnimaradev', attachment.path);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
const content = Buffer.concat(chunks);
|
||||
|
||||
emailAttachments.push({
|
||||
filename: attachment.name,
|
||||
content: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to attach file ${attachment.name}:`, error);
|
||||
// Continue with other attachments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
const fromName = sig.name || defaultName;
|
||||
const info = await transporter.sendMail({
|
||||
|
|
@ -89,7 +120,8 @@ export default defineEventHandler(async (event) => {
|
|||
to: to,
|
||||
subject: subject,
|
||||
text: emailBody, // Plain text version
|
||||
html: htmlBody // HTML version with signature
|
||||
html: htmlBody, // HTML version with signature
|
||||
attachments: emailAttachments
|
||||
});
|
||||
|
||||
// Store email in MinIO for thread history and update EOI Time Sent
|
||||
|
|
|
|||
Loading…
Reference in New Issue