feat(emailing): local unsubscribe URL + full-content log driver (#22412)

In LOG mode, emit a working
http://unsubscribe.<subdomain>.localhost/emailing/unsubscribe link (from
SERVER_URL + workspace subdomain) instead of empty content, and log the
full text/html body + List-Unsubscribe header so the flow is inspectable
locally. buildUnsubscribeUrls now takes a full base URL (field renamed
httpsUrl -> webUrl).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22412?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-07-02 19:06:57 +05:30
committed by GitHub
parent 4aaf171d63
commit 7cf8b58f1b
13 changed files with 174 additions and 95 deletions
@@ -5,7 +5,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { MessageChannelType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { EMPTY_UNSUBSCRIBE_CONTENT } from 'src/engine/core-modules/emailing-domain/constants/empty-unsubscribe-content.constant';
import {
EmailingDomainDriverException,
EmailingDomainDriverExceptionCode,
@@ -14,21 +13,12 @@ import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-do
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
import { type EmailingDomainEmailContent } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-email-content.type';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailRequest } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
import { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type DeliverableRecipients } from 'src/engine/core-modules/emailing-domain/types/deliverable-recipients.type';
import { type UnsubscribeContent } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-content.type';
import { buildUnsubscribeHeaders } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-headers.util';
import { buildUnsubscribeHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-html-footer.util';
import { buildUnsubscribeTextFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-text-footer.util';
import { buildUnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-urls.util';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@@ -41,8 +31,6 @@ export class EmailingDomainSenderService {
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
private readonly messageSuppressionService: MessageSuppressionService,
private readonly unsubscribeTokenService: UnsubscribeTokenService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
) {}
@@ -65,31 +53,18 @@ export class EmailingDomainSenderService {
emailContent,
);
const unsubscribe = this.buildUnsubscribeContent(
workspaceId,
emailingDomain,
recipients.to[0],
emailContent.unsubscribeTopicId,
);
const replyTo = await this.resolveReplyTo(workspaceId, emailContent);
const emailToSend = {
const emailToSend: EmailingDomainSendEmailRequest = {
...emailContent,
workspaceId,
domain: emailingDomain.domain,
from: emailContent.from,
emailingDomain,
replyTo,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject: emailContent.subject,
text: `${emailContent.text}${unsubscribe.textFooter}`,
html: isNonEmptyString(emailContent.html)
? `${emailContent.html}${unsubscribe.htmlFooter}`
: emailContent.html,
attachments: emailContent.attachments,
headers: [...(emailContent.headers ?? []), ...unsubscribe.headers],
} as EmailingDomainSendEmailInput;
};
return this.emailingDomainDriverFactory
.getCurrentDriver()
@@ -230,48 +205,4 @@ export class EmailingDomainSenderService {
unsubscribeTopicId,
);
}
private buildUnsubscribeContent(
workspaceId: string,
emailingDomain: EmailingDomainEntity,
primaryRecipient: string,
unsubscribeTopicId: string | undefined,
): UnsubscribeContent {
const isDemoMode =
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
EmailingDomainDriver.LOG;
if (isDemoMode) {
return EMPTY_UNSUBSCRIBE_CONTENT;
}
if (
emailingDomain.unsubscribeHostnameStatus !==
UnsubscribeHostnameStatus.ACTIVE ||
!isNonEmptyString(emailingDomain.unsubscribeHostname)
) {
throw new EmailingDomainDriverException(
`Cannot send email for ${emailingDomain.domain}: unsubscribe domain is not active (status: ${emailingDomain.unsubscribeHostnameStatus})`,
EmailingDomainDriverExceptionCode.UNSUBSCRIBE_NOT_READY,
);
}
const token = this.unsubscribeTokenService.sign({
workspaceId,
emailAddress: primaryRecipient,
...(isNonEmptyString(unsubscribeTopicId) ? { unsubscribeTopicId } : {}),
});
const unsubscribeUrls = buildUnsubscribeUrls({
unsubscribeHostname: emailingDomain.unsubscribeHostname,
domain: emailingDomain.domain,
token,
});
return {
headers: buildUnsubscribeHeaders(unsubscribeUrls),
textFooter: buildUnsubscribeTextFooter(unsubscribeUrls.httpsUrl),
htmlFooter: buildUnsubscribeHtmlFooter(unsubscribeUrls.httpsUrl),
};
}
}