Add EOI automation system with email processing and document management

- Implement automated EOI processing from sales emails
- Add EOI document upload and management capabilities
- Enhance email thread handling with better parsing and grouping
- Add retry logic and error handling for file operations
- Introduce Documeso integration for document processing
- Create server tasks and plugins infrastructure
- Update email composer with improved attachment handling
This commit is contained in:
2025-06-10 13:59:09 +02:00
parent 5c30411c2b
commit 218705da52
25 changed files with 2351 additions and 71 deletions

153
server/utils/documeso.ts Normal file
View File

@@ -0,0 +1,153 @@
// Documeso API client utilities
interface DocumesoConfig {
apiUrl: string;
apiKey: string;
}
interface DocumesoRecipient {
id: number;
documentId: number;
email: string;
name: string;
role: 'SIGNER' | 'APPROVER' | 'VIEWER';
signingOrder: number;
token: string;
signedAt: string | null;
readStatus: 'NOT_OPENED' | 'OPENED';
signingStatus: 'NOT_SIGNED' | 'SIGNED';
sendStatus: 'NOT_SENT' | 'SENT';
signingUrl: string;
}
interface DocumesoDocument {
id: number;
externalId: string;
userId: number;
teamId: number;
title: string;
status: 'DRAFT' | 'PENDING' | 'COMPLETED' | 'CANCELLED';
documentDataId: string;
createdAt: string;
updatedAt: string;
completedAt: string | null;
recipients: DocumesoRecipient[];
}
interface DocumesoListResponse {
documents: DocumesoDocument[];
total: number;
page: number;
perPage: number;
}
// Get Documeso configuration
const getDocumesoConfig = (): DocumesoConfig => {
return {
apiUrl: 'https://signatures.portnimara.dev/api/v1',
apiKey: 'Bearer api_malptg62zqyb0wrp'
};
};
// Fetch a single document by ID
export const getDocumesoDocument = async (documentId: number): Promise<DocumesoDocument> => {
const config = getDocumesoConfig();
try {
const response = await $fetch<DocumesoDocument>(`${config.apiUrl}/documents/${documentId}`, {
headers: {
'Authorization': config.apiKey,
'Content-Type': 'application/json'
}
});
return response;
} catch (error) {
console.error('Failed to fetch Documeso document:', error);
throw error;
}
};
// Search documents by external ID (e.g., 'loi-94')
export const searchDocumesoDocuments = async (externalId?: string): Promise<DocumesoDocument[]> => {
const config = getDocumesoConfig();
try {
const response = await $fetch<DocumesoListResponse>(`${config.apiUrl}/documents`, {
headers: {
'Authorization': config.apiKey,
'Content-Type': 'application/json'
},
params: {
perPage: 100
}
});
// If externalId is provided, filter by it
if (externalId) {
return response.documents.filter(doc => doc.externalId === externalId);
}
return response.documents;
} catch (error) {
console.error('Failed to search Documeso documents:', error);
throw error;
}
};
// Get document by external ID (e.g., 'loi-94')
export const getDocumesoDocumentByExternalId = async (externalId: string): Promise<DocumesoDocument | null> => {
const documents = await searchDocumesoDocuments(externalId);
return documents.length > 0 ? documents[0] : null;
};
// Check signature status for a document
export const checkDocumentSignatureStatus = async (documentId: number): Promise<{
documentStatus: string;
unsignedRecipients: DocumesoRecipient[];
signedRecipients: DocumesoRecipient[];
clientSigned: boolean;
allSigned: boolean;
}> => {
const document = await getDocumesoDocument(documentId);
const unsignedRecipients = document.recipients.filter(r => r.signingStatus === 'NOT_SIGNED');
const signedRecipients = document.recipients.filter(r => r.signingStatus === 'SIGNED');
// Check if client (signingOrder = 1) has signed
const clientRecipient = document.recipients.find(r => r.signingOrder === 1);
const clientSigned = clientRecipient ? clientRecipient.signingStatus === 'SIGNED' : false;
const allSigned = unsignedRecipients.length === 0;
return {
documentStatus: document.status,
unsignedRecipients,
signedRecipients,
clientSigned,
allSigned
};
};
// Get recipients who need to sign (excluding client)
export const getRecipientsToRemind = async (documentId: number): Promise<DocumesoRecipient[]> => {
const status = await checkDocumentSignatureStatus(documentId);
// Only remind if client has signed
if (!status.clientSigned) {
return [];
}
// Return unsigned recipients with signingOrder > 1
return status.unsignedRecipients.filter(r => r.signingOrder > 1);
};
// Format recipient name for emails
export const formatRecipientName = (recipient: DocumesoRecipient): string => {
const firstName = recipient.name.split(' ')[0];
return firstName;
};
// Get signing URL for a recipient
export const getSigningUrl = (recipient: DocumesoRecipient): string => {
return recipient.signingUrl;
};

View File

@@ -0,0 +1,81 @@
import { simpleParser } from 'mailparser';
import type { ParsedMail } from 'mailparser';
import Imap from 'imap';
export type { ParsedMail };
export interface EmailCredentials {
user: string;
password: string;
host: string;
port: number;
tls: boolean;
}
export async function parseEmail(emailContent: string): Promise<ParsedMail> {
return await simpleParser(emailContent);
}
export function getIMAPConnection(credentials: EmailCredentials): Promise<Imap> {
return new Promise((resolve, reject) => {
const imap = new Imap({
user: credentials.user,
password: credentials.password,
host: credentials.host,
port: credentials.port,
tls: credentials.tls,
tlsOptions: { rejectUnauthorized: false }
});
imap.once('ready', () => {
console.log('[IMAP] Connection ready');
resolve(imap);
});
imap.once('error', (err: Error) => {
console.error('[IMAP] Connection error:', err);
reject(err);
});
imap.connect();
});
}
export function searchEmails(imap: Imap, criteria: any[]): Promise<number[]> {
return new Promise((resolve, reject) => {
imap.search(criteria, (err: Error | null, results: number[]) => {
if (err) reject(err);
else resolve(results || []);
});
});
}
export function fetchEmail(imap: Imap, msgId: number, options: any): Promise<string> {
return new Promise((resolve, reject) => {
let emailData = '';
const fetch = imap.fetch(msgId, options);
fetch.on('message', (msg: any) => {
msg.on('body', (stream: any) => {
stream.on('data', (chunk: Buffer) => {
emailData += chunk.toString();
});
stream.once('end', () => {
resolve(emailData);
});
});
});
fetch.once('error', (err: Error) => {
reject(err);
});
fetch.once('end', () => {
if (!emailData) {
reject(new Error('No email data received'));
}
});
});
}

45
server/utils/email.ts Normal file
View File

@@ -0,0 +1,45 @@
import nodemailer from 'nodemailer';
interface EmailOptions {
from: string;
to: string;
subject: string;
html?: string;
text?: string;
}
interface SmtpConfig {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string;
};
}
export async function sendEmail(options: EmailOptions, config?: SmtpConfig) {
// Use provided config or default to environment config
const smtpConfig = config || {
host: process.env.SMTP_HOST || 'mail.portnimara.com',
port: parseInt(process.env.SMTP_PORT || '465'),
secure: process.env.SMTP_SECURE !== 'false',
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || ''
}
};
// Create transporter
const transporter = nodemailer.createTransport(smtpConfig);
// Send email
try {
const info = await transporter.sendMail(options);
console.log('Email sent:', info.messageId);
return info;
} catch (error) {
console.error('Failed to send email:', error);
throw error;
}
}

View File

@@ -297,3 +297,21 @@ export const renameFolder = async (oldPath: string, newPath: string) => {
});
});
};
// Create bucket if it doesn't exist
export const createBucketIfNotExists = async (bucketName?: string) => {
const client = getMinioClient();
const bucket = bucketName || useRuntimeConfig().minio.bucketName;
try {
const exists = await client.bucketExists(bucket);
if (!exists) {
await client.makeBucket(bucket);
console.log(`Bucket '${bucket}' created successfully`);
}
return true;
} catch (error) {
console.error('Error creating bucket:', error);
throw error;
}
};

View File

@@ -299,3 +299,34 @@ export const triggerWebhook = async (url: string, payload: any) =>
method: "POST",
body: payload,
});
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
// Get existing EOI Document array or create new one
const interest = await getInterestById(id);
const existingDocuments = interest['EOI Document'] || [];
// Add the new document to the array
const updatedDocuments = [...existingDocuments, documentData];
// Update the interest with the new EOI Document array
return updateInterest(id, {
'EOI Document': updatedDocuments
});
};
export const getInterestByFieldAsync = async (fieldName: string, value: any): Promise<Interest | null> => {
try {
const response = await getInterests();
const interests = response.list || [];
// Find interest where the field matches the value
const interest = interests.find(i => (i as any)[fieldName] === value);
return interest || null;
} catch (error) {
console.error('Error fetching interest by field:', error);
return null;
}
};