import { InjectRepository } from "@mikro-orm/nestjs"; import { Injectable, Logger } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; import { EmailMessage } from "../../../common/enums/message.enum"; import { MailboxResolverService } from "../../email-utils/services/mailbox-resolver.service"; import { ListDomainAccessQueryDto } from "../../mail-server/DTO/domain-access.dto"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { User } from "../../users/entities/user.entity"; import { UserRepository } from "../../users/repositories/user.repository"; import { MessageSenderInfo, SpamActionOptions } from "../interfaces/email-spam.interface"; @Injectable() export class EmailSpamService { private readonly logger = new Logger(EmailSpamService.name); constructor( private readonly mailServerService: MailServerService, @InjectRepository(User) private readonly userRepository: UserRepository, private readonly mailboxResolverService: MailboxResolverService, ) {} async getMessageSenderInfo(userId: string, messageId: number): Promise { try { const mailboxes = await firstValueFrom(this.mailServerService.mailboxes.listMailboxes(userId)); for (const mailbox of mailboxes.results) { try { const message = await firstValueFrom(this.mailServerService.messages.getMessage(userId, mailbox.id, messageId)); if (message && message.from && message.from.address) { const senderEmail = message.from.address; const senderDomain = senderEmail.split("@")[1]?.toLowerCase(); if (senderDomain) { return { senderDomain, senderEmail, messageId, }; } } } catch (_error) { // Expected: 404 errors occur when the message doesn't exist in this mailbox // Continue searching other mailboxes continue; } } return null; } catch (error) { this.logger.error(`Failed to get sender info for message ${messageId}: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async markAsSpam(options: SpamActionOptions) { const { messageId, wildduckUserId, manageDomainList = false, domainTag = "default" } = options; this.logger.log(`Marking message ${messageId} as spam for user: ${wildduckUserId}`); try { let domainBlocked = false; if (manageDomainList) { const senderInfo = await this.getMessageSenderInfo(wildduckUserId, messageId); if (senderInfo) { try { await firstValueFrom( this.mailServerService.domainAccess.addBlockedDomain(domainTag, { domain: senderInfo.senderDomain, description: `Auto-blocked due to spam report from message ${messageId}`, }), ); domainBlocked = true; this.logger.log(`Added domain ${senderInfo.senderDomain} to blocklist for tag: ${domainTag}`); } catch (error) { this.logger.warn(`Could not add domain to blocklist: ${error instanceof Error ? error.message : "Unknown error"}`); } } } return { success: true, message: EmailMessage.MESSAGE_MOVED_TO_JUNK_SUCCESSFULLY, domainBlocked, }; } catch (error) { this.logger.error(`Failed to mark message ${messageId} as spam: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async markAsNotSpam(options: SpamActionOptions) { const { messageId, wildduckUserId, manageDomainList = false, domainTag = "default" } = options; this.logger.log(`Marking message ${messageId} as not spam for user: ${wildduckUserId}`); try { let domainAllowed = false; if (manageDomainList) { const senderInfo = await this.getMessageSenderInfo(wildduckUserId, messageId); if (senderInfo) { try { await firstValueFrom( this.mailServerService.domainAccess.addAllowedDomain(domainTag, { domain: senderInfo.senderDomain, // Removed description field as WildDuck API doesn't accept it for allowlist operations }), ); domainAllowed = true; this.logger.log(`Added domain ${senderInfo.senderDomain} to allowlist for tag: ${domainTag}`); } catch (error) { this.logger.warn(`Could not add domain to allowlist: ${error instanceof Error ? error.message : "Unknown error"}`); } } } return { success: true, message: EmailMessage.MESSAGE_MOVED_TO_INBOX_FROM_JUNK_SUCCESSFULLY, domainAllowed, }; } catch (error) { this.logger.error(`Failed to mark message ${messageId} as not spam: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async listAllowedDomains(domainTag = "default", query?: ListDomainAccessQueryDto) { this.logger.log(`Listing allowed domains for tag: ${domainTag}`); try { return await firstValueFrom(this.mailServerService.domainAccess.listAllowedDomains(domainTag, query)); } catch (error) { this.logger.error(`Failed to list allowed domains: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async listBlockedDomains(domainTag = "default", query?: ListDomainAccessQueryDto) { this.logger.log(`Listing blocked domains for tag: ${domainTag}`); try { return await firstValueFrom(this.mailServerService.domainAccess.listBlockedDomains(domainTag, query)); } catch (error) { this.logger.error(`Failed to list blocked domains: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async removeDomainFromAllowlist(domain: string, domainTag = "default") { this.logger.log(`Removing domain ${domain} from allowlist for tag: ${domainTag}`); try { return await firstValueFrom(this.mailServerService.domainAccess.removeAllowedDomain(domainTag, domain)); } catch (error) { this.logger.error(`Failed to remove domain from allowlist: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async removeDomainFromBlocklist(domain: string, domainTag = "default") { this.logger.log(`Removing domain ${domain} from blocklist for tag: ${domainTag}`); try { return await firstValueFrom(this.mailServerService.domainAccess.removeBlockedDomain(domainTag, domain)); } catch (error) { this.logger.error(`Failed to remove domain from blocklist: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async addDomainToAllowlist(domain: string, description?: string, domainTag = "default") { this.logger.log(`Adding domain ${domain} to allowlist for tag: ${domainTag}`); try { return await firstValueFrom( this.mailServerService.domainAccess.addAllowedDomain(domainTag, { domain, description: description || `Manually added to allowlist`, }), ); } catch (error) { this.logger.error(`Failed to add domain to allowlist: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== async addDomainToBlocklist(domain: string, description?: string, domainTag = "default") { this.logger.log(`Adding domain ${domain} to blocklist for tag: ${domainTag}`); try { return await firstValueFrom( this.mailServerService.domainAccess.addBlockedDomain(domainTag, { domain, description: description || `Manually added to blocklist`, }), ); } catch (error) { this.logger.error(`Failed to add domain to blocklist: ${error instanceof Error ? error.message : "Unknown error"}`); throw error; } } //============================================== /** * Diagnostic method to check if a domain is blocked for a user * and optionally unblock it */ async diagnoseEmailBlocking(recipientEmail: string, senderDomain: string, unblock: boolean = false) { this.logger.log(`Diagnosing email blocking for recipient: ${recipientEmail}, sender domain: ${senderDomain}`); // Find user by email const user = await this.userRepository.findOne({ emailAddress: recipientEmail, deletedAt: null }, { populate: ["business"] }); if (!user) { throw new Error(`User with email ${recipientEmail} not found`); } const domainTag = user.business?.id || "default"; const wildduckUserId = user.wildduckUserId; // Check blocked domains const blockedDomainsResponse = await this.listBlockedDomains(domainTag); const blockedDomains = blockedDomainsResponse?.results || []; const isBlocked = blockedDomains.some((entry: any) => entry.domain?.toLowerCase() === senderDomain.toLowerCase()); // Check filters let blockingFilters: any[] = []; let allFilters: any[] = []; try { const filtersResponse = await firstValueFrom(this.mailServerService.filters.listFilters(wildduckUserId)); allFilters = filtersResponse?.results || []; blockingFilters = allFilters.filter((filter: any) => { const queryFrom = filter.query_from || filter.query?.find((q: any[]) => q[0] === "from")?.[1]; return (queryFrom && queryFrom.toLowerCase().includes(senderDomain.toLowerCase())) || filter.action_delete || filter.action_spam; }); } catch (error) { this.logger.warn(`Could not check filters: ${error instanceof Error ? error.message : "Unknown error"}`); } // Check for emails from this sender in various mailboxes let emailsFromSender: any = { inbox: [], junk: [], trash: [], archive: [], total: 0, }; try { const mailboxIds = await this.mailboxResolverService.getUserMailboxIds(wildduckUserId); // Search for messages from this sender domain const searchQuery = { from: `@${senderDomain}`, limit: 50 }; const searchResults = await firstValueFrom(this.mailServerService.messages.searchMessages(wildduckUserId, searchQuery)); if (searchResults?.results && searchResults.results.length > 0) { emailsFromSender.total = searchResults.total || searchResults.results.length; // Check which mailbox each message is in for (const message of searchResults.results) { try { // Get message details to find mailbox const messageDetails = await firstValueFrom( this.mailServerService.messages.getMessage(wildduckUserId, message.mailbox, message.id) ); const mailboxName = await this.mailboxResolverService.getMailboxName(wildduckUserId, message.mailbox); if (mailboxName === "INBOX") { emailsFromSender.inbox.push({ id: message.id, subject: message.subject, from: message.from, date: message.date, }); } else if (mailboxName === "Junk" || mailboxName === "Spam") { emailsFromSender.junk.push({ id: message.id, subject: message.subject, from: message.from, date: message.date, }); } else if (mailboxName === "Trash") { emailsFromSender.trash.push({ id: message.id, subject: message.subject, from: message.from, date: message.date, }); } else if (mailboxName === "Archive") { emailsFromSender.archive.push({ id: message.id, subject: message.subject, from: message.from, date: message.date, }); } } catch (error) { // Skip if we can't get message details continue; } } } } catch (error) { this.logger.warn(`Could not search for emails from sender: ${error instanceof Error ? error.message : "Unknown error"}`); } // Check WildDuck user settings let userSettings: any = null; try { const userDetails = await firstValueFrom(this.mailServerService.users.getUser(wildduckUserId)); userSettings = { enabled: userDetails.retention !== undefined ? userDetails.retention > 0 : null, retention: userDetails.retention, disabled: userDetails.disabled, }; } catch (error) { this.logger.warn(`Could not get user settings: ${error instanceof Error ? error.message : "Unknown error"}`); } // Check address settings let addressSettings: any = null; try { const addresses = await firstValueFrom(this.mailServerService.addresses.listUserAddresses(wildduckUserId)); addressSettings = { addresses: addresses.results?.map((addr: any) => ({ address: addr.address, main: addr.main, verified: addr.verified, })) || [], }; } catch (error) { this.logger.warn(`Could not get address settings: ${error instanceof Error ? error.message : "Unknown error"}`); } const result: any = { recipientEmail, senderDomain, user: { id: user.id, email: user.emailAddress, wildduckUserId, businessId: user.business?.id || null, }, domainTag, blocked: isBlocked, blockedDomains: blockedDomains.map((entry: any) => ({ domain: entry.domain, description: entry.description, created: entry.created, })), allFilters: allFilters.map((filter: any) => ({ id: filter.id, name: filter.name, queryFrom: filter.query_from, queryTo: filter.query_to, querySubject: filter.query_subject, actions: filter.action || [], disabled: filter.disabled, })), blockingFilters: blockingFilters.map((filter: any) => ({ id: filter.id, name: filter.name, queryFrom: filter.query_from, actions: filter.action || [], })), emailsFromSender, userSettings, addressSettings, }; // Unblock if requested and domain is blocked if (isBlocked && unblock) { try { await this.removeDomainFromBlocklist(senderDomain, domainTag); result.unblocked = true; result.message = `Domain ${senderDomain} has been unblocked successfully`; this.logger.log(`Unblocked domain ${senderDomain} for user ${recipientEmail}`); } catch (error) { result.unblockError = error instanceof Error ? error.message : "Unknown error"; this.logger.error(`Failed to unblock domain: ${result.unblockError}`); } } else if (isBlocked && !unblock) { result.message = `Domain ${senderDomain} is blocked. Use unblock=true to unblock it.`; } else { // Provide more detailed message based on findings if (emailsFromSender.total > 0) { const locations = []; if (emailsFromSender.inbox.length > 0) locations.push(`${emailsFromSender.inbox.length} in inbox`); if (emailsFromSender.junk.length > 0) locations.push(`${emailsFromSender.junk.length} in junk/spam`); if (emailsFromSender.trash.length > 0) locations.push(`${emailsFromSender.trash.length} in trash`); if (emailsFromSender.archive.length > 0) locations.push(`${emailsFromSender.archive.length} in archive`); result.message = `Domain ${senderDomain} is not blocked. Found ${emailsFromSender.total} email(s) from this domain: ${locations.join(", ")}.`; } else { result.message = `Domain ${senderDomain} is not blocked, but no emails from this domain were found. This suggests emails may be rejected at SMTP level or not reaching the server.`; } } return result; } }