diff --git a/.env.example b/.env.example index 25d88cf..c1cde56 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,7 @@ GMAIL_PUBSUB_TOPIC=projects/{PROJECT_ID}/topics/gmail-notifications # Ngrok Auth Token (for Dockerized tunnel) NGROK_AUTHTOKEN= + +# Syncing Configuration +MAIL_SYNC_BATCH_SIZE= + diff --git a/README.md b/README.md index 6e8980a..f04e0d3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ erDiagram USERS ||--o{ EMAIL_ACCOUNTS : owns USERS ||--o{ REFRESH_TOKENS : has EMAIL_ACCOUNTS ||--o{ EMAILS : syncs + EMAILS ||--o{ EMAIL_ATTACHMENTS : contains KANBAN_COLUMNS }o--|| USERS : "belongs to" USERS { @@ -159,6 +160,7 @@ erDiagram text body string status double kanban_order + timestamp received_date timestamp snoozed_until text summary string summary_source @@ -169,6 +171,18 @@ erDiagram vector embedding_384 } + EMAIL_ATTACHMENTS { + bigint id PK + bigint email_id FK + string filename + string content_type + bigint size + string server_attachment_id + string content_id + string external_url + boolean inline + } + KANBAN_COLUMNS { bigint id PK bigint user_id FK @@ -189,7 +203,7 @@ erDiagram - **Vector Indexes**: HNSW indexes on `embedding_768` and `embedding_384` for sub-second semantic search. - **Trigram Indexes**: GIN trigram indexes on `subject` and `sender` for fast fuzzy text matching. -- **Migrations**: 6 Flyway migrations managing schema evolution from extensions to Kanban columns. +- **Migrations**: 7 Flyway migrations managing schema evolution from extensions to email attachments. --- 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 diff --git a/pom.xml b/pom.xml index b988c9a..62a73f3 100644 --- a/pom.xml +++ b/pom.xml @@ -172,6 +172,14 @@ + + + src/main/resources + + **/scripts/tests/.venv/** + + + org.apache.maven.plugins diff --git a/src/main/java/DbCheck.java b/src/main/java/DbCheck.java new file mode 100644 index 0000000..8ac6f5f --- /dev/null +++ b/src/main/java/DbCheck.java @@ -0,0 +1,34 @@ +import java.sql.*; +import java.util.*; + +public class DbCheck { + public static void main(String[] args) throws Exception { + Class.forName("org.postgresql.Driver"); + String url = "jdbc:postgresql://localhost:5432/mailboard"; + Properties props = new Properties(); + props.setProperty("user", "postgres"); + props.setProperty("password", "postgres"); + + try (Connection conn = DriverManager.getConnection(url, props)) { + System.out.println("--- EMAIL STATUS DUMP ---"); + String sql = "SELECT id, subject, status, account_id FROM emails ORDER BY id DESC LIMIT 50"; + try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + System.out.printf("ID: %d | Subject: %s | Status: [%s] | Account: %d%n", + rs.getLong("id"), + rs.getString("subject"), + rs.getString("status"), + rs.getLong("account_id")); + } + } + + System.out.println("\n--- STATUS COUNTS ---"); + sql = "SELECT status, COUNT(*) as count FROM emails GROUP BY status"; + try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + System.out.printf("Status: [%s] | Count: %d%n", rs.getString("status"), rs.getInt("count")); + } + } + } + } +} 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..524a94e 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 @@ -1,15 +1,22 @@ package com.awad.emailclientai.modules.email.controller; +import com.awad.emailclientai.modules.email.dto.response.ContactDto; import com.awad.emailclientai.modules.email.dto.response.EmailEntityDto; import com.awad.emailclientai.modules.email.dto.response.SearchResultDto; import com.awad.emailclientai.modules.email.entity.EmailEntity; 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.service.AiService; import com.awad.emailclientai.modules.email.repository.EmailAccountRepository; +import com.awad.emailclientai.modules.email.repository.EmailAttachmentRepository; +import com.awad.emailclientai.modules.user.security.UserPrincipal; +import com.awad.emailclientai.modules.email.repository.EmailSpecification; +import com.awad.emailclientai.modules.email.entity.EmailAttachment; 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; @@ -18,56 +25,86 @@ 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 org.springframework.transaction.annotation.Transactional; 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 AiService aiService; + private final EmailAttachmentRepository attachmentRepository; + private final EmailService emailService; + private final ObjectMapper objectMapper; + + public EmailController( + EmailRepository emailRepository, + EmailAccountRepository emailAccountRepository, + EmailAccountService emailAccountService, + EmailSyncService emailSyncService, + AiService aiService, + EmailAttachmentRepository attachmentRepository, + EmailService emailService, + ObjectMapper objectMapper) { + this.emailRepository = emailRepository; + this.emailAccountRepository = emailAccountRepository; + this.emailAccountService = emailAccountService; + this.emailSyncService = emailSyncService; + this.aiService = aiService; + this.attachmentRepository = attachmentRepository; + 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); - // "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)); } + @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.") - public ResponseEntity> syncEmails( + @Operation(summary = "Sync Emails from Gmail") + public ResponseEntity> syncEmails( @AuthenticationPrincipal UserPrincipal principal, @RequestParam(required = false) Long accountId, @RequestParam(defaultValue = "INBOX") String folderName, - @RequestParam(defaultValue = "10") int limit, + @RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "0") int page) { if (accountId != null) { @@ -76,39 +113,92 @@ public ResponseEntity> syncEmails( emailSyncService.syncEmailsForUser(principal.getId(), folderName, limit, page); } - return ResponseEntity.ok(ApiResponse.success("Sync completed")); + return ResponseEntity.ok(ApiResponse.success("Sync started in background")); } @PostMapping("/repair") - @Operation(summary = "Repair Corrupted Email Bodies", description = "Scans for emails with corrupted bodies and re-syncs them from Gmail.") - public ResponseEntity> repairEmails( + @Operation(summary = "Repair Corrupted Email Bodies") + public ResponseEntity> repairEmails( @AuthenticationPrincipal 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.") - public ResponseEntity> sendEmailBridgeJson( + @Operation(summary = "Bridge: Send Email (JSON)") + @Transactional + public ResponseEntity> sendEmailBridgeJson( @AuthenticationPrincipal 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): Using account {}", account.getEmailAddress()); + log.info("Bridge Send Email (JSON): Request from user {}", principal.getId()); + EmailAccount account = fetchPrimaryAccount(principal); 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)); + + // Proactively save to local DB + EmailEntity entity = emailSyncService.saveLocalOutgoingEmail(account, request, messageId, "SENT", null); + + // Trigger background sync for Sent folder so the new message appears in the app exactly with provider data + final Long acctIdJson = account.getId(); + new Thread(() -> { + try { + String sentFolderName = "[Gmail]/Sent Mail"; + emailSyncService.syncEmailsForAccount(acctIdJson, sentFolderName, 10, 0); + } catch (Exception e) { + log.warn("Post-send Sent sync failed for account {}: {}", acctIdJson, e.getMessage()); + } + }).start(); + + // Cleanup draft if it exists + String draftId = request.getGmailDraftId(); + Long localEmailId = request.getLocalEmailId(); + log.info("[CLEANUP] Send success, checking for draft cleanup: gmailDraftId={}, localEmailId={}, inReplyTo={}", + draftId, localEmailId, request.getInReplyTo()); + + if (draftId != null && !draftId.isEmpty() && !draftId.equals("undefined")) { + try { + emailAccountService.deleteDraft(principal.getId(), account.getId(), draftId); + emailRepository.findByGmailDraftId(draftId).ifPresent(d -> { + // CRITICAL: Only delete if it's NOT the same record we just updated to SENT + if (!d.getId().equals(entity.getId())) { + emailRepository.delete(d); + log.info("[CLEANUP] Deleted duplicate draft record by gmailDraftId: {} (Previous status: {})", draftId, d.getStatus()); + } else { + log.info("[CLEANUP] Skipping deletion because draft was successfully merged into SENT record ID: {}", d.getId()); + } + }); + } catch (Exception e) { + log.warn("[CLEANUP] Failed to delete draft on Gmail: {}", e.getMessage()); + } + } else if (localEmailId != null) { + emailRepository.findById(localEmailId).ifPresent(d -> { + log.info("[CLEANUP] Found local record {}, status={}, gmailMsgId={}", d.getId(), d.getStatus(), d.getGmailMessageId()); + if ("DRAFTS".equalsIgnoreCase(d.getStatus())) { + emailRepository.delete(d); + log.info("[CLEANUP] Deleted local draft record by localEmailId: {}", localEmailId); + } + }); + } + + // Aggressive cleanup by gmailMessageId to remove any ghost duplicates + if (entity.getGmailMessageId() != null) { + String gmMsgId = entity.getGmailMessageId(); + emailRepository.findByGmailMessageId(gmMsgId).ifPresent(d -> { + if (!d.getId().equals(entity.getId()) && "DRAFTS".equalsIgnoreCase(d.getStatus())) { + emailRepository.delete(d); + log.info("[CLEANUP] Deleted ghost duplicate draft record by gmailMessageId: {}", gmMsgId); + } + }); + } + + return ResponseEntity.ok(ApiResponse.success("Email sent successfully", emailService.mapToDto(entity))); } @PostMapping(value = "/send", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "Bridge: Send Email (Multipart)", description = "Handles multipart email sending from frontend.") - public ResponseEntity> sendEmailBridgeMultipart( + @Operation(summary = "Bridge: Send Email (Multipart)") + @Transactional + public ResponseEntity> sendEmailBridgeMultipart( @AuthenticationPrincipal UserPrincipal principal, @RequestParam(value = "attachments", required = false) List attachments, @RequestParam(value = "to", required = false) String toString, @@ -116,56 +206,219 @@ public ResponseEntity> sendEmailBridgeMultipart( @RequestParam(value = "bcc", required = false) String bccString, @RequestParam(value = "subject", required = false) String subject, @RequestParam(value = "body", required = false) String body, - @RequestParam(value = "threadId", required = false) String threadId + @RequestParam(value = "threadId", required = false) String threadId, + @RequestParam(value = "gmailDraftId", required = false) String gmailDraftId, + @RequestParam(value = "localEmailId", required = false) Long localEmailId ) throws MessagingException, IOException { - 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(); + log.info("Bridge Send Email (Multipart): Request from user {}", principal.getId()); + EmailAccount account = fetchPrimaryAccount(principal); SendEmailRequestDto request = new SendEmailRequestDto(); + request.setGmailDraftId(gmailDraftId); + request.setLocalEmailId(localEmailId); - 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); 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); + + // Proactively save to local DB + EmailEntity entity = emailSyncService.saveLocalOutgoingEmail(account, request, messageId, "SENT", null); + + // Trigger background sync for Sent folder + final Long acctIdMulti = account.getId(); + new Thread(() -> { + try { + String sentFolderName = "[Gmail]/Sent Mail"; + emailSyncService.syncEmailsForAccount(acctIdMulti, sentFolderName, 10, 0); + } catch (Exception e) { + log.warn("Post-send Sent sync failed for account {}: {}", acctIdMulti, e.getMessage()); + } + }).start(); + + // Cleanup draft if it exists + String dId = request.getGmailDraftId(); + Long lId = request.getLocalEmailId(); + log.info("[CLEANUP-MULTI] Send success, checking for draft cleanup: gmailDraftId={}, localEmailId={}", dId, lId); + + if (dId != null && !dId.isEmpty() && !dId.equals("undefined")) { + try { + emailAccountService.deleteDraft(principal.getId(), account.getId(), dId); + emailRepository.findByGmailDraftId(dId).ifPresent(d -> { + if (!d.getId().equals(entity.getId())) { + emailRepository.delete(d); + log.info("[CLEANUP-MULTI] Deleted duplicate draft record by gmailDraftId: {} (Previous status: {})", dId, d.getStatus()); + } else { + log.info("[CLEANUP-MULTI] Skipping deletion because draft was successfully merged into SENT record ID: {}", d.getId()); + } + }); + } catch (Exception e) { + log.warn("[CLEANUP-MULTI] Failed to delete multipart draft on Gmail: {}", e.getMessage()); + } + } else if (lId != null) { + emailRepository.findById(lId).ifPresent(d -> { + if (!d.getId().equals(entity.getId())) { + emailRepository.delete(d); + log.info("[CLEANUP-MULTI] Deleted local draft record by localEmailId: {}", lId); + } + }); + } - return ResponseEntity.ok(ApiResponse.success("Email sent successfully", messageId)); + // Aggressive cleanup by gmailMessageId to remove any ghost duplicates + if (entity.getGmailMessageId() != null) { + String gmMsgId = entity.getGmailMessageId(); + emailRepository.findByGmailMessageId(gmMsgId).ifPresent(d -> { + if (!d.getId().equals(entity.getId()) && "DRAFTS".equalsIgnoreCase(d.getStatus())) { + emailRepository.delete(d); + log.info("[CLEANUP-MULTI] Deleted ghost duplicate draft record by gmailMessageId: {}", gmMsgId); + } + }); + } + + return ResponseEntity.ok(ApiResponse.success("Email sent successfully", emailService.mapToDto(entity))); } - private EmailAccount getPrimaryAccount(UserPrincipal principal) { + @PostMapping({"/draft", "/drafts"}) + @Operation(summary = "Save or Update Draft") + @Transactional + public ResponseEntity> saveDraft( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody SendEmailRequestDto request) throws MessagingException, IOException { + EmailAccount account = fetchPrimaryAccount(principal); + String draftId = request.getGmailDraftId(); + Map draftData = null; + + // 1. Try to recover draftId from local emailId if not provided in request + String recoveredDraftId = draftId; + if ((recoveredDraftId == null || recoveredDraftId.isEmpty()) && request.getLocalEmailId() != null) { + var existingOpt = emailRepository.findById(request.getLocalEmailId()); + if (existingOpt.isPresent() && existingOpt.get().getGmailDraftId() != null) { + recoveredDraftId = existingOpt.get().getGmailDraftId(); + request.setGmailDraftId(recoveredDraftId); + } + } + final String finalDraftIdForGmail = recoveredDraftId; + + // 2. Perform Save or Update on Gmail + boolean isNewDraft = (finalDraftIdForGmail == null || finalDraftIdForGmail.isEmpty()); + if (!isNewDraft) { + draftData = emailAccountService.updateDraft(principal.getId(), account.getId(), finalDraftIdForGmail, request); + } else { + draftData = emailAccountService.saveDraft(principal.getId(), account.getId(), request); + } + + final String finalDraftId = draftData != null ? draftData.get("draftId") : finalDraftIdForGmail; + final String gmMsgId = draftData != null ? draftData.get("messageId") : null; + + // Proactively save to local DB + EmailEntity entity; + try { + entity = emailSyncService.saveLocalOutgoingEmail(account, request, "DRAFT-" + finalDraftId, "DRAFTS", gmMsgId); + entity.setGmailDraftId(finalDraftId); + if (gmMsgId != null) { + entity.setGmailMessageId(gmMsgId); + } + entity = emailRepository.save(entity); + + // 3. CRITICAL: If we just created a NEW draft for an EXISTING local record, + // we must delete the old record to prevent duplicates. + if (isNewDraft && request.getLocalEmailId() != null) { + Long oldId = request.getLocalEmailId(); + if (!entity.getId().equals(oldId)) { + emailRepository.deleteById(oldId); + log.info("Deleted old duplicate draft record {} after creating new draft {}", oldId, entity.getId()); + } + } + } catch (Exception e) { + log.warn("Conflict or error during proactive draft save, attempting fallback lookup: {}", e.getMessage()); + entity = emailRepository.findByGmailDraftId(finalDraftId) + .orElseGet(() -> emailRepository.findByMessageId("DRAFT-" + finalDraftId).orElse(new EmailEntity())); + + entity.setAccount(account); + entity.setGmailDraftId(finalDraftId); + entity.setGmailMessageId(gmMsgId); + entity.setStatus("DRAFTS"); + entity.setSubject(request.getSubject()); + entity.setBody(request.getBodyHtml() != null ? request.getBodyHtml() : request.getBodyText()); + entity = emailRepository.save(entity); + } + + return ResponseEntity.ok(ApiResponse.success("Draft saved", emailService.mapToDto(entity))); + } + + @DeleteMapping("/draft/{draftId}") + @Operation(summary = "Discard and Delete Draft") + @Transactional + public ResponseEntity> deleteDraft( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String draftId, + @RequestParam(required = false) Long emailId) throws IOException { + EmailAccount account = fetchPrimaryAccount(principal); + + // 1. Move to Trash on Gmail if we have a valid Gmail Message ID + if (draftId != null && !draftId.isEmpty() && !draftId.equals("undefined")) { + try { + // To trash a draft in Gmail, we should trash its associated message + // We fetch the entity to get the gmailMessageId + EmailEntity entity = emailId != null + ? emailRepository.findById(emailId).orElse(null) + : emailRepository.findByGmailDraftId(draftId).orElse(null); + + if (entity != null && entity.getGmailMessageId() != null) { + emailAccountService.trashDraft(principal.getId(), account.getId(), entity.getGmailMessageId()); + log.info("Trashed Gmail draft message: {}", entity.getGmailMessageId()); + } else { + // Fallback to permanent delete if we don't have messageId + emailAccountService.deleteDraft(principal.getId(), account.getId(), draftId); + } + } catch (Exception e) { + log.warn("Failed to trash/delete draft from Gmail (id: {}): {}", draftId, e.getMessage()); + } + } + + // 2. Update local repository status to TRASH instead of deleting + if (emailId != null) { + emailRepository.findById(emailId).ifPresent(entity -> { + entity.setPreviousStatus(entity.getStatus()); + entity.setStatus("TRASH"); + entity.setDeletedAt(java.time.LocalDateTime.now()); + emailRepository.save(entity); + log.info("Moved local draft record to TRASH: {}", emailId); + }); + } else if (draftId != null && !draftId.isEmpty() && !draftId.equals("undefined")) { + emailRepository.findByGmailDraftId(draftId).ifPresent(entity -> { + entity.setPreviousStatus(entity.getStatus()); + entity.setStatus("TRASH"); + entity.setDeletedAt(java.time.LocalDateTime.now()); + emailRepository.save(entity); + log.info("Moved local draft record to TRASH by draftId: {}", draftId); + }); + } + + return ResponseEntity.ok(ApiResponse.success("Draft discarded")); + } + + // RENAMED from getPrimaryAccount to fetchPrimaryAccount to avoid any resolution conflicts + private EmailAccount fetchPrimaryAccount( + 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); } } @@ -178,67 +431,78 @@ 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")); + request.setGmailDraftId((String) jsonBody.get("gmailDraftId")); + Object localId = jsonBody.get("localEmailId"); + if (localId != null) { + if (localId instanceof Number) request.setLocalEmailId(((Number) localId).longValue()); + else if (localId instanceof String) request.setLocalEmailId(Long.parseLong((String) localId)); + } } return request; } @PostMapping("/{id}/refresh") - @Operation(summary = "Force Refresh Email Content", description = "Re-fetches the full email content from Gmail for a specific email ID.") + @org.springframework.transaction.annotation.Transactional public ResponseEntity> refreshEmail(@PathVariable Long id) { emailSyncService.refreshEmail(id); return ResponseEntity.ok(ApiResponse.success("Email refreshed successfully")); } + @GetMapping("/{id}") + public ResponseEntity> getEmailDetail(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(emailService.getEmailDetail(id))); + } + + @GetMapping("/{id}/attachments/{atId}/download") + public ResponseEntity downloadAttachment( + @PathVariable Long id, + @PathVariable Long atId) throws MessagingException, IOException { + var resource = emailService.getInlineAttachment(id, atId); + String contentType = emailService.getAttachmentContentType(atId); + String filename = attachmentRepository.findById(atId) + .map(EmailAttachment::getFilename) + .orElse("attachment"); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(resource); + } + + @GetMapping("/{id}/attachments/{atId}/inline") + public ResponseEntity getInlineAttachment( + @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 -> { - 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, @@ -246,117 +510,55 @@ 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; - 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(this::mapToDto) - .collect(Collectors.toList()); + EmailSpecification.filterEmails(accountId, status, unread, hasAttachments); + 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 RuntimeException("Email not found")); - - String oldStatus = email.getStatus(); + @org.springframework.transaction.annotation.Transactional + public ResponseEntity> updateStatus(@PathVariable Long id, @RequestParam String status) { + EmailEntity email = emailRepository.findById(id).orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); + String previousStatus = email.getStatus(); email.setStatus(status); + if (!EmailStatus.SNOOZED.equals(status)) email.setSnoozedUntil(null); + EmailEntity saved = emailRepository.save(email); - // If moving out of snoozed, clear the date - if (!EmailStatus.SNOOZED.equals(status)) { - email.setSnoozedUntil(null); - } + emailService.syncStatusToProvider(email.getId(), previousStatus, status); - EmailEntity saved = emailRepository.save(email); - - try { - // Look up old label to remove - 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); - } - - // Look up new label to add - 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(mapToDto(saved))); + 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.") + @org.springframework.transaction.annotation.Transactional public ResponseEntity> snoozeEmail( @PathVariable Long id, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime until) { - - EmailEntity email = emailRepository.findById(id) - .orElseThrow(() -> new RuntimeException("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(mapToDto(saved))); + return ResponseEntity.ok(ApiResponse.success(emailService.mapToDto(saved))); + } + + @GetMapping("/suggest") + public ResponseEntity> suggestSearch( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam String input) { + String suggestion = aiService.suggestSearchQuery(input, principal.getId()); + return ResponseEntity.ok(ApiResponse.success("Suggestion generated", suggestion)); } - 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("/contacts") + @Operation(summary = "Get all known email contacts for autocomplete") + public ResponseEntity>> getContacts() { + return ResponseEntity.ok(ApiResponse.success(emailService.getContacts())); } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/controller/GmailPubSubController.java b/src/main/java/com/awad/emailclientai/modules/email/controller/GmailPubSubController.java index 1deb4e7..b0db831 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/controller/GmailPubSubController.java +++ b/src/main/java/com/awad/emailclientai/modules/email/controller/GmailPubSubController.java @@ -3,7 +3,6 @@ import com.awad.emailclientai.modules.email.entity.EmailAccount; import com.awad.emailclientai.modules.email.repository.EmailAccountRepository; import com.awad.emailclientai.modules.email.service.EmailSyncService; -import com.awad.emailclientai.modules.email.service.NotificationWebSocketHandler; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -15,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Base64; +import java.util.List; import java.util.Optional; @RestController @@ -25,8 +25,12 @@ public class GmailPubSubController { private final EmailAccountRepository accountRepository; private final EmailSyncService emailSyncService; - private final NotificationWebSocketHandler webSocketHandler; + private final com.awad.emailclientai.modules.email.service.ImapService imapService; private final ObjectMapper objectMapper; + private final java.util.concurrent.Executor mailSyncExecutor; + + @org.springframework.beans.factory.annotation.Value("${app.mail.sync.batch-size:20}") + private int batchSize; /** * Webhook endpoint to receive push notifications from Google Cloud Pub/Sub. @@ -56,17 +60,38 @@ public ResponseEntity handleWebhook(@RequestBody JsonNode payload) { if (accountOpt.isPresent()) { EmailAccount account = accountOpt.get(); - // Trigger sync in a separate thread (non-blocking for Google) - new Thread(() -> { + // Trigger sync in a managed thread pool (non-blocking for Google) + mailSyncExecutor.execute(() -> { try { - emailSyncService.syncEmailsForAccount(account.getId(), "INBOX", 20, 0); - - // Notify frontend via WebSocket - webSocketHandler.sendNotification(account.getId(), "{\"type\": \"NEW_EMAILS\", \"message\": \"Sync completed for " + emailAddress + "\"}"); + // Use efficient Gmail History API to find exactly what changed (Labels, Moves, etc.) + try { + emailSyncService.syncEmailsByHistory(account, historyId); + } catch (Exception historyEx) { + log.warn("History sync failed for {}: {}. Falling back to folder sync.", emailAddress, historyEx.getMessage()); + + // Fallback: Sync key system folders so label moves (e.g. Gmail delete -> TRASH) + // are reflected back to local status promptly. + String physicalTrash = imapService.findPhysicalFolderByType(account, "TRASH"); + String physicalSpam = imapService.findPhysicalFolderByType(account, "SPAM"); + List foldersToSync = List.of("INBOX", physicalTrash, physicalSpam); + + // Deduplicate in case localized fallback matched INBOX + foldersToSync = foldersToSync.stream().distinct().toList(); + + log.info("Resolving physical folders to sync for webhook on {}: {}", emailAddress, foldersToSync); + + for (String folder : foldersToSync) { + try { + emailSyncService.syncEmailsForAccount(account.getId(), folder, batchSize, 0); + } catch (Exception folderEx) { + log.warn("Triggered sync failed for {} folder {}: {}", emailAddress, folder, folderEx.getMessage()); + } + } + } } catch (Exception e) { log.error("Error during triggered sync for {}", emailAddress, e); } - }).start(); + }); } else { log.warn("Received notification for unknown account: {}", emailAddress); } 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..a438a4f 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 @@ -1,4 +1,6 @@ package com.awad.emailclientai.modules.email.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.awad.emailclientai.modules.email.service.NotificationWebSocketHandler; import com.awad.emailclientai.modules.email.entity.EmailAccount; import com.awad.emailclientai.modules.email.entity.EmailStatus; import java.net.URLEncoder; @@ -13,6 +15,9 @@ import com.awad.emailclientai.modules.kanban.entity.KanbanColumn; import com.awad.emailclientai.modules.kanban.service.KanbanService; import com.awad.emailclientai.modules.user.security.UserPrincipal; +import com.awad.emailclientai.modules.email.dto.response.MailMessageDetailDto; +import com.awad.emailclientai.modules.email.entity.EmailProvider; +import org.springframework.http.HttpStatus; import org.springframework.data.domain.PageRequest; import com.awad.emailclientai.shared.dto.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -20,13 +25,18 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; @@ -47,6 +57,9 @@ 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; + private final NotificationWebSocketHandler notificationWebSocketHandler; + private final ObjectMapper objectMapper; @GetMapping("/check") public ResponseEntity check() { @@ -61,20 +74,37 @@ public ResponseEntity>> getMailboxes( List> mailboxes = new ArrayList<>(); int unreadCount = 0; + int draftsCount = 0; + int starredCount = 0; + int trashCount = 0; + int spamCount = 0; + try { EmailAccount account = getPrimaryAccount(principal); - unreadCount = (int) emailRepository.countUnreadByAccountId(account.getId(), LocalDateTime.of(1970, 1, 1, 0, 0)); + unreadCount = (int) emailRepository.countUnreadByAccountId(account.getId()); + starredCount = (int) emailRepository.countStarredByAccountId(account.getId()); + + draftsCount = (int) (emailRepository.countByAccountIdAndStatus(account.getId(), "DRAFTS") + + emailRepository.countByAccountIdAndStatus(account.getId(), "DRAFT")); + + trashCount = (int) emailRepository.countByAccountIdAndStatus(account.getId(), "TRASH"); + spamCount = (int) emailRepository.countByAccountIdAndStatus(account.getId(), "SPAM"); + long sentCount = emailRepository.countByAccountIdAndStatus(account.getId(), "SENT"); + + mailboxes.add(createMailbox("INBOX", "Inbox", "InboxOutlined", unreadCount, "system")); + mailboxes.add(createMailbox("STARRED", "Starred", "StarOutlined", starredCount, "system")); + mailboxes.add(createMailbox("SENT", "Sent", "SendOutlined", (int) sentCount, "system")); + mailboxes.add(createMailbox("DRAFTS", "Drafts", "FileOutlined", draftsCount, "system")); + mailboxes.add(createMailbox("TRASH", "Trash", "DeleteOutlined", trashCount, "system")); + mailboxes.add(createMailbox("SPAM", "Spam", "FolderOutlined", spamCount, "system")); } catch (Exception e) { - log.warn("Could not get unread count for mailbox list: {}", e.getMessage()); + log.warn("Could not get counts for mailbox list: {}", e.getMessage()); + // Ensure we at least have basic mailboxes even on failure + if (mailboxes.isEmpty()) { + mailboxes.add(createMailbox("INBOX", "Inbox", "InboxOutlined", 0, "system")); + } } - mailboxes.add(createMailbox("INBOX", "Inbox", "InboxOutlined", unreadCount, "system")); - mailboxes.add(createMailbox("STARRED", "Starred", "StarOutlined", 0, "system")); - mailboxes.add(createMailbox("SENT", "Sent", "SendOutlined", 0, "system")); - mailboxes.add(createMailbox("DRAFTS", "Drafts", "FileOutlined", 0, "system")); - mailboxes.add(createMailbox("TRASH", "Trash", "DeleteOutlined", 0, "system")); - mailboxes.add(createMailbox("SPAM", "Spam", "FolderOutlined", 0, "system")); - Map data = new HashMap<>(); data.put("mailboxes", mailboxes); try { @@ -86,7 +116,38 @@ public ResponseEntity>> getMailboxes( return ResponseEntity.ok(ApiResponse.success(data)); } + @GetMapping("/emails/{id}/imap-detail") + public ResponseEntity>> getImapDetail( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + EmailEntity entity = emailRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Email not found")); + + EmailAccount primary = getPrimaryAccount(principal); + if (!primary.getId().equals(entity.getAccount().getId())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error("Not allowed to access this email")); + } + + String folderName = "INBOX"; + if (entity.getStatus() != null && entity.getStatus().equalsIgnoreCase("SENT")) { + if (entity.getAccount().getProvider() == EmailProvider.GMAIL) folderName = "[Gmail]/Sent Mail"; + else folderName = "Sent"; + } + + try { + MailMessageDetailDto detail = imapService.getMessageDetail(entity.getAccount(), folderName, entity.getUid()); + Map data = new HashMap<>(); + data.put("detail", detail); + return ResponseEntity.ok(ApiResponse.success(data)); + } catch (Exception e) { + log.error("Failed to fetch IMAP detail for email {}: {}", id, e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error("Failed to fetch IMAP detail: " + e.getMessage())); + } + } + @GetMapping("/mailboxes/{id}/emails") + @Transactional(readOnly = true) public ResponseEntity>> getEmailsByMailbox( @AuthenticationPrincipal UserPrincipal principal, @PathVariable String id, @@ -94,144 +155,157 @@ public ResponseEntity>> getEmailsByMailbox( @RequestParam(required = false) Boolean hasAttachments, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int perPage, - @RequestParam(defaultValue = "date") String sortBy, - @RequestParam(defaultValue = "desc") String sortOrder + @RequestParam(required = false) List sort ) { EmailAccount account = getPrimaryAccount(principal); - - // Map mailbox ID to status (lowercase) String status = id.toUpperCase(); - List emails = emailRepository.findAllByAccountIdOrderByKanbanOrderDescReceivedDateDesc(account.getId()); - log.info("Bridge: Fetched {} emails from DB for account ID {}", emails.size(), account.getId()); + log.info("[API-REQUEST] getEmailsByMailbox: mailboxId={}, status={}, accountId={}", id, status, account.getId()); - String finalStatus = status; - List filteredStream = emails.stream() - .filter(e -> { - boolean match = false; - if ("STARRED".equalsIgnoreCase(finalStatus)) { - match = e.isStarred(); - } else if ("INBOX".equalsIgnoreCase(finalStatus)) { - // Show ALL emails except TRASH and SPAM — same as Kanban view - String s = e.getStatus(); - match = s == null || (!s.equalsIgnoreCase("TRASH") && !s.equalsIgnoreCase("SPAM")); - } else { - match = finalStatus.equalsIgnoreCase(e.getStatus()); - } - - if (!match && log.isDebugEnabled()) { - log.debug("Email ID {} rejected by status filter (Status: {}, Target: {})", e.getId(), e.getStatus(), finalStatus); - } - return match; - }) - .filter(e -> { - boolean match = unread == null || !unread || !e.isRead(); - if (!match && log.isDebugEnabled()) { - log.debug("Email ID {} rejected by unread filter", e.getId()); - } - return match; - }) + // Final sort list construction + List sortList = (sort == null || sort.isEmpty()) + ? List.of("receivedDate:desc") + : sort; + + // V43: Fetch filtered data directly from DB for performance and consistency + List filteredStream; + if ("INBOX".equalsIgnoreCase(status)) { + // Inbox is a special "folder" that excludes Sent, Drafts, Trash, Spam + filteredStream = emailRepository.findAllByAccountIdOrderByKanbanOrderDescReceivedDateDesc(account.getId()).stream() .filter(e -> { - boolean match = hasAttachments == null || !hasAttachments || e.isHasAttachments(); - if (!match && log.isDebugEnabled()) { - log.debug("Email ID {} rejected by attachments filter", e.getId()); - } - return match; - }) + String s = e.getStatus(); + // V43: NULL status should be treated as INBOX + if (s == null) return true; + return !s.equalsIgnoreCase("SENT") && !s.equalsIgnoreCase("DRAFTS") && !s.equalsIgnoreCase("DRAFT") && !s.equalsIgnoreCase("TRASH") && !s.equalsIgnoreCase("SPAM"); + }).collect(Collectors.toList()); + } else if ("STARRED".equalsIgnoreCase(status)) { + filteredStream = emailRepository.findStarredByAccountId(account.getId()); + } else if ("DRAFTS".equalsIgnoreCase(status) || "DRAFT".equalsIgnoreCase(status)) { + filteredStream = emailRepository.findAllByAccountIdAndStatus(account.getId(), "DRAFTS"); + if (filteredStream.isEmpty()) { + filteredStream = emailRepository.findAllByAccountIdAndStatus(account.getId(), "DRAFT"); + } + } else { + filteredStream = emailRepository.findAllByAccountIdAndStatus(account.getId(), status); + } + + // Apply secondary filters + List processedList = filteredStream.stream() + .filter(e -> unread == null || !unread || !e.isRead()) + .filter(e -> hasAttachments == null || !hasAttachments || e.isHasAttachments()) .collect(Collectors.toList()); - // Apply Sorting - if (sortBy != null) { - filteredStream.sort((a, b) -> { + // Apply Multi-Layer Sorting + processedList.sort((a, b) -> { + for (String s : sortList) { + String[] parts = s.split(":"); + String field = parts[0]; + String order = parts.length > 1 ? parts[1] : "desc"; + int cmp = 0; - if ("date".equals(sortBy)) { + if ("date".equals(field) || "receivedDate".equals(field)) { if (a.getReceivedDate() == null || b.getReceivedDate() == null) cmp = 0; else cmp = a.getReceivedDate().compareTo(b.getReceivedDate()); - } else if ("sender".equals(sortBy)) { - String s1 = a.getSender() != null ? a.getSender() : ""; - String s2 = b.getSender() != null ? b.getSender() : ""; - cmp = s1.compareToIgnoreCase(s2); + } else if ("fromName".equals(field) || "sender".equals(field)) { + String n1 = (a.getFromName() != null && !a.getFromName().isBlank()) ? a.getFromName() : (a.getSender() != null ? a.getSender() : ""); + String n2 = (b.getFromName() != null && !b.getFromName().isBlank()) ? b.getFromName() : (b.getSender() != null ? b.getSender() : ""); + cmp = n1.compareToIgnoreCase(n2); + } else if ("subject".equals(field)) { + String sub1 = (a.getSubject() != null ? a.getSubject() : "").replaceAll("'", "\u0027").replaceAll(""", "\"").replaceAll("&", "&").replaceAll("^(?i)(Re|Fwd|Fw):\\s*", "").replaceAll("^[^\\p{L}\\p{N}]+", "").trim(); + String sub2 = (b.getSubject() != null ? b.getSubject() : "").replaceAll("'", "\u0027").replaceAll(""", "\"").replaceAll("&", "&").replaceAll("^(?i)(Re|Fwd|Fw):\\s*", "").replaceAll("^[^\\p{L}\\p{N}]+", "").trim(); + cmp = sub1.compareToIgnoreCase(sub2); } - return "desc".equalsIgnoreCase(sortOrder) ? -cmp : cmp; - }); - } + + if (cmp != 0) { + return "desc".equalsIgnoreCase(order) ? -cmp : cmp; + } + } + return 0; + }); - List> mapped = filteredStream.stream() - .map(e -> this.mapToFrontendEmail(e, account)) - .collect(Collectors.toList()); + int total = processedList.size(); + int fromIndex = Math.max(0, (page - 1) * perPage); + int toIndex = Math.min(fromIndex + perPage, total); + List> pageSlice = fromIndex < total ? processedList.subList(fromIndex, toIndex).stream() + .map(e -> this.mapToFrontendEmail(e, account, principal)).collect(Collectors.toList()) : new ArrayList<>(); Map data = new HashMap<>(); - data.put("emails", mapped); - data.put("total", mapped.size()); + data.put("emails", pageSlice); + data.put("total", total); data.put("page", page); data.put("perPage", perPage); - data.put("hasNextPage", false); - - log.info("Bridge: Returning {}/{} emails for mailbox {} account {} (Filters: unread={}, hasAttachments={}, sort={} {})", - mapped.size(), emails.size(), id, account.getId(), unread, hasAttachments, sortBy, sortOrder); + data.put("hasNextPage", toIndex < total); + + log.info("[API-RESPONSE] getEmailsByMailbox: mailboxId={}, found={} emails, total={}", id, pageSlice.size(), total); return ResponseEntity.ok(ApiResponse.success(data)); } - @GetMapping("/kanban") + @GetMapping("/mailboxes/{id}/kanban") public ResponseEntity>> getKanban( @AuthenticationPrincipal UserPrincipal principal, - @RequestParam(required = false) Boolean unread, - @RequestParam(required = false) Boolean hasAttachments, - @RequestParam(defaultValue = "date") String sortBy, - @RequestParam(defaultValue = "desc") String sortOrder + @PathVariable String id, + @RequestParam(required = false) List sort ) { EmailAccount account = getPrimaryAccount(principal); + String status = id.toUpperCase(); List emails = emailRepository.findAllByAccountIdOrderByKanbanOrderDescReceivedDateDesc(account.getId()); - // Apply Filters + List sortList = (sort == null || sort.isEmpty()) ? List.of("receivedDate:desc") : sort; + + String finalStatus = status; List filtered = emails.stream() - .filter(e -> unread == null || !unread || !e.isRead()) - .filter(e -> hasAttachments == null || !hasAttachments || e.isHasAttachments()) + .filter(e -> { + if ("INBOX".equalsIgnoreCase(finalStatus)) { + String s = e.getStatus(); + return s != null && !s.equalsIgnoreCase("SENT") && !s.equalsIgnoreCase("DRAFTS") && !s.equalsIgnoreCase("TRASH") && !s.equalsIgnoreCase("SPAM"); + } + return finalStatus.equalsIgnoreCase(e.getStatus()); + }) .collect(Collectors.toList()); - // Apply Sorting - if (sortBy != null) { - filtered.sort((a, b) -> { + // Apply Multi-Layer Sorting + filtered.sort((a, b) -> { + for (String s : sortList) { + String[] parts = s.split(":"); + String field = parts[0]; + String order = parts.length > 1 ? parts[1] : "desc"; int cmp = 0; - if ("date".equals(sortBy)) { + if ("date".equals(field) || "receivedDate".equals(field)) { if (a.getReceivedDate() == null || b.getReceivedDate() == null) cmp = 0; else cmp = a.getReceivedDate().compareTo(b.getReceivedDate()); - } else if ("sender".equals(sortBy)) { - String s1 = a.getSender() != null ? a.getSender() : ""; - String s2 = b.getSender() != null ? b.getSender() : ""; - cmp = s1.compareToIgnoreCase(s2); + } else if ("fromName".equals(field) || "sender".equals(field)) { + String n1 = (a.getFromName() != null && !a.getFromName().isBlank()) ? a.getFromName() : (a.getSender() != null ? a.getSender() : ""); + String n2 = (b.getFromName() != null && !b.getFromName().isBlank()) ? b.getFromName() : (b.getSender() != null ? b.getSender() : ""); + cmp = n1.compareToIgnoreCase(n2); + } else if ("subject".equals(field)) { + String sub1 = (a.getSubject() != null ? a.getSubject() : "").replaceAll("'", "\u0027").replaceAll(""", "\"").replaceAll("&", "&").replaceAll("^(?i)(Re|Fwd|Fw):\\s*", "").replaceAll("^[^\\p{L}\\p{N}]+", "").trim(); + String sub2 = (b.getSubject() != null ? b.getSubject() : "").replaceAll("'", "\u0027").replaceAll(""", "\"").replaceAll("&", "&").replaceAll("^(?i)(Re|Fwd|Fw):\\s*", "").replaceAll("^[^\\p{L}\\p{N}]+", "").trim(); + cmp = sub1.compareToIgnoreCase(sub2); } - return "desc".equalsIgnoreCase(sortOrder) ? -cmp : cmp; - }); - } + if (cmp != 0) return "desc".equalsIgnoreCase(order) ? -cmp : cmp; + } + return 0; + }); + // Map to Columns Map>> columnsData = new HashMap<>(); - - // Fetch dynamic columns for this account List columns = kanbanService.getColumns(account.getId()); - - // Initialize columns using linkedStatus for (KanbanColumn col : columns) { String statusKey = col.getLinkedStatus() != null ? col.getLinkedStatus().toUpperCase() : "INBOX"; columnsData.put(statusKey, new ArrayList<>()); } for (EmailEntity email : filtered) { - String status = email.getStatus() != null ? email.getStatus().toUpperCase() : "INBOX"; - if (columnsData.containsKey(status)) { - columnsData.get(status).add(mapToKanbanCard(email, account)); - } else { - // Fallback to INBOX if status doesn't match any dynamic column - if (columnsData.containsKey("INBOX")) { - columnsData.get("INBOX").add(mapToKanbanCard(email, account)); - } + String eStatus = email.getStatus() != null ? email.getStatus().toUpperCase() : "INBOX"; + if (columnsData.containsKey(eStatus)) { + columnsData.get(eStatus).add(mapToKanbanCard(email, account)); + } else if (columnsData.containsKey("INBOX")) { + columnsData.get("INBOX").add(mapToKanbanCard(email, account)); } } Map result = new HashMap<>(); result.put("columns", columnsData); - log.info("Bridge: Returning Dynamic Kanban board with {} columns (Filters: unread={}, hasAttachments={}, sort={} {})", - columnsData.size(), unread, hasAttachments, sortBy, sortOrder); return ResponseEntity.ok(ApiResponse.success(result)); } @@ -246,13 +320,26 @@ public ResponseEntity>> moveCard( EmailEntity email = emailRepository.findById(emailId) .orElseThrow(() -> new RuntimeException("Email not found")); + String previousStatus = email.getStatus(); email.setStatus(toStatus); + // Handle Trash logic + if ("TRASH".equalsIgnoreCase(toStatus)) { + email.setPreviousStatus(previousStatus); + email.setDeletedAt(LocalDateTime.now()); + } else if ("TRASH".equalsIgnoreCase(previousStatus)) { + // Restoring from trash + email.setDeletedAt(null); + } + if (request.containsKey("kanban_order")) { email.setKanbanOrder(Double.parseDouble(request.get("kanban_order").toString())); } emailRepository.save(email); + + emailService.syncStatusToProvider(email.getId(), previousStatus, toStatus); + EmailAccount account = getPrimaryAccount(principal); return ResponseEntity.ok(ApiResponse.success(mapToKanbanCard(email, account))); @@ -315,7 +402,144 @@ public ResponseEntity>> getKanbanMeta( return ResponseEntity.ok(ApiResponse.success(data)); } + @PostMapping("/emails/bulk-modify") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity>> bulkModifyEmails( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map request + ) { + List emailIds = null; + if (request.get("ids") != null) { + emailIds = ((List) request.get("ids")).stream() + .map(id -> Long.parseLong(id.toString())) + .collect(Collectors.toList()); + } + + String mailboxId = (String) request.get("mailboxId"); + Boolean unreadOnly = (Boolean) request.get("unread"); + Boolean hasAttachments = (Boolean) request.get("hasAttachments"); + + @SuppressWarnings("unchecked") + Map> actions = (Map>) request.get("actions"); + List addLabels = actions.getOrDefault("addLabels", new ArrayList<>()); + List removeLabels = actions.getOrDefault("removeLabels", new ArrayList<>()); + List normalizedAdd = addLabels.stream().map(v -> v == null ? "" : v.toUpperCase(Locale.ROOT)).collect(Collectors.toList()); + List normalizedRemove = removeLabels.stream().map(v -> v == null ? "" : v.toUpperCase(Locale.ROOT)).collect(Collectors.toList()); + + EmailAccount account = getPrimaryAccount(principal); + List emails; + + if (emailIds != null && !emailIds.isEmpty()) { + emails = emailRepository.findAllById(emailIds); + } else if (mailboxId != null) { + log.info("[V50-BULK] Processing by filter: mailbox={}, unread={}, attachments={}", mailboxId, unreadOnly, hasAttachments); + // Re-use logic from getEmailsByMailbox but for modification + String status = mailboxId.toUpperCase(); + List stream; + if ("INBOX".equalsIgnoreCase(status)) { + stream = emailRepository.findAllByAccountIdOrderByKanbanOrderDescReceivedDateDesc(account.getId()).stream() + .filter(e -> { + String s = e.getStatus(); + if (s == null) return true; + return !s.equalsIgnoreCase("SENT") && !s.equalsIgnoreCase("DRAFTS") && !s.equalsIgnoreCase("DRAFT") && !s.equalsIgnoreCase("TRASH") && !s.equalsIgnoreCase("SPAM"); + }).collect(Collectors.toList()); + } else if ("STARRED".equalsIgnoreCase(status)) { + stream = emailRepository.findStarredByAccountId(account.getId()); + } else if ("DRAFTS".equalsIgnoreCase(status) || "DRAFT".equalsIgnoreCase(status)) { + stream = emailRepository.findAllByAccountIdAndStatus(account.getId(), "DRAFTS"); + if (stream.isEmpty()) stream = emailRepository.findAllByAccountIdAndStatus(account.getId(), "DRAFT"); + } else { + stream = emailRepository.findAllByAccountIdAndStatus(account.getId(), status); + } + emails = stream.stream() + .filter(e -> unreadOnly == null || !unreadOnly || !e.isRead()) + .filter(e -> hasAttachments == null || !hasAttachments || e.isHasAttachments()) + .collect(Collectors.toList()); + } else { + return ResponseEntity.badRequest().body(ApiResponse.error("Either ids or mailboxId must be provided")); + } + + log.info("[V50-BULK] Bulk modify request for {} emails. Add: {}, Remove: {}", emails.size(), normalizedAdd, normalizedRemove); + int updatedCount = 0; + List updatedIds = new ArrayList<>(); + Map previousStatuses = new HashMap<>(); + + for (EmailEntity email : emails) { + String previousStatus = email.getStatus(); + boolean changed = false; + + if (normalizedAdd.contains("STARRED")) { email.setStarred(true); changed = true; } + if (normalizedRemove.contains("STARRED")) { + email.setStarred(false); + if ("STARRED".equalsIgnoreCase(email.getStatus())) email.setStatus(EmailStatus.INBOX); + changed = true; + } + if (normalizedRemove.contains("UNREAD")) { email.setRead(true); changed = true; } + if (normalizedAdd.contains("UNREAD")) { email.setRead(false); changed = true; } + if (normalizedAdd.contains("SPAM")) { email.setStatus("SPAM"); changed = true; } + if (normalizedAdd.contains("TRASH")) { + email.setPreviousStatus(email.getStatus()); + email.setDeletedAt(LocalDateTime.now()); + email.setStatus("TRASH"); + changed = true; + } + if (normalizedAdd.contains("INBOX")) { + if (email.getPreviousStatus() != null && !email.getPreviousStatus().equalsIgnoreCase("TRASH")) { + email.setStatus(email.getPreviousStatus()); + } else { + email.setStatus(EmailStatus.INBOX); + } + email.setDeletedAt(null); + changed = true; + } + + if (changed) { + emailRepository.save(email); + updatedCount++; + updatedIds.add(email.getId()); + previousStatuses.put(email.getId(), previousStatus); + } + } + + // V51: Register a SINGLE synchronization for the entire batch + if (updatedCount > 0) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + // 1. Bulk Notify UI + try { + Map msg = new HashMap<>(); + msg.put("type", "UPDATED_EMAILS"); + msg.put("emailIds", updatedIds); + msg.put("accountId", account.getId()); + notificationWebSocketHandler.sendRawNotification(account.getId(), objectMapper.writeValueAsString(msg)); + log.info("[V51-BULK-NOTIFY] Sent post-commit notification for {} emails", updatedIds.size()); + } catch (Exception e) { + log.warn("Failed to send bulk WebSocket notification: {}", e.getMessage()); + } + + // 2. Trigger async sync for each email + for (Long id : updatedIds) { + emailService.syncFlagsAndLabelsToProvider(id, previousStatuses.get(id), normalizedAdd, normalizedRemove); + } + } + }); + } else { + // Fallback for non-transactional + for (Long id : updatedIds) { + emailService.syncFlagsAndLabelsToProvider(id, previousStatuses.get(id), normalizedAdd, normalizedRemove); + } + } + } + + Map data = new HashMap<>(); + data.put("updatedCount", updatedCount); + return ResponseEntity.ok(ApiResponse.success(data)); + } + @PostMapping("/emails/{id}/modify") + @org.springframework.transaction.annotation.Transactional public ResponseEntity>> modifyEmail( @AuthenticationPrincipal UserPrincipal principal, @PathVariable String id, @@ -324,18 +548,21 @@ public ResponseEntity>> modifyEmail( Long emailId = Long.parseLong(id); EmailEntity email = emailRepository.findById(emailId) .orElseThrow(() -> new RuntimeException("Email not found")); + String previousStatus = email.getStatus(); List addLabels = request.getOrDefault("addLabels", new ArrayList<>()); List removeLabels = request.getOrDefault("removeLabels", new ArrayList<>()); + List normalizedAdd = addLabels.stream().map(v -> v == null ? "" : v.toUpperCase(Locale.ROOT)).collect(Collectors.toList()); + List normalizedRemove = removeLabels.stream().map(v -> v == null ? "" : v.toUpperCase(Locale.ROOT)).collect(Collectors.toList()); boolean changed = false; - if (addLabels.contains("STARRED")) { + if (normalizedAdd.contains("STARRED")) { email.setStarred(true); changed = true; } - if (removeLabels.contains("STARRED")) { + if (normalizedRemove.contains("STARRED")) { email.setStarred(false); // Also reset legacy STARRED status if present if ("STARRED".equalsIgnoreCase(email.getStatus())) { @@ -344,60 +571,153 @@ public ResponseEntity>> modifyEmail( changed = true; } - if (removeLabels.contains("UNREAD")) { + if (normalizedRemove.contains("UNREAD")) { email.setRead(true); changed = true; } + + if (normalizedAdd.contains("UNREAD")) { + email.setRead(false); + changed = true; + } + + if (normalizedAdd.contains("SPAM")) { + email.setStatus("SPAM"); + changed = true; + } - if (addLabels.contains("TRASH")) { + if (normalizedAdd.contains("TRASH")) { + email.setPreviousStatus(email.getStatus()); + email.setDeletedAt(LocalDateTime.now()); email.setStatus("TRASH"); changed = true; } + + if (normalizedAdd.contains("INBOX")) { + // Restore logic: return to previous status if available (e.g. DRAFTS) + if (email.getPreviousStatus() != null && !email.getPreviousStatus().equalsIgnoreCase("TRASH")) { + email.setStatus(email.getPreviousStatus()); + } else { + email.setStatus(EmailStatus.INBOX); + } + email.setDeletedAt(null); + changed = true; + } if (changed) { - emailRepository.save(email); + emailRepository.saveAndFlush(email); - // Sync to Gmail - try { - if (removeLabels.contains("UNREAD")) { - imapService.setMessageRead(email.getAccount(), "INBOX", email.getUid(), true); - } else if (addLabels.contains("UNREAD")) { - imapService.setMessageRead(email.getAccount(), "INBOX", email.getUid(), false); - } - - if (addLabels.contains("STARRED")) { - imapService.setMessageStarred(email.getAccount(), "INBOX", email.getUid(), true); - } else if (removeLabels.contains("STARRED")) { - imapService.setMessageStarred(email.getAccount(), "INBOX", email.getUid(), false); - } - - if (addLabels.contains("TRASH")) { - imapService.trashMessage(email.getAccount(), "INBOX", email.getUid()); - log.info("Successfully trashed email (UID: {}) from Gmail", email.getUid()); - // Crucial: remove from local DB so it doesn't re-sync from Inbox - emailRepository.delete(email); - log.info("Deleted local email record for UID: {}", email.getUid()); - } - } catch (Exception e) { - log.error("Failed to sync flags/deletion to Gmail for email {}: {}", email.getUid(), e.getMessage()); + // V43: Use TransactionSynchronization to ensure both WebSocket notification AND async sync start ONLY after DB commit + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + // Notify UI only after commit so re-fetch sees latest state + try { + Map msg = new HashMap<>(); + msg.put("type", "UPDATED_EMAILS"); + msg.put("emailIds", List.of(email.getId())); + msg.put("accountId", email.getAccount().getId()); + + String jsonPayload = objectMapper.writeValueAsString(msg); + notificationWebSocketHandler.sendRawNotification(email.getAccount().getId(), jsonPayload); + log.info("[V49-NOTIFY] Sent post-commit notification for email ID: {}", email.getId()); + } catch (Exception e) { + log.warn("Failed to send WebSocket notification: {}", e.getMessage()); + } + + emailService.syncFlagsAndLabelsToProvider(email.getId(), previousStatus, normalizedAdd, normalizedRemove); + } + }); + } else { + // Fallback for non-transactional (rare but safe) + try { + Map msg = new HashMap<>(); + msg.put("type", "UPDATED_EMAILS"); + msg.put("emailIds", List.of(email.getId())); + msg.put("accountId", email.getAccount().getId()); + notificationWebSocketHandler.sendRawNotification(email.getAccount().getId(), objectMapper.writeValueAsString(msg)); + } catch (Exception e) {} + emailService.syncFlagsAndLabelsToProvider(email.getId(), previousStatus, normalizedAdd, normalizedRemove); } } EmailAccount account = getPrimaryAccount(principal); - return ResponseEntity.ok(ApiResponse.success(mapToFrontendEmail(email, account))); + return ResponseEntity.ok(ApiResponse.success(mapToFrontendEmail(email, account, principal))); } - @GetMapping("/emails/{id}") - public ResponseEntity>> getEmailDetail( + @PostMapping("/emails/bulk-delete") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity>> bulkDeleteEmails( @AuthenticationPrincipal UserPrincipal principal, - @PathVariable String id + @RequestBody Map request ) { - Long emailId = Long.parseLong(id); - EmailEntity email = emailRepository.findById(emailId) + List ids = null; + if (request.get("ids") != null) { + ids = ((List) request.get("ids")).stream() + .map(id -> Long.parseLong(id.toString())) + .collect(Collectors.toList()); + } + + String mailboxId = (String) request.get("mailboxId"); + Boolean unreadOnly = (Boolean) request.get("unread"); + Boolean hasAttachments = (Boolean) request.get("hasAttachments"); + EmailAccount account = getPrimaryAccount(principal); + + if (ids != null && !ids.isEmpty()) { + log.info("[V50-BULK] Bulk permanent delete request for {} email IDs", ids.size()); + for (Long id : ids) { + emailService.deleteEmailPermanently(id); + } + Map data = new HashMap<>(); + data.put("deletedCount", ids.size()); + return ResponseEntity.ok(ApiResponse.success(data)); + } else if (mailboxId != null) { + // Find all matching emails to delete permanently + String status = mailboxId.toUpperCase(); + List emails = emailRepository.findAllByAccountIdAndStatus(account.getId(), status).stream() + .filter(e -> unreadOnly == null || !unreadOnly || !e.isRead()) + .filter(e -> hasAttachments == null || !hasAttachments || e.isHasAttachments()) + .collect(Collectors.toList()); + + log.info("[V50-BULK] Bulk permanent delete request for all {} matching emails in {}", emails.size(), mailboxId); + for (EmailEntity e : emails) { + emailService.deleteEmailPermanently(e.getId()); + } + Map data = new HashMap<>(); + data.put("deletedCount", emails.size()); + return ResponseEntity.ok(ApiResponse.success(data)); + } + + return ResponseEntity.badRequest().body(ApiResponse.error("Either ids or mailboxId must be provided")); + } + + @DeleteMapping("/emails/{id}") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity> deleteEmail( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + EmailAccount account = getPrimaryAccount(principal); + EmailEntity email = emailRepository.findById(id) .orElseThrow(() -> new RuntimeException("Email not found")); + if (!email.getAccount().getId().equals(account.getId())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + emailService.deleteEmailPermanently(id); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @DeleteMapping("/mailboxes/TRASH/empty") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity> emptyTrash( + @AuthenticationPrincipal UserPrincipal principal + ) { EmailAccount account = getPrimaryAccount(principal); - return ResponseEntity.ok(ApiResponse.success(mapToFrontendEmail(email, account))); + emailService.emptyTrash(account); + return ResponseEntity.ok(ApiResponse.success("Empty trash process started in background")); } @GetMapping("/gmail/labels") @@ -486,7 +806,7 @@ private Map createMailbox(String id, String name, String icon, i } - private Map mapToFrontendEmail(EmailEntity entity, EmailAccount activeAccount) { + private Map mapToFrontendEmail(EmailEntity entity, EmailAccount activeAccount, UserPrincipal principal) { try { Map m = new HashMap<>(); m.put("id", entity.getId().toString()); @@ -499,45 +819,88 @@ private Map mapToFrontendEmail(EmailEntity entity, EmailAccount // Generate gmailLink String emailAddr = activeAccount != null ? activeAccount.getEmailAddress() : entity.getAccount().getEmailAddress(); String encodedEmail = URLEncoder.encode(emailAddr, StandardCharsets.UTF_8); - String gmailLink = entity.getGmailMessageId() != null ? - String.format("https://mail.google.com/mail/u/%s/#inbox/%s", encodedEmail, entity.getGmailMessageId()) : - String.format("https://mail.google.com/mail/u/%s/#search/rfc822msgid:%s", + String gmailLink = entity.getGmailMessageId() != null ? + String.format("https://mail.google.com/mail/u/0/?authuser=%s#inbox/%s", encodedEmail, entity.getGmailMessageId()) : + String.format("https://mail.google.com/mail/u/0/?authuser=%s#search/rfc822msgid:%s", encodedEmail, URLEncoder.encode(entity.getMessageId(), StandardCharsets.UTF_8)); m.put("gmailLink", gmailLink); - // Map from: { name, email } - Map from = new HashMap<>(); - String sender = entity.getSender() != null ? entity.getSender() : "Unknown "; - if (sender.contains("<")) { - int open = sender.indexOf("<"); - int close = sender.indexOf(">"); - from.put("name", sender.substring(0, open).trim()); - from.put("email", sender.substring(open + 1, close).trim()); + // V42: Robust Sender Mapping + Map from = new HashMap<>(); + String rawSender = entity.getSender() != null ? entity.getSender() : "Unknown "; + String senderEmail = ""; + String senderName = entity.getFromName(); + + // Extract email address + if (rawSender.contains("<") && rawSender.contains(">")) { + int open = rawSender.indexOf("<"); + int close = rawSender.indexOf(">"); + senderEmail = rawSender.substring(open + 1, close).trim(); + if (senderName == null || senderName.isBlank() || senderName.equalsIgnoreCase(senderEmail)) { + senderName = rawSender.substring(0, open).trim(); + senderName = senderName.replaceAll("^\"|\"$", "").trim(); + } } else { - from.put("name", sender); - from.put("email", sender); + senderEmail = rawSender.trim(); + if (senderName == null || senderName.isBlank()) senderName = senderEmail; + } + + // Universal "You" recognition + boolean isFromMe = false; + if (activeAccount != null && senderEmail.equalsIgnoreCase(activeAccount.getEmailAddress())) { + isFromMe = true; + senderName = activeAccount.getDisplayName(); + } else if (principal != null && senderEmail.equalsIgnoreCase(principal.getEmail())) { + isFromMe = true; + senderName = principal.getName(); + } + + // Final Cleanup + if (senderName == null || senderName.isBlank() || senderName.equalsIgnoreCase(senderEmail)) { + senderName = senderEmail.split("@")[0]; } - if (from.get("name").isEmpty()) from.put("name", from.get("email")); + + from.put("name", senderName); + from.put("email", senderEmail); m.put("from", from); + m.put("fromName", senderName); + m.put("isFromMe", isFromMe); - // Required empty arrays - m.put("to", new ArrayList<>()); - m.put("cc", new ArrayList<>()); - m.put("bcc", new ArrayList<>()); + // Labels placeholder; recipients and attachments populated from DTO below m.put("labels", new ArrayList<>()); - m.put("attachments", new ArrayList<>()); 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()); + + // Optimization: Avoid calling mapToDto() here as it's too heavy and causes state corruption in some Hibernate versions + m.put("to", entity.getRecipientTo() != null ? java.util.Arrays.asList(entity.getRecipientTo().split(",\\s*")) : new ArrayList<>()); + m.put("cc", entity.getRecipientCc() != null ? java.util.Arrays.asList(entity.getRecipientCc().split(",\\s*")) : new ArrayList<>()); + m.put("bcc", new java.util.ArrayList<>()); + m.put("attachments", new java.util.ArrayList<>()); // Placeholder for legacy compat m.put("hasAttachments", entity.isHasAttachments()); + m.put("hasCloudLinks", false); // Default for legacy + m.put("hasPhysicalAttachments", entity.isHasAttachments()); - String dateStr = entity.getReceivedDate() != null ? entity.getReceivedDate().toString() + "Z" : "2024-01-01T00:00:00Z"; + String dateStr; + if (entity.getReceivedDate() != null) { + dateStr = entity.getReceivedDate().atZone(ZoneId.systemDefault()).toInstant().toString(); + } else { + dateStr = "2024-01-01T00:00:00Z"; + } m.put("receivedAt", dateStr); m.put("createdAt", dateStr); m.put("summary", entity.getSummary()); + m.put("status", entity.getStatus()); + m.put("mailboxId", entity.getStatus() != null ? entity.getStatus() : "INBOX"); + m.put("deletedAt", entity.getDeletedAt() != null ? entity.getDeletedAt().atZone(ZoneId.systemDefault()).toInstant().toString() : null); + m.put("previousStatus", entity.getPreviousStatus()); return m; } catch (Exception e) { @@ -558,21 +921,39 @@ private Map mapToKanbanCard(EmailEntity email, EmailAccount acti card.put("thread_id", email.getThreadId()); card.put("account_email", activeAccount != null ? activeAccount.getEmailAddress() : email.getAccount().getEmailAddress()); card.put("sender", email.getSender()); + + // Universal "You" recognition for Kanban + String senderEmail = email.getSender(); + if (senderEmail != null && senderEmail.contains("<")) { + senderEmail = senderEmail.substring(senderEmail.indexOf("<") + 1, senderEmail.indexOf(">")).trim(); + } + boolean isFromMe = false; + if (activeAccount != null && senderEmail != null && senderEmail.equalsIgnoreCase(activeAccount.getEmailAddress())) { + isFromMe = true; + } + card.put("is_from_me", isFromMe); + card.put("subject", email.getSubject()); card.put("summary", email.getSummary()); + card.put("summary_source", email.getSummarySource() != null ? email.getSummarySource().name() : null); card.put("preview", email.getSnippet()); String encodedEmail = URLEncoder.encode(email.getAccount().getEmailAddress(), StandardCharsets.UTF_8); - String gmailUrl = email.getGmailMessageId() != null ? - String.format("https://mail.google.com/mail/u/%s/#inbox/%s", encodedEmail, email.getGmailMessageId()) : - String.format("https://mail.google.com/mail/u/%s/#search/rfc822msgid:%s", - encodedEmail, URLEncoder.encode(email.getMessageId(), StandardCharsets.UTF_8)); - + String gmailUrl = email.getGmailMessageId() != null ? + String.format("https://mail.google.com/mail/u/0/?authuser=%s#inbox/%s", encodedEmail, email.getGmailMessageId()) : + String.format("https://mail.google.com/mail/u/0/?authuser=%s#search/rfc822msgid:%s", + encodedEmail, URLEncoder.encode(email.getMessageId(), StandardCharsets.UTF_8)); + card.put("gmail_url", gmailUrl); - card.put("received_at", email.getReceivedDate() != null ? email.getReceivedDate().toString() + "Z" : ""); + card.put("received_at", email.getReceivedDate() != null ? email.getReceivedDate().atZone(ZoneId.systemDefault()).toInstant().toString() : ""); card.put("is_read", email.isRead()); card.put("is_starred", email.isStarred()); - card.put("has_attachments", email.isHasAttachments()); + + com.awad.emailclientai.modules.email.dto.response.EmailEntityDto dto = emailService.mapToDto(email); + card.put("has_attachments", dto.isHasAttachments()); + card.put("has_cloud_links", dto.isHasCloudLinks()); + card.put("has_physical_attachments", dto.isHasPhysicalAttachments()); + return card; } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/request/SendEmailRequestDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/request/SendEmailRequestDto.java index c734848..d7025d2 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/dto/request/SendEmailRequestDto.java +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/request/SendEmailRequestDto.java @@ -55,6 +55,12 @@ public class SendEmailRequestDto { * References chain (for threading). */ private List references; + + /** + * Gmail draft ID if this is an update to an existing draft. + */ + private String gmailDraftId; + private Long localEmailId; // Note: Attachments would be handled separately via multipart upload } diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/response/ContactDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/response/ContactDto.java new file mode 100644 index 0000000..2d3e05a --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/response/ContactDto.java @@ -0,0 +1,15 @@ +package com.awad.emailclientai.modules.email.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ContactDto { + private String name; + private String email; +} 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..4faa89d 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,33 +1,80 @@ package com.awad.emailclientai.modules.email.dto.response; -import lombok.Builder; -import lombok.Data; +import lombok.*; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.List; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class EmailEntityDto { private Long id; private String messageId; private String threadId; private String gmailMessageId; + private String gmailDraftId; private Long uid; private String subject; - private String sender; + private EmailAddressDto from; + private List to; + private List cc; + private String sender; // Legacy fallback + private String fromName; // Legacy fallback + private List recipientTo; // Legacy fallback + private List recipientCc; // Legacy fallback private String snippet; + private String preview; // Frontend alias for snippet private String body; private String status; + private String mailboxId; private LocalDateTime receivedDate; private String receivedAt; private OffsetDateTime snoozedUntil; private String summary; private String summarySource; + @com.fasterxml.jackson.annotation.JsonProperty("isRead") private boolean isRead; + @com.fasterxml.jackson.annotation.JsonProperty("isStarred") private boolean isStarred; + @com.fasterxml.jackson.annotation.JsonProperty("hasAttachments") private boolean hasAttachments; + @com.fasterxml.jackson.annotation.JsonProperty("hasCloudLinks") + private boolean hasCloudLinks; // V10.35 + @com.fasterxml.jackson.annotation.JsonProperty("hasPhysicalAttachments") + private boolean hasPhysicalAttachments; // V10.35 private String gmailLink; private String accountEmail; + @com.fasterxml.jackson.annotation.JsonProperty("isFromMe") + private boolean isFromMe; + private LocalDateTime deletedAt; + private List attachments; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EmailAddressDto { + private String name; + private String email; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + 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; + private String externalUrl; + } } diff --git a/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDetailDto.java b/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDetailDto.java index 3b05ee5..7d6376d 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDetailDto.java +++ b/src/main/java/com/awad/emailclientai/modules/email/dto/response/MailMessageDetailDto.java @@ -14,7 +14,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -@Builder +@Builder(toBuilder = true) public class MailMessageDetailDto { private long uid; @@ -69,5 +69,6 @@ public static class AttachmentDto { private long size; private boolean inline; private String contentId; // For inline images + private String externalUrl; } } 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..d5ceeec 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 @@ -14,7 +14,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -@Builder +@Builder(toBuilder = true) public class MailMessageDto { /** @@ -89,6 +89,22 @@ public class MailMessageDto { */ private boolean hasAttachments; + /** + * Whether the message has cloud links. + */ + private boolean hasCloudLinks; + + /** + * Whether the message has physical attachments. + */ + private boolean hasPhysicalAttachments; + + /** + * Whether the message is from one of the user's own accounts. + */ + @com.fasterxml.jackson.annotation.JsonProperty("isFromMe") + private boolean isFromMe; + /** * Gmail labels (fetched via X-GM-LABELS). */ @@ -98,4 +114,23 @@ 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; + private String externalUrl; + } } 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/entity/EmailAttachment.java b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailAttachment.java new file mode 100644 index 0000000..a9777d2 --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailAttachment.java @@ -0,0 +1,44 @@ +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; + + private String externalUrl; + + @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..7a03a44 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") @@ -21,19 +23,28 @@ public class EmailEntity { private Long id; @Column(unique = true, nullable = false) - private String messageId; // Unique Message-ID header + private String messageId; - private String threadId; // Gmail Thread ID (hex) - private String gmailMessageId; // Gmail Message ID (hex) + private String threadId; + private String gmailMessageId; + private String gmailDraftId; - private Long uid; // IMAP UID + private Long uid; private String subject; private String sender; + private String fromName; + + @Column(columnDefinition = "TEXT") + private String recipientTo; + + @Column(columnDefinition = "TEXT") + private String recipientCc; + @Column(length = 500) - private String snippet; // Short preview + private String snippet; @Column(columnDefinition = "TEXT") private String body; @@ -41,6 +52,9 @@ public class EmailEntity { @Builder.Default private String status = EmailStatus.INBOX; + private String previousStatus; // NEW: Store status before moving to trash + private LocalDateTime deletedAt; // NEW: Timestamp when moved to trash + private LocalDateTime receivedDate; private OffsetDateTime snoozedUntil; @@ -72,4 +86,8 @@ 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/entity/EmailSender.java b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailSender.java new file mode 100644 index 0000000..85b0b08 --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/entity/EmailSender.java @@ -0,0 +1,22 @@ +package com.awad.emailclientai.modules.email.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "email_senders") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EmailSender { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String email; + + private String bestKnownName; +} 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/repository/EmailRepository.java b/src/main/java/com/awad/emailclientai/modules/email/repository/EmailRepository.java index 7333a1c..20cfe09 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/repository/EmailRepository.java +++ b/src/main/java/com/awad/emailclientai/modules/email/repository/EmailRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -18,8 +19,22 @@ @Repository public interface EmailRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByMessageId(String messageId); + Optional findByGmailMessageId(String gmailMessageId); + Optional findByGmailDraftId(String gmailDraftId); + boolean existsByMessageId(String messageId); + List findByStatus(String status); + List findAllByAccountIdAndStatus(Long accountId, String status); + long countByAccountIdAndStatus(Long accountId, String status); + java.util.Optional findFirstByAccountIdAndThreadIdAndStatusOrderByReceivedDateDesc(Long accountId, String threadId, String status); + + @Query(value = "SELECT * FROM emails WHERE account_id = :accountId AND " + + "subject = :subject AND received_date >= :timeThreshold " + + "ORDER BY received_date DESC LIMIT 1", nativeQuery = true) + java.util.Optional findRecentEmailBySubject(@Param("accountId") Long accountId, @Param("subject") String subject, @Param("timeThreshold") java.time.LocalDateTime timeThreshold); + + List findBySnoozedUntilBeforeAndStatus(OffsetDateTime now, String status); @@ -49,10 +64,12 @@ public interface EmailRepository extends JpaRepository, JpaSp List searchEmailsWithScore(@Param("accountId") Long accountId, @Param("query") String query); @Modifying + @Transactional @Query(value = "UPDATE emails SET embedding_768 = cast(:embedding as vector) WHERE id = :id", nativeQuery = true) void updateEmbedding768(@Param("id") Long id, @Param("embedding") String embedding); @Modifying + @Transactional @Query(value = "UPDATE emails SET embedding_384 = cast(:embedding as vector) WHERE id = :id", nativeQuery = true) void updateEmbedding384(@Param("id") Long id, @Param("embedding") String embedding); @@ -108,13 +125,28 @@ public interface EmailRepository extends JpaRepository, JpaSp @Query("SELECT COUNT(e) FROM EmailEntity e WHERE e.account.id = :accountId AND e.receivedDate >= :startDate") long countByAccountId(@Param("accountId") Long accountId, @Param("startDate") LocalDateTime startDate); - @Query("SELECT COUNT(e) FROM EmailEntity e WHERE e.account.id = :accountId AND e.isRead = false AND e.receivedDate >= :startDate") - long countUnreadByAccountId(@Param("accountId") Long accountId, @Param("startDate") LocalDateTime startDate); + @Query("SELECT COUNT(e) FROM EmailEntity e WHERE e.account.id = :accountId AND e.isRead = false AND e.status = 'INBOX'") + long countUnreadByAccountId(@Param("accountId") Long accountId); - @Query("SELECT COUNT(e) FROM EmailEntity e WHERE e.account.id = :accountId AND e.isStarred = true AND e.receivedDate >= :startDate") - long countStarredByAccountId(@Param("accountId") Long accountId, @Param("startDate") LocalDateTime startDate); + @Query("SELECT COUNT(e) FROM EmailEntity e WHERE e.account.id = :accountId AND e.isStarred = true") + long countStarredByAccountId(@Param("accountId") Long accountId); + + @Query("SELECT e FROM EmailEntity e WHERE e.account.id = :accountId AND e.isStarred = true") + List findStarredByAccountId(@Param("accountId") Long accountId); @Query("SELECT e FROM EmailEntity e WHERE e.account.id = :accountId AND " + "(e.body LIKE '%body {%' OR e.body LIKE '%.ie-browser%' OR e.body LIKE '%.mso-container%' OR e.body LIKE '%ExternalClass%')") List findCorruptedEmails(@Param("accountId") Long accountId); + @Query("SELECT DISTINCT e.fromName FROM EmailEntity e WHERE LOWER(e.sender) LIKE LOWER(CONCAT('%', :sender, '%')) AND e.fromName IS NOT NULL AND e.fromName != e.sender AND e.fromName NOT LIKE '%@%'") + List findDistinctFromNamesBySender(@Param("sender") String sender); + + @Modifying + @Transactional + @Query("UPDATE EmailEntity e SET e.fromName = :name WHERE (e.fromName IS NULL OR e.fromName = e.sender OR e.fromName LIKE '%@%') AND LOWER(e.sender) LIKE LOWER(CONCAT('%', :sender, '%'))") + void updateFromNameBySender(@Param("sender") String sender, @Param("name") String name); + + @Modifying + @Transactional + @Query("DELETE FROM EmailEntity e WHERE e.status = 'TRASH' AND e.deletedAt < :threshold") + void deleteOldTrash(@Param("threshold") LocalDateTime threshold); } diff --git a/src/main/java/com/awad/emailclientai/modules/email/repository/EmailSenderRepository.java b/src/main/java/com/awad/emailclientai/modules/email/repository/EmailSenderRepository.java new file mode 100644 index 0000000..4604e96 --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/repository/EmailSenderRepository.java @@ -0,0 +1,11 @@ +package com.awad.emailclientai.modules.email.repository; + +import com.awad.emailclientai.modules.email.entity.EmailSender; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface EmailSenderRepository extends JpaRepository { + Optional findByEmail(String email); + List findAllByOrderByBestKnownNameAsc(); +} 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..8fb80d0 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 @@ -16,6 +16,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpMethod; +import org.springframework.core.ParameterizedTypeReference; import java.util.*; import java.util.regex.Matcher; @@ -31,7 +33,11 @@ public class AiService { private final RestTemplate restTemplate = new RestTemplate(); @Value("${GEMINI_API_KEY:}") - private String geminiApiKey; + private String geminiApiKeyRaw; + + // Parsed/rotating API keys (supports comma-separated keys in .env for rotation) + private String[] geminiApiKeys = new String[0]; + private int geminiKeyIndex = 0; @Value("${gemini.chat-model:gemini-2.5-flash}") private String geminiModel; @@ -42,6 +48,94 @@ private String getGeminiUrl() { return GEMINI_BASE_URL + geminiModel + ":generateContent"; } + @Value("${LOCAL_LLM_URL:}") + private String localLlmUrl; + + @Value("${LOCAL_LLM_ENABLED:false}") + private boolean localLlmEnabled; + + private String callLocalModelApi(String text) { + if (!localLlmEnabled || localLlmUrl == null || localLlmUrl.isBlank()) { + throw new RuntimeException("Local LLM not configured/enabled"); + } + + try { + Map req = new HashMap<>(); + // Generic payload: many local LLM proxies accept { "text": "..." } or { "prompt": "..." } + req.put("text", "Summarize this email in 1-2 sentences: " + text); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(req, headers); + + ResponseEntity> resp = restTemplate.exchange( + localLlmUrl, + HttpMethod.POST, + request, + new ParameterizedTypeReference>() {} + ); + Map body = resp.getBody(); + if (body == null) throw new RuntimeException("Empty response from local LLM"); + + // Common response shapes + // 1) { "text": "..." } + if (body.containsKey("text") && body.get("text") instanceof String) { + return (String) body.get("text"); + } + + // 2) { "result": "..." } or { "output": "..." } + if (body.containsKey("result") && body.get("result") instanceof String) { + return (String) body.get("result"); + } + if (body.containsKey("output") && body.get("output") instanceof String) { + return (String) body.get("output"); + } + + // 3) OpenAI-ish: { "choices": [ { "text": "..." } ] } + if (body.containsKey("choices")) { + Object choicesObj = body.get("choices"); + if (choicesObj instanceof List) { + List choices = (List) choicesObj; + if (!choices.isEmpty() && choices.get(0) instanceof Map) { + @SuppressWarnings("unchecked") + Map first = (Map) choices.get(0); + if (first.containsKey("text") && first.get("text") instanceof String) { + return (String) first.get("text"); + } + if (first.containsKey("message") && first.get("message") instanceof Map) { + @SuppressWarnings("unchecked") + Map msg = (Map) first.get("message"); + if (msg.containsKey("content") && msg.get("content") instanceof String) { + return (String) msg.get("content"); + } + } + } + } + } + + // 4) Ollama-like: { "result": [{"content": "..."}] } + if (body.containsKey("result")) { + Object resObj = body.get("result"); + if (resObj instanceof List) { + List resList = (List) resObj; + if (!resList.isEmpty() && resList.get(0) instanceof Map) { + @SuppressWarnings("unchecked") + Map entry = (Map) resList.get(0); + if (entry.containsKey("content") && entry.get("content") instanceof String) { + return (String) entry.get("content"); + } + } + } + } + + // Fallback: try to stringify + return body.toString(); + } catch (Exception e) { + log.error("Local LLM request failed: {}", e.getMessage()); + throw new RuntimeException("Local LLM call failed", e); + } + } + @Transactional public String summarizeEmail(Long emailId) { EmailEntity email = emailRepository.findById(emailId) @@ -75,24 +169,40 @@ public String summarizeEmail(Long emailId) { emailRepository.save(email); return email.getSummary(); } catch (Exception e) { - log.warn("Gemini upgrade failed, checking for local model fallback: {}", e.getMessage()); - - // Tier 2: Local Model (Placeholder for Ollama/Local LLM) - // Currently we don't have a local LLM, so we fallback to Local Algo if current is ALGO or null. - // If current is already LOCAL_MODEL, we keep it. - - if (email.getSummarySource() == SummarySource.LOCAL_MODEL && email.getSummary() != null) { + log.warn("Gemini upgrade failed: {}", e.getMessage()); + + // Tier 2: Local Model (if enabled/configured) + if (localLlmEnabled && localLlmUrl != null && !localLlmUrl.isBlank()) { + try { + String localSummary = callLocalModelApi(content); + if (localSummary != null && !localSummary.isBlank()) { + email.setSummary("[Local Model] " + localSummary); + email.setSummarySource(SummarySource.LOCAL_MODEL); + emailRepository.save(email); + return email.getSummary(); + } + } catch (Exception le) { + log.warn("Local model fallback failed: {}", le.getMessage()); + // fall through to extractive + } + } else { + log.debug("Local LLM not configured or disabled, skipping local model fallback"); + } + + // If current email already has a LOCAL_MODEL summary, keep it + if (email.getSummarySource() == SummarySource.LOCAL_MODEL && email.getSummary() != null && !email.getSummary().isEmpty()) { + log.info("Keeping existing LOCAL_MODEL summary for email ID {}", email.getId()); return email.getSummary(); } // Tier 3: Local Algorithm (Extractive) if (email.getSummarySource() != SummarySource.LOCAL_ALGO) { - String summary = extractiveSummary(content, 3, 300); - email.setSummary("[Local Algo] " + summary); + String algoSummary = extractiveSummary(content, 3, 300); + email.setSummary("[Local Algo] " + algoSummary); email.setSummarySource(SummarySource.LOCAL_ALGO); emailRepository.save(email); } - + return email.getSummary(); } } @@ -143,20 +253,38 @@ private String fetchBodyOnDemand(EmailEntity email) { @PostConstruct public void init() { - log.info("AiService initialized. Gemini API Key Present: {}", - (geminiApiKey != null && !geminiApiKey.isEmpty())); - if (geminiApiKey != null && !geminiApiKey.isEmpty()) { - log.debug("Gemini Key Length: {}", geminiApiKey.length()); + // Parse possible comma-separated GEMINI_API_KEY into an array for rotation + if (geminiApiKeyRaw != null && !geminiApiKeyRaw.isBlank()) { + String[] parts = geminiApiKeyRaw.split(","); + List keys = new ArrayList<>(); + for (String p : parts) { + String t = p.trim(); + if (!t.isEmpty()) keys.add(t); + } + geminiApiKeys = keys.toArray(new String[0]); } + + log.info("AiService initialized. Gemini API Keys configured: {}", geminiApiKeys.length); + if (geminiApiKeys.length > 0) { + log.debug("Gemini first key length: {}", geminiApiKeys[0].length()); + } + log.info("Local LLM enabled: {}. Local LLM URL present: {}", localLlmEnabled, (localLlmUrl != null && !localLlmUrl.isBlank())); + } + + private synchronized String getNextGeminiApiKey() { + if (geminiApiKeys == null || geminiApiKeys.length == 0) return null; + String k = geminiApiKeys[geminiKeyIndex]; + geminiKeyIndex = (geminiKeyIndex + 1) % geminiApiKeys.length; + return k; } - @SuppressWarnings("unchecked") private String callGeminiApi(String text) { - if (geminiApiKey == null || geminiApiKey.isEmpty()) { + String key = getNextGeminiApiKey(); + if (key == null || key.isEmpty()) { throw new RuntimeException("Gemini API Key not configured"); } - String url = getGeminiUrl() + "?key=" + geminiApiKey; + String url = getGeminiUrl() + "?key=" + key; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); @@ -175,18 +303,35 @@ private String callGeminiApi(String text) { log.debug("Sending request to Gemini..."); HttpEntity request = new HttpEntity<>(requestBody, headers); - - @SuppressWarnings("rawtypes") - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - - Map body = (Map) response.getBody(); + + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.POST, + request, + new ParameterizedTypeReference>() {} + ); + + Map body = response.getBody(); if (body != null && body.containsKey("candidates")) { - List> candidates = (List>) body.get("candidates"); - if (!candidates.isEmpty()) { - Map candContent = (Map) candidates.get(0).get("content"); - List> parts = (List>) candContent.get("parts"); - if (!parts.isEmpty()) { - return (String) parts.get(0).get("text"); + Object candObj = body.get("candidates"); + if (candObj instanceof List) { + List candidates = (List) candObj; + if (!candidates.isEmpty() && candidates.get(0) instanceof Map) { + @SuppressWarnings("unchecked") + Map first = (Map) candidates.get(0); + Object contentObj = first.get("content"); + if (contentObj instanceof Map) { + @SuppressWarnings("unchecked") + Map candContent = (Map) contentObj; + Object partsObj = candContent.get("parts"); + if (partsObj instanceof List) { + @SuppressWarnings("unchecked") + List> parts = (List>) partsObj; + if (!parts.isEmpty() && parts.get(0).get("text") instanceof String) { + return (String) parts.get(0).get("text"); + } + } + } } } } @@ -205,13 +350,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()) { @@ -278,11 +429,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 (geminiApiKeys == null || geminiApiKeys.length == 0) { + 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/EmailAccountService.java b/src/main/java/com/awad/emailclientai/modules/email/service/EmailAccountService.java index 118c9e5..45bdc1c 100644 --- a/src/main/java/com/awad/emailclientai/modules/email/service/EmailAccountService.java +++ b/src/main/java/com/awad/emailclientai/modules/email/service/EmailAccountService.java @@ -1,5 +1,7 @@ package com.awad.emailclientai.modules.email.service; +import java.util.Map; + import com.awad.emailclientai.modules.email.dto.request.*; import com.awad.emailclientai.modules.email.dto.response.*; import com.awad.emailclientai.modules.email.entity.EmailAccount; @@ -36,6 +38,7 @@ public class EmailAccountService { private final ImapService imapService; private final SmtpService smtpService; private final GmailWatchService gmailWatchService; + private final GmailLabelService gmailLabelService; /** * Connects a new email account for the user. @@ -199,6 +202,50 @@ public String sendEmailWithAttachments(Long userId, Long accountId, return message.getMessageID(); } + /** + * Saves a draft to the provider (e.g. Gmail Drafts). + */ + public Map saveDraft(Long userId, Long accountId, SendEmailRequestDto request) throws MessagingException, IOException { + EmailAccount account = getAccountForUser(userId, accountId); + // Create a dummy session for building the MimeMessage + jakarta.mail.Session session = jakarta.mail.Session.getInstance(new java.util.Properties()); + jakarta.mail.internet.MimeMessage message = smtpService.createMimeMessage(session, account, request); + + if (account.getProvider() == EmailProvider.GMAIL) { + return gmailLabelService.createDraft(account, message); + } + return null; // Not implemented for other providers yet + } + + /** + * Updates an existing draft on the provider. + */ + public Map updateDraft(Long userId, Long accountId, String draftId, SendEmailRequestDto request) throws MessagingException, IOException { + EmailAccount account = getAccountForUser(userId, accountId); + // Create a dummy session for building the MimeMessage + jakarta.mail.Session session = jakarta.mail.Session.getInstance(new java.util.Properties()); + jakarta.mail.internet.MimeMessage message = smtpService.createMimeMessage(session, account, request); + + if (account.getProvider() == EmailProvider.GMAIL) { + return gmailLabelService.updateDraft(account, draftId, message); + } + return null; + } + + public void deleteDraft(Long userId, Long accountId, String draftId) throws IOException { + EmailAccount account = getAccountForUser(userId, accountId); + if (account.getProvider() == EmailProvider.GMAIL) { + gmailLabelService.deleteDraft(account, draftId); + } + } + + public void trashDraft(Long userId, Long accountId, String gmailMessageId) throws IOException { + EmailAccount account = getAccountForUser(userId, accountId); + if (account.getProvider() == EmailProvider.GMAIL) { + gmailLabelService.trashDraft(account, gmailMessageId); + } + } + /** * Downloads an attachment. */ 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..dc686f9 --- /dev/null +++ b/src/main/java/com/awad/emailclientai/modules/email/service/EmailService.java @@ -0,0 +1,972 @@ +package com.awad.emailclientai.modules.email.service; + +import com.awad.emailclientai.modules.email.dto.response.ContactDto; +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.entity.EmailStatus; +import com.awad.emailclientai.modules.email.repository.EmailAttachmentRepository; +import com.awad.emailclientai.modules.email.repository.EmailRepository; +import com.awad.emailclientai.modules.email.repository.EmailSenderRepository; +import com.awad.emailclientai.modules.email.entity.EmailAccount; +import com.awad.emailclientai.modules.email.repository.EmailAccountRepository; +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 org.springframework.scheduling.annotation.Async; + +import jakarta.mail.MessagingException; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.Optional; +import java.util.stream.Collectors; +import java.time.ZoneId; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailService { + + private final EmailRepository emailRepository; + private final EmailAttachmentRepository attachmentRepository; + private final ImapService imapService; + private final EmailSyncService emailSyncService; + private final GmailLabelService gmailLabelService; + private final EmailSenderRepository emailSenderRepository; + private final EmailAccountRepository emailAccountRepository; + private final NotificationWebSocketHandler notificationWebSocketHandler; + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; + + @PostConstruct + public void init() { + log.info(">>>> Initialized and Monitoring Rendering <<<<"); + } + + @Transactional + public EmailEntityDto getEmailDetail(Long id) { + EmailEntity email = emailRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); + + // V30: Live Healing - If body is completely missing, reach out to IMAP to recover it + if (email.getBody() == null || email.getBody().trim().isEmpty()) { + log.info("[LIVE-HEALING] Body missing for email ID: {}. Attempting recovery from server...", id); + try { + // Determine folder name (Simplified: LinkedIn/Standard incoming is almost always INBOX) + String folderName = "INBOX"; + if ("SENT".equalsIgnoreCase(email.getStatus())) { + folderName = "SENT"; + } + + String liveBody = imapService.fetchLiveBody(email.getAccount(), folderName, email.getUid()); + + if (liveBody != null && !liveBody.isEmpty()) { + email.setBody(liveBody); + + // Also heal snippet if it was just the subject before + if (email.getSnippet() == null || email.getSnippet().trim().isEmpty() || email.getSnippet().equals(email.getSubject())) { + String cleanSnippet = imapService.stripHtml(liveBody); + email.setSnippet(cleanSnippet.length() > 200 ? cleanSnippet.substring(0, 197) + "..." : cleanSnippet); + } + + emailRepository.save(email); + log.info("[LIVE-HEALING] Success! Body recovered and persisted for email ID: {}", id); + } else { + log.warn("[LIVE-HEALING] Server returned empty body for email ID: {}", id); + } + } catch (Exception e) { + log.error("[LIVE-HEALING] Error during recovery for email ID: {}: {}", id, e.getMessage()); + } + } + + // If backend marks email as having attachments but DB record has none, + // attempt a targeted refresh to fetch attachment metadata from the server. + if (email.isHasAttachments() && (email.getAttachments() == null || email.getAttachments().isEmpty())) { + log.info("[LIVE-HEALING] Attachments missing for email ID: {}. Attempting detail refresh...", id); + try { + emailSyncService.refreshEmail(id); + // Reload entity after refresh + email = emailRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); + } catch (Exception e) { + log.warn("[LIVE-HEALING] Failed to refresh attachments for email ID {}: {}", id, e.getMessage()); + } + } + + 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(); + final String finalBody = body; // Make it effectively final for lambda + + List attachments = entity.getAttachments().stream() + .filter(at -> { + boolean isImg = at.getContentType() != null && at.getContentType().startsWith("image/"); + if (isImg) { + if (at.isInline() || at.getContentId() != null) { + return false; // Standard inline image + } + // Edge Case: If the image filename is explicitly referenced in the HTML body + // (e.g. as an 'alt' attribute or src), it is acting as an inline image/banner. + if (at.getFilename() != null && !at.getFilename().isEmpty() && + finalBody != null && finalBody.contains(at.getFilename())) { + return false; + } + } + return true; + }) + .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() || at.getContentId() != null) + .externalUrl(at.getExternalUrl()) + .url(at.getExternalUrl() != null ? at.getExternalUrl() : (at.isInline() || at.getContentId() != null ? + String.format("emails/%d/attachments/%d/inline", entity.getId(), at.getId()) : + String.format("emails/%d/attachments/%d/download", entity.getId(), at.getId()))) + .build()) + .collect(Collectors.toList()); + + // Transform body for all emails (Sanitization, Meta-fix, CID resolution) + body = processEmailBody(body, entity.getId(), entity.getAttachments()); + + // Dynamic Discovery Fallback for existing emails + imapService.scanForCloudLinksEntityDto(body, attachments, new int[]{attachments.size()}); + + // Final flag calculation for accurate UI indicators + boolean hasCloud = attachments.stream().anyMatch(a -> a.getExternalUrl() != null); + boolean hasPhysical = attachments.stream().anyMatch(a -> a.getExternalUrl() == null); + + // V41: Universal Account Branding & Aggressive Discovery + String rawSender = entity.getSender(); + String senderEmail = cleanEmail(rawSender); + String currentName = entity.getFromName(); + Long userId = (entity.getAccount() != null && entity.getAccount().getUser() != null) ? entity.getAccount().getUser().getId() : null; + com.awad.emailclientai.modules.email.entity.EmailAccount account = entity.getAccount(); + + // 1. Priority: If this sender matches ANY of the user's connected accounts or main profile, use "You" logic + boolean fromMe = false; + if (senderEmail != null && account != null && account.getUser() != null) { + List userAccounts = emailAccountRepository.findByUserId(userId); + + String primaryEmail = entity.getAccount().getUser().getEmail(); + boolean isUserAccount = userAccounts.stream() + .anyMatch(acc -> senderEmail.equalsIgnoreCase(acc.getEmailAddress())); + + if (isUserAccount || senderEmail.equalsIgnoreCase(primaryEmail)) { + fromMe = true; + currentName = entity.getAccount().getDisplayName(); + if (currentName == null || currentName.isBlank() || currentName.equalsIgnoreCase(senderEmail)) { + currentName = entity.getAccount().getUser().getName(); + } + } + } + + boolean nameIsRaw = currentName == null || currentName.isBlank() || currentName.equalsIgnoreCase(senderEmail) || currentName.contains("@"); + + // V41: Self-Healing - If name is missing/raw, try to extract from rawSender "Name " + if (nameIsRaw && rawSender != null && rawSender.contains("<") && rawSender.contains(">")) { + int start = rawSender.indexOf("<"); + String extracted = rawSender.substring(0, start).trim(); + extracted = extracted.replaceAll("^\"|\"$", "").trim(); + String username = senderEmail != null && senderEmail.contains("@") ? senderEmail.split("@")[0] : ""; + + if (!extracted.isEmpty() && !extracted.equalsIgnoreCase(senderEmail) && !extracted.equalsIgnoreCase(username) && !extracted.contains("@")) { + currentName = extracted; + nameIsRaw = false; + } + } + + if (nameIsRaw && senderEmail != null) { + // 1. Try Smart Directory (Fast) + currentName = emailSenderRepository.findByEmail(senderEmail.toLowerCase()) + .map(com.awad.emailclientai.modules.email.entity.EmailSender::getBestKnownName) + .orElse(currentName); + + // 2. Fallback: Search DB for ANY other email from this sender that HAS a real name + if (currentName == null || currentName.equalsIgnoreCase(senderEmail) || currentName.contains("@") || currentName.equalsIgnoreCase(senderEmail.split("@")[0])) { + List names = emailRepository.findDistinctFromNamesBySender(senderEmail.trim()); + String bestFound = null; + for (String n : names) { + if (n != null && !n.isBlank()) { + String cleanedN = n; + if (cleanedN.contains("<") && cleanedN.contains(">")) { + cleanedN = cleanedN.substring(0, cleanedN.indexOf("<")).trim(); + } + cleanedN = cleanedN.replaceAll("^\"|\"$", "").trim(); + + String username = senderEmail.split("@")[0]; + boolean isRealName = !cleanedN.isBlank() + && !cleanedN.equalsIgnoreCase(senderEmail.trim()) + && !cleanedN.equalsIgnoreCase(username) + && !cleanedN.contains("@"); + + if (isRealName) { + // Prioritize names with spaces (likely Full Names) + if (cleanedN.contains(" ")) { + bestFound = cleanedN; + break; + } + bestFound = cleanedN; + } + } + } + if (bestFound != null) currentName = bestFound; + } + } + final String bestName = currentName; + + return EmailEntityDto.builder() + .id(entity.getId()) + .messageId(entity.getMessageId()) + .threadId(entity.getThreadId()) + .gmailMessageId(entity.getGmailMessageId()) + .gmailDraftId(entity.getGmailDraftId()) + .uid(entity.getUid()) + .subject(entity.getSubject()) + .from(EmailEntityDto.EmailAddressDto.builder() + .name(bestName) + .email(senderEmail) + .build()) + .to(parseRecipientString(entity.getRecipientTo(), account)) + .cc(parseRecipientString(entity.getRecipientCc(), account)) + .sender(senderEmail) + .fromName(bestName) + .recipientTo(entity.getRecipientTo() != null ? java.util.Arrays.asList(entity.getRecipientTo().split(",\\s*")) : java.util.Collections.emptyList()) + .recipientCc(entity.getRecipientCc() != null ? java.util.Arrays.asList(entity.getRecipientCc().split(",\\s*")) : java.util.Collections.emptyList()) + .snippet(entity.getSnippet()) + .preview(entity.getSnippet()) + .body(body) + .status(entity.getStatus()) + .mailboxId(entity.getStatus() != null ? entity.getStatus() : "INBOX") + .receivedDate(entity.getReceivedDate()) + .receivedAt(entity.getReceivedDate() != null ? entity.getReceivedDate().atZone(ZoneId.systemDefault()).toInstant().toString() : null) + .snoozedUntil(entity.getSnoozedUntil()) + .summary(entity.getSummary()) + .summarySource(entity.getSummarySource() != null ? entity.getSummarySource().name() : null) + .isRead(entity.isRead()) + .isStarred(entity.isStarred()) + .isFromMe(fromMe) + .hasAttachments(hasCloud || hasPhysical) + .hasCloudLinks(hasCloud) + .hasPhysicalAttachments(hasPhysical) + .accountEmail(entity.getAccount().getEmailAddress()) + .attachments(attachments) + .gmailDraftId(entity.getGmailDraftId()) + .deletedAt(entity.getDeletedAt()) + .gmailLink(entity.getGmailMessageId() != null ? + // Use u/0 plus authuser param to allow Gmail to open the correct signed-in account + String.format("https://mail.google.com/mail/u/0/?authuser=%s#inbox/%s", + URLEncoder.encode(entity.getAccount().getEmailAddress(), StandardCharsets.UTF_8), + entity.getGmailMessageId()) : + String.format("https://mail.google.com/mail/u/0/?authuser=%s#search/rfc822msgid:%s", + URLEncoder.encode(entity.getAccount().getEmailAddress(), StandardCharsets.UTF_8), + URLEncoder.encode(entity.getMessageId(), StandardCharsets.UTF_8))) + .build(); + } + + private java.util.List parseRecipientString(String recipientString, com.awad.emailclientai.modules.email.entity.EmailAccount account) { + Long userId = account != null && account.getUser() != null ? account.getUser().getId() : null; + if (recipientString == null || recipientString.isBlank()) { + return java.util.Collections.emptyList(); + } + return java.util.Arrays.stream(recipientString.split(",\\s*")) + .map(part -> { + String name = ""; + String email = part.trim(); + + // V41: Robust parsing using split or index + if (email.contains("<") && email.contains(">")) { + int start = email.indexOf("<"); + int end = email.indexOf(">"); + name = email.substring(0, start).trim(); + email = email.substring(start + 1, end).trim(); + } + + // Clean quotes from name and email (Java-compliant) + name = name.replaceAll("^\"|\"$", "").trim(); + email = email.replaceAll("^\"|\"$", "").trim(); + + // V42: Aggressive Recipient Name Discovery + String finalName = name; + String finalEmail = email; + + // 1. Check EmailSender table + if (finalName.isBlank() || finalName.equalsIgnoreCase(finalEmail) || finalName.contains("@")) { + finalName = emailSenderRepository.findByEmail(finalEmail.toLowerCase()) + .map(com.awad.emailclientai.modules.email.entity.EmailSender::getBestKnownName) + .orElse(finalName); + } + + // 2. Check EmailAccount table (User's other accounts) + String localToMatch = finalEmail.contains("@") ? finalEmail.split("@")[0].toLowerCase().trim() : ""; + + if (finalName.isBlank() || finalName.equalsIgnoreCase(finalEmail) || finalName.equals("You")) { + if (userId != null) { + String emailToMatch = finalEmail.toLowerCase().trim(); + List userAccs = emailAccountRepository.findByUserId(userId); + + // Exact Match + Optional matchedAcc = userAccs.stream() + .filter(acc -> acc.getEmailAddress().equalsIgnoreCase(emailToMatch)) + .findFirst(); + + if (matchedAcc.isPresent()) { + finalName = matchedAcc.get().getDisplayName(); + } + // Fuzzy Match by local part (for multi-provider accounts) + else if (localToMatch.length() > 3) { + finalName = userAccs.stream() + .filter(acc -> { + String alp = acc.getEmailAddress().split("@")[0].toLowerCase(); + boolean prefixMatch = localToMatch.length() > 10 && alp.length() > 10 && + (localToMatch.startsWith(alp.substring(0, 10)) || alp.startsWith(localToMatch.substring(0, 10))); + return alp.equalsIgnoreCase(localToMatch) || prefixMatch; + }) + .map(EmailAccount::getDisplayName) + .filter(n -> n != null && !n.isBlank()) + .findFirst() + .orElse(finalName); + } + } + } + + // 3. Last Resort: User profile name fallback + if ((finalName.isBlank() || finalName.equalsIgnoreCase(finalEmail) || finalName.equals("You")) && account != null) { + com.awad.emailclientai.modules.user.entity.User u = account.getUser(); + if (u != null) { + String userPrimaryEmail = u.getEmail().toLowerCase().trim(); + if (finalEmail.equalsIgnoreCase(userPrimaryEmail) || (localToMatch.length() > 3 && userPrimaryEmail.contains("@") && userPrimaryEmail.split("@")[0].equalsIgnoreCase(localToMatch))) { + finalName = u.getName(); + } + } + } + + if (log.isDebugEnabled() && !finalName.equalsIgnoreCase(name)) { + log.debug("[RECIPIENT-DISCOVERY] Discovered name '{}' for email '{}' (User ID: {})", + finalName, finalEmail, userId); + } + + return EmailEntityDto.EmailAddressDto.builder() + .name(finalName.isBlank() || finalName.equalsIgnoreCase(finalEmail) ? null : finalName) + .email(finalEmail) + .build(); + }) + .collect(java.util.stream.Collectors.toList()); + } + + public String processEmailBody(String html, Long emailId, List attachments) { + if (html == null) return null; + + log.info("[DEBUG-START] Processing Email ID: {}", emailId); + + String resolvedHtml = html; + + // V31: Plain-text detection for legacy DB records that were stored before the ImapService fix. + // If the body contains no HTML tags at all (e.g. GitHub text-only notifications), + // convert it to proper HTML with line breaks and clickable links. + if (!resolvedHtml.contains("<") && !resolvedHtml.contains(">")) { + log.info("[PLAIN-TEXT-FIX] Email {} body has no HTML tags. Converting plain text to HTML.", emailId); + resolvedHtml = convertPlainTextToHtml(resolvedHtml); + } + // Also catch bodies that only have the mb-plain-text-body wrapper but no
inside + // (legacy records from the old wrapping logic) + else if (resolvedHtml.startsWith("
") && !resolvedHtml.contains("
") && !resolvedHtml.contains("", "").replace("
", ""); + resolvedHtml = convertPlainTextToHtml(inner); + } + + int scriptCount = 0; + int onEventCount = 0; + int jsUrlCount = 0; + int frameCount = 0; + int styleStrippedCount = 0; + + // 1. HTML Sanitization + + // Strip "); + Matcher scriptMatcher = scriptPattern.matcher(resolvedHtml); + while (scriptMatcher.find()) scriptCount++; + resolvedHtml = scriptMatcher.replaceAll(""); + + // Strip dangerous containers: