Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing

- V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки;
  закрытая рассылка — только от вошедших отправителей (550 снаружи)
- bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя)
- TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются
- /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки
- MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах
- Настройки → LISTS для администраторов
- 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:15:24 +01:00
co-authored by Claude Opus 5.5
parent 3603b9d315
commit cf84797e68
28 changed files with 1013 additions and 71 deletions
@@ -162,6 +162,34 @@ public class MailService {
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
}
private static final java.util.regex.Pattern DELIVERY_FOLDER = java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
/**
* A "+tag" / list folder that may be used as a folder name, or null (→ INBOX). A sender never puts mail into
* a system folder (Spam, Trash, Sent …) this way.
*/
static String deliveryFolderName(String raw) {
if (raw == null || !DELIVERY_FOLDER.matcher(raw).matches() || SYSTEM_FOLDERS.stream().anyMatch(raw::equalsIgnoreCase)) {
return null;
}
return raw;
}
/** The folder with this name (any case), created as an own folder when missing; two at once — one wins. */
private Mono<Folder> deliveryFolder(Long userId, String name) {
Mono<Folder> existing = folderRepository.findByUserId(userId)
.filter(folder -> folder.getName().equalsIgnoreCase(name))
.next();
return existing.switchIfEmpty(Mono.defer(() -> {
Folder folder = new Folder();
folder.setUserId(userId);
folder.setName(name);
folder.setCreatedAt(Instant.now());
return folderRepository.save(folder)
.onErrorResume(org.springframework.dao.DataIntegrityViolationException.class, e -> existing);
}));
}
private Mono<Folder> systemFolder(Long userId, String name) {
return folderRepository.findByUserIdAndName(userId, name)
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
@@ -253,12 +281,25 @@ public class MailService {
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml, boolean externalSpamFlag,
String toHeader, String ccHeader) {
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag,
toHeader, ccHeader, null);
}
/**
* @param folder own folder for the message ("+tag" of the address, folder of a mailing list), created when
* missing; null or not a valid folder name — INBOX. Spam still goes to Spam.
*/
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml, boolean externalSpamFlag,
String toHeader, String ccHeader, String targetFolder) {
return userRepository.findByEmail(recipientEmail)
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
.flatMap(blacklisted -> {
boolean isSpam = blacklisted || externalSpamFlag;
return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
String own = isSpam ? null : deliveryFolderName(targetFolder);
return (own == null ? systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
: deliveryFolder(recipient.getId(), own))
.flatMap(folder -> {
Message message = new Message();
message.setFolderId(folder.getId());