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>
|
</div>
|
||||||
|
|
||||||
<!-- EOI Status Badge -->
|
<!-- EOI Status Badge -->
|
||||||
<div v-if="hasEOI" class="mb-4">
|
<div v-if="hasEOI" class="mb-4 d-flex align-center">
|
||||||
<v-chip
|
<v-chip
|
||||||
:color="getStatusColor(interest['EOI Status'])"
|
:color="getStatusColor(interest['EOI Status'])"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
prepend-icon="mdi-file-document-check"
|
prepend-icon="mdi-file-document-check"
|
||||||
class="mr-2"
|
|
||||||
>
|
>
|
||||||
{{ interest['EOI Status'] }}
|
{{ interest['EOI Status'] }}
|
||||||
</v-chip>
|
</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']) }}
|
Sent: {{ formatDate(interest['EOI Time Sent']) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,21 @@
|
||||||
placeholder="Type your message here..."
|
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 -->
|
<!-- Quick Actions -->
|
||||||
<div class="d-flex ga-2 mb-4">
|
<div class="d-flex ga-2 mb-4">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|
@ -57,6 +72,15 @@
|
||||||
<v-icon start>mdi-form-select</v-icon>
|
<v-icon start>mdi-form-select</v-icon>
|
||||||
Insert Form Link
|
Insert Form Link
|
||||||
</v-btn>
|
</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-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="text"
|
variant="text"
|
||||||
|
|
@ -145,11 +169,42 @@
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted, watch } from 'vue';
|
import { ref, onMounted, watch } from 'vue';
|
||||||
import type { Interest } from '@/utils/types';
|
import type { Interest } from '@/utils/types';
|
||||||
|
import FileBrowserComponent from '~/pages/dashboard/file-browser.vue';
|
||||||
|
|
||||||
|
interface AttachedFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
interest: Interest;
|
interest: Interest;
|
||||||
|
|
@ -171,6 +226,8 @@ const sending = ref(false);
|
||||||
const generatingEOI = ref(false);
|
const generatingEOI = ref(false);
|
||||||
const showSignatureSettings = ref(false);
|
const showSignatureSettings = ref(false);
|
||||||
const includeSignature = ref(true);
|
const includeSignature = ref(true);
|
||||||
|
const showFileBrowser = ref(false);
|
||||||
|
const attachments = ref<AttachedFile[]>([]);
|
||||||
|
|
||||||
const email = ref({
|
const email = ref({
|
||||||
to: '',
|
to: '',
|
||||||
|
|
@ -269,6 +326,29 @@ const getSignaturePreview = () => {
|
||||||
</div>`;
|
</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 sendEmail = async () => {
|
||||||
const { valid } = await form.value.validate();
|
const { valid } = await form.value.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
@ -288,7 +368,8 @@ const sendEmail = async () => {
|
||||||
interestId: props.interest.Id,
|
interestId: props.interest.Id,
|
||||||
sessionId: getSessionId(),
|
sessionId: getSessionId(),
|
||||||
includeSignature: includeSignature.value,
|
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
|
// Clear form
|
||||||
email.value.subject = '';
|
email.value.subject = '';
|
||||||
email.value.body = '';
|
email.value.body = '';
|
||||||
|
attachments.value = [];
|
||||||
emit('sent', response.messageId);
|
emit('sent', response.messageId);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,7 @@
|
||||||
<v-icon start>mdi-email-outline</v-icon>
|
<v-icon start>mdi-email-outline</v-icon>
|
||||||
Request Info
|
Request Info
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<!-- EOI to Sales button hidden -->
|
||||||
@click="eoiSendToSales"
|
|
||||||
variant="text"
|
|
||||||
:loading="isSendingEOI"
|
|
||||||
:disabled="
|
|
||||||
isRequestingMoreInfo ||
|
|
||||||
isRequestingMoreInformation ||
|
|
||||||
isSendingEOI ||
|
|
||||||
isDeleting
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<v-icon start>mdi-send</v-icon>
|
|
||||||
EOI to Sales
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="confirmDelete"
|
@click="confirmDelete"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
|
@ -206,26 +193,7 @@
|
||||||
<span class="text-caption">Request Info</span>
|
<span class="text-caption">Request Info</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="12">
|
||||||
<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-btn
|
<v-btn
|
||||||
@click="saveInterest"
|
@click="saveInterest"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,9 @@ export default defineEventHandler(async (event) => {
|
||||||
new Map(allEmails.map(email => [email.id, email])).values()
|
new Map(allEmails.map(email => [email.id, email])).values()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort by timestamp
|
// Sort by timestamp (newest first)
|
||||||
uniqueEmails.sort((a, b) =>
|
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
|
// Group into threads
|
||||||
|
|
@ -414,12 +414,14 @@ function groupIntoThreads(emails: EmailMessage[]): any[] {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to array format
|
// Convert to array format and sort threads by latest timestamp (newest first)
|
||||||
return Array.from(threads.entries()).map(([threadId, emails]) => ({
|
return Array.from(threads.entries())
|
||||||
|
.map(([threadId, emails]) => ({
|
||||||
id: threadId,
|
id: threadId,
|
||||||
subject: emails[0].subject,
|
subject: emails[0].subject,
|
||||||
emailCount: emails.length,
|
emailCount: emails.length,
|
||||||
latestTimestamp: emails[emails.length - 1].timestamp,
|
latestTimestamp: emails[0].timestamp, // First email is newest since we sorted desc
|
||||||
emails: emails
|
emails: emails
|
||||||
}));
|
}))
|
||||||
|
.sort((a, b) => new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
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';
|
import { updateInterest } from '~/server/utils/nocodb';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -19,7 +19,8 @@ export default defineEventHandler(async (event) => {
|
||||||
interestId,
|
interestId,
|
||||||
sessionId,
|
sessionId,
|
||||||
includeSignature = true,
|
includeSignature = true,
|
||||||
signatureConfig
|
signatureConfig,
|
||||||
|
attachments = []
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!to || !subject || !emailBody || !sessionId) {
|
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
|
// Send email
|
||||||
const fromName = sig.name || defaultName;
|
const fromName = sig.name || defaultName;
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
|
|
@ -89,7 +120,8 @@ export default defineEventHandler(async (event) => {
|
||||||
to: to,
|
to: to,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
text: emailBody, // Plain text version
|
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
|
// Store email in MinIO for thread history and update EOI Time Sent
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue