From 95155a973680627199ccd297ad9f6a03098ce985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Sun, 12 Apr 2026 00:29:21 +0700 Subject: [PATCH 01/47] feat(email): implement email attachment handling and retrieval features --- .../email/controller/EmailController.java | 83 +++++------ .../controller/LegacyDashboardController.java | 12 -- .../email/dto/response/EmailEntityDto.java | 15 ++ .../email/dto/response/MailMessageDto.java | 18 +++ .../modules/email/entity/EmailAttachment.java | 42 ++++++ .../modules/email/entity/EmailEntity.java | 5 + .../repository/EmailAttachmentRepository.java | 14 ++ .../modules/email/service/AiService.java | 21 ++- .../modules/email/service/EmailService.java | 131 ++++++++++++++++++ .../email/service/EmailSyncService.java | 27 ++++ .../modules/email/service/ImapService.java | 54 ++++++-- 11 files changed, 353 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/awad/emailclientai/modules/email/entity/EmailAttachment.java create mode 100644 src/main/java/com/awad/emailclientai/modules/email/repository/EmailAttachmentRepository.java create mode 100644 src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java diff --git a/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java b/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java index 7fa6011..7c96313 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java +++ b/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java @@ -10,6 +10,9 @@ import com.awad.emailclientai.modules.kanban.repository.KanbanColumnRepository; import com.awad.emailclientai.modules.email.repository.EmailAccountRepository; import com.awad.emailclientai.modules.email.service.EmailAccountService; +import com.awad.emailclientai.modules.email.service.EmailService; +import com.awad.emailclientai.shared.exception.BusinessException; +import com.awad.emailclientai.shared.exception.ErrorCode; import com.awad.emailclientai.modules.email.dto.request.SendEmailRequestDto; import com.awad.emailclientai.shared.dto.response.ApiResponse; import org.springframework.web.multipart.MultipartFile; @@ -51,13 +54,12 @@ public class EmailController { private final EmailAccountService emailAccountService; private final EmailSyncService emailSyncService; private final com.awad.emailclientai.modules.email.service.AiService aiService; + private final EmailService emailService; @PostMapping("/{id}/summarize") @Operation(summary = "Generate AI Email Summary", description = "Generates a summary using AI or local fallback.") public ResponseEntity> summarizeEmail(@PathVariable Long id) { String summary = aiService.summarizeEmail(id); - // "Summary generated" is the message, summary is the data. - // This avoids collision with ApiResponse.success(String message) return ResponseEntity.ok(ApiResponse.success("Summary generated", summary)); } @@ -97,12 +99,9 @@ public ResponseEntity> sendEmailBridgeJson( log.info("Bridge Send Email (JSON): Received request from user {}", principal.getId()); EmailAccount account = getPrimaryAccount(principal); - log.info("Bridge Send Email (JSON): Using account {}", account.getEmailAddress()); SendEmailRequestDto request = mapJsonToDto(jsonBody); - log.info("Bridge Send Email (JSON): Calling emailAccountService.sendEmail..."); String messageId = emailAccountService.sendEmail(principal.getId(), account.getId(), request); - log.info("Bridge Send Email (JSON): Success, messageId={}", messageId); return ResponseEntity.ok(ApiResponse.success("Email sent successfully", messageId)); } @@ -121,7 +120,6 @@ public ResponseEntity> sendEmailBridgeMultipart( log.info("Bridge Send Email (Multipart): Received request from user {}", principal.getId()); EmailAccount account = getPrimaryAccount(principal); - log.info("Bridge Send Email (Multipart): Using account {}", account.getEmailAddress()); ObjectMapper mapper = new ObjectMapper(); SendEmailRequestDto request = new SendEmailRequestDto(); @@ -139,14 +137,11 @@ public ResponseEntity> sendEmailBridgeMultipart( request.setInReplyTo(threadId); String messageId; - log.info("Bridge Send Email (Multipart): Calling emailAccountService..."); if (attachments != null && !attachments.isEmpty()) { - log.info("Bridge Send Email (Multipart): Sending with {} attachments", attachments.size()); messageId = emailAccountService.sendEmailWithAttachments(principal.getId(), account.getId(), request, attachments); } else { messageId = emailAccountService.sendEmail(principal.getId(), account.getId(), request); } - log.info("Bridge Send Email (Multipart): Success, messageId={}", messageId); return ResponseEntity.ok(ApiResponse.success("Email sent successfully", messageId)); } @@ -178,7 +173,6 @@ private SendEmailRequestDto mapJsonToDto(Map jsonBody) { request.setCc((List) jsonBody.get("cc")); request.setBcc((List) jsonBody.get("bcc")); request.setSubject((String) jsonBody.get("subject")); - // Map FE 'body' to BE 'bodyText' request.setBodyText((String) jsonBody.get("body")); request.setInReplyTo((String) jsonBody.get("threadId")); } @@ -192,6 +186,27 @@ public ResponseEntity> refreshEmail(@PathVariable Long id) { return ResponseEntity.ok(ApiResponse.success("Email refreshed successfully")); } + @GetMapping("/{id}") + @Operation(summary = "Get Full Email Detail", description = "Retrieve full email content including processed body and attachments.") + public ResponseEntity> getEmailDetail(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(emailService.getEmailDetail(id))); + } + + @GetMapping("/{id}/attachments/{atId}/inline") + @Operation(summary = "Get Inline Attachment Proxy", description = "Stream an inline attachment/image for display in email body.") + public ResponseEntity getInlineAttachment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id, + @PathVariable Long atId) throws MessagingException, IOException { + + var resource = emailService.getInlineAttachment(id, atId); + String contentType = emailService.getAttachmentContentType(atId); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .body(resource); + } + @GetMapping("/search") @Operation(summary = "Fuzzy Search Emails with Relevance Ranking", description = "Fuzzy search by subject or sender with relevance ranking.") public ResponseEntity>> searchEmails( @@ -201,6 +216,7 @@ public ResponseEntity>> searchEmails( List rows = emailRepository.searchEmailsWithScore(accountId, q); List results = rows.stream() .map(row -> { + // Manual mapping for search results since they return Object[] from native query EmailEntityDto emailDto = EmailEntityDto.builder() .id(((Number) row[0]).longValue()) .messageId((String) row[1]) @@ -246,7 +262,6 @@ public ResponseEntity>> getEmails( @RequestParam(required = false) Boolean hasAttachments, @RequestParam(defaultValue = "receivedDate,desc") String sort) { - // Parse sort parameter (simple implementation: "field,direction") String[] sortParts = sort.split(","); String sortField = sortParts[0]; org.springframework.data.domain.Sort.Direction direction = org.springframework.data.domain.Sort.Direction.DESC; @@ -259,9 +274,8 @@ public ResponseEntity>> getEmails( com.awad.emailclientai.modules.email.repository.EmailSpecification.filterEmails(accountId, status, unread, hasAttachments); List entities = emailRepository.findAll(spec, sortObj); - List dtos = entities.stream() - .map(this::mapToDto) + .map(emailService::mapToDto) .collect(Collectors.toList()); return ResponseEntity.ok(ApiResponse.success(dtos)); @@ -274,12 +288,11 @@ public ResponseEntity> updateStatus( @RequestParam String status) { EmailEntity email = emailRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Email not found")); + .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); String oldStatus = email.getStatus(); email.setStatus(status); - // If moving out of snoozed, clear the date if (!EmailStatus.SNOOZED.equals(status)) { email.setSnoozedUntil(null); } @@ -287,7 +300,6 @@ public ResponseEntity> updateStatus( EmailEntity saved = emailRepository.save(email); try { - // Look up old label to remove String oldLabelId = null; if (oldStatus != null) { oldLabelId = kanbanColumnRepository @@ -297,7 +309,6 @@ public ResponseEntity> updateStatus( .orElse(null); } - // Look up new label to add String finalOldLabelId = oldLabelId; kanbanColumnRepository.findByAccountIdAndLinkedStatus(email.getAccount().getId(), status) .ifPresent(column -> { @@ -314,7 +325,7 @@ public ResponseEntity> updateStatus( log.warn("Failed to find kanban column mapping: {}", e.getMessage()); } - return ResponseEntity.ok(ApiResponse.success(mapToDto(saved))); + return ResponseEntity.ok(ApiResponse.success(emailService.mapToDto(saved))); } @PutMapping("/{id}/snooze") @@ -324,39 +335,21 @@ public ResponseEntity> snoozeEmail( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime until) { EmailEntity email = emailRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Email not found")); + .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); email.setStatus(EmailStatus.SNOOZED); email.setSnoozedUntil(until); EmailEntity saved = emailRepository.save(email); - return ResponseEntity.ok(ApiResponse.success(mapToDto(saved))); + return ResponseEntity.ok(ApiResponse.success(emailService.mapToDto(saved))); } - private EmailEntityDto mapToDto(EmailEntity entity) { - return EmailEntityDto.builder() - .id(entity.getId()) - .messageId(entity.getMessageId()) - .uid(entity.getUid()) - .subject(entity.getSubject()) - .sender(entity.getSender()) - .snippet(entity.getSnippet()) - .body(entity.getBody()) - .status(entity.getStatus()) - .receivedDate(entity.getReceivedDate()) - .snoozedUntil(entity.getSnoozedUntil()) - .summary(entity.getSummary()) - .summarySource(entity.getSummarySource() != null ? entity.getSummarySource().name() : null) - .isRead(entity.isRead()) - .hasAttachments(entity.isHasAttachments()) - .accountEmail(entity.getAccount().getEmailAddress()) - .gmailLink(entity.getGmailMessageId() != null ? - String.format("https://mail.google.com/mail/u/%s/#inbox/%s", - URLEncoder.encode(entity.getAccount().getEmailAddress(), StandardCharsets.UTF_8), - entity.getGmailMessageId()) : - String.format("https://mail.google.com/mail/u/%s/#search/rfc822msgid:%s", - URLEncoder.encode(entity.getAccount().getEmailAddress(), StandardCharsets.UTF_8), - URLEncoder.encode(entity.getMessageId(), StandardCharsets.UTF_8))) - .build(); + @GetMapping("/suggest") + @Operation(summary = "AI Suggest Search Query", description = "Suggest search query based on current input and user context.") + public ResponseEntity> suggestSearch( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam String input) { + String suggestion = aiService.suggestSearchQuery(input, principal.getId()); + return ResponseEntity.ok(ApiResponse.success("Suggestion generated", suggestion)); } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java b/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java index 58ab943..98eb2a6 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java +++ b/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java @@ -387,18 +387,6 @@ public ResponseEntity>> modifyEmail( return ResponseEntity.ok(ApiResponse.success(mapToFrontendEmail(email, account))); } - @GetMapping("/emails/{id}") - public ResponseEntity>> getEmailDetail( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable String id - ) { - Long emailId = Long.parseLong(id); - EmailEntity email = emailRepository.findById(emailId) - .orElseThrow(() -> new RuntimeException("Email not found")); - - EmailAccount account = getPrimaryAccount(principal); - return ResponseEntity.ok(ApiResponse.success(mapToFrontendEmail(email, account))); - } @GetMapping("/gmail/labels") public ResponseEntity>> getGmailLabels( diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java index c7b3c40..a977819 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java @@ -6,6 +6,7 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.List; @Data @Builder @@ -30,4 +31,18 @@ public class EmailEntityDto { private boolean hasAttachments; private String gmailLink; private String accountEmail; + private List attachments; + + @Data + @Builder + public static class AttachmentDto { + private String id; + private String filename; + private long size; + private String contentType; + private String serverAttachmentId; + private String contentId; + private boolean inline; + private String url; + } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDto.java index b95cc8b..7c602df 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDto.java +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDto.java @@ -98,4 +98,22 @@ public class MailMessageDto { * Size of the message in bytes. */ private int size; + + /** + * List of attachments (metadata only). + */ + private List attachments; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AttachmentMetadataDto { + private String id; + private String filename; + private String contentType; + private long size; + private String contentId; + private boolean inline; + } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/entity/EmailAttachment.java b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailAttachment.java new file mode 100644 index 0000000..cee00f0 --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailAttachment.java @@ -0,0 +1,42 @@ +package com.awad.emailclientai.modules.email.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "email_attachments") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EmailAttachment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "email_id", nullable = false) + private EmailEntity email; + + @Column(nullable = false) + private String filename; + + private String contentType; + + private long size; + + /** + * The ID used by the mail server (e.g., IMAP attachment index or Gmail attachmentId) + */ + private String serverAttachmentId; + + /** + * Content-ID used for inline images (e.g., ) + */ + private String contentId; + + @Builder.Default + private boolean inline = false; +} diff --git a/src/main/java/com/awad/emailclientai/modules/email/entity/EmailEntity.java b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailEntity.java index 20ef795..e137303 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/entity/EmailEntity.java +++ b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailEntity.java @@ -6,6 +6,8 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "emails") @@ -72,4 +74,7 @@ public class EmailEntity { @Column(name = "embedding_384", columnDefinition = "vector", insertable = false, updatable = false) private String embedding384; + @OneToMany(mappedBy = "email", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List attachments = new ArrayList<>(); } diff --git a/src/main/java/com/awad/emailclientai/modules/email/repository/EmailAttachmentRepository.java b/src/main/java/com/awad/emailclientai/modules/email/repository/EmailAttachmentRepository.java new file mode 100644 index 0000000..c3beb0c --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/repository/EmailAttachmentRepository.java @@ -0,0 +1,14 @@ +package com.awad.emailclientai.modules.email.repository; + +import com.awad.emailclientai.modules.email.entity.EmailAttachment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface EmailAttachmentRepository extends JpaRepository { + List findByEmailId(Long emailId); + Optional findByEmailIdAndContentId(Long emailId, String contentId); +} diff --git a/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java b/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java index 44fb480..95d00fa 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java +++ b/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java @@ -278,11 +278,24 @@ public String extractiveSummary(String text, int topSentences, int maxChars) { outLen += c.text.length(); } - String finalRes = result.toString(); - if (finalRes.length() > maxChars) { - finalRes = finalRes.substring(0, maxChars) + "..."; + String res = result.toString(); + if (res.length() > maxChars) { + res = res.substring(0, maxChars) + "..."; + } + return res.trim(); + } + + public String suggestSearchQuery(String input, Long userId) { + if (geminiApiKey == null || geminiApiKey.isEmpty()) { + return "from: " + input; + } + + String prompt = "Give a 3-5 word email search query for: " + input + ". Respond with ONLY the query."; + try { + return callGeminiApi(prompt); + } catch (Exception e) { + return "from: " + input; } - return finalRes.trim(); } private static class SentenceScore { diff --git a/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java b/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java new file mode 100644 index 0000000..6a0a361 --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java @@ -0,0 +1,131 @@ +package com.awad.emailclientai.modules.email.service; + +import com.awad.emailclientai.modules.email.dto.response.EmailEntityDto; +import com.awad.emailclientai.modules.email.entity.EmailAttachment; +import com.awad.emailclientai.modules.email.entity.EmailEntity; +import com.awad.emailclientai.modules.email.repository.EmailAttachmentRepository; +import com.awad.emailclientai.modules.email.repository.EmailRepository; +import com.awad.emailclientai.shared.exception.BusinessException; +import com.awad.emailclientai.shared.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.mail.MessagingException; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailService { + + private final EmailRepository emailRepository; + private final EmailAttachmentRepository attachmentRepository; + private final ImapService imapService; + + @Transactional(readOnly = true) + public EmailEntityDto getEmailDetail(Long id) { + EmailEntity email = emailRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); + + return mapToDto(email); + } + + @Transactional(readOnly = true) + public Resource getInlineAttachment(Long emailId, Long attachmentId) throws MessagingException, IOException { + EmailEntity email = emailRepository.findById(emailId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); + + EmailAttachment at = attachmentRepository.findById(attachmentId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + + var attachmentResource = imapService.downloadAttachment( + email.getAccount(), + "INBOX", + email.getUid(), + at.getServerAttachmentId() + ); + + return new InputStreamResource(attachmentResource.getInputStream()); + } + + public String getAttachmentContentType(Long attachmentId) { + return attachmentRepository.findById(attachmentId) + .map(EmailAttachment::getContentType) + .orElse("application/octet-stream"); + } + + public EmailEntityDto mapToDto(EmailEntity entity) { + String body = entity.getBody(); + List attachments = entity.getAttachments().stream() + .map(at -> EmailEntityDto.AttachmentDto.builder() + .id(at.getId().toString()) + .filename(at.getFilename()) + .size(at.getSize()) + .contentType(at.getContentType()) + .serverAttachmentId(at.getServerAttachmentId()) + .contentId(at.getContentId()) + .inline(at.isInline()) + .url(at.isInline() ? + String.format("/api/v1/emails/%d/attachments/%d/inline", entity.getId(), at.getId()) : + String.format("/api/v1/email-accounts/%d/folders/INBOX/messages/%d/attachments/%s", + entity.getAccount().getId(), entity.getUid(), at.getServerAttachmentId())) + .build()) + .collect(Collectors.toList()); + + // Transform body to resolve inline images (cid:) + if (body != null && body.contains("cid:")) { + body = resolveInlineImages(body, entity.getId(), entity.getAttachments()); + } + + return EmailEntityDto.builder() + .id(entity.getId()) + .messageId(entity.getMessageId()) + .uid(entity.getUid()) + .subject(entity.getSubject()) + .sender(entity.getSender()) + .snippet(entity.getSnippet()) + .body(body) + .status(entity.getStatus()) + .receivedDate(entity.getReceivedDate()) + .snoozedUntil(entity.getSnoozedUntil()) + .summary(entity.getSummary()) + .summarySource(entity.getSummarySource() != null ? entity.getSummarySource().name() : null) + .isRead(entity.isRead()) + .hasAttachments(entity.isHasAttachments()) + .accountEmail(entity.getAccount().getEmailAddress()) + .attachments(attachments) + .gmailLink(entity.getGmailMessageId() != null ? + String.format("https://mail.google.com/mail/u/%s/#inbox/%s", + URLEncoder.encode(entity.getAccount().getEmailAddress(), StandardCharsets.UTF_8), + entity.getGmailMessageId()) : + String.format("https://mail.google.com/mail/u/%s/#search/rfc822msgid:%s", + URLEncoder.encode(entity.getAccount().getEmailAddress(), StandardCharsets.UTF_8), + URLEncoder.encode(entity.getMessageId(), StandardCharsets.UTF_8))) + .build(); + } + + private String resolveInlineImages(String html, Long emailId, List attachments) { + if (html == null || attachments == null) return html; + String resolvedHtml = html; + for (EmailAttachment at : attachments) { + if (at.isInline() && at.getContentId() != null) { + // Normalize Content-ID (strip brackets if present) + String cid = at.getContentId().replaceAll("[<>]", ""); + String proxyUrl = String.format("/api/v1/emails/%d/attachments/%d/inline", emailId, at.getId()); + + // Replace both with-brackets and without-brackets versions in HTML + resolvedHtml = resolvedHtml.replace("cid:" + cid, proxyUrl); + resolvedHtml = resolvedHtml.replace("cid:<" + cid + ">", proxyUrl); + } + } + return resolvedHtml; + } +} diff --git a/src/main/java/com/awad/emailclientai/modules/email/service/EmailSyncService.java b/src/main/java/com/awad/emailclientai/modules/email/service/EmailSyncService.java index 7097990..d38abc8 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/service/EmailSyncService.java +++ b/src/main/java/com/awad/emailclientai/modules/email/service/EmailSyncService.java @@ -248,6 +248,14 @@ private void syncAccount(EmailAccount account, String folderName, int limit, int changed = true; } + // Update attachments if missing or changed + if (msg.getAttachments() != null && !msg.getAttachments().isEmpty()) { + if (existing.getAttachments() == null || existing.getAttachments().isEmpty()) { + existing.setAttachments(mapAttachments(msg.getAttachments(), existing)); + changed = true; + } + } + if (changed) { emailRepository.save(existing); } @@ -271,6 +279,11 @@ private void syncAccount(EmailAccount account, String folderName, int limit, int .kanbanOrder((double) msg.getReceivedAt().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()) .build(); + // Map and set attachments + if (msg.getAttachments() != null && !msg.getAttachments().isEmpty()) { + entity.setAttachments(mapAttachments(msg.getAttachments(), entity)); + } + try { emailRepository.save(entity); // Generate embedding after entity is persisted (has ID) @@ -410,4 +423,18 @@ private KanbanColumn findOrCreateColumn(Long accountId, String labelName) { log.info("Creating new Kanban column for label: '{}'", labelName); return kanbanService.createColumn(accountId, labelName, labelName, "#f1f5f9"); } + + private List mapAttachments( + List dtos, EmailEntity email) { + return dtos.stream().map(dto -> com.awad.emailclientai.modules.email.entity.EmailAttachment.builder() + .email(email) + .filename(dto.getFilename()) + .contentType(dto.getContentType()) + .size(dto.getSize()) + .serverAttachmentId(dto.getId()) + .contentId(dto.getContentId()) + .inline(dto.isInline()) + .build()) + .collect(Collectors.toList()); + } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/service/ImapService.java b/src/main/java/com/awad/emailclientai/modules/email/service/ImapService.java index 508b277..08d39a7 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/service/ImapService.java +++ b/src/main/java/com/awad/emailclientai/modules/email/service/ImapService.java @@ -577,6 +577,19 @@ private MailMessageDto convertToDto(Message message, Folder folder) throws Messa // Fetch Gmail labels using raw IMAP FETCH X-GM-LABELS command List labels = fetchGmailLabels(message, folder); + String body = fetchBodyContent(message); + String preview = generatePreview(message); + + // Collect attachment metadata + List attachments = new ArrayList<>(); + try { + if (message.getContent() instanceof Multipart) { + collectAttachmentMetadata((Multipart) message.getContent(), attachments, new int[]{0}); + } + } catch (Exception e) { + log.warn("Failed to extract attachment metadata for UID {}: {}", uid, e.getMessage()); + } + return MailMessageDto.builder() .uid(uid) .messageId(getHeaderValue(message, "Message-ID")) @@ -585,13 +598,14 @@ private MailMessageDto convertToDto(Message message, Folder folder) throws Messa .to(to) .cc(cc) .subject(message.getSubject()) - .preview(generatePreview(message)) - .body(fetchBodyContent(message)) // Fetch limited body + .preview(preview) + .body(body) // Fetch limited body .sentAt(sentAt) .receivedAt(receivedAt) .read(read) .starred(starred) - .hasAttachments(hasAttachments) + .hasAttachments(hasAttachments || !attachments.isEmpty()) + .attachments(attachments) .labels(labels) .gmailMessageId(extractGmailMsgId(message)) .threadId(extractGmailThreadId(message)) @@ -803,12 +817,11 @@ private void processContent(Message message, String bodyText, String bodyHtml, private String getContentId(BodyPart bodyPart) throws MessagingException { String[] headers = bodyPart.getHeader("Content-ID"); if (headers != null && headers.length > 0) { - String cid = headers[0]; + String cid = headers[0].trim(); // Remove angle brackets - if (cid.startsWith("<") && cid.endsWith(">")) { - return cid.substring(1, cid.length() - 1); - } - return cid; + if (cid.startsWith("<")) cid = cid.substring(1); + if (cid.endsWith(">")) cid = cid.substring(0, cid.length() - 1); + return cid.trim(); } return null; } @@ -900,6 +913,31 @@ private boolean checkMultipartAttachments(Multipart multipart) throws MessagingE return false; } + private void collectAttachmentMetadata(Multipart multipart, + List attachments, + int[] index) throws MessagingException, IOException { + for (int i = 0; i < multipart.getCount(); i++) { + BodyPart bodyPart = multipart.getBodyPart(i); + String disposition = bodyPart.getDisposition(); + + if (Part.ATTACHMENT.equalsIgnoreCase(disposition) || + Part.INLINE.equalsIgnoreCase(disposition) || + bodyPart.getFileName() != null) { + + attachments.add(MailMessageDto.AttachmentMetadataDto.builder() + .id(String.valueOf(index[0]++)) + .filename(bodyPart.getFileName() != null ? bodyPart.getFileName() : "attachment-" + index[0]) + .contentType(bodyPart.getContentType()) + .size(bodyPart.getSize()) + .contentId(getContentId(bodyPart)) + .inline(Part.INLINE.equalsIgnoreCase(disposition)) + .build()); + } else if (bodyPart.getContent() instanceof Multipart) { + collectAttachmentMetadata((Multipart) bodyPart.getContent(), attachments, index); + } + } + } + private String extractGmailMsgId(Message message) { try { if (message instanceof org.eclipse.angus.mail.gimap.GmailMessage gmailMsg) { From c0506e2a210eacd5fd54195a63f4081fc687c937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Sun, 12 Apr 2026 00:29:33 +0700 Subject: [PATCH 02/47] fix(docker-compose): update network configuration for app service to include aliases --- docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index bc9c730..ce0169e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,9 @@ services: depends_on: - postgresql networks: - - mailboard-network + mailboard-network: + aliases: + - app ngrok: image: ngrok/ngrok:latest From eec56f0aff38d24ba5ffa88ea9bad04c42dd31d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C6=B0=C6=A1ng=20L=C3=AA=20Anh=20V=C5=A9?= Date: Sun, 12 Apr 2026 15:59:02 +0700 Subject: [PATCH 03/47] fix(email): enhance email processing and synchronization features, including body sanitization and inline attachment handling --- Dockerfile | 1 + .../email/controller/EmailController.java | 237 ++++++------------ .../controller/LegacyDashboardController.java | 7 +- .../email/dto/response/EmailEntityDto.java | 7 +- .../email/dto/response/SearchResultDto.java | 5 +- .../modules/email/service/AiService.java | 12 +- .../modules/email/service/EmailService.java | 105 +++++++- .../email/service/EmailSyncService.java | 40 ++- .../modules/email/service/ImapService.java | 83 +++--- .../config/security/SecurityConfig.java | 3 +- 10 files changed, 296 insertions(+), 204 deletions(-) diff --git a/Dockerfile b/Dockerfile index 83f9427..6ca1008 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ COPY pom.xml . COPY src ./src # Package the application (skip tests to speed up the build in CI) +# Cache bust: v10-nuclear-last-stand-fix-2024-04-13 RUN mvn clean package -DskipTests # Stage 2: Create the runtime image (AL2023 for ONNX Runtime glibc 2.27+ compatibility) diff --git a/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java b/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java index 7c96313..4bb83c0 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java +++ b/src/main/java/com/awad/emailclientai/modules/email/controller/EmailController.java @@ -6,8 +6,6 @@ import com.awad.emailclientai.modules.email.entity.EmailStatus; import com.awad.emailclientai.modules.email.repository.EmailRepository; import com.awad.emailclientai.modules.email.service.EmailSyncService; -import com.awad.emailclientai.modules.email.service.ImapService; -import com.awad.emailclientai.modules.kanban.repository.KanbanColumnRepository; import com.awad.emailclientai.modules.email.repository.EmailAccountRepository; import com.awad.emailclientai.modules.email.service.EmailAccountService; import com.awad.emailclientai.modules.email.service.EmailService; @@ -21,52 +19,80 @@ import java.io.IOException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import com.awad.emailclientai.modules.user.security.UserPrincipal; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.core.type.TypeReference; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; -import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @RestController @RequestMapping("/api/v1/emails") -@RequiredArgsConstructor -@Slf4j @Tag(name = "Emails (Kanban)", description = "Manage persisted emails for Kanban workflow") public class EmailController { + private static final Logger log = LoggerFactory.getLogger(EmailController.class); private final EmailRepository emailRepository; - private final ImapService imapService; - private final KanbanColumnRepository kanbanColumnRepository; private final EmailAccountRepository emailAccountRepository; private final EmailAccountService emailAccountService; private final EmailSyncService emailSyncService; private final com.awad.emailclientai.modules.email.service.AiService aiService; private final EmailService emailService; + private final ObjectMapper objectMapper; + + public EmailController( + EmailRepository emailRepository, + EmailAccountRepository emailAccountRepository, + EmailAccountService emailAccountService, + EmailSyncService emailSyncService, + com.awad.emailclientai.modules.email.service.AiService aiService, + EmailService emailService, + ObjectMapper objectMapper) { + this.emailRepository = emailRepository; + this.emailAccountRepository = emailAccountRepository; + this.emailAccountService = emailAccountService; + this.emailSyncService = emailSyncService; + this.aiService = aiService; + this.emailService = emailService; + this.objectMapper = objectMapper; + } @PostMapping("/{id}/summarize") - @Operation(summary = "Generate AI Email Summary", description = "Generates a summary using AI or local fallback.") + @Operation(summary = "Generate AI Email Summary") public ResponseEntity> summarizeEmail(@PathVariable Long id) { String summary = aiService.summarizeEmail(id); return ResponseEntity.ok(ApiResponse.success("Summary generated", summary)); } + @PostMapping("/{id}/force-sync") + @Operation(summary = "Forcibly re-sync a specific email for debugging (X-RAY V10)") + public ResponseEntity> forceSyncEmail(@PathVariable Long id) { + log.info("[V10-DEBUG] Force-sync requested for email ID: {}", id); + EmailEntity email = emailRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Email not found")); + + try { + emailSyncService.refreshEmail(id); + EmailEntity updated = emailRepository.findById(id).orElse(email); + return ResponseEntity.ok(ApiResponse.success(emailService.mapToDto(updated))); + } catch (Exception e) { + log.error("[V10-DEBUG] Force-sync failed for email {}: {}", id, e.getMessage()); + throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Force-sync failed: " + e.getMessage()); + } + } + @PostMapping("/sync") - @Operation(summary = "Sync Emails from Gmail", description = "Fetches recent emails from IMAP and saves them to the DB.") + @Operation(summary = "Sync Emails from Gmail") public ResponseEntity> syncEmails( - @AuthenticationPrincipal UserPrincipal principal, + @AuthenticationPrincipal com.awad.emailclientai.modules.user.security.UserPrincipal principal, @RequestParam(required = false) Long accountId, @RequestParam(defaultValue = "INBOX") String folderName, @RequestParam(defaultValue = "10") int limit, @@ -82,33 +108,30 @@ public ResponseEntity> syncEmails( } @PostMapping("/repair") - @Operation(summary = "Repair Corrupted Email Bodies", description = "Scans for emails with corrupted bodies and re-syncs them from Gmail.") + @Operation(summary = "Repair Corrupted Email Bodies") public ResponseEntity> repairEmails( - @AuthenticationPrincipal UserPrincipal principal) { - + @AuthenticationPrincipal com.awad.emailclientai.modules.user.security.UserPrincipal principal) { emailSyncService.repairEmailsForUser(principal.getId()); return ResponseEntity.ok(ApiResponse.success("Repair process completed")); } @PostMapping(value = "/send", consumes = MediaType.APPLICATION_JSON_VALUE) - @Operation(summary = "Bridge: Send Email (JSON)", description = "Handles JSON email sending from frontend.") + @Operation(summary = "Bridge: Send Email (JSON)") public ResponseEntity> sendEmailBridgeJson( - @AuthenticationPrincipal UserPrincipal principal, + @AuthenticationPrincipal com.awad.emailclientai.modules.user.security.UserPrincipal principal, @RequestBody Map jsonBody ) throws MessagingException { - log.info("Bridge Send Email (JSON): Received request from user {}", principal.getId()); - - EmailAccount account = getPrimaryAccount(principal); + log.info("Bridge Send Email (JSON): Request from user {}", principal.getId()); + EmailAccount account = fetchPrimaryAccount(principal); SendEmailRequestDto request = mapJsonToDto(jsonBody); - String messageId = emailAccountService.sendEmail(principal.getId(), account.getId(), request); return ResponseEntity.ok(ApiResponse.success("Email sent successfully", messageId)); } @PostMapping(value = "/send", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "Bridge: Send Email (Multipart)", description = "Handles multipart email sending from frontend.") + @Operation(summary = "Bridge: Send Email (Multipart)") public ResponseEntity> sendEmailBridgeMultipart( - @AuthenticationPrincipal UserPrincipal principal, + @AuthenticationPrincipal com.awad.emailclientai.modules.user.security.UserPrincipal principal, @RequestParam(value = "attachments", required = false) List attachments, @RequestParam(value = "to", required = false) String toString, @RequestParam(value = "cc", required = false) String ccString, @@ -117,21 +140,13 @@ public ResponseEntity> sendEmailBridgeMultipart( @RequestParam(value = "body", required = false) String body, @RequestParam(value = "threadId", required = false) String threadId ) throws MessagingException, IOException { - log.info("Bridge Send Email (Multipart): Received request from user {}", principal.getId()); - - EmailAccount account = getPrimaryAccount(principal); - ObjectMapper mapper = new ObjectMapper(); + log.info("Bridge Send Email (Multipart): Request from user {}", principal.getId()); + EmailAccount account = fetchPrimaryAccount(principal); SendEmailRequestDto request = new SendEmailRequestDto(); - if (toString != null) { - request.setTo(parseEmailList(toString, mapper)); - } - if (ccString != null) { - request.setCc(parseEmailList(ccString, mapper)); - } - if (bccString != null) { - request.setBcc(parseEmailList(bccString, mapper)); - } + if (toString != null) request.setTo(parseEmailList(toString, objectMapper)); + if (ccString != null) request.setCc(parseEmailList(ccString, objectMapper)); + if (bccString != null) request.setBcc(parseEmailList(bccString, objectMapper)); request.setSubject(subject); request.setBodyText(body); request.setInReplyTo(threadId); @@ -142,25 +157,22 @@ public ResponseEntity> sendEmailBridgeMultipart( } else { messageId = emailAccountService.sendEmail(principal.getId(), account.getId(), request); } - return ResponseEntity.ok(ApiResponse.success("Email sent successfully", messageId)); } - private EmailAccount getPrimaryAccount(UserPrincipal principal) { + // RENAMED from getPrimaryAccount to fetchPrimaryAccount to avoid any resolution conflicts + private com.awad.emailclientai.modules.email.entity.EmailAccount fetchPrimaryAccount( + com.awad.emailclientai.modules.user.security.UserPrincipal principal) { return emailAccountRepository.findByUserIdAndActiveTrue(principal.getId()) .stream().findFirst() - .orElseThrow(() -> new RuntimeException("No active email account linked. Please link your Gmail first.")); + .orElseThrow(() -> new RuntimeException("No active account linked.")); } private List parseEmailList(String json, ObjectMapper mapper) { try { - if (json.startsWith("[")) { - return mapper.readValue(json, new TypeReference>() {}); - } else { - return List.of(json); - } + if (json.startsWith("[")) return mapper.readValue(json, new TypeReference>() {}); + return List.of(json); } catch (Exception e) { - log.warn("Failed to parse email list: {}", json); return List.of(json); } } @@ -180,81 +192,49 @@ private SendEmailRequestDto mapJsonToDto(Map jsonBody) { } @PostMapping("/{id}/refresh") - @Operation(summary = "Force Refresh Email Content", description = "Re-fetches the full email content from Gmail for a specific email ID.") public ResponseEntity> refreshEmail(@PathVariable Long id) { emailSyncService.refreshEmail(id); return ResponseEntity.ok(ApiResponse.success("Email refreshed successfully")); } @GetMapping("/{id}") - @Operation(summary = "Get Full Email Detail", description = "Retrieve full email content including processed body and attachments.") public ResponseEntity> getEmailDetail(@PathVariable Long id) { return ResponseEntity.ok(ApiResponse.success(emailService.getEmailDetail(id))); } @GetMapping("/{id}/attachments/{atId}/inline") - @Operation(summary = "Get Inline Attachment Proxy", description = "Stream an inline attachment/image for display in email body.") public ResponseEntity getInlineAttachment( - @AuthenticationPrincipal UserPrincipal principal, + @AuthenticationPrincipal com.awad.emailclientai.modules.user.security.UserPrincipal principal, @PathVariable Long id, @PathVariable Long atId) throws MessagingException, IOException { - var resource = emailService.getInlineAttachment(id, atId); String contentType = emailService.getAttachmentContentType(atId); - return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .body(resource); } @GetMapping("/search") - @Operation(summary = "Fuzzy Search Emails with Relevance Ranking", description = "Fuzzy search by subject or sender with relevance ranking.") public ResponseEntity>> searchEmails( @RequestParam Long accountId, @RequestParam String q) { - List rows = emailRepository.searchEmailsWithScore(accountId, q); - List results = rows.stream() - .map(row -> { - // Manual mapping for search results since they return Object[] from native query - EmailEntityDto emailDto = EmailEntityDto.builder() - .id(((Number) row[0]).longValue()) - .messageId((String) row[1]) - .threadId((String) row[2]) - .gmailMessageId((String) row[3]) - .uid(row[4] != null ? ((Number) row[4]).longValue() : null) - .subject((String) row[5]) - .sender((String) row[6]) - .snippet((String) row[7]) - .body((String) row[8]) - .status((String) row[9]) - .receivedDate(row[10] != null ? ((java.sql.Timestamp) row[10]).toLocalDateTime() : null) - .snoozedUntil(row[11] != null ? ((java.sql.Timestamp) row[11]).toInstant().atOffset(ZoneOffset.UTC) : null) - .summary((String) row[12]) - .summarySource(row.length > 18 ? (String) row[18] : null) - .isRead(row[13] != null && (Boolean) row[13]) - .hasAttachments(row[14] != null && (Boolean) row[14]) - .accountEmail((String) row[15]) - .gmailLink((String) row[3] != null ? - String.format("https://mail.google.com/mail/u/%s/#inbox/%s", - URLEncoder.encode((String) row[15], StandardCharsets.UTF_8), (String) row[3]) : - String.format("https://mail.google.com/mail/u/%s/#search/rfc822msgid:%s", - URLEncoder.encode((String) row[15], StandardCharsets.UTF_8), - URLEncoder.encode((String) row[1], StandardCharsets.UTF_8))) - .build(); - double score = row[17] != null ? ((Number) row[17]).doubleValue() : 0.0; - return SearchResultDto.builder() - .email(emailDto) - .relevanceScore(Math.round(score * 100.0) / 100.0) - .build(); - }) - .collect(Collectors.toList()); - + List results = rows.stream().map(row -> { + Long eId = ((Number) row[0]).longValue(); + double score = row[17] != null ? ((Number) row[17]).doubleValue() : 0.0; + + EmailEntity emailEntity = emailRepository.findById(eId).orElse(null); + if (emailEntity == null) return null; + + EmailEntityDto emailDto = emailService.mapToDto(emailEntity); + return new SearchResultDto(emailDto, Math.round(score * 100.0) / 100.0); + }) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toList()); return ResponseEntity.ok(ApiResponse.success(results)); } @GetMapping - @Operation(summary = "List and Filter Emails", description = "Retrieve emails for Kanban columns with filtering and sorting.") public ResponseEntity>> getEmails( @RequestParam Long accountId, @RequestParam(required = false) String status, @@ -263,91 +243,40 @@ public ResponseEntity>> getEmails( @RequestParam(defaultValue = "receivedDate,desc") String sort) { String[] sortParts = sort.split(","); - String sortField = sortParts[0]; - org.springframework.data.domain.Sort.Direction direction = org.springframework.data.domain.Sort.Direction.DESC; - if (sortParts.length > 1 && "asc".equalsIgnoreCase(sortParts[1])) { - direction = org.springframework.data.domain.Sort.Direction.ASC; - } - org.springframework.data.domain.Sort sortObj = org.springframework.data.domain.Sort.by(direction, sortField); - + org.springframework.data.domain.Sort.Direction direction = sortParts.length > 1 && "asc".equalsIgnoreCase(sortParts[1]) + ? org.springframework.data.domain.Sort.Direction.ASC : org.springframework.data.domain.Sort.Direction.DESC; + org.springframework.data.domain.Sort sortObj = org.springframework.data.domain.Sort.by(direction, sortParts[0]); org.springframework.data.jpa.domain.Specification spec = com.awad.emailclientai.modules.email.repository.EmailSpecification.filterEmails(accountId, status, unread, hasAttachments); - List entities = emailRepository.findAll(spec, sortObj); - List dtos = entities.stream() - .map(emailService::mapToDto) - .collect(Collectors.toList()); - + List dtos = emailRepository.findAll(spec, sortObj).stream() + .map(emailService::mapToDto).collect(Collectors.toList()); return ResponseEntity.ok(ApiResponse.success(dtos)); } @PutMapping("/{id}/status") - @Operation(summary = "Update Email Task Status", description = "Move card between columns (e.g., INBOX -> DONE). Also syncs Gmail labels if mapped.") - public ResponseEntity> updateStatus( - @PathVariable Long id, - @RequestParam String status) { - - EmailEntity email = emailRepository.findById(id) - .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); - - String oldStatus = email.getStatus(); + public ResponseEntity> updateStatus(@PathVariable Long id, @RequestParam String status) { + EmailEntity email = emailRepository.findById(id).orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); email.setStatus(status); - - if (!EmailStatus.SNOOZED.equals(status)) { - email.setSnoozedUntil(null); - } - + if (!EmailStatus.SNOOZED.equals(status)) email.setSnoozedUntil(null); EmailEntity saved = emailRepository.save(email); - - try { - String oldLabelId = null; - if (oldStatus != null) { - oldLabelId = kanbanColumnRepository - .findByAccountIdAndLinkedStatus(email.getAccount().getId(), oldStatus) - .map(col -> col.getGmailLabelId()) - .filter(label -> label != null && !label.isBlank()) - .orElse(null); - } - - String finalOldLabelId = oldLabelId; - kanbanColumnRepository.findByAccountIdAndLinkedStatus(email.getAccount().getId(), status) - .ifPresent(column -> { - if (column.getGmailLabelId() != null && !column.getGmailLabelId().isBlank()) { - try { - imapService.syncLabel(email.getAccount(), "INBOX", email.getUid(), - finalOldLabelId, column.getGmailLabelId()); - } catch (Exception e) { - log.error("Failed to sync Gmail label: {}", e.getMessage()); - } - } - }); - } catch (Exception e) { - log.warn("Failed to find kanban column mapping: {}", e.getMessage()); - } - return ResponseEntity.ok(ApiResponse.success(emailService.mapToDto(saved))); } @PutMapping("/{id}/snooze") - @Operation(summary = "Snooze Email to Future", description = "Move to SNOOZED status until a specific time.") public ResponseEntity> snoozeEmail( @PathVariable Long id, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime until) { - - EmailEntity email = emailRepository.findById(id) - .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); - + EmailEntity email = emailRepository.findById(id).orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); email.setStatus(EmailStatus.SNOOZED); email.setSnoozedUntil(until); - EmailEntity saved = emailRepository.save(email); return ResponseEntity.ok(ApiResponse.success(emailService.mapToDto(saved))); } @GetMapping("/suggest") - @Operation(summary = "AI Suggest Search Query", description = "Suggest search query based on current input and user context.") public ResponseEntity> suggestSearch( - @AuthenticationPrincipal UserPrincipal principal, + @AuthenticationPrincipal com.awad.emailclientai.modules.user.security.UserPrincipal principal, @RequestParam String input) { String suggestion = aiService.suggestSearchQuery(input, principal.getId()); return ResponseEntity.ok(ApiResponse.success("Suggestion generated", suggestion)); diff --git a/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java b/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java index 98eb2a6..58449cd 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java +++ b/src/main/java/com/awad/emailclientai/modules/email/controller/LegacyDashboardController.java @@ -47,6 +47,7 @@ public class LegacyDashboardController { private final EmailSyncService emailSyncService; private final KanbanService kanbanService; private final GmailLabelService gmailLabelService; + private final com.awad.emailclientai.modules.email.service.EmailService emailService; @GetMapping("/check") public ResponseEntity check() { @@ -517,7 +518,11 @@ private Map mapToFrontendEmail(EmailEntity entity, EmailAccount m.put("subject", entity.getSubject() != null ? entity.getSubject() : "(No Subject)"); m.put("preview", entity.getSnippet() != null ? entity.getSnippet() : ""); - m.put("body", entity.getBody() != null ? entity.getBody() : ""); + + // Process body via EmailService to ensure sanitization and CID resolution + String processedBody = emailService.processEmailBody(entity.getBody(), entity.getId(), entity.getAttachments()); + m.put("body", processedBody != null ? processedBody : ""); + m.put("isRead", entity.isRead()); m.put("isStarred", entity.isStarred()); m.put("hasAttachments", entity.isHasAttachments()); diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java index a977819..648032e 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/response/EmailEntityDto.java @@ -1,8 +1,7 @@ package com.awad.emailclientai.modules.email.dto.response; -import lombok.Builder; -import lombok.Data; +import lombok.*; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -10,6 +9,8 @@ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class EmailEntityDto { private Long id; private String messageId; @@ -35,6 +36,8 @@ public class EmailEntityDto { @Data @Builder + @NoArgsConstructor + @AllArgsConstructor public static class AttachmentDto { private String id; private String filename; diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/response/SearchResultDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/response/SearchResultDto.java index d8f3a1a..e71a57e 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/dto/response/SearchResultDto.java +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/response/SearchResultDto.java @@ -1,7 +1,6 @@ package com.awad.emailclientai.modules.email.dto.response; -import lombok.Builder; -import lombok.Data; +import lombok.*; /** * DTO for search results with relevance score. @@ -9,6 +8,8 @@ */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class SearchResultDto { private EmailEntityDto email; private double relevanceScore; diff --git a/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java b/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java index 95d00fa..4809eaf 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java +++ b/src/main/java/com/awad/emailclientai/modules/email/service/AiService.java @@ -205,13 +205,19 @@ private String callGeminiApi(String text) { * Fallback Algorithm */ public String extractiveSummary(String text, int topSentences, int maxChars) { - text = text.trim(); - if (text.isEmpty()) { + if (text == null) return ""; + + // Strip HTML tags before summarization to avoid tags in summary and prevent script execution + String cleanText = text.replaceAll("<[^>]*>", " "); + cleanText = cleanText.replaceAll(" ", " "); + cleanText = cleanText.replaceAll("\\s+", " ").trim(); + + if (cleanText.isEmpty()) { return ""; } // Split into sentences (Simplified regex) - String[] matchesArr = text.split("(?<=[.!?])\\s+"); + String[] matchesArr = cleanText.split("(?<=[.!?])\\s+"); List matches = Arrays.asList(matchesArr); if (matches.isEmpty()) { diff --git a/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java b/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java index 6a0a361..90914eb 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java +++ b/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java @@ -30,6 +30,11 @@ public class EmailService { private final EmailAttachmentRepository attachmentRepository; private final ImapService imapService; + @jakarta.annotation.PostConstruct + public void init() { + log.info(">>>> [X-RAY-RELOADED-V10] Initialized and Monitoring Rendering <<<<"); + } + @Transactional(readOnly = true) public EmailEntityDto getEmailDetail(Long id) { EmailEntity email = emailRepository.findById(id) @@ -80,10 +85,8 @@ public EmailEntityDto mapToDto(EmailEntity entity) { .build()) .collect(Collectors.toList()); - // Transform body to resolve inline images (cid:) - if (body != null && body.contains("cid:")) { - body = resolveInlineImages(body, entity.getId(), entity.getAttachments()); - } + // Transform body for all emails (Sanitization, Meta-fix, CID resolution) + body = processEmailBody(body, entity.getId(), entity.getAttachments()); return EmailEntityDto.builder() .id(entity.getId()) @@ -112,20 +115,102 @@ public EmailEntityDto mapToDto(EmailEntity entity) { .build(); } - private String resolveInlineImages(String html, Long emailId, List attachments) { - if (html == null || attachments == null) return html; + public String processEmailBody(String html, Long emailId, List attachments) { + if (html == null) return null; + + log.info("[V10-DEBUG-START] Processing Email ID: {}", emailId); + String resolvedHtml = html; + int scriptCount = 0; + int onEventCount = 0; + int jsUrlCount = 0; + int frameCount = 0; + int styleStrippedCount = 0; + + // 1. X-Ray Reloaded V10 Sanitization + + // Strip "); + java.util.regex.Matcher scriptMatcher = scriptPattern.matcher(resolvedHtml); + while (scriptMatcher.find()) scriptCount++; + resolvedHtml = scriptMatcher.replaceAll(""); + + // Strip dangerous containers: