package com.mailserver.controller; import com.mailserver.dto.AttachmentResponse; import com.mailserver.dto.FolderResponse; import com.mailserver.dto.MessageResponse; import com.mailserver.dto.DraftRequest; import com.mailserver.dto.FolderRequest; import com.mailserver.dto.MessageUpdateRequest; import com.mailserver.dto.SendRequest; import com.mailserver.service.MailService; import com.mailserver.service.UserService; import jakarta.validation.Valid; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.security.core.Authentication; import com.mailserver.service.SubmissionService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Map; import java.util.UUID; @RestController @RequestMapping("/api") public class MailController { private static final Logger log = LoggerFactory.getLogger(MailController.class); private final UserService userService; private final MailService mailService; private final SubmissionService submissionService; public MailController(UserService userService, MailService mailService, SubmissionService submissionService) { this.userService = userService; this.mailService = mailService; this.submissionService = submissionService; } private Mono user(Mono auth) { return auth.flatMap(a -> userService.findByEmail(a.getName())); } // --- Folders --- @GetMapping("/folders") public Flux getFolders(Mono auth) { return user(auth).flatMapMany(user -> mailService.folders(user.getId())); } @PostMapping("/folders") public Mono> createFolder(Mono auth, @Valid @RequestBody Mono body) { return user(auth).zipWith(body) .flatMap(pair -> mailService.createFolder(pair.getT1().getId(), pair.getT2().name())) .map(folder -> ResponseEntity.status(HttpStatus.CREATED).body(folder)); } @PutMapping("/folders/{id}") public Mono> renameFolder(@PathVariable Long id, Mono auth, @Valid @RequestBody Mono body) { return user(auth).zipWith(body) .flatMap(pair -> mailService.renameFolder(id, pair.getT1().getId(), pair.getT2().name())) .thenReturn(Map.of("message", "Folder renamed")); } /** Own folder only; its messages go to the Trash. */ @DeleteMapping("/folders/{id}") public Mono> deleteFolder(@PathVariable Long id, Mono auth) { return user(auth).flatMap(user -> mailService.deleteFolder(id, user.getId())) .thenReturn(Map.of("message", "Folder deleted")); } /** Trash or Spam: removes all its messages for good. */ @DeleteMapping("/folders/{id}/messages") public Mono> emptyFolder(@PathVariable Long id, Mono auth) { return user(auth).flatMap(user -> mailService.emptyFolder(id, user.getId())) .map(count -> Map.of("deleted", count)); } // --- Messages --- /** Newest first, bodies cut to a preview. */ @GetMapping("/messages") public Flux getMessages(Mono auth, @RequestParam Long folderId, @RequestParam(defaultValue = "" + MailService.LIST_LIMIT) int limit) { return user(auth).flatMapMany(user -> mailService.listMessages(folderId, user.getId(), limit)); } @GetMapping("/messages/search") public Flux searchMessages( Mono auth, @RequestParam Long folderId, @RequestParam(required = false) String keyword, @RequestParam(required = false) String subject, @RequestParam(required = false) String body, @RequestParam(required = false) String sender, @RequestParam(required = false) String recipient) { return user(auth).flatMapMany(user -> keyword != null && !keyword.isBlank() ? mailService.searchMessages(folderId, user.getId(), keyword.trim(), 0, MailService.LIST_LIMIT) : mailService.searchMessagesWithFilters(folderId, user.getId(), subject, body, sender, recipient, 0, MailService.LIST_LIMIT)); } @GetMapping("/messages/{id}") public Mono getMessage(@PathVariable Long id, Mono auth) { return user(auth).flatMap(user -> mailService.getMessage(id, user.getId())); } /** read / flagged / folderId — only the fields that are set. */ @PatchMapping("/messages/{id}") public Mono updateMessage(@PathVariable Long id, Mono auth, @RequestBody Mono body) { return user(auth).zipWith(body) .flatMap(pair -> mailService.updateMessage(id, pair.getT1().getId(), pair.getT2())); } @PutMapping("/messages/{id}/read") public Mono> markAsRead(@PathVariable Long id, Mono auth) { return user(auth).flatMap(user -> mailService.markAsRead(id, user.getId())) .thenReturn(Map.of("message", "Marked as read")); } /** Into the Trash; from the Trash — for good. */ @DeleteMapping("/messages/{id}") public Mono> deleteMessage(@PathVariable Long id, Mono auth) { return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId())) .thenReturn(Map.of("message", "Message deleted")); } @PostMapping("/send") public Mono sendMessage(Mono auth, @Valid @RequestBody Mono requestMono) { return auth.zipWith(requestMono) .flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); } // --- Drafts --- @PostMapping("/drafts") public Mono createDraft(Mono auth, @Valid @RequestBody Mono body) { return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), null, pair.getT2())); } @PutMapping("/drafts/{id}") public Mono updateDraft(@PathVariable Long id, Mono auth, @Valid @RequestBody Mono body) { return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), id, pair.getT2())); } // --- Attachments --- /** A file for a draft (form field "file"); stored on disk, the draft gets the record. */ @PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Mono> uploadAttachment( @PathVariable Long messageId, Mono auth, @RequestPart("file") Mono fileMono) { return user(auth).zipWith(fileMono).flatMap(pair -> { var user = pair.getT1(); FilePart file = pair.getT2(); String originalFilename = safeFilename(file.filename()); Path filePath = Paths.get(mailService.getAttachmentsDir(), UUID.randomUUID() + "_" + originalFilename); String contentType = file.headers().getContentType() != null ? file.headers().getContentType().toString() : "application/octet-stream"; // the draft must be the user's before anything is written return mailService.ownedDraft(messageId, user.getId()) .then(Mono.fromCallable(() -> Files.createDirectories(filePath.getParent())) .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())) .then(file.transferTo(filePath)) .then(Mono.fromCallable(() -> Files.size(filePath)) .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())) .flatMap(size -> mailService.saveAttachmentReactive(messageId, user.getId(), originalFilename, contentType, size, filePath.toString())) .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response)); }); } @GetMapping("/messages/{messageId}/attachments") public Flux getMessageAttachments( @PathVariable Long messageId, Mono authenticationMono) { return authenticationMono.flatMapMany(auth -> userService.findByEmail(auth.getName()) .flatMapMany(user -> mailService.getMessageAttachments(messageId, user.getId())) ); } @GetMapping("/attachments/{attachmentId}/download") public Mono> downloadAttachment( @PathVariable Long attachmentId, Mono authenticationMono) { return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName())) .flatMap(user -> mailService.getAttachment(attachmentId, user.getId())) .flatMap(attachment -> Mono.fromCallable(() -> Files.readAllBytes(Paths.get(attachment.getFilePath()))) .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()) .map(bytes -> ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() .filename(attachment.getFilename(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) .contentType(contentType(attachment.getContentType())) .body(bytes))); } private static MediaType contentType(String value) { try { return value == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(value); } catch (org.springframework.util.InvalidMimeTypeException e) { return MediaType.APPLICATION_OCTET_STREAM; } } /** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */ public static String safeFilename(String name) { String base = name == null ? "" : name.replace('\\', '/'); base = base.substring(base.lastIndexOf('/') + 1).replaceAll("[\\p{Cntrl}\"]", "").trim(); if (base.isEmpty() || base.equals(".") || base.equals("..")) { return "file"; } return base.length() > 200 ? base.substring(base.length() - 200) : base; } @DeleteMapping("/attachments/{attachmentId}") public Mono> deleteAttachment( @PathVariable Long attachmentId, Mono authenticationMono) { return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()) .flatMap(user -> mailService.deleteAttachment(attachmentId, user.getId()) .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Attachment deleted"))))) .onErrorResume(IllegalArgumentException.class, e -> Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))) ); } // --- Contacts --- @GetMapping("/contacts") public Flux getContacts(Mono authenticationMono) { return authenticationMono.flatMapMany(auth -> userService.findByEmail(auth.getName()) .flatMapMany(user -> mailService.getContacts(user.getId())) ); } @PostMapping("/contacts") public Mono addContact( Mono authenticationMono, @RequestBody Mono> bodyMono) { return authenticationMono.zipWith(bodyMono) .flatMap(pair -> { String email = pair.getT1().getName(); Map body = pair.getT2(); return userService.findByEmail(email) .flatMap(user -> mailService.addContact( user.getId(), body.get("name"), body.get("email"), body.get("phone"), body.get("company") )); }) .map(contact -> (Object) org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(contact)) .onErrorResume(IllegalArgumentException.class, e -> Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); } @PutMapping("/contacts/{id}") public Mono updateContact( @PathVariable Long id, Mono authenticationMono, @RequestBody Mono> bodyMono) { return authenticationMono.zipWith(bodyMono) .flatMap(pair -> { String email = pair.getT1().getName(); Map body = pair.getT2(); return userService.findByEmail(email) .flatMap(user -> mailService.updateContact( id, user.getId(), body.get("name"), body.get("email"), body.get("phone"), body.get("company") )); }) .map(contact -> (Object) org.springframework.http.ResponseEntity.ok().body(contact)) .onErrorResume(IllegalArgumentException.class, e -> Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))); } @DeleteMapping("/contacts/{id}") public Mono> deleteContact( @PathVariable Long id, Mono authenticationMono) { return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()) .flatMap(user -> mailService.deleteContact(id, user.getId()) .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Contact deleted"))))) .onErrorResume(IllegalArgumentException.class, e -> Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))) ); } }