From 986c4eb48b87a545c8f2c3cd83b2d11360ee4f00 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 6 Jun 2023 19:53:18 +0800 Subject: [PATCH] fix: parse unsubscribe mailto email address and set subject --- .../api/src/resolvers/subscriptions/index.ts | 25 +++--- packages/api/src/services/subscriptions.ts | 32 +++++-- packages/api/test/db.ts | 17 ++-- .../api/test/resolvers/subscriptions.test.ts | 83 +++++++++++++++++-- 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts index 67c2fe978..6293cf5e0 100644 --- a/packages/api/src/resolvers/subscriptions/index.ts +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -1,4 +1,8 @@ -import { authorized } from '../../utils/helpers' +import { ILike } from 'typeorm' +import { Subscription } from '../../entity/subscription' +import { User } from '../../entity/user' +import { getRepository } from '../../entity/utils' +import { env } from '../../env' import { MutationSubscribeArgs, MutationUnsubscribeArgs, @@ -16,13 +20,9 @@ import { UnsubscribeErrorCode, UnsubscribeSuccess, } from '../../generated/graphql' -import { analytics } from '../../utils/analytics' -import { env } from '../../env' -import { getRepository } from '../../entity/utils' -import { User } from '../../entity/user' -import { Subscription } from '../../entity/subscription' import { getSubscribeHandler, unsubscribe } from '../../services/subscriptions' -import { ILike } from 'typeorm' +import { analytics } from '../../utils/analytics' +import { authorized } from '../../utils/helpers' import { createImageProxyUrl } from '../../utils/imageproxy' export const subscriptionsResolver = authorized< @@ -90,10 +90,13 @@ export const unsubscribeResolver = authorized< } } - const subscription = await getRepository(Subscription).findOne({ - where: { name: ILike(name), user: { id: uid } }, - relations: ['newsletterEmail'], - }) + const subscription = await getRepository(Subscription) + .createQueryBuilder('subscription') + .innerJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail') + .where({ user: { id: uid } }) + .andWhere('LOWER(name) = LOWER(:name)', { name }) // case insensitive + .getOne() + if (!subscription) { return { errorCodes: [UnsubscribeErrorCode.NotFound], diff --git a/packages/api/src/services/subscriptions.ts b/packages/api/src/services/subscriptions.ts index c58e770b4..d108db610 100644 --- a/packages/api/src/services/subscriptions.ts +++ b/packages/api/src/services/subscriptions.ts @@ -1,9 +1,9 @@ +import axios from 'axios' +import { NewsletterEmail } from '../entity/newsletter_email' import { Subscription } from '../entity/subscription' import { getRepository } from '../entity/utils' import { SubscriptionStatus } from '../generated/graphql' import { sendEmail } from '../utils/sendEmail' -import axios from 'axios' -import { NewsletterEmail } from '../entity/newsletter_email' import { createNewsletterEmail } from './newsletters' interface SaveSubscriptionInput { @@ -15,14 +15,36 @@ interface SaveSubscriptionInput { icon?: string } +export const UNSUBSCRIBE_EMAIL_TEXT = + 'This message was automatically generated by Omnivore.' + +export const parseUnsubscribeMailTo = (unsubscribeMailTo: string) => { + const parsed = new URL(`mailto://${unsubscribeMailTo}`) + const subject = parsed.searchParams.get('subject') || 'Unsubscribe' + const to = unsubscribeMailTo.replace(parsed.search, '') + + // validate email address + if (!to || !to.includes('@')) { + throw new Error(`Invalid unsubscribe email address: ${unsubscribeMailTo}`) + } + + return { + to, + subject, + } +} + const sendUnsubscribeEmail = async ( unsubscribeMailTo: string, newsletterEmail: string ): Promise => { + // get subject from unsubscribe email address if exists + const parsed = parseUnsubscribeMailTo(unsubscribeMailTo) + const sent = await sendEmail({ - to: unsubscribeMailTo, - subject: 'Unsubscribe', - text: `This message was automatically generated by Omnivore.`, + to: parsed.to, + subject: parsed.subject, + text: UNSUBSCRIBE_EMAIL_TEXT, from: newsletterEmail, }) diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index b861acf3d..37d1b90d6 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -198,15 +198,16 @@ export const createTestSubscription = async ( user: User, name: string, newsletterEmail?: NewsletterEmail, - status = SubscriptionStatus.Active + status = SubscriptionStatus.Active, + unsubscribeMailTo?: string ): Promise => { - const subscription = new Subscription() - subscription.user = user - subscription.name = name - newsletterEmail && (subscription.newsletterEmail = newsletterEmail) - subscription.status = status - - return getRepository(Subscription).save(subscription) + return getRepository(Subscription).save({ + user, + name, + newsletterEmail, + status, + unsubscribeMailTo, + }) } export const deleteTestLabels = async ( diff --git a/packages/api/test/resolvers/subscriptions.test.ts b/packages/api/test/resolvers/subscriptions.test.ts index 962b97f16..d4843c362 100644 --- a/packages/api/test/resolvers/subscriptions.test.ts +++ b/packages/api/test/resolvers/subscriptions.test.ts @@ -1,12 +1,18 @@ -import { createTestSubscription, createTestUser, deleteTestUser } from '../db' -import { graphqlRequest, request } from '../util' -import { Subscription } from '../../src/entity/subscription' -import { expect } from 'chai' +import chai, { expect } from 'chai' import 'mocha' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import { NewsletterEmail } from '../../src/entity/newsletter_email' +import { Subscription } from '../../src/entity/subscription' import { User } from '../../src/entity/user' import { getRepository } from '../../src/entity/utils' -import { NewsletterEmail } from '../../src/entity/newsletter_email' import { SubscriptionStatus } from '../../src/generated/graphql' +import { UNSUBSCRIBE_EMAIL_TEXT } from '../../src/services/subscriptions' +import * as sendEmail from '../../src/utils/sendEmail' +import { createTestSubscription, createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' + +chai.use(sinonChai) describe('Subscriptions API', () => { let user: User @@ -95,4 +101,71 @@ describe('Subscriptions API', () => { return graphqlRequest(query, invalidAuthToken).expect(500) }) }) + + describe('Unsubscribe', () => { + const query = (name: string) => ` + mutation { + unsubscribe(name: "${name}") { + ... on UnsubscribeSuccess { + subscription { + id + } + } + ... on UnsubscribeError { + errorCodes + } + } + } + ` + + it('unsubscribes', async () => { + const name = 'Sub_5' + const to = 'unsubscribe@omnivore.app' + const subject = 'test' + // create test newsletter subscriptions + const newsletterEmail = await getRepository(NewsletterEmail).save({ + user, + address: 'test_2@inbox.omnivore.app', + confirmationCode: 'test', + }) + const subscription = await createTestSubscription( + user, + name, + newsletterEmail, + SubscriptionStatus.Active, + `${to}?subject=${subject}` + ) + + // fake sendEmail function + const fake = sinon.replace( + sendEmail, + 'sendEmail', + sinon.fake.resolves(true) + ) + + const res = await graphqlRequest( + query(name.toUpperCase()), + authToken + ).expect(200) + + expect(res.body.data.unsubscribe.subscription).to.eql({ + id: subscription.id, + }) + + const deletedSubscription = await getRepository(Subscription).findOneBy({ + id: subscription.id, + }) + expect(deletedSubscription).to.be.null + + // check if the email was sent + expect(fake).to.have.been.calledOnceWith({ + to, + subject, + text: UNSUBSCRIBE_EMAIL_TEXT, + from: newsletterEmail.address, + }) + + sinon.restore() + }) + }) })