Files
dmail-api/src/modules/email/services/email-spam.service.ts
T
2025-12-07 21:17:33 +03:30

420 lines
16 KiB
TypeScript

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<MessageSenderInfo | null> {
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;
}
}