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

View File

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

View File

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

View File

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

View File

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