Рассылки, папки по +метке, ловушка только для чужих адресов, служебное 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
@@ -7,18 +7,25 @@ import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
/**
* Where mail goes — one rule for SMTP and for the web client:
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
* A logged-in TRAP mailbox (staging@) sends nowhere: every recipient is accepted and ONE copy is stored in the
* sender's own INBOX — a staging stand uses the real server, but its test mail never reaches people.
* <ul>
* <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
* <li>a mailing list of ours ({@code alerts-prod@}) → a copy to every member, into the list's folder
* (or the "+tag" folder); a closed list takes mail only from logged-in senders;</li>
* <li>a TRAP sender (staging@) and an address that is not ours → nothing leaves: one copy into the sender's INBOX
* (our own mailboxes and lists still get their mail);</li>
* <li>catch-all set → everything else into the catch-all mailbox; otherwise our domain → "no such user",
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).</li>
* </ul>
*/
@Service
public class MailRouter implements SmtpBackend {
@@ -30,9 +37,10 @@ public class MailRouter implements SmtpBackend {
private final OutboundQueue queue;
private final DkimService dkim;
private final SmtpDeliveryClient client;
private final MailLists lists;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) {
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists) {
this.domains = domains;
this.users = users;
this.userService = userService;
@@ -40,6 +48,7 @@ public class MailRouter implements SmtpBackend {
this.queue = queue;
this.dkim = dkim;
this.client = client;
this.lists = lists;
}
@Override
@@ -50,11 +59,18 @@ public class MailRouter implements SmtpBackend {
@Override
public Route route(String recipient, String authenticatedUser) {
if (isTrap(authenticatedUser)) {
return Route.LOCAL;
if (domains.isLocal(recipient)) {
String base = MailDomains.baseOf(recipient);
if (mailboxExists(recipient) || mailboxExists(base)) {
return Route.LOCAL;
}
MailLists.MailList list = lists.find(base).block();
if (list != null) {
return list.openToOutside() || authenticatedUser != null ? Route.LOCAL : Route.LIST_CLOSED;
}
}
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
return Route.LOCAL;
if (isTrap(authenticatedUser)) {
return Route.TRAP;
}
if (domains.catchAll() != null) {
return Route.LOCAL;
@@ -71,18 +87,13 @@ public class MailRouter implements SmtpBackend {
@Override
public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null;
if (isTrap(envelope.authenticatedUser())) {
localDelivery.deliver(message, envelope.authenticatedUser().toLowerCase(Locale.ROOT), envelope.remoteIp(),
envelope.mailFrom(), true);
return;
}
// one copy per mailbox + folder, even when an address and a list both lead there
Set<Target> targets = new LinkedHashSet<>();
byte[] signed = null;
for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) {
case LOCAL -> {
String mailbox = mailbox(recipient.address());
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
}
case LOCAL -> targets.addAll(targets(recipient.address()));
case TRAP -> targets.add(new Target(envelope.authenticatedUser().toLowerCase(Locale.ROOT), null));
case OUTBOUND -> {
if (signed == null) {
signed = dkim.sign(message);
@@ -92,17 +103,50 @@ public class MailRouter implements SmtpBackend {
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
}
}
}
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */
String mailbox(String address) {
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
return address;
for (Target target : targets) {
localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted);
}
return domains.catchAll();
}
/** The logged-in sender is an active trap mailbox. */
/** Mailbox + folder (null — INBOX) that store mail to a local address. */
record Target(String mailbox, String folder) {
}
Set<Target> targets(String address) {
Set<Target> result = new LinkedHashSet<>();
String lower = address.toLowerCase(Locale.ROOT);
if (domains.isLocal(lower)) {
if (mailboxExists(lower)) {
result.add(new Target(lower, null));
return result;
}
String base = MailDomains.baseOf(lower);
String tag = MailDomains.tagOf(address);
if (mailboxExists(base)) {
result.add(new Target(base, tag));
return result;
}
MailLists.MailList list = lists.find(base).block();
if (list != null) {
for (String member : list.members()) {
result.add(new Target(member, tag != null ? tag : list.folder()));
}
return result;
}
}
if (domains.catchAll() != null) {
result.add(new Target(domains.catchAll(), null));
}
return result;
}
/** Mailbox that stores mail to this address: its own (without "+tag") if it exists here, otherwise the catch-all one. */
String mailbox(String address) {
Set<Target> found = targets(address);
return found.isEmpty() ? address : found.iterator().next().mailbox();
}
/** The logged-in sender is a trap mailbox. */
boolean isTrap(String authenticatedUser) {
if (authenticatedUser == null) {
return false;
@@ -10,8 +10,12 @@ public interface SmtpBackend {
/** Where a recipient goes, decided at RCPT TO. */
enum Route {
/** A mailbox of ours (or the catch-all mailbox) — store it. */
/** A mailbox or mailing list of ours (or the catch-all mailbox) — store it. */
LOCAL,
/** The sender is a trap mailbox and the address is not ours: one copy into the sender's own INBOX. */
TRAP,
/** A mailing list of ours that takes mail only from logged-in senders. */
LIST_CLOSED,
/** Someone else's address, sender is logged in — queue for delivery. */
OUTBOUND,
/** Address of our domain that does not exist. */
@@ -438,7 +438,7 @@ public class SmtpServer {
String user = authenticatedUser;
return blocking(() -> backend.route(address, user))
.map(route -> switch (route) {
case LOCAL, OUTBOUND -> {
case LOCAL, OUTBOUND, TRAP -> {
recipients.add(new SmtpBackend.Recipient(address, route));
yield "250 2.1.5 Recipient OK\r\n";
}
@@ -448,6 +448,7 @@ public class SmtpServer {
yield "550 5.7.1 Relaying denied: authentication required\r\n";
}
case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n";
case LIST_CLOSED -> "550 5.7.1 This list takes mail only from logged-in senders\r\n";
});
}