Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing
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:
@@ -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";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user