Files
reactive-mail-server/backend/src/main/java/com/mailserver/service/MailboxAccess.java
T
Liosha84andClaude Opus 5.5 99e12d33f3
continuous-integration/drone/pr Build is passing
Вход через Jambotron (OAuth 2.0 code + PKCE), пароли приложений, системные ящики
- /api/oauth/start → JAMBOTRON_URL/oauth/authorize (client mail, PKCE S256);
  /api/oauth/callback: код меняется на сервере у Jambotron /api/oauth/token
  (Bearer SERVICE_TOKEN) → рабочий ящик сотрудника → сессия, ADMIN Jambotron — ADMIN.
- Вход по паролю в веб — только встроенный администратор (когда вход через Jambotron включён).
- Пароли приложений (V9 app_passwords): User panel → APP PASSWORDS, только SMTP/IMAP.
- Системные ящики (V9 users.is_system): без входа в веб, открывают встроенные
  администраторы, не в USERS; Admin panel → MAILBOXES → кнопка SYSTEM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:57:30 +01:00

134 lines
6.7 KiB
Java

package com.mailserver.service;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.Locale;
/**
* Several mailboxes for one login (V7): own, shared ones it is a MEMBER of, ATTACHED ones (by their password).
* One rule for the web client, SMTP and IMAP; in mail programs another mailbox is
* {@code mailbox*login} with the login's password (the Dovecot "master user" form).
*/
@Service
public class MailboxAccess {
public static final String MEMBER = "MEMBER";
public static final String ATTACHED = "ATTACHED";
/** @param kind OWN, MEMBER or ATTACHED; unread — INBOX and own folders (not Spam/Trash/Sent/Drafts) */
public record Entry(String email, String displayName, String kind, boolean shared, long unread) {
}
private final DatabaseClient db;
private final UserRepository users;
private final UserService userService;
private final AppPasswords appPasswords;
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService, AppPasswords appPasswords) {
this.appPasswords = appPasswords;
this.db = db;
this.users = users;
this.userService = userService;
}
/**
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app
* passwords (V9) → the mailbox the session works with, or null. A shared mailbox never logs in by itself.
*/
public User login(String username, String password) {
String name = username.trim().toLowerCase(Locale.ROOT);
int star = name.indexOf('*');
String mailbox = star < 0 ? name : name.substring(0, star);
String login = star < 0 ? name : name.substring(star + 1);
User user = users.findByEmail(login).block();
if (user == null || !user.isActive() || user.isShared()
|| !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) {
return null;
}
if (mailbox.equals(login)) {
return user;
}
User target = users.findByEmail(mailbox).block();
return target != null && target.isActive() && Boolean.TRUE.equals(has(target.getId(), user.getId()).block())
? target : null;
}
/** The login may open this mailbox (own or by access). */
public Mono<Boolean> has(Long mailboxId, Long userId) {
if (mailboxId.equals(userId)) {
return Mono.just(true);
}
return db.sql("SELECT 1 FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u")
.bind("m", mailboxId).bind("u", userId).map((row, meta) -> true).one().defaultIfEmpty(false);
}
/** Own mailbox first, then the others by address. */
public Flux<Entry> mailboxes(User login) {
Mono<Entry> own = unread(login.getId()).map(n -> new Entry(login.getEmail(), login.getDisplayName(), "OWN", false, n));
Flux<Entry> others = db.sql("SELECT u.id, u.email, u.display_name, u.is_shared, a.kind FROM mailbox_access a "
+ "JOIN users u ON u.id = a.mailbox_id WHERE a.user_id = :u AND u.is_active ORDER BY u.email")
.bind("u", login.getId())
.map((row, meta) -> new Object[]{row.get("id", Long.class), row.get("email", String.class),
row.get("display_name", String.class), Boolean.TRUE.equals(row.get("is_shared", Boolean.class)),
row.get("kind", String.class)})
.all()
.concatMap(r -> unread((Long) r[0]).map(n -> new Entry((String) r[1], (String) r[2], (String) r[4], (Boolean) r[3], n)));
return own.flux().concatWith(others);
}
public Mono<Long> unread(Long mailboxId) {
return db.sql("SELECT COUNT(*) AS n FROM messages m JOIN folders f ON f.id = m.folder_id "
+ "WHERE f.user_id = :u AND NOT m.is_read AND NOT m.is_deleted AND (NOT f.is_system OR f.name = 'INBOX')")
.bind("u", mailboxId).map((row, meta) -> row.get("n", Long.class)).one().defaultIfEmpty(0L);
}
public Mono<Void> grant(Long mailboxId, Long userId, String kind) {
return db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) "
+ "ON CONFLICT (mailbox_id, user_id) DO NOTHING")
.bind("m", mailboxId).bind("u", userId).bind("k", kind).then();
}
public Mono<Boolean> revoke(Long mailboxId, Long userId) {
return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u")
.bind("m", mailboxId).bind("u", userId).fetch().rowsUpdated().map(n -> n > 0);
}
/** Exactly these MEMBERs (ATTACHED ones stay — they know the password). */
@Transactional
public Mono<Void> setMembers(Long mailboxId, Collection<Long> memberIds) {
return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND kind = :k").bind("m", mailboxId).bind("k", MEMBER).then()
.thenMany(Flux.fromIterable(memberIds).distinct().filter(id -> !id.equals(mailboxId))
.concatMap(id -> db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) "
+ "ON CONFLICT (mailbox_id, user_id) DO UPDATE SET kind = EXCLUDED.kind")
.bind("m", mailboxId).bind("u", id).bind("k", MEMBER).then()))
.then();
}
public Flux<String> members(Long mailboxId) {
return db.sql("SELECT u.email FROM mailbox_access a JOIN users u ON u.id = a.user_id "
+ "WHERE a.mailbox_id = :m AND a.kind = :k ORDER BY u.email")
.bind("m", mailboxId).bind("k", MEMBER).map((row, meta) -> row.get("email", String.class)).all();
}
/** The login loses every MEMBER access (role removed in Jambotron); ATTACHED stays with the mailbox owner's say. */
public Mono<Long> removeEverywhere(Long userId) {
return db.sql("DELETE FROM mailbox_access WHERE user_id = :u AND kind = :k").bind("u", userId).bind("k", MEMBER)
.fetch().rowsUpdated();
}
/** Reply from the web client: the original is answered, and by whom. */
public Mono<Void> markAnswered(Long messageId, Long mailboxId, String by) {
return db.sql("UPDATE messages SET is_answered = TRUE, answered_by = :b WHERE id = :id "
+ "AND folder_id IN (SELECT id FROM folders WHERE user_id = :u)")
.bind("b", by).bind("id", messageId).bind("u", mailboxId).then();
}
}