Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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>
This commit is contained in:
2026-11-05 16:41:29 +01:00
parent f605fbfd85
commit 29b1beabe0
70 changed files with 3468 additions and 1737 deletions
@@ -3,31 +3,30 @@ package com.mailserver.controller;
import com.mailserver.dto.AttachmentResponse;
import com.mailserver.dto.FolderResponse;
import com.mailserver.dto.MessageResponse;
import com.mailserver.dto.RegisterRequest;
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.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
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.config.MailDomains;
import com.mailserver.service.SubmissionService;
import org.springframework.beans.factory.annotation.Value;
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;
@@ -51,165 +50,148 @@ public class MailController {
private final UserService userService;
private final MailService mailService;
private final SubmissionService submissionService;
private final MailDomains domains;
private final boolean registrationEnabled;
public MailController(UserService userService, MailService mailService, SubmissionService submissionService,
MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
public MailController(UserService userService, MailService mailService, SubmissionService submissionService) {
this.userService = userService;
this.mailService = mailService;
this.submissionService = submissionService;
this.domains = domains;
this.registrationEnabled = registrationEnabled;
}
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
return authenticationMono.map(Authentication::getName);
private Mono<com.mailserver.domain.User> user(Mono<Authentication> auth) {
return auth.flatMap(a -> userService.findByEmail(a.getName()));
}
// --- Users ---
@PostMapping("/register")
public Mono<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
if (!registrationEnabled) {
return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Registration is closed")));
}
return requestMono.flatMap(request -> !domains.isLocal(request.getEmail())
? Mono.just(org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains()))))
: userService.register(request.getEmail().trim().toLowerCase(java.util.Locale.ROOT), request.getPassword(), request.getDisplayName())
.map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message", "User registered")))
.onErrorResume(IllegalArgumentException.class, e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()))))
);
}
// --- Folders ---
@GetMapping("/folders")
public Flux<FolderResponse> getFolders(Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> userService.getFolders(user.getId()))
);
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> authenticationMono,
@RequestParam Long folderId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> mailService.getMessages(folderId, user.getId(), page, size))
);
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> authenticationMono,
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,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> {
if (keyword != null && !keyword.trim().isEmpty()) {
return mailService.searchMessages(folderId, user.getId(), keyword.trim(), page, size);
}
return mailService.searchMessagesWithFilters(folderId, user.getId(),
subject, body, sender, recipient, page, size);
})
);
@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> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.getMessage(id, user.getId()))
);
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> auth) {
return user(auth).flatMap(user -> mailService.getMessage(id, user.getId()));
}
@PostMapping("/send")
public Mono<MessageResponse> sendMessage(
Mono<Authentication> authenticationMono,
@Valid @RequestBody Mono<SendRequest> requestMono) {
return authenticationMono.zipWith(requestMono)
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
/** 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<ResponseEntity<?>> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.markAsRead(id, user.getId())
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Marked as 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<ResponseEntity<?>> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.deleteMessage(id, user.getId())
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message deleted")))))
);
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<?>> uploadAttachment(
public Mono<ResponseEntity<AttachmentResponse>> uploadAttachment(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono,
@RequestBody Mono<Flux<FilePart>> filePartsMono) {
return authenticationMono.zipWith(filePartsMono)
.flatMap(pair -> {
String email = pair.getT1().getName();
return userService.findByEmail(email)
.flatMapMany(user -> pair.getT2())
.next()
.flatMap(file -> {
String originalFilename = safeFilename(file.filename());
String uniqueName = UUID.randomUUID() + "_" + originalFilename;
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName);
try {
Files.createDirectories(filePath.getParent());
} catch (IOException e) {
return Mono.error(new RuntimeException("Failed to create directory", e));
}
return DataBufferUtils.join(file.content())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
try {
Files.write(filePath, bytes);
} catch (IOException e) {
return Mono.error(new RuntimeException("Failed to save file", e));
}
return userService.findByEmail(email)
.flatMap(user -> mailService.saveAttachmentReactive(
messageId, user.getId(),
originalFilename,
file.headers().getContentType() != null ? file.headers().getContentType().toString() : "application/octet-stream",
bytes.length, filePath.toString()))
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
});
});
});
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")
@@ -246,7 +228,7 @@ public class MailController {
}
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
static String safeFilename(String name) {
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("..")) {