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:
commit
13b6097a12
7 changed files with 162 additions and 3 deletions
|
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,14 +6,16 @@
|
||||||
import { URLSearchParams } from 'node:url';
|
import { URLSearchParams } from 'node:url';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import juice from 'juice';
|
import juice from 'juice';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { validate as validateEmail } from 'deep-email-validator';
|
import { validate as validateEmail } from 'deep-email-validator';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type Logger from '@/logger.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 { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
|
|
||||||
|
|
@ -34,12 +36,13 @@ export class EmailService {
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private httpRequestService: HttpRequestService,
|
private httpRequestService: HttpRequestService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('email');
|
this.logger = this.loggerService.getLogger('email');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@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;
|
if (!this.meta.enableEmail) return;
|
||||||
|
|
||||||
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
|
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
|
||||||
|
|
@ -142,6 +145,19 @@ export class EmailService {
|
||||||
|
|
||||||
const inlinedHtml = juice(htmlContent);
|
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 {
|
try {
|
||||||
// TODO: htmlサニタイズ
|
// TODO: htmlサニタイズ
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
|
|
@ -150,6 +166,7 @@ export class EmailService {
|
||||||
subject: subject,
|
subject: subject,
|
||||||
text: text,
|
text: text,
|
||||||
html: inlinedHtml,
|
html: inlinedHtml,
|
||||||
|
headers: headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.info(`Message sent: ${info.messageId}`);
|
this.logger.info(`Message sent: ${info.messageId}`);
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,11 @@ export class MiUserProfile {
|
||||||
})
|
})
|
||||||
public receiveAnnouncementEmail: boolean;
|
public receiveAnnouncementEmail: boolean;
|
||||||
|
|
||||||
|
@Column('text', {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public oneClickUnsubscribeToken: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
...id(),
|
...id(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import multipart from '@fastify/multipart';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||||
import type { Config } from '@/config.js';
|
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 { DI } from '@/di-symbols.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
@ -18,6 +18,7 @@ import { ApiCallService } from './ApiCallService.js';
|
||||||
import { SignupApiService } from './SignupApiService.js';
|
import { SignupApiService } from './SignupApiService.js';
|
||||||
import { SigninApiService } from './SigninApiService.js';
|
import { SigninApiService } from './SigninApiService.js';
|
||||||
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
|
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -34,11 +35,15 @@ export class ApiServerService {
|
||||||
@Inject(DI.accessTokensRepository)
|
@Inject(DI.accessTokensRepository)
|
||||||
private accessTokensRepository: AccessTokensRepository,
|
private accessTokensRepository: AccessTokensRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private apiCallService: ApiCallService,
|
private apiCallService: ApiCallService,
|
||||||
private signupApiService: SignupApiService,
|
private signupApiService: SignupApiService,
|
||||||
private signinApiService: SigninApiService,
|
private signinApiService: SigninApiService,
|
||||||
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
|
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
//this.createServer = this.createServer.bind(this);
|
//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));
|
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) => {
|
fastify.get('/v1/instance/peers', async (request, reply) => {
|
||||||
const instances = await this.instancesRepository.find({
|
const instances = await this.instancesRepository.find({
|
||||||
select: ['host'],
|
select: ['host'],
|
||||||
|
|
|
||||||
84
packages/frontend/src/pages/unsubscribe.vue
Normal file
84
packages/frontend/src/pages/unsubscribe.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: наб and other Sharkey contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageWithAnimBg>
|
||||||
|
<div :class="$style.formContainer">
|
||||||
|
<form :class="$style.form" class="_panel" @submit.prevent="submit()">
|
||||||
|
<div :class="$style.banner">
|
||||||
|
<i class="ti ti-user-edit"></i>
|
||||||
|
</div>
|
||||||
|
<div class="_gaps_m" style="padding: 32px;">
|
||||||
|
<div>{{ i18n.ts.clickToUnsubscribe }}</div>
|
||||||
|
<div>
|
||||||
|
<MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;">
|
||||||
|
{{ submitting ? i18n.ts.processing : i18n.ts.ok }}<MkEllipsis v-if="submitting"/>
|
||||||
|
</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</PageWithAnimBg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: string;
|
||||||
|
token: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
misskeyApi(`unsubscribe/${props.user}/${props.token}`).then(res => {
|
||||||
|
submitting.value = false;
|
||||||
|
}).catch(err => {
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.somethingHappened,
|
||||||
|
text: i18n.ts.unsubscribeError,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.formContainer {
|
||||||
|
min-height: 100svh;
|
||||||
|
padding: 32px 32px 64px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: var(--MI-radius);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: clip;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26px;
|
||||||
|
background-color: var(--MI_THEME-accentedBg);
|
||||||
|
color: var(--MI_THEME-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -202,6 +202,9 @@ export const ROUTE_DEF = [{
|
||||||
}, {
|
}, {
|
||||||
path: '/signup-complete/:code',
|
path: '/signup-complete/:code',
|
||||||
component: page(() => import('@/pages/signup-complete.vue')),
|
component: page(() => import('@/pages/signup-complete.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/unsubscribe/:user/:token',
|
||||||
|
component: page(() => import('@/pages/unsubscribe.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/announcements',
|
path: '/announcements',
|
||||||
component: page(() => import('@/pages/announcements.vue')),
|
component: page(() => import('@/pages/announcements.vue')),
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ flash: "Flash"
|
||||||
filesRemoved: "Files removed"
|
filesRemoved: "Files removed"
|
||||||
fileImported: "File imported"
|
fileImported: "File imported"
|
||||||
cannotLoadNote: "Failed to load note"
|
cannotLoadNote: "Failed to load note"
|
||||||
|
clickToUnsubscribe: "Please click [OK] to unsubscribe from announcement e-mails."
|
||||||
|
unsubscribeError: "There was a problem unsubscribing."
|
||||||
_flash:
|
_flash:
|
||||||
contentHidden: "Flash Content Hidden"
|
contentHidden: "Flash Content Hidden"
|
||||||
poweredByRuffle: "Powered by Ruffle."
|
poweredByRuffle: "Powered by Ruffle."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue