Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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:
@@ -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("..")) {
|
||||
|
||||
Reference in New Issue
Block a user