1. Import

This commit is contained in:
2026-01-22 19:40:24 +01:00
parent bf050e8cb3
commit e46f12807a
22 changed files with 2517 additions and 66 deletions

View File

@@ -1,83 +1,61 @@
# AI TOOL GUIDANCE # CLAUDE.md
This file provides guidance when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Technology Stack ## Technology Stack
This is a Vaadin application built with: - **Java 21**
- Java - **Vaadin 25.0.3** (server-side Java UI framework with Lumo theme)
- Spring Boot - **Spring Boot 4.0.1**
- Spring Data JPA with H2 database - **Maven build system**
- Maven build system
## Development Commands ## Development Commands
### Running the Application
```bash ```bash
./mvnw # Start in development mode (default goal: spring-boot:run) ./mvnw # Start in development mode (default goal)
./mvnw spring-boot:run # Explicit development mode ./mvnw spring-boot:run # Explicit development mode
```
The application will be available at http://localhost:8080
### Building for Production
```bash
./mvnw -Pproduction package # Build production JAR
docker build -t my-application:latest . # Build Docker image
```
### Testing
```bash
./mvnw test # Run all tests ./mvnw test # Run all tests
./mvnw test -Dtest=TaskServiceTest # Run a single test class ./mvnw test -Dtest=MyTest # Run a single test class
./mvnw test -Dtest=TaskServiceTest#tasks_are_stored_in_the_database_with_the_current_timestamp # Run a single test method ./mvnw test -Dtest=MyTest#method # Run a single test method
./mvnw -Pproduction package # Build production JAR
``` ```
## Architecture Application runs at http://localhost:8080 (auto-launches browser in dev mode).
This project follows a **feature-based package structure** rather than traditional layered architecture. Code is organized by functional units (features), not by technical layers. ## Project Structure
### Package Structure ```
de.assecutor.aimailassistant/
├── Application.java # Entry point with @SpringBootApplication
└── [feature packages] # Add feature packages here
```
- **`de.assecutor.aimailassistant.base`**: Reusable components and base classes for all features This project uses **feature-based packaging**: each feature is self-contained with its own entities, repositories, services, and UI views.
- `base.ui.MainLayout`: AppLayout with drawer navigation using SideNav, automatically populated from @Menu annotations
- `base.ui.component.ViewToolbar`: Reusable toolbar component for views
- **`de.assecutor.aimailassistant.examplefeature`**: Example feature demonstrating the structure ## Architecture Guidelines
- `Task.java`: JPA entity with validation
- `TaskRepository.java`: Spring Data JPA repository
- `TaskService.java`: Service layer with @Transactional methods
- `ui.TaskListView.java`: Vaadin Flow view component (server-side UI)
- `TaskServiceTest.java`: Integration test using @SpringBootTest
- **`Application.java`**: Main entry point, annotated with @SpringBootApplication and @Theme("default") ### Adding Features
### Key Architecture Patterns Create new packages under `de.assecutor.aimailassistant` (e.g., `de.assecutor.aimailassistant.mail`). Each feature package should contain:
- JPA entities
- Spring Data repositories
- Service classes with `@Transactional`
- Vaadin UI views
1. **Feature Packages**: Each feature is self-contained with its own UI, business logic, data access, and tests ### Vaadin Patterns
2. **Navigation**: Views use `@Route` and `@Menu` annotations. MainLayout automatically builds navigation from menu entries
3. **Service Layer**: Use `@Transactional` for write operations and `@Transactional(readOnly = true)` for read operations
4. **Validation**: Domain validation in entity setters (see Task.setDescription)
5. **Dependency Injection**: Constructor injection throughout (no @Autowired on fields)
## Adding New Features - **Server-side rendering**: UI components are Java classes
- **Routing**: Use `@Route("path")` annotation on view classes
- **Navigation**: Use `@Menu` annotation to add views to automatic navigation
- **Lazy loading**: Use `VaadinSpringDataHelpers.toSpringPageRequest(query)` for Grid pagination
- **Styling**: Custom styles in `src/main/resources/META-INF/resources/styles.css`
When creating a new feature: ### Spring Patterns
1. Create a new package under `de.assecutor.aimailassistant` (e.g., `de.assecutor.aimailassistant.myfeature`)
2. Include: Entity, Repository, Service, and UI view classes
3. Use the `examplefeature` package as a reference
4. Once your features are complete, **delete the `examplefeature` package entirely**
## Vaadin-Specific Notes - Constructor injection (no `@Autowired` on fields)
- `@Transactional` for write operations
- **Server-side rendering**: UI components are Java classes extending Vaadin components - `@Transactional(readOnly = true)` for read operations
- **Grid lazy loading**: Use `VaadinSpringDataHelpers.toSpringPageRequest(query)` for pagination
- **Themes**: Located in `src/main/frontend/themes/default/`, based on Lumo theme
- **Routing**: `@Route("")` for root path, `@Route("path")` for specific paths
- **Menu**: `@Menu` annotation controls navigation items (order, icon, title)
## Database ## Database
- H2 in-memory database for development No database configured yet. To add persistence, include Spring Data JPA and a database driver in pom.xml.
- JPA entities use `@GeneratedValue(strategy = GenerationType.SEQUENCE)`
- Entity equality based on ID (see Task.equals/hashCode pattern)

34
pom.xml
View File

@@ -45,6 +45,40 @@
<groupId>com.vaadin</groupId> <groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId> <artifactId>vaadin-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- Spring Data JPA + H2 Database -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring Mail for IMAP/SMTP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.angus</groupId>
<artifactId>angus-mail</artifactId>
</dependency>
<!-- Spring WebFlux for LLM API calls -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
html, body, #outlet {
height: 100%;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>

View File

@@ -1,20 +1,22 @@
package de.assecutor.aimailassistant; package de.assecutor.aimailassistant;
import com.vaadin.flow.theme.lumo.Lumo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import com.vaadin.flow.component.dependency.StyleSheet; import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.lumo.Lumo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@StyleSheet(Lumo.STYLESHEET) // Use Aura.STYLESHEET to use Aura instead @EnableScheduling
@Push
@StyleSheet(Lumo.STYLESHEET)
@StyleSheet(Lumo.UTILITY_STYLESHEET) @StyleSheet(Lumo.UTILITY_STYLESHEET)
@StyleSheet("styles.css") // Your custom styles @StyleSheet("styles.css")
public class Application implements AppShellConfigurator { public class Application implements AppShellConfigurator {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(Application.class, args); SpringApplication.run(Application.class, args);
} }
} }

View File

@@ -0,0 +1,17 @@
package de.assecutor.aimailassistant.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
}

View File

@@ -0,0 +1,17 @@
package de.assecutor.aimailassistant.mail.domain;
public enum EmailType {
ORDER("Auftrag"),
QUOTE_REQUEST("Angebotsanfrage"),
UNKNOWN("Unbekannt");
private final String displayName;
EmailType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}

View File

@@ -0,0 +1,17 @@
package de.assecutor.aimailassistant.mail.domain;
public enum Gender {
MALE("Herr"),
FEMALE("Frau"),
UNKNOWN("Unbekannt");
private final String displayName;
Gender(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}

View File

@@ -0,0 +1,163 @@
package de.assecutor.aimailassistant.mail.domain;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.Objects;
@Entity
@Table(name = "order_emails")
public class OrderEmail {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_email_seq")
@SequenceGenerator(name = "order_email_seq", sequenceName = "order_email_seq", allocationSize = 1)
private Long id;
@Column(nullable = false)
private String fromAddress;
private String fromName;
@Column(nullable = false)
private String subject;
@Column(columnDefinition = "TEXT")
private String content;
@Column(nullable = false)
private LocalDateTime receivedDate;
private boolean processed;
private boolean confirmed;
private boolean deleted;
@Column(columnDefinition = "TEXT")
private String summaryJson;
@Enumerated(EnumType.STRING)
private EmailType type = EmailType.UNKNOWN;
@Column(unique = true)
private String messageId;
public OrderEmail() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFromAddress() {
return fromAddress;
}
public void setFromAddress(String fromAddress) {
this.fromAddress = fromAddress;
}
public String getFromName() {
return fromName;
}
public void setFromName(String fromName) {
this.fromName = fromName;
}
public String getSubject() {
return subject;
}
public void setSubject(String subject) {
this.subject = subject;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getReceivedDate() {
return receivedDate;
}
public void setReceivedDate(LocalDateTime receivedDate) {
this.receivedDate = receivedDate;
}
public boolean isProcessed() {
return processed;
}
public void setProcessed(boolean processed) {
this.processed = processed;
}
public boolean isConfirmed() {
return confirmed;
}
public void setConfirmed(boolean confirmed) {
this.confirmed = confirmed;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public String getSummaryJson() {
return summaryJson;
}
public void setSummaryJson(String summaryJson) {
this.summaryJson = summaryJson;
}
public EmailType getType() {
return type;
}
public void setType(EmailType type) {
this.type = type;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getDisplaySender() {
if (fromName != null && !fromName.isBlank()) {
return fromName;
}
return fromAddress;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderEmail that = (OrderEmail) o;
return id != null && Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@@ -0,0 +1,29 @@
package de.assecutor.aimailassistant.mail.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface OrderEmailRepository extends JpaRepository<OrderEmail, Long> {
List<OrderEmail> findAllByDeletedFalseOrderByReceivedDateDesc();
// Sortierung: Neue Emails zuerst (processed=false), dann verarbeitet (confirmed=false), dann bestätigt
// Nur nicht-gelöschte Emails anzeigen
@Query("SELECT e FROM OrderEmail e WHERE e.deleted = false ORDER BY " +
"CASE WHEN e.processed = false THEN 0 " +
"WHEN e.confirmed = false THEN 1 " +
"ELSE 2 END, " +
"e.receivedDate DESC")
List<OrderEmail> findAllSortedByStatusAndDate();
List<OrderEmail> findByProcessedFalseAndDeletedFalseOrderByReceivedDateDesc();
Optional<OrderEmail> findByMessageId(String messageId);
boolean existsByMessageId(String messageId);
}

View File

@@ -0,0 +1,64 @@
package de.assecutor.aimailassistant.mail.domain;
import java.util.ArrayList;
import java.util.List;
public class OrderSummary {
private EmailType orderType;
private String requesterName;
private String requesterCompany;
private Gender requesterGender = Gender.UNKNOWN;
private List<Station> stations = new ArrayList<>();
private String remarks;
public OrderSummary() {
}
public EmailType getOrderType() {
return orderType;
}
public void setOrderType(EmailType orderType) {
this.orderType = orderType;
}
public String getRequesterName() {
return requesterName;
}
public void setRequesterName(String requesterName) {
this.requesterName = requesterName;
}
public String getRequesterCompany() {
return requesterCompany;
}
public void setRequesterCompany(String requesterCompany) {
this.requesterCompany = requesterCompany;
}
public Gender getRequesterGender() {
return requesterGender;
}
public void setRequesterGender(Gender requesterGender) {
this.requesterGender = requesterGender;
}
public List<Station> getStations() {
return stations;
}
public void setStations(List<Station> stations) {
this.stations = stations;
}
public String getRemarks() {
return remarks;
}
public void setRemarks(String remarks) {
this.remarks = remarks;
}
}

View File

@@ -0,0 +1,40 @@
package de.assecutor.aimailassistant.mail.domain;
public class Station {
private String name;
private String address;
private StationAction action;
public Station() {
}
public Station(String name, String address, StationAction action) {
this.name = name;
this.address = address;
this.action = action;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public StationAction getAction() {
return action;
}
public void setAction(StationAction action) {
this.action = action;
}
}

View File

@@ -0,0 +1,17 @@
package de.assecutor.aimailassistant.mail.domain;
public enum StationAction {
PICKUP("Abholung"),
DELIVERY("Zustellung"),
BOTH("Abholung & Zustellung");
private final String displayName;
StationAction(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}

View File

@@ -0,0 +1,20 @@
package de.assecutor.aimailassistant.mail.event;
import com.vaadin.flow.shared.Registration;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
public class EmailBroadcaster {
private static final CopyOnWriteArrayList<Consumer<Void>> listeners = new CopyOnWriteArrayList<>();
public static Registration register(Consumer<Void> listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
public static void broadcast() {
listeners.forEach(listener -> listener.accept(null));
}
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.aimailassistant.mail.event;
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
import org.springframework.context.ApplicationEvent;
public class NewEmailEvent extends ApplicationEvent {
private final OrderEmail orderEmail;
public NewEmailEvent(Object source, OrderEmail orderEmail) {
super(source);
this.orderEmail = orderEmail;
}
public OrderEmail getOrderEmail() {
return orderEmail;
}
}

View File

@@ -0,0 +1,308 @@
package de.assecutor.aimailassistant.mail.service;
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
import de.assecutor.aimailassistant.mail.domain.OrderEmailRepository;
import de.assecutor.aimailassistant.mail.domain.OrderSummary;
import de.assecutor.aimailassistant.mail.event.EmailBroadcaster;
import jakarta.mail.*;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.search.FlagTerm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Properties;
@Service
public class ImapEmailService {
private static final Logger log = LoggerFactory.getLogger(ImapEmailService.class);
private final OrderEmailRepository orderEmailRepository;
private final LlmService llmService;
private final String host;
private final int port;
private final String username;
private final String password;
private final boolean ssl;
private final String folderName;
public ImapEmailService(
OrderEmailRepository orderEmailRepository,
LlmService llmService,
@Value("${mail.imap.host}") String host,
@Value("${mail.imap.port}") int port,
@Value("${mail.imap.username}") String username,
@Value("${mail.imap.password}") String password,
@Value("${mail.imap.ssl}") boolean ssl,
@Value("${mail.imap.folder}") String folderName) {
this.orderEmailRepository = orderEmailRepository;
this.llmService = llmService;
this.host = host;
this.port = port;
this.username = username;
this.password = password;
this.ssl = ssl;
this.folderName = folderName;
}
@Scheduled(fixedDelayString = "${mail.imap.poll-interval-seconds:60}000")
public void pollEmails() {
log.info("Polling IMAP mailbox for new emails...");
fetchAndProcessEmails();
}
@Transactional
public void fetchAndProcessEmails() {
Store store = null;
Folder folder = null;
try {
Properties props = new Properties();
props.put("mail.store.protocol", "imaps");
props.put("mail.imaps.host", host);
props.put("mail.imaps.port", String.valueOf(port));
props.put("mail.imaps.ssl.enable", String.valueOf(ssl));
props.put("mail.imaps.ssl.trust", "*");
Session session = Session.getInstance(props);
store = session.getStore("imaps");
store.connect(host, port, username, password);
folder = store.getFolder(folderName);
// READ_WRITE um Nachrichten als gelesen markieren zu können
folder.open(Folder.READ_WRITE);
// Nur ungelesene Nachrichten abrufen
FlagTerm unseenFlagTerm = new FlagTerm(new Flags(Flags.Flag.SEEN), false);
Message[] messages = folder.search(unseenFlagTerm);
log.info("Found {} unread messages in mailbox", messages.length);
for (Message message : messages) {
processMessage(message);
// Nach dem Verarbeiten als gelesen markieren
message.setFlag(Flags.Flag.SEEN, true);
}
} catch (Exception e) {
log.error("Error fetching emails from IMAP server", e);
} finally {
try {
if (folder != null && folder.isOpen()) {
folder.close(false);
}
if (store != null) {
store.close();
}
} catch (MessagingException e) {
log.error("Error closing IMAP connection", e);
}
}
}
private void processMessage(Message message) {
try {
String subject = message.getSubject();
if (subject == null) {
subject = "(Kein Betreff)";
}
// Get unique message ID
String[] messageIdHeaders = message.getHeader("Message-ID");
String messageId = messageIdHeaders != null && messageIdHeaders.length > 0
? messageIdHeaders[0]
: generateMessageId(message);
// Skip if already processed
if (orderEmailRepository.existsByMessageId(messageId)) {
return;
}
log.info("Processing new email: {}", subject);
OrderEmail orderEmail = new OrderEmail();
orderEmail.setMessageId(messageId);
orderEmail.setSubject(subject);
orderEmail.setContent(extractTextContent(message));
orderEmail.setReceivedDate(
message.getReceivedDate() != null
? LocalDateTime.ofInstant(message.getReceivedDate().toInstant(), ZoneId.systemDefault())
: LocalDateTime.now()
);
// Extract sender information
Address[] fromAddresses = message.getFrom();
if (fromAddresses != null && fromAddresses.length > 0) {
if (fromAddresses[0] instanceof InternetAddress internetAddress) {
orderEmail.setFromAddress(internetAddress.getAddress());
orderEmail.setFromName(internetAddress.getPersonal());
} else {
orderEmail.setFromAddress(fromAddresses[0].toString());
}
} else {
orderEmail.setFromAddress("unknown@unknown.com");
}
// Save first to get ID
orderEmail = orderEmailRepository.save(orderEmail);
// Process with LLM
try {
OrderSummary summary = llmService.summarizeEmail(orderEmail);
orderEmail.setSummaryJson(llmService.serializeSummary(summary));
orderEmail.setType(summary.getOrderType());
// processed bleibt false - wird erst gesetzt wenn der Benutzer die Email öffnet
orderEmailRepository.save(orderEmail);
log.info("Email processed successfully: {} (Type: {})", subject, summary.getOrderType());
// Notify UI about new email
EmailBroadcaster.broadcast();
} catch (Exception e) {
log.error("Error processing email with LLM: {}", subject, e);
// Email is saved but not processed - still notify UI
EmailBroadcaster.broadcast();
}
} catch (Exception e) {
log.error("Error processing message", e);
}
}
private String generateMessageId(Message message) {
try {
return "gen-" + message.getSubject().hashCode() + "-" +
(message.getReceivedDate() != null ? message.getReceivedDate().getTime() : System.currentTimeMillis());
} catch (MessagingException e) {
return "gen-" + System.currentTimeMillis();
}
}
private String extractTextContent(Message message) throws MessagingException, IOException {
Object content = message.getContent();
if (content instanceof String) {
return (String) content;
}
if (content instanceof MimeMultipart multipart) {
return extractTextFromMultipart(multipart);
}
return content.toString();
}
private String extractTextFromMultipart(MimeMultipart multipart) throws MessagingException, IOException {
StringBuilder result = new StringBuilder();
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
String contentType = bodyPart.getContentType().toLowerCase();
if (contentType.contains("text/plain")) {
result.append(bodyPart.getContent().toString());
} else if (contentType.contains("text/html") && result.isEmpty()) {
// Use HTML content only if no plain text is available
String html = bodyPart.getContent().toString();
result.append(stripHtml(html));
} else if (bodyPart.getContent() instanceof MimeMultipart nestedMultipart) {
result.append(extractTextFromMultipart(nestedMultipart));
}
}
return result.toString();
}
private String stripHtml(String html) {
// Basic HTML stripping - remove tags and decode common entities
return html
.replaceAll("<br\\s*/?>", "\n")
.replaceAll("<p>", "\n")
.replaceAll("</p>", "\n")
.replaceAll("<[^>]+>", "")
.replaceAll("&nbsp;", " ")
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"")
.replaceAll("\\s+\n", "\n")
.replaceAll("\n{3,}", "\n\n")
.trim();
}
public boolean deleteEmailFromServer(String messageId) {
if (messageId == null || messageId.isBlank()) {
log.warn("Cannot delete email: messageId is null or blank");
return false;
}
Store store = null;
Folder folder = null;
try {
Properties props = new Properties();
props.put("mail.store.protocol", "imaps");
props.put("mail.imaps.host", host);
props.put("mail.imaps.port", String.valueOf(port));
props.put("mail.imaps.ssl.enable", String.valueOf(ssl));
props.put("mail.imaps.ssl.trust", "*");
Session session = Session.getInstance(props);
store = session.getStore("imaps");
store.connect(host, port, username, password);
folder = store.getFolder(folderName);
folder.open(Folder.READ_WRITE);
Message[] messages = folder.getMessages();
boolean found = false;
for (Message message : messages) {
String[] messageIdHeaders = message.getHeader("Message-ID");
String currentMessageId = messageIdHeaders != null && messageIdHeaders.length > 0
? messageIdHeaders[0]
: generateMessageId(message);
if (messageId.equals(currentMessageId)) {
message.setFlag(Flags.Flag.DELETED, true);
found = true;
log.info("Marked email for deletion on IMAP server: {}", message.getSubject());
break;
}
}
if (found) {
// Expunge to permanently delete marked messages
folder.close(true);
folder = null;
log.info("Email deleted from IMAP server");
return true;
} else {
log.warn("Email not found on IMAP server with messageId: {}", messageId);
return false;
}
} catch (Exception e) {
log.error("Error deleting email from IMAP server", e);
return false;
} finally {
try {
if (folder != null && folder.isOpen()) {
folder.close(false);
}
if (store != null) {
store.close();
}
} catch (MessagingException e) {
log.error("Error closing IMAP connection", e);
}
}
}
}

View File

@@ -0,0 +1,252 @@
package de.assecutor.aimailassistant.mail.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.aimailassistant.mail.domain.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
public class LlmService {
private static final Logger log = LoggerFactory.getLogger(LlmService.class);
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final String model;
public LlmService(
@Value("${llm.api.url}") String apiUrl,
@Value("${llm.api.model}") String model,
ObjectMapper objectMapper) {
this.webClient = WebClient.builder()
.baseUrl(apiUrl)
.build();
this.model = model;
this.objectMapper = objectMapper;
}
public OrderSummary summarizeEmail(OrderEmail email) {
String prompt = buildPrompt(email);
try {
Map<String, Object> request = Map.of(
"model", model,
"messages", List.of(
Map.of("role", "system", "content", getSystemPrompt()),
Map.of("role", "user", "content", prompt)
),
"temperature", 0.3,
"max_tokens", 2000
);
log.info("Sending request to LLM API...");
String response = webClient.post()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(String.class)
.block();
log.debug("LLM Response: {}", response);
OrderSummary summary = parseResponse(response);
log.info("Classified email as: {}", summary.getOrderType());
return summary;
} catch (Exception e) {
log.error("Error calling LLM API", e);
return createFallbackSummary(email);
}
}
private String getSystemPrompt() {
return """
Du bist ein Assistent für die Analyse von Auftrags-Emails eines Kurier- und Transportdienstes.
Deine Aufgabe ist es, Emails zu analysieren und strukturierte Informationen zu extrahieren.
Antworte IMMER im folgenden JSON-Format:
{
"orderType": "ORDER" oder "QUOTE_REQUEST",
"requesterName": "Name des Anforderers (mit Anrede wenn vorhanden, z.B. 'Herr Max Müller' oder 'Frau Anna Schmidt')",
"requesterCompany": "Firma des Anforderers",
"stations": [
{
"name": "Name der Station/Person",
"address": "Vollständige Adresse",
"action": "PICKUP" oder "DELIVERY" oder "BOTH"
}
],
"remarks": "Sonderwünsche oder Bemerkungen"
}
WICHTIG - Extraktion des Anforderer-Namens (requesterName):
- Suche den Namen in der Email-Signatur (z.B. "Mit freundlichen Grüßen, Max Müller")
- Oder im Absender-Feld der Email
- Wenn eine Anrede erkennbar ist (Herr/Frau), füge diese hinzu
- Format: "Herr Vorname Nachname" oder "Frau Vorname Nachname"
- Wenn kein Geschlecht erkennbar, nur den Namen extrahieren
WICHTIG - Unterscheidung zwischen ORDER und QUOTE_REQUEST:
Setze "QUOTE_REQUEST" wenn:
- Nach einem Preis, Kosten oder Angebot gefragt wird
- Formulierungen wie "Was würde es kosten...", "Können Sie mir ein Angebot machen...", "Preisanfrage", "Kostenvoranschlag"
- Unverbindliche Anfragen ohne konkreten Auftrag
- Der Absender noch keine Entscheidung getroffen hat
- Worte wie "würde", "könnte", "möchte wissen", "interessiert an"
Setze "ORDER" NUR wenn:
- Ein konkreter, verbindlicher Auftrag erteilt wird
- Feste Termine und Daten genannt werden
- Formulierungen wie "Bitte holen Sie ab...", "Wir beauftragen Sie...", "Hiermit bestellen wir..."
- Der Kunde klar eine Leistung beauftragt (nicht nur anfragt)
Im Zweifel: Wähle "QUOTE_REQUEST"
Weitere Regeln:
- Extrahiere alle Stationen (Abholung und Zustellung)
- Bei unklaren Informationen, setze null
""";
}
private String buildPrompt(OrderEmail email) {
return String.format("""
Analysiere die folgende Email und extrahiere die relevanten Informationen:
Von: %s <%s>
Betreff: %s
Inhalt:
%s
""",
email.getFromName() != null ? email.getFromName() : "",
email.getFromAddress(),
email.getSubject(),
email.getContent()
);
}
private OrderSummary parseResponse(String response) {
try {
JsonNode root = objectMapper.readTree(response);
String content = root.path("choices").get(0).path("message").path("content").asText();
// Extract JSON from response (handle markdown code blocks)
String jsonContent = extractJson(content);
JsonNode summaryNode = objectMapper.readTree(jsonContent);
OrderSummary summary = new OrderSummary();
String orderTypeStr = summaryNode.path("orderType").asText("UNKNOWN");
summary.setOrderType(parseEmailType(orderTypeStr));
summary.setRequesterName(getTextOrNull(summaryNode, "requesterName"));
summary.setRequesterCompany(getTextOrNull(summaryNode, "requesterCompany"));
summary.setRemarks(getTextOrNull(summaryNode, "remarks"));
List<Station> stations = new ArrayList<>();
JsonNode stationsNode = summaryNode.path("stations");
if (stationsNode.isArray()) {
for (JsonNode stationNode : stationsNode) {
Station station = new Station();
station.setName(getTextOrNull(stationNode, "name"));
station.setAddress(getTextOrNull(stationNode, "address"));
station.setAction(parseStationAction(stationNode.path("action").asText("PICKUP")));
stations.add(station);
}
}
summary.setStations(stations);
return summary;
} catch (JsonProcessingException e) {
log.error("Error parsing LLM response", e);
return new OrderSummary();
}
}
private String extractJson(String content) {
// Remove markdown code blocks if present
if (content.contains("```json")) {
int start = content.indexOf("```json") + 7;
int end = content.lastIndexOf("```");
if (end > start) {
return content.substring(start, end).trim();
}
}
if (content.contains("```")) {
int start = content.indexOf("```") + 3;
int end = content.lastIndexOf("```");
if (end > start) {
return content.substring(start, end).trim();
}
}
return content.trim();
}
private String getTextOrNull(JsonNode node, String field) {
JsonNode fieldNode = node.path(field);
if (fieldNode.isNull() || fieldNode.isMissingNode()) {
return null;
}
String text = fieldNode.asText();
return text.isEmpty() || "null".equals(text) ? null : text;
}
private EmailType parseEmailType(String type) {
return switch (type.toUpperCase()) {
case "ORDER" -> EmailType.ORDER;
case "QUOTE_REQUEST" -> EmailType.QUOTE_REQUEST;
default -> EmailType.UNKNOWN;
};
}
private StationAction parseStationAction(String action) {
return switch (action.toUpperCase()) {
case "DELIVERY" -> StationAction.DELIVERY;
case "BOTH" -> StationAction.BOTH;
default -> StationAction.PICKUP;
};
}
private OrderSummary createFallbackSummary(OrderEmail email) {
OrderSummary summary = new OrderSummary();
summary.setOrderType(EmailType.UNKNOWN);
summary.setRemarks("Automatische Analyse fehlgeschlagen. Bitte manuell prüfen.");
return summary;
}
public String serializeSummary(OrderSummary summary) {
try {
return objectMapper.writeValueAsString(summary);
} catch (JsonProcessingException e) {
log.error("Error serializing summary", e);
return "{}";
}
}
public OrderSummary deserializeSummary(String json) {
if (json == null || json.isBlank()) {
return new OrderSummary();
}
try {
return objectMapper.readValue(json, OrderSummary.class);
} catch (JsonProcessingException e) {
log.error("Error deserializing summary", e);
return new OrderSummary();
}
}
public OrderSummary reprocessEmail(OrderEmail email) {
log.info("Reprocessing email: {}", email.getSubject());
return summarizeEmail(email);
}
}

View File

@@ -0,0 +1,202 @@
package de.assecutor.aimailassistant.mail.service;
import de.assecutor.aimailassistant.mail.domain.Gender;
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
import de.assecutor.aimailassistant.mail.domain.OrderSummary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
@Service
public class SmtpEmailService {
private static final Logger log = LoggerFactory.getLogger(SmtpEmailService.class);
private final JavaMailSender mailSender;
private final String fromAddress;
public SmtpEmailService(
JavaMailSender mailSender,
@Value("${spring.mail.username}") String fromAddress) {
this.mailSender = mailSender;
this.fromAddress = fromAddress;
}
public void sendConfirmation(OrderEmail orderEmail, OrderSummary summary, String confirmationText, String recipient) {
String subject = "Re: " + orderEmail.getSubject() + " - Auftragsbestätigung";
String body = buildOrderConfirmationBody(orderEmail, summary, confirmationText);
String to = (recipient != null && !recipient.isBlank()) ? recipient : orderEmail.getFromAddress();
sendEmail(to, subject, body);
}
public void sendOffer(OrderEmail orderEmail, OrderSummary summary, String offerText, String recipient) {
String subject = "Re: " + orderEmail.getSubject() + " - Ihr Angebot";
String body = buildOfferBody(orderEmail, summary, offerText);
String to = (recipient != null && !recipient.isBlank()) ? recipient : orderEmail.getFromAddress();
sendEmail(to, subject, body);
}
private String buildSalutation(OrderSummary summary) {
StringBuilder sb = new StringBuilder();
String name = summary.getRequesterName();
Gender gender = summary.getRequesterGender();
if (name != null && !name.isBlank()) {
String trimmedName = name.trim();
// Entferne eventuell vorhandene Anrede aus dem Namen
String cleanName = trimmedName;
if (trimmedName.toLowerCase().startsWith("herr ")) {
cleanName = trimmedName.substring(5).trim();
} else if (trimmedName.toLowerCase().startsWith("frau ")) {
cleanName = trimmedName.substring(5).trim();
}
// Verwende das ausgewählte Geschlecht für die Anrede
if (gender == Gender.MALE) {
sb.append("Sehr geehrter Herr ").append(cleanName).append(",");
} else if (gender == Gender.FEMALE) {
sb.append("Sehr geehrte Frau ").append(cleanName).append(",");
} else {
// Geschlechtsneutrale Anrede mit Namen
sb.append("Guten Tag ").append(cleanName).append(",");
}
} else if (summary.getRequesterCompany() != null && !summary.getRequesterCompany().isBlank()) {
sb.append("Sehr geehrte Damen und Herren von ").append(summary.getRequesterCompany()).append(",");
} else {
sb.append("Sehr geehrte Damen und Herren,");
}
return sb.toString();
}
private String buildOrderConfirmationBody(OrderEmail orderEmail, OrderSummary summary, String confirmationText) {
StringBuilder sb = new StringBuilder();
// Nur Anrede hinzufügen wenn der Text nicht bereits mit "Sehr geehrte" beginnt
if (confirmationText == null || !confirmationText.trim().startsWith("Sehr geehrte")) {
sb.append(buildSalutation(summary));
sb.append("\n\n");
sb.append("vielen Dank für Ihren Auftrag.\n\n");
}
if (confirmationText != null && !confirmationText.isBlank()) {
sb.append(confirmationText);
} else {
sb.append("Wir bestätigen hiermit den Eingang Ihres Auftrags und werden diesen schnellstmöglich bearbeiten.\n\n");
if (summary.getStations() != null && !summary.getStations().isEmpty()) {
sb.append("Auftragsdaten:\n");
summary.getStations().forEach(station -> {
sb.append("- ").append(station.getAction().getDisplayName());
if (station.getName() != null) {
sb.append(": ").append(station.getName());
}
if (station.getAddress() != null) {
sb.append("\n Adresse: ").append(station.getAddress());
}
sb.append("\n");
});
sb.append("\n");
}
}
sb.append("\n\nBei Rückfragen stehen wir Ihnen gerne zur Verfügung.\n\n");
sb.append("Mit freundlichen Grüßen\n");
sb.append("Ihr Kurier-Team");
return sb.toString();
}
private String buildOfferBody(OrderEmail orderEmail, OrderSummary summary, String offerText) {
StringBuilder sb = new StringBuilder();
// Nur Anrede hinzufügen wenn der Text nicht bereits mit "Sehr geehrte" beginnt
if (offerText == null || !offerText.trim().startsWith("Sehr geehrte")) {
sb.append(buildSalutation(summary));
sb.append("\n\n");
sb.append("vielen Dank für Ihre Anfrage.\n\n");
}
if (offerText != null && !offerText.isBlank()) {
sb.append(offerText);
} else {
sb.append("Gerne unterbreiten wir Ihnen folgendes Angebot:\n\n");
sb.append("[Angebotdetails hier einfügen]\n\n");
}
sb.append("\n\nDieses Angebot ist freibleibend und unverbindlich.\n\n");
sb.append("Bei Rückfragen stehen wir Ihnen gerne zur Verfügung.\n\n");
sb.append("Mit freundlichen Grüßen\n");
sb.append("Ihr Kurier-Team");
return sb.toString();
}
private void sendEmail(String to, String subject, String body) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(fromAddress);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body, false);
helper.setReplyTo(fromAddress);
mailSender.send(message);
log.info("Email sent successfully to {}", to);
} catch (MessagingException e) {
log.error("Failed to send email to {}", to, e);
throw new RuntimeException("Failed to send email", e);
}
}
public String getDefaultOfferTemplate(OrderSummary summary) {
StringBuilder sb = new StringBuilder();
sb.append(buildSalutation(summary));
sb.append("\n\n");
sb.append("vielen Dank für Ihre Anfrage.\n\n");
sb.append("""
Gerne unterbreiten wir Ihnen folgendes Angebot:
Leistung: [Beschreibung der Leistung]
Preis: [Preis] EUR zzgl. MwSt.
Lieferzeit: Nach Vereinbarung
Gültigkeit: 14 Tage ab heute
""");
return sb.toString();
}
public String getDefaultConfirmationTemplate(OrderSummary summary) {
StringBuilder sb = new StringBuilder();
sb.append(buildSalutation(summary));
sb.append("\n\n");
sb.append("vielen Dank für Ihren Auftrag.\n\n");
sb.append("Wir bestätigen hiermit den Eingang Ihres Auftrags und werden diesen schnellstmöglich bearbeiten.\n\n");
if (summary.getStations() != null && !summary.getStations().isEmpty()) {
sb.append("Auftragsdaten:\n");
summary.getStations().forEach(station -> {
sb.append("- ").append(station.getAction().getDisplayName());
if (station.getName() != null) {
sb.append(": ").append(station.getName());
}
if (station.getAddress() != null) {
sb.append("\n Adresse: ").append(station.getAddress());
}
sb.append("\n");
});
}
return sb.toString();
}
}

View File

@@ -0,0 +1,301 @@
package de.assecutor.aimailassistant.mail.ui;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.grid.GridVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.Menu;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
import de.assecutor.aimailassistant.mail.domain.OrderEmailRepository;
import de.assecutor.aimailassistant.mail.event.EmailBroadcaster;
import de.assecutor.aimailassistant.mail.service.ImapEmailService;
import de.assecutor.aimailassistant.mail.service.LlmService;
import de.assecutor.aimailassistant.mail.service.SmtpEmailService;
import org.springframework.beans.factory.annotation.Value;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Route("")
@PageTitle("Email-Assistent")
@Menu(order = 0, icon = "vaadin:envelope")
public class MainView extends VerticalLayout {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final OrderEmailRepository orderEmailRepository;
private final LlmService llmService;
private final SmtpEmailService smtpEmailService;
private final ImapEmailService imapEmailService;
private final String googleMapsApiKey;
private Grid<OrderEmail> grid;
private Registration broadcasterRegistration;
public MainView(
OrderEmailRepository orderEmailRepository,
LlmService llmService,
SmtpEmailService smtpEmailService,
ImapEmailService imapEmailService,
@Value("${google.maps.api.key}") String googleMapsApiKey) {
this.orderEmailRepository = orderEmailRepository;
this.llmService = llmService;
this.smtpEmailService = smtpEmailService;
this.imapEmailService = imapEmailService;
this.googleMapsApiKey = googleMapsApiKey;
setSizeFull();
setPadding(true);
setSpacing(true);
add(createHeader());
add(createGrid());
}
private Component createHeader() {
HorizontalLayout header = new HorizontalLayout();
header.setWidthFull();
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
HorizontalLayout titleSection = new HorizontalLayout();
titleSection.setAlignItems(FlexComponent.Alignment.CENTER);
titleSection.setSpacing(true);
Icon mailIcon = VaadinIcon.ENVELOPE_O.create();
mailIcon.setSize("32px");
mailIcon.addClassName(LumoUtility.TextColor.PRIMARY);
H1 title = new H1("AI Email-Assistent");
title.addClassNames(
LumoUtility.Margin.NONE,
LumoUtility.FontSize.XLARGE
);
titleSection.add(mailIcon, title);
Button refreshButton = new Button("Aktualisieren", VaadinIcon.REFRESH.create(), e -> refreshGrid());
refreshButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
header.add(titleSection, refreshButton);
return header;
}
private Component createGrid() {
grid = new Grid<>(OrderEmail.class, false);
grid.setSizeFull();
grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES);
// Date column
grid.addColumn(email -> email.getReceivedDate().format(DATE_FORMATTER))
.setHeader("Datum")
.setAutoWidth(true)
.setFlexGrow(0)
.setSortable(true);
// Sender column
grid.addColumn(OrderEmail::getDisplaySender)
.setHeader("Absender")
.setAutoWidth(true)
.setFlexGrow(1);
// Subject column
grid.addColumn(OrderEmail::getSubject)
.setHeader("Betreff")
.setFlexGrow(2);
// Type column with badge
grid.addColumn(new ComponentRenderer<>(this::createTypeBadge))
.setHeader("Typ")
.setAutoWidth(true)
.setFlexGrow(0);
// Status column with badge
grid.addColumn(new ComponentRenderer<>(this::createStatusBadge))
.setHeader("Status")
.setAutoWidth(true)
.setFlexGrow(0);
// Actions column with delete button
grid.addColumn(new ComponentRenderer<>(this::createDeleteButton))
.setHeader("")
.setAutoWidth(true)
.setFlexGrow(0);
// Click handler
grid.addItemClickListener(event -> openDetailDialog(event.getItem()));
// Load data
refreshGrid();
return grid;
}
private Span createTypeBadge(OrderEmail email) {
Span badge = new Span(email.getType().getDisplayName());
badge.addClassNames(
LumoUtility.Padding.Horizontal.SMALL,
LumoUtility.Padding.Vertical.XSMALL,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.FontSize.SMALL,
LumoUtility.FontWeight.SEMIBOLD
);
switch (email.getType()) {
case ORDER -> {
badge.addClassName(LumoUtility.Background.SUCCESS_10);
badge.addClassName(LumoUtility.TextColor.SUCCESS);
}
case QUOTE_REQUEST -> {
badge.addClassName(LumoUtility.Background.PRIMARY_10);
badge.addClassName(LumoUtility.TextColor.PRIMARY);
}
default -> {
badge.addClassName(LumoUtility.Background.CONTRAST_10);
badge.addClassName(LumoUtility.TextColor.SECONDARY);
}
}
return badge;
}
private Span createStatusBadge(OrderEmail email) {
String statusText;
String bgClass;
String textClass;
if (email.isConfirmed()) {
statusText = "Bestätigt";
bgClass = LumoUtility.Background.SUCCESS_10;
textClass = LumoUtility.TextColor.SUCCESS;
} else if (email.isProcessed()) {
statusText = "Verarbeitet";
bgClass = LumoUtility.Background.PRIMARY_10;
textClass = LumoUtility.TextColor.PRIMARY;
} else {
statusText = "Neu";
bgClass = LumoUtility.Background.WARNING_10;
textClass = ""; // Will use custom color
}
Span badge = new Span(statusText);
badge.addClassNames(
LumoUtility.Padding.Horizontal.SMALL,
LumoUtility.Padding.Vertical.XSMALL,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.FontSize.SMALL,
LumoUtility.FontWeight.SEMIBOLD,
bgClass
);
if (!textClass.isEmpty()) {
badge.addClassName(textClass);
} else {
badge.getStyle().set("color", "var(--lumo-warning-text-color)");
}
return badge;
}
private Button createDeleteButton(OrderEmail email) {
Button deleteButton = new Button(new Icon(VaadinIcon.TRASH));
deleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_SMALL);
deleteButton.getElement().setAttribute("title", "Löschen");
deleteButton.addClickListener(event -> {
event.getSource().getElement().getNode().runWhenAttached(ui -> {
// Stop event propagation to prevent row click
});
confirmDeleteEmail(email);
});
return deleteButton;
}
private void confirmDeleteEmail(OrderEmail email) {
ConfirmDialog confirmDialog = new ConfirmDialog();
confirmDialog.setHeader("Email löschen");
confirmDialog.setText("Möchten Sie die Email \"" + email.getSubject() + "\" wirklich löschen?");
confirmDialog.setCancelable(true);
confirmDialog.setCancelText("Abbrechen");
confirmDialog.setConfirmText("Löschen");
confirmDialog.setConfirmButtonTheme("error primary");
confirmDialog.addConfirmListener(event -> {
onEmailDeleted(email);
Notification.show("Email wurde gelöscht", 3000, Notification.Position.BOTTOM_START);
});
confirmDialog.open();
}
private void openDetailDialog(OrderEmail email) {
OrderDetailDialog dialog = new OrderDetailDialog(
email,
llmService,
smtpEmailService,
googleMapsApiKey,
this::onEmailProcessed,
this::onEmailDeleted
);
dialog.open();
}
private void onEmailProcessed(OrderEmail email) {
orderEmailRepository.save(email);
refreshGrid();
Notification.show("Email erfolgreich verarbeitet", 3000, Notification.Position.BOTTOM_START);
}
private void onEmailDeleted(OrderEmail email) {
// Lösche die Email vom IMAP Server
imapEmailService.deleteEmailFromServer(email.getMessageId());
// Lösche die Email aus der Datenbank
orderEmailRepository.delete(email);
refreshGrid();
}
private void refreshGrid() {
List<OrderEmail> emails = orderEmailRepository.findAllSortedByStatusAndDate();
grid.setItems(emails);
}
@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
refreshGrid();
// Register for broadcast updates
UI ui = attachEvent.getUI();
broadcasterRegistration = EmailBroadcaster.register(unused -> {
ui.access(() -> {
refreshGrid();
Notification.show("Neue Email empfangen", 3000, Notification.Position.BOTTOM_START);
});
});
}
@Override
protected void onDetach(DetachEvent detachEvent) {
super.onDetach(detachEvent);
if (broadcasterRegistration != null) {
broadcasterRegistration.remove();
broadcasterRegistration = null;
}
}
}

View File

@@ -0,0 +1,574 @@
package de.assecutor.aimailassistant.mail.ui;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Pre;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.aimailassistant.mail.domain.EmailType;
import de.assecutor.aimailassistant.mail.domain.Gender;
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
import de.assecutor.aimailassistant.mail.domain.OrderSummary;
import de.assecutor.aimailassistant.mail.domain.Station;
import de.assecutor.aimailassistant.mail.service.LlmService;
import de.assecutor.aimailassistant.mail.service.SmtpEmailService;
import java.time.format.DateTimeFormatter;
import java.util.function.Consumer;
public class OrderDetailDialog extends Dialog {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final OrderEmail orderEmail;
private OrderSummary summary;
private final LlmService llmService;
private final SmtpEmailService smtpEmailService;
private final Consumer<OrderEmail> onProcessed;
private final Consumer<OrderEmail> onDelete;
private final String googleMapsApiKey;
private TextArea offerTextArea;
private TextArea confirmationTextArea;
private EmailField recipientField;
private HorizontalLayout contentLayout;
public OrderDetailDialog(
OrderEmail orderEmail,
LlmService llmService,
SmtpEmailService smtpEmailService,
String googleMapsApiKey,
Consumer<OrderEmail> onProcessed,
Consumer<OrderEmail> onDelete) {
this.orderEmail = orderEmail;
this.llmService = llmService;
this.summary = llmService.deserializeSummary(orderEmail.getSummaryJson());
this.smtpEmailService = smtpEmailService;
this.googleMapsApiKey = googleMapsApiKey;
this.onProcessed = onProcessed;
this.onDelete = onDelete;
setHeaderTitle("Email-Details: " + orderEmail.getSubject());
setCloseOnOutsideClick(false);
setWidth("80vw");
setHeight("85vh");
contentLayout = createContent();
add(contentLayout);
createFooter();
}
private void reprocessEmail() {
try {
Notification.show("Analysiere Email erneut...", 2000, Notification.Position.MIDDLE);
OrderSummary newSummary = llmService.reprocessEmail(orderEmail);
this.summary = newSummary;
orderEmail.setSummaryJson(llmService.serializeSummary(newSummary));
orderEmail.setType(newSummary.getOrderType());
onProcessed.accept(orderEmail);
remove(contentLayout);
getFooter().removeAll();
contentLayout = createContent();
add(contentLayout);
createFooter();
Notification.show("Email neu klassifiziert als: " + newSummary.getOrderType().getDisplayName(),
3000, Notification.Position.BOTTOM_START);
} catch (Exception e) {
Notification.show("Fehler bei der Neuanalyse: " + e.getMessage(),
5000, Notification.Position.MIDDLE);
}
}
private HorizontalLayout createContent() {
HorizontalLayout layout = new HorizontalLayout();
layout.setSizeFull();
layout.setSpacing(true);
layout.setPadding(false);
layout.getStyle()
.set("overflow", "hidden")
.set("gap", "var(--lumo-space-m)");
// Left side - Email content (50%)
VerticalLayout leftPanel = createLeftPanel();
leftPanel.getStyle()
.set("flex", "1 1 50%")
.set("min-width", "0")
.set("overflow", "hidden");
// Right side - Details (50%)
VerticalLayout rightPanel = createRightPanel();
rightPanel.getStyle()
.set("flex", "1 1 50%")
.set("min-width", "0")
.set("overflow", "hidden");
layout.add(leftPanel, rightPanel);
return layout;
}
private VerticalLayout createLeftPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(false);
panel.setSpacing(false);
panel.setSizeFull();
H4 title = new H4("Email-Inhalt");
title.addClassName(LumoUtility.Margin.Bottom.SMALL);
title.addClassName(LumoUtility.Margin.Top.NONE);
Div contentBox = new Div();
contentBox.addClassNames(
LumoUtility.Background.CONTRAST_5,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.MEDIUM
);
contentBox.setWidthFull();
contentBox.getStyle()
.set("overflow-x", "hidden")
.set("overflow-y", "auto")
.set("box-sizing", "border-box");
Pre emailContent = new Pre();
emailContent.setText(orderEmail.getContent());
emailContent.getStyle()
.set("white-space", "pre-wrap")
.set("word-wrap", "break-word")
.set("overflow-wrap", "break-word")
.set("margin", "0")
.set("max-width", "100%")
.set("font-family", "var(--lumo-font-family)")
.set("font-size", "var(--lumo-font-size-s)");
contentBox.add(emailContent);
Scroller scroller = new Scroller(contentBox);
scroller.setSizeFull();
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
panel.add(title, scroller);
panel.setFlexGrow(1, scroller);
return panel;
}
private VerticalLayout createRightPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(false);
panel.setSpacing(true);
panel.setSizeFull();
H4 title = new H4("Analyse & Details");
title.addClassName(LumoUtility.Margin.Bottom.SMALL);
title.addClassName(LumoUtility.Margin.Top.NONE);
VerticalLayout detailsContent = new VerticalLayout();
detailsContent.setPadding(false);
detailsContent.setSpacing(true);
detailsContent.setWidthFull();
detailsContent.getStyle().set("overflow-x", "hidden");
detailsContent.add(createMetadataSection());
detailsContent.add(createTypeComboBox());
detailsContent.add(createSummarySection());
if (summary.getStations() != null && !summary.getStations().isEmpty()) {
detailsContent.add(createStationsSection());
}
if (summary.getRemarks() != null && !summary.getRemarks().isBlank()) {
detailsContent.add(createRemarksSection());
}
if (orderEmail.getType() == EmailType.QUOTE_REQUEST) {
detailsContent.add(createOfferSection());
} else if (orderEmail.getType() == EmailType.ORDER) {
detailsContent.add(createConfirmationSection());
}
Scroller scroller = new Scroller(detailsContent);
scroller.setSizeFull();
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
panel.add(title, scroller);
panel.setFlexGrow(1, scroller);
return panel;
}
private Div createMetadataSection() {
Div section = new Div();
section.addClassName(LumoUtility.Background.CONTRAST_5);
section.addClassName(LumoUtility.BorderRadius.MEDIUM);
section.addClassName(LumoUtility.Padding.MEDIUM);
section.setWidthFull();
section.getStyle().set("box-sizing", "border-box");
FormLayout form = new FormLayout();
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
form.setWidthFull();
Span senderSpan = new Span(orderEmail.getDisplaySender());
senderSpan.getStyle().set("word-break", "break-word");
form.addFormItem(senderSpan, "Absender");
Span emailSpan = new Span(orderEmail.getFromAddress());
emailSpan.getStyle().set("word-break", "break-all");
form.addFormItem(emailSpan, "Email");
form.addFormItem(new Span(orderEmail.getReceivedDate().format(DATE_FORMATTER)), "Empfangen");
section.add(form);
return section;
}
private HorizontalLayout createTypeComboBox() {
HorizontalLayout layout = new HorizontalLayout();
layout.setAlignItems(FlexComponent.Alignment.CENTER);
layout.setSpacing(true);
layout.setWidthFull();
Span label = new Span("Klassifizierung:");
label.addClassName(LumoUtility.FontWeight.SEMIBOLD);
ComboBox<EmailType> typeComboBox = new ComboBox<>();
typeComboBox.setItems(EmailType.ORDER, EmailType.QUOTE_REQUEST, EmailType.UNKNOWN);
typeComboBox.setItemLabelGenerator(EmailType::getDisplayName);
typeComboBox.setValue(orderEmail.getType());
typeComboBox.setWidth("200px");
typeComboBox.addValueChangeListener(event -> {
if (event.getValue() != null && event.getValue() != event.getOldValue()) {
EmailType newType = event.getValue();
orderEmail.setType(newType);
summary.setOrderType(newType);
onProcessed.accept(orderEmail);
// Refresh the dialog content
remove(contentLayout);
getFooter().removeAll();
contentLayout = createContent();
add(contentLayout);
createFooter();
Notification.show("Klassifizierung geändert zu: " + newType.getDisplayName(),
3000, Notification.Position.BOTTOM_START);
}
});
layout.add(label, typeComboBox);
return layout;
}
private VerticalLayout createSummarySection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(false);
section.setWidthFull();
H4 title = new H4("Extrahierte Daten");
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
section.add(title);
FormLayout form = new FormLayout();
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
if (summary.getRequesterName() != null) {
form.addFormItem(new Span(summary.getRequesterName()), "Anforderer");
// Geschlecht ComboBox direkt unter Anforderer
ComboBox<Gender> genderComboBox = new ComboBox<>();
genderComboBox.setItems(Gender.MALE, Gender.FEMALE, Gender.UNKNOWN);
genderComboBox.setItemLabelGenerator(Gender::getDisplayName);
genderComboBox.setValue(summary.getRequesterGender() != null ? summary.getRequesterGender() : Gender.UNKNOWN);
genderComboBox.setWidth("150px");
genderComboBox.addValueChangeListener(event -> {
if (event.getValue() != null) {
summary.setRequesterGender(event.getValue());
orderEmail.setSummaryJson(llmService.serializeSummary(summary));
onProcessed.accept(orderEmail);
// Aktualisiere die Mail-Templates
updateMailTemplates();
}
});
form.addFormItem(genderComboBox, "Anrede");
}
if (summary.getRequesterCompany() != null) {
form.addFormItem(new Span(summary.getRequesterCompany()), "Firma");
}
if (form.getChildren().count() == 0) {
Span noData = new Span("Keine Daten extrahiert");
noData.addClassName(LumoUtility.TextColor.SECONDARY);
section.add(noData);
} else {
section.add(form);
}
return section;
}
private void updateMailTemplates() {
if (offerTextArea != null) {
offerTextArea.setValue(smtpEmailService.getDefaultOfferTemplate(summary));
}
if (confirmationTextArea != null) {
confirmationTextArea.setValue(smtpEmailService.getDefaultConfirmationTemplate(summary));
}
}
private VerticalLayout createStationsSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(true);
section.setWidthFull();
H4 title = new H4("Stationen");
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
section.add(title);
for (Station station : summary.getStations()) {
Div stationCard = new Div();
stationCard.addClassNames(
LumoUtility.Background.CONTRAST_5,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.SMALL
);
stationCard.setWidthFull();
HorizontalLayout stationContent = new HorizontalLayout();
stationContent.setAlignItems(FlexComponent.Alignment.CENTER);
stationContent.setSpacing(true);
stationContent.setWidthFull();
Span actionBadge = new Span(station.getAction().getDisplayName());
actionBadge.addClassNames(
LumoUtility.Padding.Horizontal.SMALL,
LumoUtility.Padding.Vertical.XSMALL,
LumoUtility.BorderRadius.SMALL,
LumoUtility.FontSize.XSMALL,
LumoUtility.FontWeight.SEMIBOLD
);
switch (station.getAction()) {
case PICKUP -> {
actionBadge.addClassName(LumoUtility.Background.WARNING_10);
actionBadge.getStyle().set("color", "var(--lumo-warning-text-color)");
}
case DELIVERY -> {
actionBadge.addClassName(LumoUtility.Background.SUCCESS_10);
actionBadge.addClassName(LumoUtility.TextColor.SUCCESS);
}
case BOTH -> {
actionBadge.addClassName(LumoUtility.Background.PRIMARY_10);
actionBadge.addClassName(LumoUtility.TextColor.PRIMARY);
}
}
VerticalLayout stationInfo = new VerticalLayout();
stationInfo.setPadding(false);
stationInfo.setSpacing(false);
stationInfo.getStyle().set("min-width", "0");
if (station.getName() != null) {
Span name = new Span(station.getName());
name.addClassName(LumoUtility.FontWeight.SEMIBOLD);
stationInfo.add(name);
}
if (station.getAddress() != null) {
Span address = new Span(station.getAddress());
address.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);
address.getStyle().set("word-break", "break-word");
stationInfo.add(address);
}
stationContent.add(actionBadge, stationInfo);
stationContent.setFlexGrow(1, stationInfo);
stationCard.add(stationContent);
section.add(stationCard);
}
// Tour anzeigen Button
if (summary.getStations().size() >= 2) {
Button showTourButton = new Button("Tour anzeigen", e -> openTourMap());
showTourButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL);
showTourButton.setWidthFull();
section.add(showTourButton);
}
return section;
}
private void openTourMap() {
TourMapDialog tourMapDialog = new TourMapDialog(summary.getStations(), googleMapsApiKey);
tourMapDialog.open();
}
private VerticalLayout createRemarksSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(false);
section.setWidthFull();
H4 title = new H4("Bemerkungen");
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
section.add(title);
Div remarksBox = new Div();
remarksBox.addClassNames(
LumoUtility.Background.CONTRAST_5,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.MEDIUM
);
remarksBox.setWidthFull();
remarksBox.getStyle()
.set("word-break", "break-word")
.set("box-sizing", "border-box");
remarksBox.setText(summary.getRemarks());
section.add(remarksBox);
return section;
}
private VerticalLayout createOfferSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(false);
section.setWidthFull();
H4 title = new H4("Angebotstext");
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
section.add(title);
offerTextArea = new TextArea();
offerTextArea.setWidthFull();
offerTextArea.setMinHeight("120px");
offerTextArea.setValue(smtpEmailService.getDefaultOfferTemplate(summary));
offerTextArea.setHelperText("Bearbeiten Sie den Angebotstext vor dem Versenden");
recipientField = new EmailField("Empfänger");
recipientField.setWidthFull();
recipientField.setValue(orderEmail.getFromAddress());
recipientField.setHelperText("Email-Adresse des Empfängers");
section.add(offerTextArea, recipientField);
return section;
}
private VerticalLayout createConfirmationSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(false);
section.setWidthFull();
H4 title = new H4("Bestätigungstext");
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
section.add(title);
confirmationTextArea = new TextArea();
confirmationTextArea.setWidthFull();
confirmationTextArea.setMinHeight("120px");
confirmationTextArea.setValue(smtpEmailService.getDefaultConfirmationTemplate(summary));
confirmationTextArea.setHelperText("Bearbeiten Sie den Bestätigungstext vor dem Versenden");
recipientField = new EmailField("Empfänger");
recipientField.setWidthFull();
recipientField.setValue(orderEmail.getFromAddress());
recipientField.setHelperText("Email-Adresse des Empfängers");
section.add(confirmationTextArea, recipientField);
return section;
}
private void createFooter() {
Button cancelButton = new Button("Abbrechen", e -> close());
Button deleteButton = new Button("Löschen", e -> confirmDelete());
deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
Button reprocessButton = new Button("Neu analysieren", e -> reprocessEmail());
reprocessButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
// Spacer to push action buttons to the right
Div spacer = new Div();
spacer.getStyle().set("flex-grow", "1");
if (orderEmail.getType() == EmailType.QUOTE_REQUEST) {
Button sendOfferButton = new Button("Angebot senden", e -> sendOffer());
sendOfferButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(deleteButton, spacer, cancelButton, reprocessButton, sendOfferButton);
} else {
Button acceptButton = new Button("Auftrag annehmen", e -> acceptOrder());
acceptButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(deleteButton, spacer, cancelButton, reprocessButton, acceptButton);
}
}
private void confirmDelete() {
ConfirmDialog confirmDialog = new ConfirmDialog();
confirmDialog.setHeader("Email löschen");
confirmDialog.setText("Möchten Sie diese Email wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.");
confirmDialog.setCancelable(true);
confirmDialog.setCancelText("Abbrechen");
confirmDialog.setConfirmText("Löschen");
confirmDialog.setConfirmButtonTheme("error primary");
confirmDialog.addConfirmListener(event -> {
onDelete.accept(orderEmail);
close();
Notification.show("Email wurde gelöscht", 3000, Notification.Position.BOTTOM_START);
});
confirmDialog.open();
}
private void acceptOrder() {
try {
String confirmationText = confirmationTextArea != null ? confirmationTextArea.getValue() : null;
String recipient = recipientField != null ? recipientField.getValue() : orderEmail.getFromAddress();
smtpEmailService.sendConfirmation(orderEmail, summary, confirmationText, recipient);
orderEmail.setProcessed(true);
orderEmail.setConfirmed(true);
onProcessed.accept(orderEmail);
close();
SuccessDialog successDialog = new SuccessDialog(true);
successDialog.open();
} catch (Exception e) {
Notification.show("Fehler beim Senden der Bestätigung: " + e.getMessage(),
5000, Notification.Position.MIDDLE);
}
}
private void sendOffer() {
try {
String offerText = offerTextArea != null ? offerTextArea.getValue() : null;
String recipient = recipientField != null ? recipientField.getValue() : orderEmail.getFromAddress();
smtpEmailService.sendOffer(orderEmail, summary, offerText, recipient);
orderEmail.setProcessed(true);
orderEmail.setConfirmed(true);
onProcessed.accept(orderEmail);
close();
SuccessDialog successDialog = new SuccessDialog(false);
successDialog.open();
} catch (Exception e) {
Notification.show("Fehler beim Senden des Angebots: " + e.getMessage(),
5000, Notification.Position.MIDDLE);
}
}
}

View File

@@ -0,0 +1,84 @@
package de.assecutor.aimailassistant.mail.ui;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.theme.lumo.LumoUtility;
public class SuccessDialog extends Dialog {
public SuccessDialog(boolean isOrder) {
setHeaderTitle("Erfolgreich verarbeitet");
setCloseOnOutsideClick(false);
setWidth("450px");
VerticalLayout content = new VerticalLayout();
content.setPadding(false);
content.setSpacing(true);
content.setAlignItems(FlexComponent.Alignment.CENTER);
// Success icon
Icon successIcon = VaadinIcon.CHECK_CIRCLE.create();
successIcon.setSize("64px");
successIcon.addClassName(LumoUtility.TextColor.SUCCESS);
H3 title = new H3(isOrder ? "Auftrag angenommen" : "Angebot versendet");
title.addClassName(LumoUtility.Margin.NONE);
content.add(successIcon, title);
// Checklist
VerticalLayout checklist = new VerticalLayout();
checklist.setPadding(true);
checklist.setSpacing(false);
checklist.addClassName(LumoUtility.Background.CONTRAST_5);
checklist.addClassName(LumoUtility.BorderRadius.MEDIUM);
checklist.setWidth("100%");
checklist.add(createChecklistItem("Bestätigungsmail versendet", true));
checklist.add(createChecklistItem("Auftrag in Votian angelegt", true));
content.add(checklist);
add(content);
// Footer
Button closeButton = new Button("Schließen", e -> close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
getFooter().add(closeButton);
}
private HorizontalLayout createChecklistItem(String text, boolean checked) {
HorizontalLayout item = new HorizontalLayout();
item.setAlignItems(FlexComponent.Alignment.CENTER);
item.setSpacing(true);
item.setPadding(false);
Icon icon;
if (checked) {
icon = VaadinIcon.CHECK.create();
icon.addClassName(LumoUtility.TextColor.SUCCESS);
} else {
icon = VaadinIcon.CLOCK.create();
icon.addClassName(LumoUtility.TextColor.SECONDARY);
}
icon.setSize("20px");
Span label = new Span(text);
if (checked) {
label.addClassName(LumoUtility.TextColor.BODY);
} else {
label.addClassName(LumoUtility.TextColor.SECONDARY);
}
item.add(icon, label);
return item;
}
}

View File

@@ -0,0 +1,258 @@
package de.assecutor.aimailassistant.mail.ui;
import com.vaadin.flow.component.ClientCallable;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.aimailassistant.mail.domain.Station;
import java.util.List;
import java.util.stream.Collectors;
public class TourMapDialog extends Dialog {
private final List<Station> stations;
private final String apiKey;
private Span distanceLabel;
private Span durationLabel;
public TourMapDialog(List<Station> stations, String apiKey) {
this.stations = stations;
this.apiKey = apiKey;
setHeaderTitle("Tour-Übersicht");
setWidth("80vw");
setHeight("85vh");
setCloseOnOutsideClick(true);
add(createContent());
createFooter();
}
private VerticalLayout createContent() {
VerticalLayout layout = new VerticalLayout();
layout.setSizeFull();
layout.setPadding(false);
layout.setSpacing(true);
// Info section
HorizontalLayout infoSection = createInfoSection();
layout.add(infoSection);
// Map container
Div mapContainer = createMapContainer();
layout.add(mapContainer);
layout.setFlexGrow(1, mapContainer);
return layout;
}
private HorizontalLayout createInfoSection() {
HorizontalLayout layout = new HorizontalLayout();
layout.setWidthFull();
layout.setSpacing(true);
layout.setAlignItems(FlexComponent.Alignment.CENTER);
// Distance info
Div distanceBox = new Div();
distanceBox.addClassNames(
LumoUtility.Background.PRIMARY_10,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.MEDIUM
);
VerticalLayout distanceContent = new VerticalLayout();
distanceContent.setPadding(false);
distanceContent.setSpacing(false);
Span distanceTitle = new Span("Entfernung");
distanceTitle.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
distanceLabel = new Span("Wird berechnet...");
distanceLabel.addClassNames(LumoUtility.FontSize.XLARGE, LumoUtility.FontWeight.BOLD);
distanceContent.add(distanceTitle, distanceLabel);
distanceBox.add(distanceContent);
// Duration info
Div durationBox = new Div();
durationBox.addClassNames(
LumoUtility.Background.SUCCESS_10,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.MEDIUM
);
VerticalLayout durationContent = new VerticalLayout();
durationContent.setPadding(false);
durationContent.setSpacing(false);
Span durationTitle = new Span("Geschätzte Fahrzeit");
durationTitle.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
durationLabel = new Span("Wird berechnet...");
durationLabel.addClassNames(LumoUtility.FontSize.XLARGE, LumoUtility.FontWeight.BOLD);
durationContent.add(durationTitle, durationLabel);
durationBox.add(durationContent);
// Stations info
Div stationsBox = new Div();
stationsBox.addClassNames(
LumoUtility.Background.CONTRAST_5,
LumoUtility.BorderRadius.MEDIUM,
LumoUtility.Padding.MEDIUM
);
VerticalLayout stationsContent = new VerticalLayout();
stationsContent.setPadding(false);
stationsContent.setSpacing(false);
Span stationsTitle = new Span("Stationen");
stationsTitle.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
Span stationsCount = new Span(String.valueOf(stations.size()));
stationsCount.addClassNames(LumoUtility.FontSize.XLARGE, LumoUtility.FontWeight.BOLD);
stationsContent.add(stationsTitle, stationsCount);
stationsBox.add(stationsContent);
layout.add(distanceBox, durationBox, stationsBox);
return layout;
}
private Div createMapContainer() {
Div mapDiv = new Div();
mapDiv.setId("tour-map");
mapDiv.setSizeFull();
mapDiv.getStyle()
.set("min-height", "400px")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("overflow", "hidden");
// Build waypoints JavaScript array
String waypointsJs = stations.stream()
.map(s -> {
String addr = s.getAddress() != null ? s.getAddress() : s.getName();
return "\"" + escapeJs(addr) + "\"";
})
.collect(Collectors.joining(", "));
// JavaScript to initialize Google Maps
String initScript = """
(function() {
const mapDiv = document.getElementById('tour-map');
if (!mapDiv) return;
// Load Google Maps script if not already loaded
if (!window.google || !window.google.maps) {
const script = document.createElement('script');
script.src = 'https://maps.googleapis.com/maps/api/js?key=%s&libraries=places';
script.async = true;
script.defer = true;
script.onload = function() {
initMap();
};
document.head.appendChild(script);
} else {
initMap();
}
function initMap() {
const waypoints = [%s];
if (waypoints.length < 2) {
mapDiv.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%%;color:var(--lumo-secondary-text-color)">Mindestens 2 Stationen erforderlich</div>';
return;
}
const map = new google.maps.Map(mapDiv, {
zoom: 10,
center: { lat: 51.1657, lng: 10.4515 }, // Germany center
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true
});
const directionsService = new google.maps.DirectionsService();
const directionsRenderer = new google.maps.DirectionsRenderer({
map: map,
suppressMarkers: false,
polylineOptions: {
strokeColor: '#1676F3',
strokeWeight: 5
}
});
const origin = waypoints[0];
const destination = waypoints[waypoints.length - 1];
const middleWaypoints = waypoints.slice(1, -1).map(wp => ({
location: wp,
stopover: true
}));
directionsService.route({
origin: origin,
destination: destination,
waypoints: middleWaypoints,
travelMode: google.maps.TravelMode.DRIVING,
region: 'de'
}, function(result, status) {
if (status === 'OK') {
directionsRenderer.setDirections(result);
// Calculate total distance and duration
let totalDistance = 0;
let totalDuration = 0;
result.routes[0].legs.forEach(leg => {
totalDistance += leg.distance.value;
totalDuration += leg.duration.value;
});
const distanceKm = (totalDistance / 1000).toFixed(1);
const hours = Math.floor(totalDuration / 3600);
const minutes = Math.floor((totalDuration %% 3600) / 60);
const durationText = hours > 0 ? hours + ' h ' + minutes + ' min' : minutes + ' min';
// Update the labels via server
const dialogElement = mapDiv.closest('vaadin-dialog-overlay');
if (dialogElement && dialogElement.__component) {
dialogElement.__component.$server.updateRouteInfo(distanceKm, durationText);
}
// Also update directly via DOM as fallback
const distanceSpan = document.querySelector('[data-distance-label]');
const durationSpan = document.querySelector('[data-duration-label]');
if (distanceSpan) distanceSpan.textContent = distanceKm + ' km';
if (durationSpan) durationSpan.textContent = durationText;
} else {
console.error('Directions request failed:', status);
mapDiv.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%%;color:var(--lumo-error-text-color)">Route konnte nicht berechnet werden: ' + status + '</div>';
}
});
}
})();
""".formatted(apiKey, waypointsJs);
// Add data attributes for JavaScript fallback update
distanceLabel.getElement().setAttribute("data-distance-label", "true");
durationLabel.getElement().setAttribute("data-duration-label", "true");
// Execute script after attach
mapDiv.getElement().executeJs(initScript);
return mapDiv;
}
private String escapeJs(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
@ClientCallable
public void updateRouteInfo(String distance, String duration) {
getUI().ifPresent(ui -> ui.access(() -> {
distanceLabel.setText(distance + " km");
durationLabel.setText(duration);
}));
}
private void createFooter() {
Button closeButton = new Button("Schließen", e -> close());
getFooter().add(closeButton);
}
}

View File

@@ -5,5 +5,38 @@ logging.level.org.atmosphere=warn
vaadin.launch-browser=true vaadin.launch-browser=true
# To improve the performance during development. # To improve the performance during development.
# For more information https://vaadin.com/docs/latest/flow/integrations/spring/configuration#special-configuration-parameters
vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant
# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:mailassistant
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.h2.console.enabled=true
# IMAP Configuration
mail.imap.host=mail.appcreation.de
mail.imap.port=993
mail.imap.username=sb@appcreation.de
mail.imap.password=SV1705CA!sb
mail.imap.ssl=true
mail.imap.folder=INBOX
mail.imap.poll-interval-seconds=60
# SMTP Configuration
spring.mail.host=mail.appcreation.de
spring.mail.port=465
spring.mail.username=sb@appcreation.de
spring.mail.password=SV1705CA!sb
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.ssl.required=true
# LM Studio Configuration
llm.api.url=http://192.168.180.10:1234/v1/chat/completions
llm.api.model=local-model
# Google Maps Configuration
google.maps.api.key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE