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:
Matt 2025-06-10 02:39:00 +02:00
parent bb2f5d37c8
commit b332f913a6
5 changed files with 134 additions and 51 deletions

View File

@ -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>

View File

@ -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) {

View File

@ -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"

View File

@ -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());
}

View File

@ -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