Add createIntegration API implementation

This commit is contained in:
Hongbo Wu
2022-08-01 22:42:55 +08:00
parent e66d047d51
commit dd2db71876
8 changed files with 237 additions and 90 deletions

View File

@ -25,7 +25,7 @@ export class Integration {
@Column('enum', { enum: IntegrationType })
type!: IntegrationType
@Column('varchar')
@Column('varchar', { length: 255 })
token!: string
@Column('boolean', { default: true })

View File

@ -331,30 +331,6 @@ export type CreateHighlightSuccess = {
highlight: Highlight;
};
export type CreateIntegrationError = {
__typename?: 'CreateIntegrationError';
errorCodes: Array<CreateIntegrationErrorCode>;
};
export enum CreateIntegrationErrorCode {
BadRequest = 'BAD_REQUEST',
InvalidToken = 'INVALID_TOKEN',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type CreateIntegrationInput = {
token: Scalars['String'];
type: IntegrationType;
};
export type CreateIntegrationResult = CreateIntegrationError | CreateIntegrationSuccess;
export type CreateIntegrationSuccess = {
__typename?: 'CreateIntegrationSuccess';
integration: Integration;
};
export type CreateLabelError = {
__typename?: 'CreateLabelError';
errorCodes: Array<CreateLabelErrorCode>;
@ -774,6 +750,7 @@ export type Integration = {
createdAt: Scalars['Date'];
enabled: Scalars['Boolean'];
id: Scalars['ID'];
token: Scalars['String'];
type: IntegrationType;
updatedAt: Scalars['Date'];
};
@ -934,7 +911,6 @@ export type Mutation = {
createArticleSavingRequest: CreateArticleSavingRequestResult;
createHighlight: CreateHighlightResult;
createHighlightReply: CreateHighlightReplyResult;
createIntegration: CreateIntegrationResult;
createLabel: CreateLabelResult;
createNewsletterEmail: CreateNewsletterEmailResult;
createReaction: CreateReactionResult;
@ -962,6 +938,7 @@ export type Mutation = {
setBookmarkArticle: SetBookmarkArticleResult;
setDeviceToken: SetDeviceTokenResult;
setFollow: SetFollowResult;
setIntegration: SetIntegrationResult;
setLabels: SetLabelsResult;
setLabelsForHighlight: SetLabelsResult;
setLinkArchived: ArchiveLinkResult;
@ -1009,11 +986,6 @@ export type MutationCreateHighlightReplyArgs = {
};
export type MutationCreateIntegrationArgs = {
input: CreateIntegrationInput;
};
export type MutationCreateLabelArgs = {
input: CreateLabelInput;
};
@ -1139,6 +1111,11 @@ export type MutationSetFollowArgs = {
};
export type MutationSetIntegrationArgs = {
input: SetIntegrationInput;
};
export type MutationSetLabelsArgs = {
input: SetLabelsInput;
};
@ -1737,6 +1714,33 @@ export type SetFollowSuccess = {
updatedUser: User;
};
export type SetIntegrationError = {
__typename?: 'SetIntegrationError';
errorCodes: Array<SetIntegrationErrorCode>;
};
export enum SetIntegrationErrorCode {
AlreadyExists = 'ALREADY_EXISTS',
BadRequest = 'BAD_REQUEST',
InvalidToken = 'INVALID_TOKEN',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SetIntegrationInput = {
enabled?: InputMaybe<Scalars['Boolean']>;
id?: InputMaybe<Scalars['ID']>;
token: Scalars['String'];
type: IntegrationType;
};
export type SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess;
export type SetIntegrationSuccess = {
__typename?: 'SetIntegrationSuccess';
integration: Integration;
};
export type SetLabelsError = {
__typename?: 'SetLabelsError';
errorCodes: Array<SetLabelsErrorCode>;
@ -2548,11 +2552,6 @@ export type ResolversTypes = {
CreateHighlightReplySuccess: ResolverTypeWrapper<CreateHighlightReplySuccess>;
CreateHighlightResult: ResolversTypes['CreateHighlightError'] | ResolversTypes['CreateHighlightSuccess'];
CreateHighlightSuccess: ResolverTypeWrapper<CreateHighlightSuccess>;
CreateIntegrationError: ResolverTypeWrapper<CreateIntegrationError>;
CreateIntegrationErrorCode: CreateIntegrationErrorCode;
CreateIntegrationInput: CreateIntegrationInput;
CreateIntegrationResult: ResolversTypes['CreateIntegrationError'] | ResolversTypes['CreateIntegrationSuccess'];
CreateIntegrationSuccess: ResolverTypeWrapper<CreateIntegrationSuccess>;
CreateLabelError: ResolverTypeWrapper<CreateLabelError>;
CreateLabelErrorCode: CreateLabelErrorCode;
CreateLabelInput: CreateLabelInput;
@ -2732,6 +2731,11 @@ export type ResolversTypes = {
SetFollowInput: SetFollowInput;
SetFollowResult: ResolversTypes['SetFollowError'] | ResolversTypes['SetFollowSuccess'];
SetFollowSuccess: ResolverTypeWrapper<SetFollowSuccess>;
SetIntegrationError: ResolverTypeWrapper<SetIntegrationError>;
SetIntegrationErrorCode: SetIntegrationErrorCode;
SetIntegrationInput: SetIntegrationInput;
SetIntegrationResult: ResolversTypes['SetIntegrationError'] | ResolversTypes['SetIntegrationSuccess'];
SetIntegrationSuccess: ResolverTypeWrapper<SetIntegrationSuccess>;
SetLabelsError: ResolverTypeWrapper<SetLabelsError>;
SetLabelsErrorCode: SetLabelsErrorCode;
SetLabelsForHighlightInput: SetLabelsForHighlightInput;
@ -2909,10 +2913,6 @@ export type ResolversParentTypes = {
CreateHighlightReplySuccess: CreateHighlightReplySuccess;
CreateHighlightResult: ResolversParentTypes['CreateHighlightError'] | ResolversParentTypes['CreateHighlightSuccess'];
CreateHighlightSuccess: CreateHighlightSuccess;
CreateIntegrationError: CreateIntegrationError;
CreateIntegrationInput: CreateIntegrationInput;
CreateIntegrationResult: ResolversParentTypes['CreateIntegrationError'] | ResolversParentTypes['CreateIntegrationSuccess'];
CreateIntegrationSuccess: CreateIntegrationSuccess;
CreateLabelError: CreateLabelError;
CreateLabelInput: CreateLabelInput;
CreateLabelResult: ResolversParentTypes['CreateLabelError'] | ResolversParentTypes['CreateLabelSuccess'];
@ -3056,6 +3056,10 @@ export type ResolversParentTypes = {
SetFollowInput: SetFollowInput;
SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess'];
SetFollowSuccess: SetFollowSuccess;
SetIntegrationError: SetIntegrationError;
SetIntegrationInput: SetIntegrationInput;
SetIntegrationResult: ResolversParentTypes['SetIntegrationError'] | ResolversParentTypes['SetIntegrationSuccess'];
SetIntegrationSuccess: SetIntegrationSuccess;
SetLabelsError: SetLabelsError;
SetLabelsForHighlightInput: SetLabelsForHighlightInput;
SetLabelsInput: SetLabelsInput;
@ -3382,20 +3386,6 @@ export type CreateHighlightSuccessResolvers<ContextType = ResolverContext, Paren
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateIntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['CreateIntegrationError'] = ResolversParentTypes['CreateIntegrationError']> = {
errorCodes?: Resolver<Array<ResolversTypes['CreateIntegrationErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateIntegrationResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['CreateIntegrationResult'] = ResolversParentTypes['CreateIntegrationResult']> = {
__resolveType: TypeResolveFn<'CreateIntegrationError' | 'CreateIntegrationSuccess', ParentType, ContextType>;
};
export type CreateIntegrationSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['CreateIntegrationSuccess'] = ResolversParentTypes['CreateIntegrationSuccess']> = {
integration?: Resolver<ResolversTypes['Integration'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateLabelErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['CreateLabelError'] = ResolversParentTypes['CreateLabelError']> = {
errorCodes?: Resolver<Array<ResolversTypes['CreateLabelErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3717,6 +3707,7 @@ export type IntegrationResolvers<ContextType = ResolverContext, ParentType exten
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
enabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
type?: Resolver<ResolversTypes['IntegrationType'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3833,7 +3824,6 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
createArticleSavingRequest?: Resolver<ResolversTypes['CreateArticleSavingRequestResult'], ParentType, ContextType, RequireFields<MutationCreateArticleSavingRequestArgs, 'input'>>;
createHighlight?: Resolver<ResolversTypes['CreateHighlightResult'], ParentType, ContextType, RequireFields<MutationCreateHighlightArgs, 'input'>>;
createHighlightReply?: Resolver<ResolversTypes['CreateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationCreateHighlightReplyArgs, 'input'>>;
createIntegration?: Resolver<ResolversTypes['CreateIntegrationResult'], ParentType, ContextType, RequireFields<MutationCreateIntegrationArgs, 'input'>>;
createLabel?: Resolver<ResolversTypes['CreateLabelResult'], ParentType, ContextType, RequireFields<MutationCreateLabelArgs, 'input'>>;
createNewsletterEmail?: Resolver<ResolversTypes['CreateNewsletterEmailResult'], ParentType, ContextType>;
createReaction?: Resolver<ResolversTypes['CreateReactionResult'], ParentType, ContextType, RequireFields<MutationCreateReactionArgs, 'input'>>;
@ -3861,6 +3851,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
setIntegration?: Resolver<ResolversTypes['SetIntegrationResult'], ParentType, ContextType, RequireFields<MutationSetIntegrationArgs, 'input'>>;
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
setLabelsForHighlight?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsForHighlightArgs, 'input'>>;
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
@ -4167,6 +4158,20 @@ export type SetFollowSuccessResolvers<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetIntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetIntegrationError'] = ResolversParentTypes['SetIntegrationError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetIntegrationErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetIntegrationResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetIntegrationResult'] = ResolversParentTypes['SetIntegrationResult']> = {
__resolveType: TypeResolveFn<'SetIntegrationError' | 'SetIntegrationSuccess', ParentType, ContextType>;
};
export type SetIntegrationSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetIntegrationSuccess'] = ResolversParentTypes['SetIntegrationSuccess']> = {
integration?: Resolver<ResolversTypes['Integration'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetLabelsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsError'] = ResolversParentTypes['SetLabelsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetLabelsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4635,9 +4640,6 @@ export type Resolvers<ContextType = ResolverContext> = {
CreateHighlightReplySuccess?: CreateHighlightReplySuccessResolvers<ContextType>;
CreateHighlightResult?: CreateHighlightResultResolvers<ContextType>;
CreateHighlightSuccess?: CreateHighlightSuccessResolvers<ContextType>;
CreateIntegrationError?: CreateIntegrationErrorResolvers<ContextType>;
CreateIntegrationResult?: CreateIntegrationResultResolvers<ContextType>;
CreateIntegrationSuccess?: CreateIntegrationSuccessResolvers<ContextType>;
CreateLabelError?: CreateLabelErrorResolvers<ContextType>;
CreateLabelResult?: CreateLabelResultResolvers<ContextType>;
CreateLabelSuccess?: CreateLabelSuccessResolvers<ContextType>;
@ -4760,6 +4762,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SetFollowError?: SetFollowErrorResolvers<ContextType>;
SetFollowResult?: SetFollowResultResolvers<ContextType>;
SetFollowSuccess?: SetFollowSuccessResolvers<ContextType>;
SetIntegrationError?: SetIntegrationErrorResolvers<ContextType>;
SetIntegrationResult?: SetIntegrationResultResolvers<ContextType>;
SetIntegrationSuccess?: SetIntegrationSuccessResolvers<ContextType>;
SetLabelsError?: SetLabelsErrorResolvers<ContextType>;
SetLabelsResult?: SetLabelsResultResolvers<ContextType>;
SetLabelsSuccess?: SetLabelsSuccessResolvers<ContextType>;

View File

@ -284,28 +284,6 @@ type CreateHighlightSuccess {
highlight: Highlight!
}
type CreateIntegrationError {
errorCodes: [CreateIntegrationErrorCode!]!
}
enum CreateIntegrationErrorCode {
BAD_REQUEST
INVALID_TOKEN
NOT_FOUND
UNAUTHORIZED
}
input CreateIntegrationInput {
token: String!
type: IntegrationType!
}
union CreateIntegrationResult = CreateIntegrationError | CreateIntegrationSuccess
type CreateIntegrationSuccess {
integration: Integration!
}
type CreateLabelError {
errorCodes: [CreateLabelErrorCode!]!
}
@ -684,6 +662,7 @@ type Integration {
createdAt: Date!
enabled: Boolean!
id: ID!
token: String!
type: IntegrationType!
updatedAt: Date!
}
@ -830,7 +809,6 @@ type Mutation {
createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult!
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
createHighlightReply(input: CreateHighlightReplyInput!): CreateHighlightReplyResult!
createIntegration(input: CreateIntegrationInput!): CreateIntegrationResult!
createLabel(input: CreateLabelInput!): CreateLabelResult!
createNewsletterEmail: CreateNewsletterEmailResult!
createReaction(input: CreateReactionInput!): CreateReactionResult!
@ -858,6 +836,7 @@ type Mutation {
setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult!
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
setFollow(input: SetFollowInput!): SetFollowResult!
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
@ -1260,6 +1239,31 @@ type SetFollowSuccess {
updatedUser: User!
}
type SetIntegrationError {
errorCodes: [SetIntegrationErrorCode!]!
}
enum SetIntegrationErrorCode {
ALREADY_EXISTS
BAD_REQUEST
INVALID_TOKEN
NOT_FOUND
UNAUTHORIZED
}
input SetIntegrationInput {
enabled: Boolean
id: ID
token: String!
type: IntegrationType!
}
union SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess
type SetIntegrationSuccess {
integration: Integration!
}
type SetLabelsError {
errorCodes: [SetLabelsErrorCode!]!
}

View File

@ -97,6 +97,7 @@ import {
generateUploadFilePathName,
} from '../utils/uploads'
import { getPageByParam } from '../elastic/pages'
import { setIntegrationResolver } from './integrations'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -165,6 +166,7 @@ export const functionResolvers = {
revokeApiKey: revokeApiKeyResolver,
setLabelsForHighlight: setLabelsForHighlightResolver,
moveLabel: moveLabelResolver,
setIntegration: setIntegrationResolver,
},
Query: {
me: getMeUserResolver,
@ -592,4 +594,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('TypeaheadSearch'),
...resultResolveTypeResolver('UpdatesSince'),
...resultResolveTypeResolver('MoveLabel'),
...resultResolveTypeResolver('SetIntegration'),
}

View File

@ -0,0 +1,97 @@
import { authorized } from '../../utils/helpers'
import {
MutationSetIntegrationArgs,
SetIntegrationError,
SetIntegrationErrorCode,
SetIntegrationSuccess,
} from '../../generated/graphql'
import { getRepository } from '../../entity/utils'
import { User } from '../../entity/user'
import { Integration } from '../../entity/integration'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { validateToken } from '../../services/integrations'
export const setIntegrationResolver = authorized<
SetIntegrationSuccess,
SetIntegrationError,
MutationSetIntegrationArgs
>(async (_, { input }, { claims: { uid }, log }) => {
log.info('setIntegrationResolver')
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SetIntegrationErrorCode.Unauthorized],
}
}
const integrationToSave: Partial<Integration> = {
user,
token: input.token,
type: input.type,
enabled: input.enabled === null ? true : input.enabled,
}
if (input.id) {
// Update
const existingIntegration = await getRepository(Integration).findOne({
where: { id: input.id },
relations: ['user'],
})
if (!existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.NotFound],
}
}
if (existingIntegration.user.id !== uid) {
return {
errorCodes: [SetIntegrationErrorCode.Unauthorized],
}
}
integrationToSave.id = input.id
} else {
// Create
const existingIntegration = await getRepository(Integration).findOneBy({
user: { id: uid },
type: input.type,
})
if (existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.AlreadyExists],
}
}
// validate token
if (!(await validateToken(input.token, input.type))) {
return {
errorCodes: [SetIntegrationErrorCode.InvalidToken],
}
}
}
const integration = await getRepository(Integration).save(integrationToSave)
analytics.track({
userId: uid,
event: 'integration_set',
properties: {
id: integration.id,
env: env.server.apiEnv,
},
})
return {
integration,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
}
})

View File

@ -1812,17 +1812,16 @@ const schema = gql`
NOT_FOUND
}
union CreateIntegrationResult =
CreateIntegrationSuccess
| CreateIntegrationError
union SetIntegrationResult = SetIntegrationSuccess | SetIntegrationError
type CreateIntegrationSuccess {
type SetIntegrationSuccess {
integration: Integration!
}
type Integration {
id: ID!
type: IntegrationType!
token: String!
enabled: Boolean!
createdAt: Date!
updatedAt: Date!
@ -1832,20 +1831,23 @@ const schema = gql`
READWISE
}
type CreateIntegrationError {
errorCodes: [CreateIntegrationErrorCode!]!
type SetIntegrationError {
errorCodes: [SetIntegrationErrorCode!]!
}
enum CreateIntegrationErrorCode {
enum SetIntegrationErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
INVALID_TOKEN
ALREADY_EXISTS
}
input CreateIntegrationInput {
input SetIntegrationInput {
id: ID
type: IntegrationType!
token: String!
enabled: Boolean
}
# Mutations
@ -1917,7 +1919,7 @@ const schema = gql`
revokeApiKey(id: ID!): RevokeApiKeyResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
moveLabel(input: MoveLabelInput!): MoveLabelResult!
createIntegration(input: CreateIntegrationInput!): CreateIntegrationResult!
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed

View File

@ -0,0 +1,27 @@
import { IntegrationType } from '../generated/graphql'
import { env } from '../env'
import axios from 'axios'
const READWISE_API_URL = 'https://readwise.io/api/v2'
export const validateToken = async (
token: string,
type: IntegrationType
): Promise<boolean> => {
switch (type) {
case IntegrationType.Readwise:
return validateReadwiseToken(token)
default:
return false
}
}
const validateReadwiseToken = async (token: string): Promise<boolean> => {
const authUrl = `${env.readwise.apiUrl || READWISE_API_URL}/auth`
const response = await axios.get(authUrl, {
headers: {
Authorization: `Token ${token}`,
},
})
return response.status === 204
}

View File

@ -84,6 +84,9 @@ interface BackendEnv {
resetPasswordTemplateId: string
installationTemplateId: string
}
readwise: {
apiUrl: string
}
}
/***
@ -132,6 +135,7 @@ const nullableEnvVars = [
'SENDGRID_REMINDER_TEMPLATE_ID',
'SENDGRID_RESET_PASSWORD_TEMPLATE_ID',
'SENDGRID_INSTALLATION_TEMPLATE_ID',
'READWISE_API_URL',
] // Allow some vars to be null/empty
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
@ -245,6 +249,10 @@ export function getEnv(): BackendEnv {
installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'),
}
const readwise = {
apiUrl: parse('READWISE_API_URL'),
}
return {
pg,
client,
@ -262,6 +270,7 @@ export function getEnv(): BackendEnv {
elastic,
sender,
sendgrid,
readwise,
}
}