package com.mailserver.smtp;
import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient;
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.MailboxAccess;
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:
*
* - a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);
* - 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;
* - 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);
* - 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).
*
*/
@Service
public class MailRouter implements SmtpBackend {
private final MailDomains domains;
private final UserRepository users;
private final UserService userService;
private final LocalDelivery localDelivery;
private final OutboundQueue queue;
private final DkimService dkim;
private final SmtpDeliveryClient client;
private final MailLists lists;
private final MailboxAccess access;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists,
MailboxAccess access) {
this.access = access;
this.domains = domains;
this.users = users;
this.userService = userService;
this.localDelivery = localDelivery;
this.queue = queue;
this.dkim = dkim;
this.client = client;
this.lists = lists;
}
@Override
public String authenticate(String username, String password) {
User mailbox = access.login(username, password);
return mailbox == null ? null : mailbox.getEmail();
}
@Override
public Route route(String recipient, String authenticatedUser) {
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 (isTrap(authenticatedUser)) {
return Route.TRAP;
}
if (domains.catchAll() != null) {
return Route.LOCAL;
}
if (domains.isLocal(recipient)) {
return Route.UNKNOWN_USER;
}
if (authenticatedUser == null) {
return Route.RELAY_DENIED;
}
return client.mode() == SmtpDeliveryClient.Mode.OFF ? Route.OUTBOUND_DISABLED : Route.OUTBOUND;
}
@Override
public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null;
// one copy per mailbox + folder, even when an address and a list both lead there
Set targets = new LinkedHashSet<>();
byte[] signed = null;
for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) {
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);
}
queue.enqueue(envelope.mailFrom(), recipient.address(), signed).block();
}
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
}
}
for (Target target : targets) {
localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted);
}
}
/** Mailbox + folder (null — INBOX) that store mail to a local address. */
record Target(String mailbox, String folder) {
}
Set targets(String address) {
Set 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 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;
}
User user = users.findByEmail(authenticatedUser.toLowerCase(Locale.ROOT)).block();
return user != null && user.isTrap();
}
private boolean mailboxExists(String address) {
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
}
}