Allow for sending announcement mails with List-Unsubscribe:

Per https://datatracker.ietf.org/doc/html/rfc8058,
we send "announcement" mails with
  List-Unsubscribe: <${apiUrl}/unsubscribe/${userId}/${oneClickUnsubscribeToken}>
  List-Unsubscribe-Post: List-Unsubscribe=One-Click
and handle
  POST /api/unsubscribe/:user/:token => this unsubscribes
  GET  /api/unsubscribe/:user/:token => 302 /unsubscribe/:user/:token
  GET  /unsubscribe/:user/:token     => user-visible page with clickthrough confirmation

In this configuration, compatible MUAs will show an "unsubscribe" button
that, when clicked, will POST to the URL directly

Less-compatible MUAs (and scanners) will open the page directly
which will redirect to a click-though; interactive users will be able to
unsubscribe, scanners won't unsubscribe by accident

Nothing /actually/ sends non-reactive mails,
so this is never used at this time

Closes #854
This commit is contained in:
наб 2025-07-13 17:47:31 +02:00
parent 3cffd4a537
commit c6e4c9294f
No known key found for this signature in database
GPG key ID: BCFD0B018D2658F1
7 changed files with 162 additions and 3 deletions

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'],