${sig.name || 'Your Name'}
-
${sig.title || 'Your Title'}
-
-
${sig.company || 'Company Name'}
-
+
${sig.title || 'Your Title'}
+
${sig.company || 'Company Name'}
${contactLines ? contactLines + '
' : ''}
${userEmail}
-

+
The information in this message is confidential and may be privileged.
diff --git a/components/InterestDetailsModal.vue b/components/InterestDetailsModal.vue
index 7c90c16..d491be8 100644
--- a/components/InterestDetailsModal.vue
+++ b/components/InterestDetailsModal.vue
@@ -626,10 +626,86 @@
+
+
+
+ mdi-link-variant
+ EOI Links
+
+
+
+
+
+
+ mdi-account
+
+
+ Client ({{ interest['Full Name'] }})
+ {{ (interest as any)['EOI Client Link'] }}
+
+
+
+
+
+
+
+
+ mdi-account-check
+
+
+ Oscar Faragher (Approver)
+ {{ (interest as any)['EOI Oscar Link'] }}
+
+
+
+
+
+
+
+
+ mdi-account-tie
+
+
+ David Mizrahi (Signer)
+ {{ (interest as any)['EOI David Link'] }}
+
+
+
+
+
+
+
+
@@ -723,6 +799,15 @@ const currentStep = computed(() => {
);
});
+const hasEOILinks = computed(() => {
+ if (!interest.value) return false;
+ return !!(
+ (interest.value as any)['EOI Client Link'] ||
+ (interest.value as any)['EOI Oscar Link'] ||
+ (interest.value as any)['EOI David Link']
+ );
+});
+
const closeModal = () => {
isOpen.value = false;
};
@@ -1092,6 +1177,41 @@ const deleteInterest = async () => {
}
};
+// Copy to clipboard function
+const copyToClipboard = async (text: string, recipient: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(`${recipient} link copied to clipboard!`);
+ } catch (err) {
+ console.error('Failed to copy text: ', err);
+ toast.error('Failed to copy link to clipboard');
+ }
+};
+
+// Handle interest updated event from EmailCommunication
+const onInterestUpdated = async () => {
+ // Reload the interest data
+ if (interest.value) {
+ try {
+ const updatedInterest = await $fetch
(`/api/get-interest-by-id`, {
+ headers: {
+ "x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
+ },
+ params: {
+ id: interest.value.Id,
+ },
+ });
+
+ if (updatedInterest) {
+ interest.value = { ...updatedInterest };
+ emit("save", interest.value); // Trigger parent refresh
+ }
+ } catch (error) {
+ console.error('Failed to reload interest:', error);
+ }
+ }
+};
+
// Load berths when component mounts
onMounted(() => {
loadAvailableBerths();
diff --git a/server/api/email/fetch-thread.ts b/server/api/email/fetch-thread.ts
index 9f763f0..9f914a9 100644
--- a/server/api/email/fetch-thread.ts
+++ b/server/api/email/fetch-thread.ts
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
- const { clientEmail, interestId, sessionId, limit = 50 } = body;
+ const { clientEmail, interestId, sessionId, limit = 20 } = body;
if (!clientEmail || !sessionId) {
throw createError({
@@ -50,14 +50,20 @@ export default defineEventHandler(async (event) => {
if (interestId) {
try {
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
+ console.log('Found cached email files:', files.length);
+
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}`);
+ // Use the getDownloadUrl function to get a proper presigned URL
+ const { getDownloadUrl } = await import('~/server/utils/minio');
+ const downloadUrl = await getDownloadUrl(file.name);
+
+ const response = await fetch(downloadUrl);
const emailData = await response.json();
cachedEmails.push(emailData);
} catch (err) {
- console.error('Failed to read cached email:', err);
+ console.error('Failed to read cached email:', file.name, err);
}
}
}
@@ -75,121 +81,26 @@ export default defineEventHandler(async (event) => {
tls: true,
tlsOptions: {
rejectUnauthorized: false
- }
+ },
+ connTimeout: 10000, // 10 seconds connection timeout
+ authTimeout: 5000 // 5 seconds auth timeout
};
- // Fetch emails from IMAP
- const imapEmails: EmailMessage[] = await new Promise((resolve, reject) => {
- const emails: EmailMessage[] = [];
- const imap = new Imap(imapConfig);
+ // Fetch emails from IMAP with timeout
+ let imapEmails: EmailMessage[] = [];
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('IMAP connection timeout')), 15000)
+ );
- 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();
- });
+ try {
+ imapEmails = await Promise.race([
+ fetchImapEmails(imapConfig, userEmail, clientEmail, limit),
+ timeoutPromise
+ ]);
+ } catch (imapError) {
+ console.error('IMAP fetch failed:', imapError);
+ // Continue with cached emails only
+ }
// Combine cached and IMAP emails, remove duplicates
const allEmails = [...cachedEmails, ...imapEmails];
@@ -226,6 +137,136 @@ export default defineEventHandler(async (event) => {
}
});
+// Separate function for IMAP fetching with proper cleanup
+async function fetchImapEmails(
+ imapConfig: any,
+ userEmail: string,
+ clientEmail: string,
+ limit: number
+): Promise {
+ return new Promise((resolve, reject) => {
+ const emails: EmailMessage[] = [];
+ const imap = new Imap(imapConfig);
+ let isResolved = false;
+
+ const cleanup = () => {
+ if (!isResolved) {
+ isResolved = true;
+ try {
+ imap.end();
+ } catch (e) {
+ console.error('Error closing IMAP connection:', e);
+ }
+ }
+ };
+
+ imap.once('ready', () => {
+ imap.openBox('INBOX', true, (err, box) => {
+ if (err) {
+ cleanup();
+ reject(err);
+ return;
+ }
+
+ const searchCriteria = [
+ ['OR', ['FROM', clientEmail], ['TO', clientEmail]]
+ ];
+
+ imap.search(searchCriteria, (err, results) => {
+ if (err) {
+ cleanup();
+ reject(err);
+ return;
+ }
+
+ if (!results || results.length === 0) {
+ cleanup();
+ resolve(emails);
+ return;
+ }
+
+ const messagesToFetch = results.slice(-limit);
+ let messagesProcessed = 0;
+
+ 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);
+ messagesProcessed++;
+ if (messagesProcessed === messagesToFetch.length) {
+ cleanup();
+ resolve(emails);
+ }
+ 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'
+ };
+
+ if (parsed.headers.has('in-reply-to')) {
+ email.threadId = parsed.headers.get('in-reply-to') as string;
+ }
+
+ emails.push(email);
+ messagesProcessed++;
+
+ if (messagesProcessed === messagesToFetch.length) {
+ cleanup();
+ resolve(emails);
+ }
+ });
+ });
+ });
+
+ fetch.once('error', (err) => {
+ console.error('Fetch error:', err);
+ cleanup();
+ reject(err);
+ });
+
+ fetch.once('end', () => {
+ if (messagesProcessed === 0) {
+ cleanup();
+ resolve(emails);
+ }
+ });
+ });
+ });
+ });
+
+ imap.once('error', (err: any) => {
+ cleanup();
+ reject(err);
+ });
+
+ imap.once('end', () => {
+ if (!isResolved) {
+ isResolved = true;
+ resolve(emails);
+ }
+ });
+
+ imap.connect();
+ });
+}
+
// Group emails into threads based on subject and references
function groupIntoThreads(emails: EmailMessage[]): any[] {
const threads = new Map();
diff --git a/server/api/email/generate-eoi-document.ts b/server/api/email/generate-eoi-document.ts
new file mode 100644
index 0000000..217951d
--- /dev/null
+++ b/server/api/email/generate-eoi-document.ts
@@ -0,0 +1,273 @@
+import { getInterestById, updateInterest } from '~/server/utils/nocodb';
+
+interface DocumensoRecipient {
+ id: number;
+ name: string;
+ email: string;
+ role: 'SIGNER' | 'APPROVER';
+ signingOrder: number;
+ signingUrl?: string;
+}
+
+interface DocumensoResponse {
+ id: string;
+ recipients: DocumensoRecipient[];
+}
+
+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 { interestId } = body;
+
+ if (!interestId) {
+ throw createError({ statusCode: 400, statusMessage: "Interest ID is required" });
+ }
+
+ // Get the interest data
+ const interest = await getInterestById(interestId);
+ if (!interest) {
+ throw createError({ statusCode: 404, statusMessage: "Interest not found" });
+ }
+
+ // Validate required fields
+ const requiredFields = [
+ { field: 'Full Name', value: interest['Full Name'] },
+ { field: 'Email Address', value: interest['Email Address'] },
+ { field: 'Address', value: interest['Address'] },
+ { field: 'Yacht Name', value: interest['Yacht Name'] },
+ { field: 'Length', value: interest['Length'] },
+ { field: 'Width', value: interest['Width'] },
+ { field: 'Depth', value: interest['Depth'] }
+ ];
+
+ const missingFields = requiredFields.filter(f => !f.value).map(f => f.field);
+ if (missingFields.length > 0) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: `Missing required fields: ${missingFields.join(', ')}`
+ });
+ }
+
+ // Get linked berths
+ const berthsResponse = await $fetch<{ list: Array<{ 'Mooring Number': string }> }>(
+ "/api/get-interest-berths",
+ {
+ headers: {
+ "x-tag": xTagHeader,
+ },
+ params: {
+ interestId: interestId,
+ linkType: "berths",
+ },
+ }
+ );
+
+ const berths = berthsResponse.list || [];
+ if (berths.length === 0) {
+ throw createError({
+ statusCode: 400,
+ statusMessage: "No berths linked to this interest. Please link at least one berth."
+ });
+ }
+
+ // Concatenate berth numbers
+ const berthNumbers = berths.map(b => b['Mooring Number']).join(', ');
+
+ // Documenso API configuration
+ const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY || 'api_malptg62zqyb0wrp';
+ const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL || 'https://signatures.portnimara.dev';
+ const templateId = '9';
+
+ // 1. Get template (optional - just to verify it exists)
+ try {
+ const templateResponse = await fetch(`${documensoBaseUrl}/api/v1/templates/${templateId}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${documensoApiKey}`
+ }
+ });
+
+ if (!templateResponse.ok) {
+ throw new Error(`Template not found: ${templateResponse.statusText}`);
+ }
+ } catch (error) {
+ console.error('Failed to verify template:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: "Failed to verify template. Please check Documenso configuration."
+ });
+ }
+
+ // 2. Create document
+ const createDocumentPayload = {
+ meta: {
+ message: `Dear ${interest['Full Name']},\n\nThank you for your interest in a berth at Port Nimara. Please click the link above to sign your LOI.\n\nBest Regards,\nPort Nimara Team`,
+ subject: "Your LOI is ready to be signed",
+ redirectUrl: "https://portnimara.com",
+ distributionMethod: "SEQUENTIAL"
+ },
+ title: `${interest['Full Name']}-EOI-NDA`,
+ externalId: `loi-${interestId}`,
+ formValues: {
+ "Name": interest['Full Name'],
+ "Draft": interest['Depth'],
+ "Email": interest['Email Address'],
+ "Width": interest['Width'],
+ "Length": interest['Length'],
+ "Address": interest['Address'],
+ "Lease_10": false,
+ "Purchase": true,
+ "Yacht Name": interest['Yacht Name'],
+ "Berth Number": berthNumbers
+ },
+ recipients: [
+ {
+ id: 155,
+ name: interest['Full Name'],
+ role: "SIGNER",
+ email: interest['Email Address'],
+ signingOrder: 1
+ },
+ {
+ id: 156,
+ name: "David Mizrahi",
+ role: "SIGNER",
+ email: "dm@portnimara.com",
+ signingOrder: 3
+ },
+ {
+ id: 157,
+ name: "Oscar Faragher",
+ role: "APPROVER",
+ email: "sales@portnimara.com",
+ signingOrder: 2
+ }
+ ]
+ };
+
+ let documentResponse: DocumensoResponse;
+ try {
+ const response = await fetch(`${documensoBaseUrl}/api/v1/templates/${templateId}/generate-document`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${documensoApiKey}`
+ },
+ body: JSON.stringify(createDocumentPayload)
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('Failed to create document:', errorText);
+ throw new Error(`Failed to create document: ${response.statusText}`);
+ }
+
+ documentResponse = await response.json();
+ } catch (error) {
+ console.error('Document creation error:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: "Failed to create EOI document. Please try again."
+ });
+ }
+
+ // 3. Setup completion emails
+ try {
+ const completionResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documentResponse.id}/send`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${documensoApiKey}`
+ },
+ body: JSON.stringify({
+ sendEmail: false,
+ sendCompletionEmails: true
+ })
+ });
+
+ if (!completionResponse.ok) {
+ console.error('Failed to setup completion emails:', await completionResponse.text());
+ // Don't fail the whole process if this fails
+ }
+ } catch (error) {
+ console.error('Completion email setup error:', error);
+ // Continue anyway
+ }
+
+ // Extract signing URLs from recipients
+ const signingLinks: Record = {};
+ if (documentResponse.recipients) {
+ documentResponse.recipients.forEach(recipient => {
+ if (recipient.signingUrl) {
+ if (recipient.email === interest['Email Address']) {
+ signingLinks['Client'] = recipient.signingUrl;
+ } else if (recipient.email === 'dm@portnimara.com') {
+ signingLinks['David Mizrahi'] = recipient.signingUrl;
+ } else if (recipient.email === 'sales@portnimara.com') {
+ signingLinks['Oscar Faragher'] = recipient.signingUrl;
+ }
+ }
+ });
+ }
+
+ // 4. Update interest record
+ const currentDate = new Date();
+ const dateTimeString = currentDate.toLocaleString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: true
+ });
+
+ const extraComments = interest['Extra Comments'] || '';
+ const updatedComments = extraComments + (extraComments ? '\n' : '') + `EOI Sent ${dateTimeString}`;
+
+ const updateData: any = {
+ 'EOI Status': 'Waiting for Signatures',
+ 'Sales Process Level': 'LOI and NDA Sent',
+ 'EOI Time Sent': currentDate.toISOString(),
+ 'Extra Comments': updatedComments
+ };
+
+ // Add signing links to update data
+ if (signingLinks['Client']) {
+ updateData['EOI Client Link'] = signingLinks['Client'];
+ }
+ if (signingLinks['David Mizrahi']) {
+ updateData['EOI David Link'] = signingLinks['David Mizrahi'];
+ }
+ if (signingLinks['Oscar Faragher']) {
+ updateData['EOI Oscar Link'] = signingLinks['Oscar Faragher'];
+ }
+
+ await updateInterest(interestId, updateData);
+
+ return {
+ success: true,
+ documentId: documentResponse.id,
+ clientSigningUrl: signingLinks['Client'] || '',
+ signingLinks: signingLinks
+ };
+ } catch (error) {
+ console.error('Failed to generate EOI document:', error);
+ if (error instanceof Error) {
+ throw createError({
+ statusCode: 500,
+ statusMessage: error.message || "Failed to generate EOI document"
+ });
+ } else {
+ throw createError({
+ statusCode: 500,
+ statusMessage: "An unexpected error occurred",
+ });
+ }
+ }
+});
diff --git a/server/api/email/send.ts b/server/api/email/send.ts
index 48485a9..6bc3965 100644
--- a/server/api/email/send.ts
+++ b/server/api/email/send.ts
@@ -47,16 +47,15 @@ export default defineEventHandler(async (event) => {
const sig = signatureConfig || {};
const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('
') : '';
const signature = includeSignature ? `
+
${sig.name || defaultName}
-
${sig.title || 'Sales & Marketing Director'}
-
-
${sig.company || 'Port Nimara'}
-
+
${sig.title || 'Sales & Marketing Director'}
+
${sig.company || 'Port Nimara'}
${contactLines ? contactLines + '
' : ''}
${sig.email || email}
-

+
The information in this message is confidential and may be privileged.