continuous-integration/drone/pr Build is passing
- V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by
- /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю
(LoginGuard), убрать из меню
- общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by
- SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot)
- открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403)
- админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address};
выключение аккаунта убирает и из общих ящиков
- UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках,
«Answered by» у письма
- 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.6 KiB
Java
166 lines
6.6 KiB
Java
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:
|
|
* <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 {
|
|
|
|
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<Target> 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<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;
|
|
}
|
|
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());
|
|
}
|
|
}
|