diff --git a/packages/backend/migration/1752383008447-UserProfile-oneClickUnsubscribeToken.js b/packages/backend/migration/1752383008447-UserProfile-oneClickUnsubscribeToken.js new file mode 100644 index 0000000000..4283e146da --- /dev/null +++ b/packages/backend/migration/1752383008447-UserProfile-oneClickUnsubscribeToken.js @@ -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"`); + } +} diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 45d7ea11e4..0a18e7a606 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -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}`); diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 9c98a1dde1..3d0bde21ce 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -223,6 +223,11 @@ export class MiUserProfile { }) public receiveAnnouncementEmail: boolean; + @Column('text', { + nullable: true, + }) + public oneClickUnsubscribeToken: string | null; + @Column({ ...id(), nullable: true, diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 12459d5698..6ed139ad77 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -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'], diff --git a/packages/frontend/src/pages/unsubscribe.vue b/packages/frontend/src/pages/unsubscribe.vue new file mode 100644 index 0000000000..4db681479c --- /dev/null +++ b/packages/frontend/src/pages/unsubscribe.vue @@ -0,0 +1,84 @@ + + + + + + + diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index b673f848c9..3ba8fedbec 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -202,6 +202,9 @@ export const ROUTE_DEF = [{ }, { path: '/signup-complete/:code', component: page(() => import('@/pages/signup-complete.vue')), +}, { + path: '/unsubscribe/:user/:token', + component: page(() => import('@/pages/unsubscribe.vue')), }, { path: '/announcements', component: page(() => import('@/pages/announcements.vue')), diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 83d5560b28..01ac64bbe5 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -177,6 +177,8 @@ flash: "Flash" filesRemoved: "Files removed" fileImported: "File imported" cannotLoadNote: "Failed to load note" +clickToUnsubscribe: "Please click [OK] to unsubscribe from announcement e-mails." +unsubscribeError: "There was a problem unsubscribing." _flash: contentHidden: "Flash Content Hidden" poweredByRuffle: "Powered by Ruffle."