merge: Allow for sending announcement mails with List-Unsubscribe: (!1164)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1164

Closes #854

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
This commit is contained in:
Hazelnoot 2025-09-13 12:32:39 -04:00
commit 13b6097a12
7 changed files with 162 additions and 3 deletions

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: наб and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { nanoid } from 'nanoid';
export class UserProfileOneClickUnsubscribeToken1752383008447 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "oneClickUnsubscribeToken" TEXT`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "oneClickUnsubscribeToken"`);
}
}

View file

@ -6,14 +6,16 @@
import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer';
import juice from 'juice';
import { nanoid } from 'nanoid';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
import type { MiMeta, MiUserProfile, UserProfilesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js';
import { CacheService } from '@/core/CacheService.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -34,12 +36,13 @@ export class EmailService {
private loggerService: LoggerService,
private utilityService: UtilityService,
private httpRequestService: HttpRequestService,
private cacheService: CacheService,
) {
this.logger = this.loggerService.getLogger('email');
}
@bindThis
public async sendEmail(to: string, subject: string, html: string, text: string) {
public async sendEmail(to: string, subject: string, html: string, text: string, opts?: { announcementFor?: MiUserProfile } | undefined) {
if (!this.meta.enableEmail) return;
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
@ -142,6 +145,19 @@ export class EmailService {
const inlinedHtml = juice(htmlContent);
const headers: any = {};
if (opts && opts.announcementFor) {
const { userId } = opts.announcementFor;
let { oneClickUnsubscribeToken } = opts.announcementFor;
if (!oneClickUnsubscribeToken) {
oneClickUnsubscribeToken = nanoid();
await this.userProfilesRepository.update({ userId }, { oneClickUnsubscribeToken });
await this.cacheService.userProfileCache.delete(userId);
}
headers['List-Unsubscribe'] = `<${this.config.apiUrl}/unsubscribe/${userId}/${oneClickUnsubscribeToken}>`;
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
@ -150,6 +166,7 @@ export class EmailService {
subject: subject,
text: text,
html: inlinedHtml,
headers: headers,
});
this.logger.info(`Message sent: ${info.messageId}`);

View file

@ -223,6 +223,11 @@ export class MiUserProfile {
})
public receiveAnnouncementEmail: boolean;
@Column('text', {
nullable: true,
})
public oneClickUnsubscribeToken: string | null;
@Column({
...id(),
nullable: true,

View file

@ -9,7 +9,7 @@ import multipart from '@fastify/multipart';
import { ModuleRef } from '@nestjs/core';
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { Config } from '@/config.js';
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
import type { InstancesRepository, AccessTokensRepository, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@ -18,6 +18,7 @@ import { ApiCallService } from './ApiCallService.js';
import { SignupApiService } from './SignupApiService.js';
import { SigninApiService } from './SigninApiService.js';
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
import { CacheService } from '@/core/CacheService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@Injectable()
@ -34,11 +35,15 @@ export class ApiServerService {
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private apiCallService: ApiCallService,
private signupApiService: SignupApiService,
private signinApiService: SigninApiService,
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
private cacheService: CacheService,
) {
//this.createServer = this.createServer.bind(this);
}
@ -145,6 +150,33 @@ export class ApiServerService {
fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply));
// POST unsubscribes (and is sent by compatible MUAs), GET redirects to the interactive user-facing non-API page
fastify.get<{ Params: { user: string, token: string; } }>('/unsubscribe/:user/:token', (request, reply) => {
return reply.redirect(`${this.config.url}/unsubscribe/${request.params.user}/${request.params.token}`, 302);
});
fastify.post<{ Params: { user: string, token: string; } }>('/unsubscribe/:user/:token', async (request, reply) => {
const { affected } = await this.userProfilesRepository.update({
userId: request.params.user,
oneClickUnsubscribeToken: request.params.token,
}, {
receiveAnnouncementEmail: false,
});
if (affected) {
await this.cacheService.userProfileCache.delete(request.params.user);
return ["Unsubscribed."];
} else {
reply.code(401);
return {
error: {
message: 'Invalid parameters.',
code: 'INVALID_PARAMETERS',
id: '26654194-410e-44e2-b42e-460ff6f92476',
},
};
}
});
fastify.get('/v1/instance/peers', async (request, reply) => {
const instances = await this.instancesRepository.find({
select: ['host'],