Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)
- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея, MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера - отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке; Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM) - DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается - права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../ - секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта, admin123 и /api/simulate/receive — только профиль dev - веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health; docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080 - gradle-wrapper.jar был в .gitignore — сборка не запускалась - тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь) α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,10 @@ 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.PathVariable;
|
||||
@@ -46,10 +50,18 @@ 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) {
|
||||
public MailController(UserService userService, MailService mailService, SubmissionService submissionService,
|
||||
MailDomains domains,
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.submissionService = submissionService;
|
||||
this.domains = domains;
|
||||
this.registrationEnabled = registrationEnabled;
|
||||
}
|
||||
|
||||
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
|
||||
@@ -59,9 +71,15 @@ public class MailController {
|
||||
// --- Users ---
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
|
||||
return requestMono.flatMap(request ->
|
||||
userService.register(request.getEmail(), request.getPassword(), request.getDisplayName())
|
||||
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 ->
|
||||
@@ -124,25 +142,15 @@ public class MailController {
|
||||
}
|
||||
|
||||
@PostMapping("/send")
|
||||
public Mono<Object> sendMessage(
|
||||
public Mono<MessageResponse> sendMessage(
|
||||
Mono<Authentication> authenticationMono,
|
||||
@Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||
return authenticationMono.zipWith(requestMono)
|
||||
.flatMap(pair -> {
|
||||
String email = pair.getT1().getName();
|
||||
SendRequest request = pair.getT2();
|
||||
return mailService.sendMessage(email, request)
|
||||
.map(response -> (Object) org.springframework.http.ResponseEntity.ok(response))
|
||||
.onErrorResume(e -> {
|
||||
log.error("Ошибка при отправке письма: {}", e.getMessage(), e);
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
|
||||
.body(Map.of("error", e.getMessage())));
|
||||
});
|
||||
});
|
||||
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
|
||||
}
|
||||
|
||||
@PutMapping("/messages/{id}/read")
|
||||
public Mono<?> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) {
|
||||
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())
|
||||
@@ -151,7 +159,7 @@ public class MailController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/messages/{id}")
|
||||
public Mono<?> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
|
||||
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())
|
||||
@@ -159,29 +167,10 @@ public class MailController {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Simulate incoming message (для тестов) ---
|
||||
|
||||
@PostMapping("/simulate/receive")
|
||||
public Mono<?> simulateReceive(@RequestBody Mono<Map<String, String>> bodyMono) {
|
||||
return bodyMono.flatMap(body ->
|
||||
mailService.receiveMessage(
|
||||
body.get("senderEmail"),
|
||||
body.get("senderName"),
|
||||
body.get("recipientEmail"),
|
||||
body.get("subject"),
|
||||
body.get("body"),
|
||||
"true".equals(body.get("html"))
|
||||
)
|
||||
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message received"))))
|
||||
.onErrorResume(e ->
|
||||
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
|
||||
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Mono<?> uploadAttachment(
|
||||
public Mono<ResponseEntity<?>> uploadAttachment(
|
||||
@PathVariable Long messageId,
|
||||
Mono<Authentication> authenticationMono,
|
||||
@RequestBody Mono<Flux<FilePart>> filePartsMono) {
|
||||
@@ -192,7 +181,7 @@ public class MailController {
|
||||
.flatMapMany(user -> pair.getT2())
|
||||
.next()
|
||||
.flatMap(file -> {
|
||||
String originalFilename = file.filename();
|
||||
String originalFilename = safeFilename(file.filename());
|
||||
String uniqueName = UUID.randomUUID() + "_" + originalFilename;
|
||||
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName);
|
||||
try {
|
||||
@@ -214,7 +203,8 @@ public class MailController {
|
||||
return userService.findByEmail(email)
|
||||
.flatMap(user -> mailService.saveAttachmentReactive(
|
||||
messageId, user.getId(),
|
||||
originalFilename, file.headers().getContentType().toString(),
|
||||
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));
|
||||
});
|
||||
@@ -233,39 +223,40 @@ public class MailController {
|
||||
}
|
||||
|
||||
@GetMapping("/attachments/{attachmentId}/download")
|
||||
public Mono<Object> downloadAttachment(
|
||||
public Mono<ResponseEntity<byte[]>> downloadAttachment(
|
||||
@PathVariable Long attachmentId,
|
||||
Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
userService.findByEmail(auth.getName())
|
||||
.flatMap(user -> {
|
||||
try {
|
||||
AttachmentResponse info = mailService.getAttachmentInfo(attachmentId, user.getId());
|
||||
Path filePath = Paths.get(mailService.getAttachmentFilePath(attachmentId, user.getId()));
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = Files.readAllBytes(filePath);
|
||||
} catch (IOException e) {
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Failed to read file: " + e.getMessage())));
|
||||
}
|
||||
DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + info.getFilename() + "\"")
|
||||
.contentType(MediaType.parseMediaType(
|
||||
info.getContentType() != null ? info.getContentType() : "application/octet-stream"))
|
||||
.body(buffer)
|
||||
);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())));
|
||||
}
|
||||
})
|
||||
);
|
||||
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. */
|
||||
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(
|
||||
public Mono<ResponseEntity<?>> deleteAttachment(
|
||||
@PathVariable Long attachmentId,
|
||||
Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
@@ -333,7 +324,7 @@ public class MailController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/contacts/{id}")
|
||||
public Mono<?> deleteContact(
|
||||
public Mono<ResponseEntity<?>> deleteContact(
|
||||
@PathVariable Long id,
|
||||
Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
|
||||
Reference in New Issue
Block a user