420 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|