From e46f12807a405d474f9080e596596920056e5c9e Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 22 Jan 2026 19:40:24 +0100 Subject: [PATCH] 1. Import --- CLAUDE.md | 94 ++- pom.xml | 34 ++ src/main/frontend/index.html | 23 + .../aimailassistant/Application.java | 16 +- .../aimailassistant/config/JacksonConfig.java | 17 + .../mail/domain/EmailType.java | 17 + .../aimailassistant/mail/domain/Gender.java | 17 + .../mail/domain/OrderEmail.java | 163 +++++ .../mail/domain/OrderEmailRepository.java | 29 + .../mail/domain/OrderSummary.java | 64 ++ .../aimailassistant/mail/domain/Station.java | 40 ++ .../mail/domain/StationAction.java | 17 + .../mail/event/EmailBroadcaster.java | 20 + .../mail/event/NewEmailEvent.java | 18 + .../mail/service/ImapEmailService.java | 308 ++++++++++ .../mail/service/LlmService.java | 252 ++++++++ .../mail/service/SmtpEmailService.java | 202 ++++++ .../aimailassistant/mail/ui/MainView.java | 301 +++++++++ .../mail/ui/OrderDetailDialog.java | 574 ++++++++++++++++++ .../mail/ui/SuccessDialog.java | 84 +++ .../mail/ui/TourMapDialog.java | 258 ++++++++ src/main/resources/application.properties | 35 +- 22 files changed, 2517 insertions(+), 66 deletions(-) create mode 100644 src/main/frontend/index.html create mode 100644 src/main/java/de/assecutor/aimailassistant/config/JacksonConfig.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/EmailType.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/Gender.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmail.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmailRepository.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/OrderSummary.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/domain/StationAction.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/event/EmailBroadcaster.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/event/NewEmailEvent.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/service/ImapEmailService.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/service/SmtpEmailService.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/ui/SuccessDialog.java create mode 100644 src/main/java/de/assecutor/aimailassistant/mail/ui/TourMapDialog.java diff --git a/CLAUDE.md b/CLAUDE.md index 73f9b97..06dd926 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -This is a Vaadin application built with: -- Java -- Spring Boot -- Spring Data JPA with H2 database -- Maven build system +- **Java 21** +- **Vaadin 25.0.3** (server-side Java UI framework with Lumo theme) +- **Spring Boot 4.0.1** +- **Maven build system** ## Development Commands -### Running the Application ```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 -``` - -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 -Dtest=TaskServiceTest # 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 # Run a single test class +./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 - - `base.ui.MainLayout`: AppLayout with drawer navigation using SideNav, automatically populated from @Menu annotations - - `base.ui.component.ViewToolbar`: Reusable toolbar component for views +This project uses **feature-based packaging**: each feature is self-contained with its own entities, repositories, services, and UI views. -- **`de.assecutor.aimailassistant.examplefeature`**: Example feature demonstrating the structure - - `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 +## Architecture Guidelines -- **`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 -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) +### Vaadin Patterns -## 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: -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** +### Spring Patterns -## Vaadin-Specific Notes - -- **Server-side rendering**: UI components are Java classes extending Vaadin components -- **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) +- Constructor injection (no `@Autowired` on fields) +- `@Transactional` for write operations +- `@Transactional(readOnly = true)` for read operations ## Database -- H2 in-memory database for development -- JPA entities use `@GeneratedValue(strategy = GenerationType.SEQUENCE)` -- Entity equality based on ID (see Task.equals/hashCode pattern) +No database configured yet. To add persistence, include Spring Data JPA and a database driver in pom.xml. diff --git a/pom.xml b/pom.xml index b7294e3..a72222d 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,40 @@ com.vaadin vaadin-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-mail + + + org.eclipse.angus + angus-mail + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + com.fasterxml.jackson.core + jackson-databind + + org.springframework.boot spring-boot-starter-test diff --git a/src/main/frontend/index.html b/src/main/frontend/index.html new file mode 100644 index 0000000..eb0c53b --- /dev/null +++ b/src/main/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + +
+ + diff --git a/src/main/java/de/assecutor/aimailassistant/Application.java b/src/main/java/de/assecutor/aimailassistant/Application.java index a578f49..3938020 100644 --- a/src/main/java/de/assecutor/aimailassistant/Application.java +++ b/src/main/java/de/assecutor/aimailassistant/Application.java @@ -1,20 +1,22 @@ 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.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 -@StyleSheet(Lumo.STYLESHEET) // Use Aura.STYLESHEET to use Aura instead +@EnableScheduling +@Push +@StyleSheet(Lumo.STYLESHEET) @StyleSheet(Lumo.UTILITY_STYLESHEET) -@StyleSheet("styles.css") // Your custom styles +@StyleSheet("styles.css") public class Application implements AppShellConfigurator { public static void main(String[] args) { SpringApplication.run(Application.class, args); } - } diff --git a/src/main/java/de/assecutor/aimailassistant/config/JacksonConfig.java b/src/main/java/de/assecutor/aimailassistant/config/JacksonConfig.java new file mode 100644 index 0000000..2dd6689 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/config/JacksonConfig.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/EmailType.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/EmailType.java new file mode 100644 index 0000000..b12b4be --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/EmailType.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/Gender.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/Gender.java new file mode 100644 index 0000000..aec26e5 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/Gender.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmail.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmail.java new file mode 100644 index 0000000..a28c27f --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmail.java @@ -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(); + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmailRepository.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmailRepository.java new file mode 100644 index 0000000..626765f --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderEmailRepository.java @@ -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 { + + List 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 findAllSortedByStatusAndDate(); + + List findByProcessedFalseAndDeletedFalseOrderByReceivedDateDesc(); + + Optional findByMessageId(String messageId); + + boolean existsByMessageId(String messageId); +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderSummary.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderSummary.java new file mode 100644 index 0000000..89e9346 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/OrderSummary.java @@ -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 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 getStations() { + return stations; + } + + public void setStations(List stations) { + this.stations = stations; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java new file mode 100644 index 0000000..f3553f1 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/StationAction.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/StationAction.java new file mode 100644 index 0000000..6aad985 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/StationAction.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/event/EmailBroadcaster.java b/src/main/java/de/assecutor/aimailassistant/mail/event/EmailBroadcaster.java new file mode 100644 index 0000000..d9cbc07 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/event/EmailBroadcaster.java @@ -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> listeners = new CopyOnWriteArrayList<>(); + + public static Registration register(Consumer listener) { + listeners.add(listener); + return () -> listeners.remove(listener); + } + + public static void broadcast() { + listeners.forEach(listener -> listener.accept(null)); + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/event/NewEmailEvent.java b/src/main/java/de/assecutor/aimailassistant/mail/event/NewEmailEvent.java new file mode 100644 index 0000000..c98f38c --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/event/NewEmailEvent.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/service/ImapEmailService.java b/src/main/java/de/assecutor/aimailassistant/mail/service/ImapEmailService.java new file mode 100644 index 0000000..9f43dce --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/service/ImapEmailService.java @@ -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("", "\n") + .replaceAll("

", "\n") + .replaceAll("

", "\n") + .replaceAll("<[^>]+>", "") + .replaceAll(" ", " ") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", "\"") + .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); + } + } + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java b/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java new file mode 100644 index 0000000..d119f33 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java @@ -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 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 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); + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/service/SmtpEmailService.java b/src/main/java/de/assecutor/aimailassistant/mail/service/SmtpEmailService.java new file mode 100644 index 0000000..535b59a --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/service/SmtpEmailService.java @@ -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(); + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java new file mode 100644 index 0000000..4411927 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java @@ -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 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 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; + } + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java new file mode 100644 index 0000000..d04daf0 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java @@ -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 onProcessed; + private final Consumer 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 onProcessed, + Consumer 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 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 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); + } + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/SuccessDialog.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/SuccessDialog.java new file mode 100644 index 0000000..7047cfc --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/SuccessDialog.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/TourMapDialog.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/TourMapDialog.java new file mode 100644 index 0000000..06897a3 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/TourMapDialog.java @@ -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 stations; + private final String apiKey; + private Span distanceLabel; + private Span durationLabel; + + public TourMapDialog(List 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 = '
Mindestens 2 Stationen erforderlich
'; + 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 = '
Route konnte nicht berechnet werden: ' + status + '
'; + } + }); + } + })(); + """.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); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b145b25..da0ff76 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,5 +5,38 @@ logging.level.org.atmosphere=warn vaadin.launch-browser=true # 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 + +# 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