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>
130 lines
6.5 KiB
Java
130 lines
6.5 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;
|
|
|
|
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) {
|
|
this.db = db;
|
|
this.users = users;
|
|
this.userService = userService;
|
|
}
|
|
|
|
/**
|
|
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → 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)) {
|
|
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();
|
|
}
|
|
}
|