Files
Atlas/lib/email-sync.ts
Julia Wehden a2c95c70e7 feat: Equipment-System, Buchungsbearbeitung, Kundenadresse, LexOffice-Fix
- Vintage Modell hinzugefuegt
- Equipment Multi-Select (Neue Buchung + Bearbeitung)
- Kundenadresse in Formularen
- Bearbeiten-Seite fuer Buchungen
- Abbau-Zeiten in Formularen und Uebersicht
- Vertrag PDF nur bei Privatkunden
- LexOffice Kontakt-Erstellung Fix (BUSINESS)
- Zurueck-Pfeil auf Touren-Seite
2026-03-19 16:21:55 +01:00

362 lines
12 KiB
TypeScript

import Imap from 'imap';
import { simpleParser } from 'mailparser';
import { prisma } from './prisma';
import { emailParser } from './email-parser';
import { bookingAutomationService } from './booking-automation';
export interface ImapConfig {
host: string;
port: number;
user: string;
password: string;
secure: boolean;
}
export class EmailSyncService {
async syncLocationEmails(locationId: string): Promise<{
success: boolean;
newEmails: number;
newBookings: number;
errors: string[];
}> {
console.log('=== Starting email sync for location:', locationId);
const location = await prisma.location.findUnique({
where: { id: locationId },
});
console.log('Location found:', location?.name, 'emailSyncEnabled:', location?.emailSyncEnabled);
if (!location || !location.emailSyncEnabled) {
return { success: false, newEmails: 0, newBookings: 0, errors: ['Location not found or sync disabled'] };
}
if (!location.imapHost || !location.imapUser || !location.imapPassword) {
console.log('IMAP config incomplete:', {
host: !!location.imapHost,
user: !!location.imapUser,
password: !!location.imapPassword,
});
return { success: false, newEmails: 0, newBookings: 0, errors: ['IMAP configuration incomplete'] };
}
const config: ImapConfig = {
host: location.imapHost,
port: location.imapPort || 993,
user: location.imapUser,
password: location.imapPassword,
secure: location.imapSecure ?? true,
};
console.log('IMAP config:', { host: config.host, port: config.port, user: config.user, secure: config.secure });
return this.fetchNewEmails(config, location.slug, locationId);
}
private async fetchNewEmails(
config: ImapConfig,
locationSlug: string,
locationId: string
): Promise<{
success: boolean;
newEmails: number;
newBookings: number;
errors: string[];
}> {
console.log('=== fetchNewEmails called for', locationSlug);
return new Promise((resolve) => {
const imap = new Imap({
user: config.user,
password: config.password,
host: config.host,
port: config.port,
tls: config.secure,
tlsOptions: { rejectUnauthorized: false },
});
console.log('IMAP instance created');
let newEmails = 0;
let newBookings = 0;
const errors: string[] = [];
imap.once('ready', () => {
console.log('IMAP connection ready!');
imap.openBox('INBOX', false, (err, box) => {
if (err) {
errors.push(`Failed to open inbox: ${err.message}`);
imap.end();
return;
}
// Fetch all emails from last 30 days (including seen/read ones for testing)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
imap.search([['SINCE', thirtyDaysAgo]], async (err, results) => {
if (err) {
console.error('IMAP search error:', err);
errors.push(`Search failed: ${err.message}`);
imap.end();
return;
}
console.log('IMAP search results:', results?.length || 0, 'emails found');
if (!results || results.length === 0) {
console.log('No emails found in search');
imap.end();
resolve({ success: true, newEmails: 0, newBookings: 0, errors });
return;
}
console.log('Fetching', results.length, 'emails...');
const fetch = imap.fetch(results, { bodies: '' });
fetch.on('message', (msg: any, seqno: number) => {
console.log('Processing email', seqno);
msg.on('body', (stream: any, info: any) => {
simpleParser(stream, async (err: any, parsed: any) => {
if (err) {
console.error('Parse error for email', seqno, ':', err);
errors.push(`Failed to parse email ${seqno}: ${err.message}`);
return;
}
console.log('Parsed email:', {
subject: parsed.subject,
from: parsed.from?.text,
messageId: parsed.messageId,
});
try {
// Save email to database
const existingEmail = await prisma.email.findUnique({
where: { messageId: parsed.messageId },
});
if (existingEmail) {
console.log('Email already exists, skipping:', parsed.messageId);
return; // Already processed
}
console.log('Saving new email to database...');
const emailRecord = await prisma.email.create({
data: {
locationSlug,
from: parsed.from?.text || '',
to: parsed.to?.text || '',
subject: parsed.subject || '',
textBody: parsed.text || '',
htmlBody: parsed.html || '',
messageId: parsed.messageId || undefined,
direction: 'INBOUND',
receivedAt: parsed.date || new Date(),
},
});
console.log('✅ Email saved with ID:', emailRecord.id);
newEmails++;
// Try to parse as Ninjaforms booking
console.log('Attempting to parse as Ninjaforms email...');
console.log('Email subject:', parsed.subject);
console.log('Text body length:', parsed.text?.length || 0);
console.log('HTML body length:', parsed.html?.length || 0);
console.log('First 500 chars of content:', (parsed.html || parsed.text || '').substring(0, 500));
const parsedData = emailParser.parseFromNinjaFormsEmail(
parsed.html || parsed.text || '',
parsed.subject || ''
);
console.log('Parse result:', parsedData ? '✅ Parsed successfully' : '❌ Not a Ninjaforms email');
if (parsedData) {
console.log('Parsed data:', JSON.stringify(parsedData, null, 2));
}
if (parsedData) {
console.log('Creating booking from parsed data:', parsedData);
// Create booking from parsed data
const booking = await this.createBookingFromParsedData(parsedData, locationSlug);
if (booking) {
console.log('✅ Booking created:', booking.id);
newBookings++;
// Update email with booking ID
await prisma.email.update({
where: { id: emailRecord.id },
data: {
parsed: true,
parsedData: parsedData as any,
bookingId: booking.id,
},
});
// Create notification
await prisma.notification.create({
data: {
type: 'NEW_BOOKING_EMAIL',
title: 'Neue Buchung per E-Mail',
message: `${parsedData.firstName || parsedData.companyName} - ${parsedData.eventDate}`,
metadata: {
bookingId: booking.id,
emailId: emailRecord.id,
},
},
});
console.log('✅ Notification created');
} else {
console.log('❌ Booking creation failed');
}
}
} catch (error: any) {
errors.push(`Failed to process email ${seqno}: ${error.message}`);
}
});
});
});
fetch.once('end', async () => {
// Update last sync time
await prisma.location.update({
where: { id: locationId },
data: { lastEmailSync: new Date() },
});
imap.end();
resolve({ success: true, newEmails, newBookings, errors });
});
});
});
});
imap.once('error', (err: any) => {
errors.push(`IMAP connection error: ${err.message}`);
resolve({ success: false, newEmails, newBookings, errors });
});
imap.connect();
});
}
private parseDateString(dateString: string): Date | null {
if (!dateString) return null;
// Try German format: DD.MM.YYYY or D.M.YYYY
const germanMatch = dateString.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (germanMatch) {
const [, day, month, year] = germanMatch;
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
// Try ISO format or other standard formats
const date = new Date(dateString);
return isNaN(date.getTime()) ? null : date;
}
private async createBookingFromParsedData(data: any, locationSlug: string) {
try {
const location = await prisma.location.findUnique({
where: { slug: locationSlug },
});
if (!location) return null;
// Parse German date format
const eventDate = this.parseDateString(data.eventDate);
if (!eventDate) {
console.error('Invalid event date:', data.eventDate);
return null;
}
const startOfDay = new Date(eventDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(eventDate);
endOfDay.setHours(23, 59, 59, 999);
const availablePhotobox = await prisma.photobox.findFirst({
where: {
locationId: location.id,
model: data.model,
active: true,
NOT: {
bookings: {
some: {
eventDate: {
gte: startOfDay,
lte: endOfDay,
},
status: {
in: ['RESERVED', 'CONFIRMED'],
},
},
},
},
},
});
if (!availablePhotobox) {
console.warn('No photobox available for parsed booking');
return null;
}
const bookingNumber = this.generateBookingNumber();
const booking = await prisma.booking.create({
data: {
bookingNumber,
locationId: location.id,
photoboxId: availablePhotobox.id,
status: 'RESERVED',
customerName: data.companyName || `${data.firstName || ''} ${data.lastName || ''}`.trim(),
customerEmail: data.email,
customerPhone: data.phone,
customerAddress: data.street,
customerCity: data.city,
customerZip: data.zip,
invoiceType: data.companyName ? 'BUSINESS' : 'PRIVATE',
companyName: data.companyName,
eventDate: eventDate,
eventAddress: data.eventLocation || '',
eventCity: data.city || '',
eventZip: data.zip || '',
eventLocation: data.eventLocation,
setupTimeStart: eventDate,
setupTimeLatest: eventDate,
calculatedPrice: data.calculatedPrice || 0,
notes: data.message,
},
});
// 🤖 Automatische Post-Booking Aktionen
console.log('📢 Starte automatische Aktionen...');
bookingAutomationService.runPostBookingActions(booking.id).catch(err => {
console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err);
});
return booking;
} catch (error) {
console.error('Failed to create booking from parsed data:', error);
return null;
}
}
private generateBookingNumber(): string {
const date = new Date();
const year = date.getFullYear().toString().slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `STM-${year}${month}-${random}`;
}
}
export const emailSyncService = new EmailSyncService();