Files
omnivore/packages/api/src/services/subscriptions.ts
2024-05-29 11:30:19 +08:00

238 lines
6.2 KiB
TypeScript

import axios from 'axios'
import { DeepPartial, DeleteResult, In } from 'typeorm'
import { appDataSource } from '../data_source'
import { NewsletterEmail } from '../entity/newsletter_email'
import { Subscription } from '../entity/subscription'
import { SubscriptionStatus, SubscriptionType } from '../generated/graphql'
import { authTrx, getRepository } from '../repository'
import { logger } from '../utils/logger'
import { sendEmail } from '../utils/sendEmail'
interface SaveSubscriptionInput {
userId: string
name: string
newsletterEmailId: string
unsubscribeMailTo?: string
unsubscribeHttpUrl?: string
icon?: string
from?: string
}
export const UNSUBSCRIBE_EMAIL_TEXT =
'This message was automatically generated by Omnivore.'
export const batchGetSubscriptionsByNames = async (
userId: string,
names: string[]
) => {
const subscriptions = await findSubscriptionsByNames(userId, names)
return names.map((name) =>
subscriptions.find((s) => s?.name === name || s?.url === name)
)
}
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<boolean> => {
try {
// get subject from unsubscribe email address if exists
const parsed = parseUnsubscribeMailTo(unsubscribeMailTo)
const sent = await sendEmail({
to: parsed.to,
subject: parsed.subject,
text: UNSUBSCRIBE_EMAIL_TEXT,
from: newsletterEmail,
})
if (!sent) {
logger.info(`Failed to send unsubscribe email: ${unsubscribeMailTo}`)
return false
}
return true
} catch (error) {
logger.info('Failed to send unsubscribe email', error)
return false
}
}
const sendUnsubscribeHttpRequest = async (url: string): Promise<boolean> => {
try {
await axios.get(url, {
timeout: 5000, // 5 seconds
})
return true
} catch (error) {
if (axios.isAxiosError(error)) {
logger.info(`Failed to send unsubscribe http request: ${error.message}`)
} else {
logger.info('Failed to send unsubscribe http request', error)
}
return false
}
}
export const getSubscriptionByName = async (
name: string,
userId: string
): Promise<Subscription | null> => {
return getRepository(Subscription).findOne({
where: { name, type: SubscriptionType.Newsletter, user: { id: userId } },
relations: ['newsletterEmail', 'user'],
})
}
export const saveSubscription = async ({
userId,
name,
newsletterEmailId,
unsubscribeMailTo,
unsubscribeHttpUrl,
icon,
}: SaveSubscriptionInput): Promise<string> => {
const subscriptionData = {
unsubscribeHttpUrl,
unsubscribeMailTo,
icon,
refreshedAt: new Date(),
}
const existingSubscription = await getSubscriptionByName(name, userId)
const result = await appDataSource.transaction(async (tx) => {
if (existingSubscription) {
// update subscription if already exists
await tx
.getRepository(Subscription)
.update(
{ id: existingSubscription.id, user: { id: userId } },
subscriptionData
)
return existingSubscription
}
return tx.getRepository(Subscription).save({
...subscriptionData,
name,
newsletterEmail: { id: newsletterEmailId },
user: { id: userId },
type: SubscriptionType.Newsletter,
})
})
return result.id
}
export const unsubscribe = async (subscription: Subscription) => {
// unsubscribe from newsletter
if (subscription.type === SubscriptionType.Newsletter) {
if (subscription.unsubscribeMailTo && subscription.newsletterEmail) {
// unsubscribe by sending email
const sent = await sendUnsubscribeEmail(
subscription.unsubscribeMailTo,
subscription.newsletterEmail.address
)
logger.info('Unsubscribe email sent', {
subscriptionId: subscription.id,
sent,
})
}
// TODO: find a good way to unsubscribe by url if email fails or not provided
// because it often requires clicking a button on the page to unsubscribe
}
return authTrx((tx) =>
tx.getRepository(Subscription).update(subscription.id, {
status: SubscriptionStatus.Unsubscribed,
})
)
}
export const unsubscribeAll = async (
newsletterEmail: NewsletterEmail
): Promise<void> => {
try {
const subscriptions = await authTrx((t) =>
t.getRepository(Subscription).find({
where: {
user: { id: newsletterEmail.user.id },
newsletterEmail: { id: newsletterEmail.id },
},
relations: ['newsletterEmail'],
})
)
for await (const subscription of subscriptions) {
try {
await unsubscribe(subscription)
} catch (error) {
logger.info('Failed to unsubscribe', error)
}
}
} catch (error) {
logger.info('Failed to unsubscribe all', error)
}
}
export const createSubscription = async (
userId: string,
name: string,
newsletterEmail?: NewsletterEmail,
status = SubscriptionStatus.Active,
unsubscribeMailTo?: string,
subscriptionType = SubscriptionType.Newsletter,
url?: string
): Promise<Subscription> => {
return getRepository(Subscription).save({
user: { id: userId },
name,
newsletterEmail,
status,
unsubscribeMailTo,
refreshedAt: new Date(),
type: subscriptionType,
url,
})
}
export const deleteSubscription = async (id: string): Promise<DeleteResult> => {
return getRepository(Subscription).delete(id)
}
export const createRssSubscriptions = async (
subscriptions: DeepPartial<Subscription>[]
) => {
return getRepository(Subscription).save(subscriptions)
}
export const findSubscriptionsByNames = async (
userId: string,
names: string[]
): Promise<Subscription[]> => {
return getRepository(Subscription).findBy([
{ user: { id: userId }, name: In(names) },
{ user: { id: userId }, url: In(names) },
])
}