Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
15 KiB
Java
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()))))
|
|
);
|
|
}
|
|
}
|