Files
reactive-mail-server/backend/src/main/java/com/mailserver/controller/MailController.java
T
Liosha84 29b1beabe0 Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону
Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма
(прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия,
вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных —
в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой.
Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:41:29 +01:00

321 lines
15 KiB
Java

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<com.mailserver.domain.User> user(Mono<Authentication> auth) {
return auth.flatMap(a -> userService.findByEmail(a.getName()));
}
// --- Folders ---
@GetMapping("/folders")
public Flux<FolderResponse> getFolders(Mono<Authentication> auth) {
return user(auth).flatMapMany(user -> mailService.folders(user.getId()));
}
@PostMapping("/folders")
public Mono<ResponseEntity<FolderResponse>> createFolder(Mono<Authentication> auth, @Valid @RequestBody Mono<FolderRequest> 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<Map<String, String>> renameFolder(@PathVariable Long id, Mono<Authentication> auth,
@Valid @RequestBody Mono<FolderRequest> 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<Map<String, String>> deleteFolder(@PathVariable Long id, Mono<Authentication> 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<Map<String, Object>> emptyFolder(@PathVariable Long id, Mono<Authentication> auth) {
return user(auth).flatMap(user -> mailService.emptyFolder(id, user.getId()))
.map(count -> Map.<String, Object>of("deleted", count));
}
// --- Messages ---
/** Newest first, bodies cut to a preview. */
@GetMapping("/messages")
public Flux<MessageResponse> getMessages(Mono<Authentication> 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<MessageResponse> searchMessages(
Mono<Authentication> 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<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> 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<MessageResponse> updateMessage(@PathVariable Long id, Mono<Authentication> auth,
@RequestBody Mono<MessageUpdateRequest> body) {
return user(auth).zipWith(body)
.flatMap(pair -> mailService.updateMessage(id, pair.getT1().getId(), pair.getT2()));
}
@PutMapping("/messages/{id}/read")
public Mono<Map<String, String>> markAsRead(@PathVariable Long id, Mono<Authentication> 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<Map<String, String>> deleteMessage(@PathVariable Long id, Mono<Authentication> auth) {
return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId()))
.thenReturn(Map.of("message", "Message deleted"));
}
@PostMapping("/send")
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
return auth.zipWith(requestMono)
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
}
// --- Drafts ---
@PostMapping("/drafts")
public Mono<MessageResponse> createDraft(Mono<Authentication> auth, @Valid @RequestBody Mono<DraftRequest> body) {
return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), null, pair.getT2()));
}
@PutMapping("/drafts/{id}")
public Mono<MessageResponse> updateDraft(@PathVariable Long id, Mono<Authentication> auth,
@Valid @RequestBody Mono<DraftRequest> 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<ResponseEntity<AttachmentResponse>> uploadAttachment(
@PathVariable Long messageId,
Mono<Authentication> auth,
@RequestPart("file") Mono<FilePart> 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<AttachmentResponse> getMessageAttachments(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> mailService.getMessageAttachments(messageId, user.getId()))
);
}
@GetMapping("/attachments/{attachmentId}/download")
public Mono<ResponseEntity<byte[]>> downloadAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> 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<ResponseEntity<?>> deleteAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> 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<Object> getContacts(Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> mailService.getContacts(user.getId()))
);
}
@PostMapping("/contacts")
public Mono<Object> addContact(
Mono<Authentication> authenticationMono,
@RequestBody Mono<Map<String, String>> bodyMono) {
return authenticationMono.zipWith(bodyMono)
.flatMap(pair -> {
String email = pair.getT1().getName();
Map<String, String> 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<Object> updateContact(
@PathVariable Long id,
Mono<Authentication> authenticationMono,
@RequestBody Mono<Map<String, String>> bodyMono) {
return authenticationMono.zipWith(bodyMono)
.flatMap(pair -> {
String email = pair.getT1().getName();
Map<String, String> 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<ResponseEntity<?>> deleteContact(
@PathVariable Long id,
Mono<Authentication> 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()))))
);
}
}