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 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 mailboxes(User login) { Mono own = unread(login.getId()).map(n -> new Entry(login.getEmail(), login.getDisplayName(), "OWN", false, n)); Flux 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 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 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 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 setMembers(Long mailboxId, Collection 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 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 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 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(); } }