From d87a91de7770ea9c3bc28040be1e8a5bc8693ff6 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 17 Jan 2023 12:31:52 +0800 Subject: [PATCH 01/16] Add received_emails db table --- .../db/migrations/0107.do.received_emails.sql | 24 +++++++++++++++++++ .../migrations/0107.undo.received_emails.sql | 9 +++++++ 2 files changed, 33 insertions(+) create mode 100755 packages/db/migrations/0107.do.received_emails.sql create mode 100755 packages/db/migrations/0107.undo.received_emails.sql diff --git a/packages/db/migrations/0107.do.received_emails.sql b/packages/db/migrations/0107.do.received_emails.sql new file mode 100755 index 000000000..8b7f283cc --- /dev/null +++ b/packages/db/migrations/0107.do.received_emails.sql @@ -0,0 +1,24 @@ +-- Type: DO +-- Name: received_emails +-- Description: Create a table for received emails + +BEGIN; + +CREATE TABLE omnivore.received_emails ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, + "from" text NOT NULL, + "to" text NOT NULL, + subject text, + "text" text NOT NULL, + html text, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp +); + +CREATE TRIGGER received_emails_modtime BEFORE UPDATE ON omnivore.received_emails + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + +GRANT SELECT, INSERT, UPDATE ON omnivore.received_emails TO omnivore_user; + +COMMIT; diff --git a/packages/db/migrations/0107.undo.received_emails.sql b/packages/db/migrations/0107.undo.received_emails.sql new file mode 100755 index 000000000..cd03d6975 --- /dev/null +++ b/packages/db/migrations/0107.undo.received_emails.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: received_emails +-- Description: Create a table for received emails + +BEGIN; + +DROP TABLE IF EXISTS omnivore.received_emails; + +COMMIT; From b7d732df7caa4b24df8180f4c6df4e960e261158 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 17 Jan 2023 12:47:42 +0800 Subject: [PATCH 02/16] Add received_email entity --- packages/api/src/entity/received_email.ts | 41 +++++++++++++++++++ .../db/migrations/0107.do.received_emails.sql | 4 +- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/entity/received_email.ts diff --git a/packages/api/src/entity/received_email.ts b/packages/api/src/entity/received_email.ts new file mode 100644 index 000000000..df8a5c705 --- /dev/null +++ b/packages/api/src/entity/received_email.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { User } from './user' + +@Entity({ name: 'received_emails' }) +export class ReceivedEmail { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('text') + from!: string + + @Column('text') + to!: string + + @Column('text') + subject!: string + + @Column('text') + text!: string + + @Column('text') + html!: string + + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date + + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date +} diff --git a/packages/db/migrations/0107.do.received_emails.sql b/packages/db/migrations/0107.do.received_emails.sql index 8b7f283cc..887e0e406 100755 --- a/packages/db/migrations/0107.do.received_emails.sql +++ b/packages/db/migrations/0107.do.received_emails.sql @@ -9,9 +9,9 @@ CREATE TABLE omnivore.received_emails ( user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, "from" text NOT NULL, "to" text NOT NULL, - subject text, + subject text NOT NULL DEFAULT '', "text" text NOT NULL, - html text, + html text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz NOT NULL DEFAULT current_timestamp ); From 2da4ca5da84bb035671685fddf1cd8ee908a495b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 17 Jan 2023 17:04:01 +0800 Subject: [PATCH 03/16] Add saveReceivedEmail service --- packages/api/src/routers/svc/emails.ts | 21 ++++++++++--------- packages/api/src/services/received_emails.ts | 20 ++++++++++++++++++ .../db/migrations/0107.do.received_emails.sql | 21 ++++++++++++++++++- 3 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 packages/api/src/services/received_emails.ts diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index 5fa138812..6c865224b 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -27,6 +27,15 @@ interface ForwardEmailMessage { forwardedFrom?: string } +function isForwardEmailMessage(data: any): data is ForwardEmailMessage { + return ( + 'from' in data && + 'to' in data && + 'subject' in data && + ('html' in data || 'text' in data) + ) +} + const logger = buildLogger('app.dispatch') export function emailsServiceRouter() { @@ -37,7 +46,6 @@ export function emailsServiceRouter() { logger.info('email forward router') const { message, expired } = readPushSubscription(req) - logger.info('pubsub message:', { message, expired }) if (!message) { res.status(400).send('Bad Request') @@ -51,15 +59,8 @@ export function emailsServiceRouter() { } try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const data: ForwardEmailMessage = JSON.parse(message) - - if ( - !('from' in data) || - !('to' in data) || - !('subject' in data) || - (!('html' in data) && !('text' in data)) - ) { + const data = JSON.parse(message) as unknown + if (!isForwardEmailMessage(data)) { logger.error('Invalid message') res.status(400).send('Bad Request') return diff --git a/packages/api/src/services/received_emails.ts b/packages/api/src/services/received_emails.ts new file mode 100644 index 000000000..272c8ff26 --- /dev/null +++ b/packages/api/src/services/received_emails.ts @@ -0,0 +1,20 @@ +import { ReceivedEmail } from '../entity/received_email' +import { getRepository } from '../entity/utils' + +export const saveReceivedEmail = async ( + from: string, + to: string, + subject: string, + text: string, + html: string, + userId: string +): Promise => { + return getRepository(ReceivedEmail).save({ + from, + to, + subject, + text, + html, + user: { id: userId }, + }) +} diff --git a/packages/db/migrations/0107.do.received_emails.sql b/packages/db/migrations/0107.do.received_emails.sql index 887e0e406..e87966bd9 100755 --- a/packages/db/migrations/0107.do.received_emails.sql +++ b/packages/db/migrations/0107.do.received_emails.sql @@ -19,6 +19,25 @@ CREATE TABLE omnivore.received_emails ( CREATE TRIGGER received_emails_modtime BEFORE UPDATE ON omnivore.received_emails FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); -GRANT SELECT, INSERT, UPDATE ON omnivore.received_emails TO omnivore_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.received_emails TO omnivore_user; + +-- Create a trigger to keep the most recent 20 emails for each user +CREATE OR REPLACE FUNCTION omnivore.delete_old_received_emails() + RETURNS trigger AS $$ + BEGIN + DELETE FROM omnivore.received_emails + WHERE id NOT IN ( + SELECT id FROM omnivore.received_emails + WHERE user_id = NEW.user_id + ORDER BY created_at DESC + LIMIT 20 + ); + RETURN NEW; + END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER delete_old_received_emails + AFTER INSERT ON omnivore.received_emails + FOR EACH ROW EXECUTE PROCEDURE omnivore.delete_old_received_emails(); COMMIT; From 3f373ab3b67cc89099dc45669992d35f516fd66e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 17 Jan 2023 17:29:04 +0800 Subject: [PATCH 04/16] Save received email --- packages/api/src/entity/received_email.ts | 3 +++ packages/api/src/routers/svc/emails.ts | 21 +++++++++++++++++ packages/api/src/services/received_emails.ts | 4 +++- .../api/src/services/save_newsletter_email.ts | 23 +++++++++++++++++++ .../db/migrations/0107.do.received_emails.sql | 1 + packages/inbound-email-handler/src/index.ts | 9 ++++---- 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/api/src/entity/received_email.ts b/packages/api/src/entity/received_email.ts index df8a5c705..b68289989 100644 --- a/packages/api/src/entity/received_email.ts +++ b/packages/api/src/entity/received_email.ts @@ -33,6 +33,9 @@ export class ReceivedEmail { @Column('text') html!: string + @Column('text') + type!: 'article' | 'non-article' + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index 6c865224b..acad80358 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -15,6 +15,7 @@ import { } from '../../utils/parser' import { saveEmail } from '../../services/save_email' import { buildLogger } from '../../utils/logger' +import { saveReceivedEmail } from '../../services/received_emails' interface ForwardEmailMessage { from: string @@ -91,6 +92,17 @@ export function emailsServiceRouter() { url: generateUniqueUrl(), originalContent: data.html || data.text, }) + + await saveReceivedEmail( + data.from, + data.to, + data.subject, + data.text, + data.html, + user.id, + 'article' + ) + res.status(200).send('Article') return } @@ -103,6 +115,15 @@ export function emailsServiceRouter() { }, }) + await saveReceivedEmail( + data.from, + data.to, + data.subject, + data.text, + data.html, + user.id + ) + // forward non-newsletter emails to the registered email address const result = await sendEmail({ from: env.sender.message, diff --git a/packages/api/src/services/received_emails.ts b/packages/api/src/services/received_emails.ts index 272c8ff26..e1df663d4 100644 --- a/packages/api/src/services/received_emails.ts +++ b/packages/api/src/services/received_emails.ts @@ -7,7 +7,8 @@ export const saveReceivedEmail = async ( subject: string, text: string, html: string, - userId: string + userId: string, + type: 'article' | 'non-article' = 'non-article' ): Promise => { return getRepository(ReceivedEmail).save({ from, @@ -15,6 +16,7 @@ export const saveReceivedEmail = async ( subject, text, html, + type, user: { id: userId }, }) } diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index e81806ec1..a9e4bcda8 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -13,8 +13,10 @@ import { NewsletterEmail } from '../entity/newsletter_email' import { fetchFavicon } from '../utils/parser' import { updatePage } from '../elastic/pages' import { isBase64Image } from '../utils/helpers' +import { saveReceivedEmail } from './received_emails' export interface NewsletterMessage { + from: string email: string content: string url: string @@ -23,6 +25,7 @@ export interface NewsletterMessage { unsubMailTo?: string unsubHttpUrl?: string newsletterEmail?: NewsletterEmail + text: string } // Returns true if the link was created successfully. Can still fail to @@ -65,9 +68,29 @@ export const saveNewsletterEmail = async ( const page = await saveEmail(saveCtx, input) if (!page) { console.log('newsletter not created:', input) + + await saveReceivedEmail( + data.from, + data.email, + data.title, + data.text, + data.content, + newsletterEmail.user.id + ) + return false } + await saveReceivedEmail( + data.from, + data.email, + data.title, + data.text, + data.content, + newsletterEmail.user.id, + 'article' + ) + if (!page.siteIcon || isBase64Image(page.siteIcon)) { // fetch favicon if not already set or is a base64 image const favicon = await fetchFavicon(page.url) diff --git a/packages/db/migrations/0107.do.received_emails.sql b/packages/db/migrations/0107.do.received_emails.sql index e87966bd9..a75dbc385 100755 --- a/packages/db/migrations/0107.do.received_emails.sql +++ b/packages/db/migrations/0107.do.received_emails.sql @@ -12,6 +12,7 @@ CREATE TABLE omnivore.received_emails ( subject text NOT NULL DEFAULT '', "text" text NOT NULL, html text NOT NULL DEFAULT '', + "type" text NOT NULL, created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz NOT NULL DEFAULT current_timestamp ); diff --git a/packages/inbound-email-handler/src/index.ts b/packages/inbound-email-handler/src/index.ts index 67d38694d..143365e92 100644 --- a/packages/inbound-email-handler/src/index.ts +++ b/packages/inbound-email-handler/src/index.ts @@ -87,10 +87,11 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( title: subject, }) if (newsletterMessage) { - await publishMessage( - NEWSLETTER_EMAIL_RECEIVED_TOPIC, - newsletterMessage - ) + await publishMessage(NEWSLETTER_EMAIL_RECEIVED_TOPIC, { + ...newsletterMessage, + text, + from, + }) return res.status(200).send('newsletter received') } From 62a3b48e002a89df3f249153a3f27a1181ff4ec0 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 17 Jan 2023 17:40:30 +0800 Subject: [PATCH 05/16] Add list recent emails api --- packages/api/src/generated/graphql.ts | 70 +++++++++++++++++++ packages/api/src/generated/schema.graphql | 27 +++++++ .../api/src/resolvers/function_resolvers.ts | 3 + .../api/src/resolvers/recent_emails/index.ts | 46 ++++++++++++ packages/api/src/schema.ts | 27 +++++++ 5 files changed, 173 insertions(+) create mode 100644 packages/api/src/resolvers/recent_emails/index.ts diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 781aa004e..b5dab497a 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1669,6 +1669,7 @@ export type Query = { labels: LabelsResult; me?: Maybe; newsletterEmails: NewsletterEmailsResult; + recentEmails: RecentEmailsResult; recentSearches: RecentSearchesResult; reminder: ReminderResult; rules: RulesResult; @@ -1812,6 +1813,35 @@ export type ReadState = { readingTime?: Maybe; }; +export type RecentEmail = { + __typename?: 'RecentEmail'; + createdAt: Scalars['Date']; + from: Scalars['String']; + html?: Maybe; + id: Scalars['ID']; + subject: Scalars['String']; + text: Scalars['String']; + to: Scalars['String']; + type: Scalars['String']; +}; + +export type RecentEmailsError = { + __typename?: 'RecentEmailsError'; + errorCodes: Array; +}; + +export enum RecentEmailsErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type RecentEmailsResult = RecentEmailsError | RecentEmailsSuccess; + +export type RecentEmailsSuccess = { + __typename?: 'RecentEmailsSuccess'; + recentEmails: Array; +}; + export type RecentSearch = { __typename?: 'RecentSearch'; createdAt: Scalars['Date']; @@ -3353,6 +3383,11 @@ export type ResolversTypes = { Reaction: ResolverTypeWrapper; ReactionType: ReactionType; ReadState: ResolverTypeWrapper; + RecentEmail: ResolverTypeWrapper; + RecentEmailsError: ResolverTypeWrapper; + RecentEmailsErrorCode: RecentEmailsErrorCode; + RecentEmailsResult: ResolversTypes['RecentEmailsError'] | ResolversTypes['RecentEmailsSuccess']; + RecentEmailsSuccess: ResolverTypeWrapper; RecentSearch: ResolverTypeWrapper; RecentSearchesError: ResolverTypeWrapper; RecentSearchesErrorCode: RecentSearchesErrorCode; @@ -3771,6 +3806,10 @@ export type ResolversParentTypes = { Query: {}; Reaction: Reaction; ReadState: ReadState; + RecentEmail: RecentEmail; + RecentEmailsError: RecentEmailsError; + RecentEmailsResult: ResolversParentTypes['RecentEmailsError'] | ResolversParentTypes['RecentEmailsSuccess']; + RecentEmailsSuccess: RecentEmailsSuccess; RecentSearch: RecentSearch; RecentSearchesError: RecentSearchesError; RecentSearchesResult: ResolversParentTypes['RecentSearchesError'] | ResolversParentTypes['RecentSearchesSuccess']; @@ -4943,6 +4982,7 @@ export type QueryResolvers; me?: Resolver, ParentType, ContextType>; newsletterEmails?: Resolver; + recentEmails?: Resolver; recentSearches?: Resolver; reminder?: Resolver>; rules?: Resolver>; @@ -4976,6 +5016,32 @@ export type ReadStateResolvers; }; +export type RecentEmailResolvers = { + createdAt?: Resolver; + from?: Resolver; + html?: Resolver, ParentType, ContextType>; + id?: Resolver; + subject?: Resolver; + text?: Resolver; + to?: Resolver; + type?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type RecentEmailsErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type RecentEmailsResultResolvers = { + __resolveType: TypeResolveFn<'RecentEmailsError' | 'RecentEmailsSuccess', ParentType, ContextType>; +}; + +export type RecentEmailsSuccessResolvers = { + recentEmails?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RecentSearchResolvers = { createdAt?: Resolver; id?: Resolver; @@ -5934,6 +6000,10 @@ export type Resolvers = { Query?: QueryResolvers; Reaction?: ReactionResolvers; ReadState?: ReadStateResolvers; + RecentEmail?: RecentEmailResolvers; + RecentEmailsError?: RecentEmailsErrorResolvers; + RecentEmailsResult?: RecentEmailsResultResolvers; + RecentEmailsSuccess?: RecentEmailsSuccessResolvers; RecentSearch?: RecentSearchResolvers; RecentSearchesError?: RecentSearchesErrorResolvers; RecentSearchesResult?: RecentSearchesResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index c58faa3e7..d31a308a3 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1218,6 +1218,7 @@ type Query { labels: LabelsResult! me: User newsletterEmails: NewsletterEmailsResult! + recentEmails: RecentEmailsResult! recentSearches: RecentSearchesResult! reminder(linkId: ID!): ReminderResult! rules(enabled: Boolean): RulesResult! @@ -1258,6 +1259,32 @@ type ReadState { readingTime: Int } +type RecentEmail { + createdAt: Date! + from: String! + html: String + id: ID! + subject: String! + text: String! + to: String! + type: String! +} + +type RecentEmailsError { + errorCodes: [RecentEmailsErrorCode!]! +} + +enum RecentEmailsErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union RecentEmailsResult = RecentEmailsError | RecentEmailsSuccess + +type RecentEmailsSuccess { + recentEmails: [RecentEmail!]! +} + type RecentSearch { createdAt: Date! id: ID! diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 39dac70c3..db2f6f9b3 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -117,6 +117,7 @@ import { getPageByParam } from '../elastic/pages' import { recentSearchesResolver } from './recent_searches' import { optInFeatureResolver } from './features' import { uploadImportFileResolver } from './importers/uploadImportFileResolver' +import { recentEmailsResolver } from './recent_emails' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -230,6 +231,7 @@ export const functionResolvers = { deviceTokens: deviceTokensResolver, filters: filtersResolver, groups: groupsResolver, + recentEmails: recentEmailsResolver, }, User: { async sharedArticles( @@ -659,4 +661,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('RecommendHighlights'), ...resultResolveTypeResolver('LeaveGroup'), ...resultResolveTypeResolver('UploadImportFile'), + ...resultResolveTypeResolver('RecentEmails'), } diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts new file mode 100644 index 000000000..b12e331ab --- /dev/null +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -0,0 +1,46 @@ +import { + RecentEmailsError, + RecentEmailsErrorCode, + RecentEmailsSuccess, +} from '../../generated/graphql' +import { authorized } from '../../utils/helpers' +import { getRepository } from '../../entity/utils' +import { ReceivedEmail } from '../../entity/received_email' + +export const recentEmailsResolver = authorized< + RecentEmailsSuccess, + RecentEmailsError +>(async (_, __, { claims, log }) => { + log.info('Getting recent emails', { + labels: { + source: 'resolver', + resolver: 'recentEmailsResolver', + uid: claims.uid, + }, + }) + + try { + const recentEmails = await getRepository(ReceivedEmail).find({ + where: { user: { id: claims.uid } }, + order: { createdAt: 'DESC' }, + take: 20, + }) + + return { + recentEmails, + } + } catch (error) { + log.error('Error getting recent emails', { + error, + labels: { + source: 'resolver', + resolver: 'recentEmailsResolver', + uid: claims.uid, + }, + }) + + return { + errorCodes: [RecentEmailsErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 6732c45d0..fb5fc8459 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2331,6 +2331,32 @@ const schema = gql` uploadSignedUrl: String } + union RecentEmailsResult = RecentEmailsSuccess | RecentEmailsError + + type RecentEmailsSuccess { + recentEmails: [RecentEmail!]! + } + + type RecentEmailsError { + errorCodes: [RecentEmailsErrorCode!]! + } + + enum RecentEmailsErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + type RecentEmail { + id: ID! + from: String! + to: String! + subject: String! + type: String! + text: String! + html: String + createdAt: Date! + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2481,6 +2507,7 @@ const schema = gql` deviceTokens: DeviceTokensResult! filters: FiltersResult! groups: GroupsResult! + recentEmails: RecentEmailsResult! } ` From 96f727a16501b58139aacfb38a8bb1ee18d61ab9 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 17 Jan 2023 18:02:50 +0800 Subject: [PATCH 06/16] Add mark a recent email as library item api --- packages/api/src/generated/graphql.ts | 49 ++++++++++++ packages/api/src/generated/schema.graphql | 17 ++++ .../api/src/resolvers/function_resolvers.ts | 4 +- .../api/src/resolvers/recent_emails/index.ts | 77 +++++++++++++++++++ packages/api/src/routers/svc/newsletters.ts | 35 ++++++++- packages/api/src/schema.ts | 17 ++++ .../api/src/services/save_newsletter_email.ts | 33 +------- 7 files changed, 198 insertions(+), 34 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index b5dab497a..f6cfc874c 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1062,6 +1062,24 @@ export type LoginSuccess = { me: User; }; +export type MarkEmailAsItemError = { + __typename?: 'MarkEmailAsItemError'; + errorCodes: Array; +}; + +export enum MarkEmailAsItemErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type MarkEmailAsItemResult = MarkEmailAsItemError | MarkEmailAsItemSuccess; + +export type MarkEmailAsItemSuccess = { + __typename?: 'MarkEmailAsItemSuccess'; + success: Scalars['Boolean']; +}; + export type MergeHighlightError = { __typename?: 'MergeHighlightError'; errorCodes: Array; @@ -1172,6 +1190,7 @@ export type Mutation = { joinGroup: JoinGroupResult; leaveGroup: LeaveGroupResult; logOut: LogOutResult; + markEmailAsItem: MarkEmailAsItemResult; mergeHighlight: MergeHighlightResult; moveFilter: MoveFilterResult; moveLabel: MoveLabelResult; @@ -1338,6 +1357,11 @@ export type MutationLeaveGroupArgs = { }; +export type MutationMarkEmailAsItemArgs = { + recentEmailId: Scalars['ID']; +}; + + export type MutationMergeHighlightArgs = { input: MergeHighlightInput; }; @@ -3346,6 +3370,10 @@ export type ResolversTypes = { LoginErrorCode: LoginErrorCode; LoginResult: ResolversTypes['LoginError'] | ResolversTypes['LoginSuccess']; LoginSuccess: ResolverTypeWrapper; + MarkEmailAsItemError: ResolverTypeWrapper; + MarkEmailAsItemErrorCode: MarkEmailAsItemErrorCode; + MarkEmailAsItemResult: ResolversTypes['MarkEmailAsItemError'] | ResolversTypes['MarkEmailAsItemSuccess']; + MarkEmailAsItemSuccess: ResolverTypeWrapper; MergeHighlightError: ResolverTypeWrapper; MergeHighlightErrorCode: MergeHighlightErrorCode; MergeHighlightInput: MergeHighlightInput; @@ -3776,6 +3804,9 @@ export type ResolversParentTypes = { LoginError: LoginError; LoginResult: ResolversParentTypes['LoginError'] | ResolversParentTypes['LoginSuccess']; LoginSuccess: LoginSuccess; + MarkEmailAsItemError: MarkEmailAsItemError; + MarkEmailAsItemResult: ResolversParentTypes['MarkEmailAsItemError'] | ResolversParentTypes['MarkEmailAsItemSuccess']; + MarkEmailAsItemSuccess: MarkEmailAsItemSuccess; MergeHighlightError: MergeHighlightError; MergeHighlightInput: MergeHighlightInput; MergeHighlightResult: ResolversParentTypes['MergeHighlightError'] | ResolversParentTypes['MergeHighlightSuccess']; @@ -4781,6 +4812,20 @@ export type LoginSuccessResolvers; }; +export type MarkEmailAsItemErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type MarkEmailAsItemResultResolvers = { + __resolveType: TypeResolveFn<'MarkEmailAsItemError' | 'MarkEmailAsItemSuccess', ParentType, ContextType>; +}; + +export type MarkEmailAsItemSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type MergeHighlightErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4852,6 +4897,7 @@ export type MutationResolvers>; leaveGroup?: Resolver>; logOut?: Resolver; + markEmailAsItem?: Resolver>; mergeHighlight?: Resolver>; moveFilter?: Resolver>; moveLabel?: Resolver>; @@ -5977,6 +6023,9 @@ export type Resolvers = { LoginError?: LoginErrorResolvers; LoginResult?: LoginResultResolvers; LoginSuccess?: LoginSuccessResolvers; + MarkEmailAsItemError?: MarkEmailAsItemErrorResolvers; + MarkEmailAsItemResult?: MarkEmailAsItemResultResolvers; + MarkEmailAsItemSuccess?: MarkEmailAsItemSuccessResolvers; MergeHighlightError?: MergeHighlightErrorResolvers; MergeHighlightResult?: MergeHighlightResultResolvers; MergeHighlightSuccess?: MergeHighlightSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index d31a308a3..4154541a5 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -943,6 +943,22 @@ type LoginSuccess { me: User! } +type MarkEmailAsItemError { + errorCodes: [MarkEmailAsItemErrorCode!]! +} + +enum MarkEmailAsItemErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union MarkEmailAsItemResult = MarkEmailAsItemError | MarkEmailAsItemSuccess + +type MarkEmailAsItemSuccess { + success: Boolean! +} + type MergeHighlightError { errorCodes: [MergeHighlightErrorCode!]! } @@ -1046,6 +1062,7 @@ type Mutation { joinGroup(inviteCode: String!): JoinGroupResult! leaveGroup(groupId: ID!): LeaveGroupResult! logOut: LogOutResult! + markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! moveFilter(input: MoveFilterInput!): MoveFilterResult! moveLabel(input: MoveLabelInput!): MoveLabelResult! diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index db2f6f9b3..75e8a36ee 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -117,7 +117,7 @@ import { getPageByParam } from '../elastic/pages' import { recentSearchesResolver } from './recent_searches' import { optInFeatureResolver } from './features' import { uploadImportFileResolver } from './importers/uploadImportFileResolver' -import { recentEmailsResolver } from './recent_emails' +import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -200,6 +200,7 @@ export const functionResolvers = { recommendHighlights: recommendHighlightsResolver, leaveGroup: leaveGroupResolver, uploadImportFile: uploadImportFileResolver, + markEmailAsItem: markEmailAsItemResolver, }, Query: { me: getMeUserResolver, @@ -662,4 +663,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('LeaveGroup'), ...resultResolveTypeResolver('UploadImportFile'), ...resultResolveTypeResolver('RecentEmails'), + ...resultResolveTypeResolver('MarkEmailAsItemResolver'), } diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts index b12e331ab..e7db4ab1b 100644 --- a/packages/api/src/resolvers/recent_emails/index.ts +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -1,4 +1,8 @@ import { + MarkEmailAsItemError, + MarkEmailAsItemErrorCode, + MarkEmailAsItemSuccess, + MutationMarkEmailAsItemArgs, RecentEmailsError, RecentEmailsErrorCode, RecentEmailsSuccess, @@ -6,6 +10,12 @@ import { import { authorized } from '../../utils/helpers' import { getRepository } from '../../entity/utils' import { ReceivedEmail } from '../../entity/received_email' +import { saveNewsletterEmail } from '../../services/save_newsletter_email' +import { NewsletterEmail } from '../../entity/newsletter_email' +import { v4 as uuid } from 'uuid' + +const FAKE_URL_PREFIX = 'https://omnivore.app/no_url?q=' +const generateUniqueUrl = () => FAKE_URL_PREFIX + uuid() export const recentEmailsResolver = authorized< RecentEmailsSuccess, @@ -44,3 +54,70 @@ export const recentEmailsResolver = authorized< } } }) + +export const markEmailAsItemResolver = authorized< + MarkEmailAsItemSuccess, + MarkEmailAsItemError, + MutationMarkEmailAsItemArgs +>(async (_, { recentEmailId }, { claims, log }) => { + log.info('Marking email as item', { + recentEmailId, + labels: { + source: 'resolver', + resolver: 'markEmailAsItemResolver', + uid: claims.uid, + }, + }) + + try { + const recentEmail = await getRepository(ReceivedEmail).findOneBy({ + id: recentEmailId, + user: { id: claims.uid }, + }) + if (!recentEmail) { + return { + errorCodes: [MarkEmailAsItemErrorCode.Unauthorized], + } + } + + const newsletterEmail = await getRepository(NewsletterEmail).findOneBy({ + address: recentEmail.to, + user: { id: claims.uid }, + }) + if (!newsletterEmail) { + return { + errorCodes: [MarkEmailAsItemErrorCode.NotFound], + } + } + + const success = await saveNewsletterEmail( + { + from: recentEmail.from, + email: recentEmail.to, + title: recentEmail.subject, + text: recentEmail.text, + content: recentEmail.html, + url: generateUniqueUrl(), + author: '', + }, + newsletterEmail + ) + + return { + success, + } + } catch (error) { + log.error('Error marking email as item', { + error, + labels: { + source: 'resolver', + resolver: 'markEmailAsItemResolver', + uid: claims.uid, + }, + }) + + return { + errorCodes: [MarkEmailAsItemErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/routers/svc/newsletters.ts b/packages/api/src/routers/svc/newsletters.ts index 8edba67a7..e62d6a53e 100644 --- a/packages/api/src/routers/svc/newsletters.ts +++ b/packages/api/src/routers/svc/newsletters.ts @@ -1,10 +1,14 @@ import express from 'express' import { readPushSubscription } from '../../datalayer/pubsub' -import { updateConfirmationCode } from '../../services/newsletters' +import { + getNewsletterEmail, + updateConfirmationCode, +} from '../../services/newsletters' import { NewsletterMessage, saveNewsletterEmail, } from '../../services/save_newsletter_email' +import { saveReceivedEmail } from '../../services/received_emails' interface SetConfirmationCodeMessage { emailAddress: string @@ -96,7 +100,14 @@ export function newsletterServiceRouter() { return } - const result = await saveNewsletterEmail(data) + // get user from newsletter email + const newsletterEmail = await getNewsletterEmail(data.email) + if (!newsletterEmail) { + console.log('newsletter email not found', data.email) + return false + } + + const result = await saveNewsletterEmail(data, newsletterEmail) if (!result) { console.log( 'Error creating newsletter link from data', @@ -104,10 +115,30 @@ export function newsletterServiceRouter() { data.title, data.author ) + + await saveReceivedEmail( + data.from, + data.email, + data.title, + data.text, + data.content, + newsletterEmail.user.id + ) + res.status(500).send('Error creating newsletter link') return } + await saveReceivedEmail( + data.from, + data.email, + data.title, + data.text, + data.content, + newsletterEmail.user.id, + 'article' + ) + // We always send 200 if it was a valid message // because we don't want the res.status(200).send('newsletter created') diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index fb5fc8459..64915652d 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2357,6 +2357,22 @@ const schema = gql` createdAt: Date! } + union MarkEmailAsItemResult = MarkEmailAsItemSuccess | MarkEmailAsItemError + + type MarkEmailAsItemSuccess { + success: Boolean! + } + + type MarkEmailAsItemError { + errorCodes: [MarkEmailAsItemErrorCode!]! + } + + enum MarkEmailAsItemErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2445,6 +2461,7 @@ const schema = gql` type: UploadImportFileType! contentType: String! ): UploadImportFileResult! + markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index a9e4bcda8..2c9b07853 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -4,7 +4,6 @@ import { UserDeviceToken } from '../entity/user_device_tokens' import { env } from '../env' import { ContentReader } from '../generated/graphql' import { analytics } from '../utils/analytics' -import { getNewsletterEmail } from './newsletters' import { SaveContext, saveEmail, SaveEmailInput } from './save_email' import { Page } from '../elastic/types' import { addLabelToPage } from './labels' @@ -13,7 +12,6 @@ import { NewsletterEmail } from '../entity/newsletter_email' import { fetchFavicon } from '../utils/parser' import { updatePage } from '../elastic/pages' import { isBase64Image } from '../utils/helpers' -import { saveReceivedEmail } from './received_emails' export interface NewsletterMessage { from: string @@ -24,7 +22,6 @@ export interface NewsletterMessage { author: string unsubMailTo?: string unsubHttpUrl?: string - newsletterEmail?: NewsletterEmail text: string } @@ -32,16 +29,9 @@ export interface NewsletterMessage { // send the push but that is ok and we wont retry in that case. export const saveNewsletterEmail = async ( data: NewsletterMessage, + newsletterEmail: NewsletterEmail, ctx?: SaveContext ): Promise => { - // get user from newsletter email - const newsletterEmail = - data.newsletterEmail || (await getNewsletterEmail(data.email)) - if (!newsletterEmail) { - console.log('newsletter email not found', data.email) - return false - } - analytics.track({ userId: newsletterEmail.user.id, event: 'newsletter_email_received', @@ -67,30 +57,11 @@ export const saveNewsletterEmail = async ( } const page = await saveEmail(saveCtx, input) if (!page) { - console.log('newsletter not created:', input) - - await saveReceivedEmail( - data.from, - data.email, - data.title, - data.text, - data.content, - newsletterEmail.user.id - ) + console.log('newsletter not created:', input.title) return false } - await saveReceivedEmail( - data.from, - data.email, - data.title, - data.text, - data.content, - newsletterEmail.user.id, - 'article' - ) - if (!page.siteIcon || isBase64Image(page.siteIcon)) { // fetch favicon if not already set or is a base64 image const favicon = await fetchFavicon(page.url) From 60e107964f1b7e152128bff18f11354b9f4eb332 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 18 Jan 2023 09:37:25 +0800 Subject: [PATCH 07/16] Fix typo --- packages/api/src/resolvers/function_resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 75e8a36ee..ea5ff9ecf 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -663,5 +663,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('LeaveGroup'), ...resultResolveTypeResolver('UploadImportFile'), ...resultResolveTypeResolver('RecentEmails'), - ...resultResolveTypeResolver('MarkEmailAsItemResolver'), + ...resultResolveTypeResolver('MarkEmailAsItem'), } From 1c52339b7aae392ba433b260bf51fdc01905bd37 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 18 Jan 2023 15:01:30 +0800 Subject: [PATCH 08/16] Fix rebase conflict --- .../{0107.do.received_emails.sql => 0108.do.received_emails.sql} | 0 ...107.undo.received_emails.sql => 0108.undo.received_emails.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/db/migrations/{0107.do.received_emails.sql => 0108.do.received_emails.sql} (100%) rename packages/db/migrations/{0107.undo.received_emails.sql => 0108.undo.received_emails.sql} (100%) diff --git a/packages/db/migrations/0107.do.received_emails.sql b/packages/db/migrations/0108.do.received_emails.sql similarity index 100% rename from packages/db/migrations/0107.do.received_emails.sql rename to packages/db/migrations/0108.do.received_emails.sql diff --git a/packages/db/migrations/0107.undo.received_emails.sql b/packages/db/migrations/0108.undo.received_emails.sql similarity index 100% rename from packages/db/migrations/0107.undo.received_emails.sql rename to packages/db/migrations/0108.undo.received_emails.sql From d5e3a29fd67390bc6be9aba2cee10f4e72dcb85b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 18 Jan 2023 15:08:28 +0800 Subject: [PATCH 09/16] Fix test --- .../test/services/save_newsletter_email.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/api/test/services/save_newsletter_email.test.ts b/packages/api/test/services/save_newsletter_email.test.ts index a11ead336..028ff0a76 100644 --- a/packages/api/test/services/save_newsletter_email.test.ts +++ b/packages/api/test/services/save_newsletter_email.test.ts @@ -19,12 +19,12 @@ describe('saveNewsletterEmail', () => { const author = 'fake author' let user: User - let email: NewsletterEmail + let newsletterEmail: NewsletterEmail let ctx: SaveContext before(async () => { user = await createTestUser('fakeUser') - email = await createNewsletterEmail(user.id) + newsletterEmail = await createNewsletterEmail(user.id) ctx = { pubsub: createPubSubClient(), refresh: true, @@ -42,12 +42,15 @@ describe('saveNewsletterEmail', () => { await saveNewsletterEmail( { - email: email.address, + from: 'fake from', + text: 'fake text', + email: newsletterEmail.address, content: `${fakeContent}`, url, title, author, }, + newsletterEmail, ctx ) @@ -59,7 +62,7 @@ describe('saveNewsletterEmail', () => { expect(page?.content).to.contain(fakeContent) const subscriptions = await getRepository(Subscription).findBy({ - newsletterEmail: { id: email.id }, + newsletterEmail: { id: newsletterEmail.id }, }) expect(subscriptions).not.to.be.empty }) @@ -73,12 +76,15 @@ describe('saveNewsletterEmail', () => { await saveNewsletterEmail( { - email: email.address, + email: newsletterEmail.address, content: `fake content 2`, url, title, author, + from: 'fake from', + text: 'fake text', }, + newsletterEmail, ctx ) From d60890d1547be585629eaf5047b5c60343596688 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 18 Jan 2023 17:13:11 +0800 Subject: [PATCH 10/16] Add pubsub to save received email --- packages/api/src/routers/svc/emails.ts | 94 +++++++++++++++---- packages/api/src/routers/svc/newsletters.ts | 18 +--- .../api/src/routers/svc/pdf_attachments.ts | 15 ++- packages/api/src/services/received_emails.ts | 26 +++++ packages/inbound-email-handler/src/index.ts | 16 +++- packages/inbound-email-handler/src/pdf.ts | 9 +- 6 files changed, 140 insertions(+), 38 deletions(-) diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index acad80358..ee02c47cc 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -15,9 +15,12 @@ import { } from '../../utils/parser' import { saveEmail } from '../../services/save_email' import { buildLogger } from '../../utils/logger' -import { saveReceivedEmail } from '../../services/received_emails' +import { + saveReceivedEmail, + updateReceivedEmail, +} from '../../services/received_emails' -interface ForwardEmailMessage { +interface EmailMessage { from: string to: string subject: string @@ -28,12 +31,13 @@ interface ForwardEmailMessage { forwardedFrom?: string } -function isForwardEmailMessage(data: any): data is ForwardEmailMessage { +function isEmailMessage(data: any): data is EmailMessage { return ( 'from' in data && 'to' in data && 'subject' in data && - ('html' in data || 'text' in data) + 'html' in data && + 'text' in data ) } @@ -61,7 +65,7 @@ export function emailsServiceRouter() { try { const data = JSON.parse(message) as unknown - if (!isForwardEmailMessage(data)) { + if (!isEmailMessage(data)) { logger.error('Invalid message') res.status(400).send('Bad Request') return @@ -93,13 +97,12 @@ export function emailsServiceRouter() { originalContent: data.html || data.text, }) - await saveReceivedEmail( + // update received email type + await updateReceivedEmail( + user.id, data.from, data.to, data.subject, - data.text, - data.html, - user.id, 'article' ) @@ -115,15 +118,6 @@ export function emailsServiceRouter() { }, }) - await saveReceivedEmail( - data.from, - data.to, - data.subject, - data.text, - data.html, - user.id - ) - // forward non-newsletter emails to the registered email address const result = await sendEmail({ from: env.sender.message, @@ -152,5 +146,69 @@ export function emailsServiceRouter() { } }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + router.post('/save', async (req, res) => { + logger.info('save received email router') + + const { message, expired } = readPushSubscription(req) + + if (!message) { + res.status(400).send('Bad Request') + return + } + + if (expired) { + logger.info('discards expired message.') + res.status(200).send('Expired') + return + } + + try { + const data = JSON.parse(message) as unknown + if (!isEmailMessage(data)) { + logger.error('Invalid message') + res.status(400).send('Bad Request') + return + } + + // get user from newsletter email + const newsletterEmail = await getNewsletterEmail(data.to) + + if (!newsletterEmail) { + logger.info('newsletter email not found', { email: data.to }) + res.status(200).send('Not Found') + return + } + + const user = newsletterEmail.user + await saveReceivedEmail( + data.from, + data.to, + data.subject, + data.text, + data.html, + user.id + ) + + analytics.track({ + userId: user.id, + event: 'received_email_saved', + properties: { + env: env.server.apiEnv, + }, + }) + + res.status(200).send('Received email saved') + } catch (e) { + logger.info(e) + if (e instanceof SyntaxError) { + // when message is not a valid json string + res.status(400).send(e) + } else { + res.status(500).send(e) + } + } + }) + return router } diff --git a/packages/api/src/routers/svc/newsletters.ts b/packages/api/src/routers/svc/newsletters.ts index e62d6a53e..aa8b66d3d 100644 --- a/packages/api/src/routers/svc/newsletters.ts +++ b/packages/api/src/routers/svc/newsletters.ts @@ -8,7 +8,7 @@ import { NewsletterMessage, saveNewsletterEmail, } from '../../services/save_newsletter_email' -import { saveReceivedEmail } from '../../services/received_emails' +import { updateReceivedEmail } from '../../services/received_emails' interface SetConfirmationCodeMessage { emailAddress: string @@ -116,26 +116,16 @@ export function newsletterServiceRouter() { data.author ) - await saveReceivedEmail( - data.from, - data.email, - data.title, - data.text, - data.content, - newsletterEmail.user.id - ) - res.status(500).send('Error creating newsletter link') return } - await saveReceivedEmail( + // update received email type + await updateReceivedEmail( + newsletterEmail.user.id, data.from, data.email, data.title, - data.text, - data.content, - newsletterEmail.user.id, 'article' ) diff --git a/packages/api/src/routers/svc/pdf_attachments.ts b/packages/api/src/routers/svc/pdf_attachments.ts index 85ea23d34..e299ce50c 100644 --- a/packages/api/src/routers/svc/pdf_attachments.ts +++ b/packages/api/src/routers/svc/pdf_attachments.ts @@ -17,6 +17,7 @@ import { createPubSubClient } from '../../datalayer/pubsub' import { ArticleSavingRequestStatus, Page } from '../../elastic/types' import { createPage } from '../../elastic/pages' import { getClaimsByToken } from '../../utils/auth' +import { updateReceivedEmail } from '../../services/received_emails' export function pdfAttachmentsRouter() { const router = express.Router() @@ -87,10 +88,11 @@ export function pdfAttachmentsRouter() { router.post('/create-article', async (req, res) => { console.log('pdf-attachments/create-article') - const { email, uploadFileId, subject } = req.body as { + const { email, uploadFileId, subject, from } = req.body as { email: string uploadFileId: string subject: string + from: string } const token = req?.headers?.authorization @@ -149,7 +151,7 @@ export function pdfAttachmentsRouter() { pageType: pageType, hash: uploadFileHash, uploadFileId: uploadFileId, - title: title, + title, content: '', userId: user.id, slug: generateSlug(title), @@ -166,6 +168,15 @@ export function pdfAttachmentsRouter() { uid: user.id, }) + // update received email type + await updateReceivedEmail( + newsletterEmail.user.id, + from, + email, + subject, + 'article' + ) + res.send({ id: pageId }) } catch (err) { console.log(err) diff --git a/packages/api/src/services/received_emails.ts b/packages/api/src/services/received_emails.ts index e1df663d4..d37ebd6d8 100644 --- a/packages/api/src/services/received_emails.ts +++ b/packages/api/src/services/received_emails.ts @@ -20,3 +20,29 @@ export const saveReceivedEmail = async ( user: { id: userId }, }) } + +export const updateReceivedEmail = async ( + userId: string, + from: string, + to: string, + subject: string, + type: 'article' | 'non-article' +) => { + await getRepository(ReceivedEmail) + .createQueryBuilder() + .update() + .set({ type }) + // .where('user_id = :userId', { userId }) + // .andWhere('from = :from', { from }) + // .andWhere('to = :to', { to }) + // .andWhere('subject = :subject', { subject }) + .where({ + user: { id: userId }, + from, + to, + subject, + }) + .orderBy('created_at', 'DESC') + .limit(1) + .execute() +} diff --git a/packages/inbound-email-handler/src/index.ts b/packages/inbound-email-handler/src/index.ts index 143365e92..b111b5e8f 100644 --- a/packages/inbound-email-handler/src/index.ts +++ b/packages/inbound-email-handler/src/index.ts @@ -17,6 +17,7 @@ import { handleNewsletter } from '@omnivore/content-handler' const NEWSLETTER_EMAIL_RECEIVED_TOPIC = 'newsletterEmailReceived' const NON_NEWSLETTER_EMAIL_TOPIC = 'nonNewsletterEmailReceived' +const RECEIVED_EMAIL_TOPIC = 'receivedEmail' const pubsub = new PubSub() export const publishMessage = async ( @@ -32,6 +33,10 @@ export const publishMessage = async ( }) } +const publishReceivedEmail = async (email: any): Promise => { + await publishMessage(RECEIVED_EMAIL_TOPIC, email) +} + export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { try { @@ -77,6 +82,14 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( const unSubHeader = headers['list-unsubscribe']?.toString() try { + await publishReceivedEmail({ + from, + to, + subject, + html, + text, + }) + // check if it is a confirmation email or forwarding newsletter const newsletterMessage = await handleNewsletter({ from, @@ -109,7 +122,8 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( to, pdfAttachmentName, pdfAttachment, - subject + subject, + from ) return res.send('ok') } diff --git a/packages/inbound-email-handler/src/pdf.ts b/packages/inbound-email-handler/src/pdf.ts index 85e68cd84..6cff1b165 100644 --- a/packages/inbound-email-handler/src/pdf.ts +++ b/packages/inbound-email-handler/src/pdf.ts @@ -13,7 +13,8 @@ export const handlePdfAttachment = async ( email: string, fileName: string | undefined, data: Buffer, - subject: string + subject: string, + from: string ): Promise => { console.log('handlePdfAttachment', email, fileName) @@ -26,7 +27,7 @@ export const handlePdfAttachment = async ( return } await uploadToSignedUrl(uploadResult.url, data) - await createArticle(email, uploadResult.id, subject) + await createArticle(email, uploadResult.id, subject, from) } catch (error) { console.error('handlePdfAttachment error', error) } @@ -77,12 +78,14 @@ const uploadToSignedUrl = async ( const createArticle = async ( email: string, uploadFileId: string, - subject: string + subject: string, + from: string ): Promise => { const data = { email, uploadFileId, subject, + from, } if (process.env.JWT_SECRET === undefined) { From 064ea5a782391b8cbb8a41457a4bd4880d5d72f0 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 18 Jan 2023 18:25:32 +0800 Subject: [PATCH 11/16] Add text in the payload in the test --- packages/api/test/routers/emails.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/test/routers/emails.test.ts b/packages/api/test/routers/emails.test.ts index e8bdf5f15..313eba38b 100644 --- a/packages/api/test/routers/emails.test.ts +++ b/packages/api/test/routers/emails.test.ts @@ -37,6 +37,7 @@ describe('Emails Router', () => { const to = newsletterEmail const subject = 'test subject' const html = 'test html' + const text = 'test text' beforeEach(async () => { sinon.replace( @@ -60,7 +61,7 @@ describe('Emails Router', () => { const data = { message: { data: Buffer.from( - JSON.stringify({ from, to, subject, html }) + JSON.stringify({ from, to, subject, html, text }) ).toString('base64'), publishTime: new Date().toISOString(), }, @@ -82,7 +83,7 @@ describe('Emails Router', () => { const data = { message: { data: Buffer.from( - JSON.stringify({ from, to, subject, html }) + JSON.stringify({ from, to, subject, html, text }) ).toString('base64'), publishTime: new Date().toISOString(), }, From 8f9c317dd152db3036d8675f3e323ff4e370f4ba Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 18 Jan 2023 22:35:07 +0800 Subject: [PATCH 12/16] Save received email before publishing data --- .../api/src/resolvers/recent_emails/index.ts | 1 + packages/api/src/routers/svc/emails.ts | 66 +++++++------------ packages/api/src/routers/svc/newsletters.ts | 8 +-- .../api/src/routers/svc/pdf_attachments.ts | 12 +--- packages/api/src/services/received_emails.ts | 23 +------ .../api/src/services/save_newsletter_email.ts | 1 + packages/api/test/routers/emails.test.ts | 2 +- .../services/save_newsletter_email.test.ts | 2 + packages/inbound-email-handler/src/index.ts | 58 ++++++++++++---- packages/inbound-email-handler/src/pdf.ts | 8 +-- 10 files changed, 86 insertions(+), 95 deletions(-) diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts index e7db4ab1b..a6ab31e19 100644 --- a/packages/api/src/resolvers/recent_emails/index.ts +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -99,6 +99,7 @@ export const markEmailAsItemResolver = authorized< content: recentEmail.html, url: generateUniqueUrl(), author: '', + receivedEmailId: recentEmail.id, }, newsletterEmail ) diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index ee02c47cc..e0b855020 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -19,6 +19,9 @@ import { saveReceivedEmail, updateReceivedEmail, } from '../../services/received_emails' +import cors from 'cors' +import { corsConfig } from '../../utils/corsConfig' +import { getClaimsByToken } from '../../utils/auth' interface EmailMessage { from: string @@ -29,6 +32,7 @@ interface EmailMessage { unsubHttpUrl?: string text: string forwardedFrom?: string + receivedEmailId: string } function isEmailMessage(data: any): data is EmailMessage { @@ -37,7 +41,8 @@ function isEmailMessage(data: any): data is EmailMessage { 'to' in data && 'subject' in data && 'html' in data && - 'text' in data + 'text' in data && + 'receivedEmailId' in data ) } @@ -98,13 +103,7 @@ export function emailsServiceRouter() { }) // update received email type - await updateReceivedEmail( - user.id, - data.from, - data.to, - data.subject, - 'article' - ) + await updateReceivedEmail(data.receivedEmailId, 'article') res.status(200).send('Article') return @@ -146,47 +145,36 @@ export function emailsServiceRouter() { } }) - // eslint-disable-next-line @typescript-eslint/no-misused-promises - router.post('/save', async (req, res) => { + router.post('/save', cors(corsConfig), async (req, res) => { logger.info('save received email router') - const { message, expired } = readPushSubscription(req) - - if (!message) { - res.status(400).send('Bad Request') - return + const token = req?.headers?.authorization + if (!(await getClaimsByToken(token))) { + return res.status(401).send('UNAUTHORIZED') } - if (expired) { - logger.info('discards expired message.') - res.status(200).send('Expired') - return + if (!isEmailMessage(req.body)) { + logger.error('Invalid message') + return res.status(400).send('Bad Request') } try { - const data = JSON.parse(message) as unknown - if (!isEmailMessage(data)) { - logger.error('Invalid message') - res.status(400).send('Bad Request') - return - } - // get user from newsletter email - const newsletterEmail = await getNewsletterEmail(data.to) + const newsletterEmail = await getNewsletterEmail(req.body.to) if (!newsletterEmail) { - logger.info('newsletter email not found', { email: data.to }) + logger.info('newsletter email not found', { email: req.body.to }) res.status(200).send('Not Found') return } const user = newsletterEmail.user - await saveReceivedEmail( - data.from, - data.to, - data.subject, - data.text, - data.html, + const receivedEmail = await saveReceivedEmail( + req.body.from, + req.body.to, + req.body.subject, + req.body.text, + req.body.html, user.id ) @@ -198,15 +186,11 @@ export function emailsServiceRouter() { }, }) - res.status(200).send('Received email saved') + res.status(200).send({ id: receivedEmail.id }) } catch (e) { logger.info(e) - if (e instanceof SyntaxError) { - // when message is not a valid json string - res.status(400).send(e) - } else { - res.status(500).send(e) - } + + res.status(500).send(e) } }) diff --git a/packages/api/src/routers/svc/newsletters.ts b/packages/api/src/routers/svc/newsletters.ts index aa8b66d3d..48fee62a4 100644 --- a/packages/api/src/routers/svc/newsletters.ts +++ b/packages/api/src/routers/svc/newsletters.ts @@ -121,13 +121,7 @@ export function newsletterServiceRouter() { } // update received email type - await updateReceivedEmail( - newsletterEmail.user.id, - data.from, - data.email, - data.title, - 'article' - ) + await updateReceivedEmail(data.receivedEmailId, 'article') // We always send 200 if it was a valid message // because we don't want the diff --git a/packages/api/src/routers/svc/pdf_attachments.ts b/packages/api/src/routers/svc/pdf_attachments.ts index e299ce50c..6a858ad23 100644 --- a/packages/api/src/routers/svc/pdf_attachments.ts +++ b/packages/api/src/routers/svc/pdf_attachments.ts @@ -88,11 +88,11 @@ export function pdfAttachmentsRouter() { router.post('/create-article', async (req, res) => { console.log('pdf-attachments/create-article') - const { email, uploadFileId, subject, from } = req.body as { + const { email, uploadFileId, subject, receivedEmailId } = req.body as { email: string uploadFileId: string subject: string - from: string + receivedEmailId: string } const token = req?.headers?.authorization @@ -169,13 +169,7 @@ export function pdfAttachmentsRouter() { }) // update received email type - await updateReceivedEmail( - newsletterEmail.user.id, - from, - email, - subject, - 'article' - ) + await updateReceivedEmail(receivedEmailId, 'article') res.send({ id: pageId }) } catch (err) { diff --git a/packages/api/src/services/received_emails.ts b/packages/api/src/services/received_emails.ts index d37ebd6d8..a3891bfcc 100644 --- a/packages/api/src/services/received_emails.ts +++ b/packages/api/src/services/received_emails.ts @@ -22,27 +22,8 @@ export const saveReceivedEmail = async ( } export const updateReceivedEmail = async ( - userId: string, - from: string, - to: string, - subject: string, + id: string, type: 'article' | 'non-article' ) => { - await getRepository(ReceivedEmail) - .createQueryBuilder() - .update() - .set({ type }) - // .where('user_id = :userId', { userId }) - // .andWhere('from = :from', { from }) - // .andWhere('to = :to', { to }) - // .andWhere('subject = :subject', { subject }) - .where({ - user: { id: userId }, - from, - to, - subject, - }) - .orderBy('created_at', 'DESC') - .limit(1) - .execute() + await getRepository(ReceivedEmail).update(id, { type }) } diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index 2c9b07853..4be80572b 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -23,6 +23,7 @@ export interface NewsletterMessage { unsubMailTo?: string unsubHttpUrl?: string text: string + receivedEmailId: string } // Returns true if the link was created successfully. Can still fail to diff --git a/packages/api/test/routers/emails.test.ts b/packages/api/test/routers/emails.test.ts index 313eba38b..ef6229126 100644 --- a/packages/api/test/routers/emails.test.ts +++ b/packages/api/test/routers/emails.test.ts @@ -36,7 +36,7 @@ describe('Emails Router', () => { const from = 'from@omnivore.app' const to = newsletterEmail const subject = 'test subject' - const html = 'test html' + const html = 'test html' const text = 'test text' beforeEach(async () => { diff --git a/packages/api/test/services/save_newsletter_email.test.ts b/packages/api/test/services/save_newsletter_email.test.ts index 028ff0a76..9295024a3 100644 --- a/packages/api/test/services/save_newsletter_email.test.ts +++ b/packages/api/test/services/save_newsletter_email.test.ts @@ -49,6 +49,7 @@ describe('saveNewsletterEmail', () => { url, title, author, + receivedEmailId: '', }, newsletterEmail, ctx @@ -83,6 +84,7 @@ describe('saveNewsletterEmail', () => { author, from: 'fake from', text: 'fake text', + receivedEmailId: '', }, newsletterEmail, ctx diff --git a/packages/inbound-email-handler/src/index.ts b/packages/inbound-email-handler/src/index.ts index b111b5e8f..b987178eb 100644 --- a/packages/inbound-email-handler/src/index.ts +++ b/packages/inbound-email-handler/src/index.ts @@ -14,10 +14,18 @@ import { import { PubSub } from '@google-cloud/pubsub' import { handlePdfAttachment } from './pdf' import { handleNewsletter } from '@omnivore/content-handler' +import axios from 'axios' +import { promisify } from 'util' +import * as jwt from 'jsonwebtoken' + +interface SaveReceivedEmailResponse { + id: string +} + +const signToken = promisify(jwt.sign) const NEWSLETTER_EMAIL_RECEIVED_TOPIC = 'newsletterEmailReceived' const NON_NEWSLETTER_EMAIL_TOPIC = 'nonNewsletterEmailReceived' -const RECEIVED_EMAIL_TOPIC = 'receivedEmail' const pubsub = new PubSub() export const publishMessage = async ( @@ -33,8 +41,31 @@ export const publishMessage = async ( }) } -const publishReceivedEmail = async (email: any): Promise => { - await publishMessage(RECEIVED_EMAIL_TOPIC, email) +const saveReceivedEmail = async ( + email: string, + data: any +): Promise => { + if (process.env.JWT_SECRET === undefined) { + throw new Error('JWT_SECRET is not defined') + } + const auth = await signToken(email, process.env.JWT_SECRET) + + if (process.env.INTERNAL_SVC_ENDPOINT === undefined) { + throw new Error('REST_BACKEND_ENDPOINT is not defined') + } + + const response = await axios.post( + `${process.env.INTERNAL_SVC_ENDPOINT}svc/pubsub/emails/save`, + data, + { + headers: { + Authorization: `${auth as string}`, + 'Content-Type': 'application/json', + }, + } + ) + + return response.data as SaveReceivedEmailResponse } export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( @@ -81,15 +112,15 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( const postHeader = headers['list-post']?.toString() const unSubHeader = headers['list-unsubscribe']?.toString() - try { - await publishReceivedEmail({ - from, - to, - subject, - html, - text, - }) + const { id: receivedEmailId } = await saveReceivedEmail(to, { + from, + to, + subject, + html, + text, + }) + try { // check if it is a confirmation email or forwarding newsletter const newsletterMessage = await handleNewsletter({ from, @@ -104,6 +135,7 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( ...newsletterMessage, text, from, + receivedEmailId, }) return res.status(200).send('newsletter received') } @@ -123,7 +155,7 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( pdfAttachmentName, pdfAttachment, subject, - from + receivedEmailId ) return res.send('ok') } @@ -140,6 +172,7 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( unsubMailTo: unsubscribe.mailTo, unsubHttpUrl: unsubscribe.httpUrl, forwardedFrom, + receivedEmailId, }, }) @@ -155,6 +188,7 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( html, text, forwardedFrom, + receivedEmailId, }, }) } diff --git a/packages/inbound-email-handler/src/pdf.ts b/packages/inbound-email-handler/src/pdf.ts index 6cff1b165..6f60c5d62 100644 --- a/packages/inbound-email-handler/src/pdf.ts +++ b/packages/inbound-email-handler/src/pdf.ts @@ -14,7 +14,7 @@ export const handlePdfAttachment = async ( fileName: string | undefined, data: Buffer, subject: string, - from: string + receivedEmailId: string ): Promise => { console.log('handlePdfAttachment', email, fileName) @@ -27,7 +27,7 @@ export const handlePdfAttachment = async ( return } await uploadToSignedUrl(uploadResult.url, data) - await createArticle(email, uploadResult.id, subject, from) + await createArticle(email, uploadResult.id, subject, receivedEmailId) } catch (error) { console.error('handlePdfAttachment error', error) } @@ -79,13 +79,13 @@ const createArticle = async ( email: string, uploadFileId: string, subject: string, - from: string + receivedEmailId: string ): Promise => { const data = { email, uploadFileId, subject, - from, + receivedEmailId, } if (process.env.JWT_SECRET === undefined) { From 7dbf8662fc636beffcb1e434a59403703e74eb7a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 19 Jan 2023 09:38:21 +0800 Subject: [PATCH 13/16] Fix test --- .../api/src/resolvers/recent_emails/index.ts | 7 +--- .../api/src/services/save_newsletter_email.ts | 4 ++ packages/api/test/routers/emails.test.ts | 38 ++++++++++++++++--- .../services/save_newsletter_email.test.ts | 31 ++++++++++++--- packages/inbound-email-handler/src/index.ts | 2 - 5 files changed, 63 insertions(+), 19 deletions(-) diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts index a6ab31e19..3eb685544 100644 --- a/packages/api/src/resolvers/recent_emails/index.ts +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -12,10 +12,7 @@ import { getRepository } from '../../entity/utils' import { ReceivedEmail } from '../../entity/received_email' import { saveNewsletterEmail } from '../../services/save_newsletter_email' import { NewsletterEmail } from '../../entity/newsletter_email' -import { v4 as uuid } from 'uuid' - -const FAKE_URL_PREFIX = 'https://omnivore.app/no_url?q=' -const generateUniqueUrl = () => FAKE_URL_PREFIX + uuid() +import { generateUniqueUrl, parseEmailAddress } from '../../utils/parser' export const recentEmailsResolver = authorized< RecentEmailsSuccess, @@ -98,7 +95,7 @@ export const markEmailAsItemResolver = authorized< text: recentEmail.text, content: recentEmail.html, url: generateUniqueUrl(), - author: '', + author: parseEmailAddress(recentEmail.from).name, receivedEmailId: recentEmail.id, }, newsletterEmail diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index 4be80572b..bc4bcbfd2 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -12,6 +12,7 @@ import { NewsletterEmail } from '../entity/newsletter_email' import { fetchFavicon } from '../utils/parser' import { updatePage } from '../elastic/pages' import { isBase64Image } from '../utils/helpers' +import { updateReceivedEmail } from './received_emails' export interface NewsletterMessage { from: string @@ -63,6 +64,9 @@ export const saveNewsletterEmail = async ( return false } + // update received email type + await updateReceivedEmail(data.receivedEmailId, 'article') + if (!page.siteIcon || isBase64Image(page.siteIcon)) { // fetch favicon if not already set or is a base64 image const favicon = await fetchFavicon(page.url) diff --git a/packages/api/test/routers/emails.test.ts b/packages/api/test/routers/emails.test.ts index ef6229126..21486c5e5 100644 --- a/packages/api/test/routers/emails.test.ts +++ b/packages/api/test/routers/emails.test.ts @@ -11,12 +11,19 @@ import { request } from '../util' import * as parser from '../../src/utils/parser' import * as sendNotification from '../../src/utils/sendNotification' import * as sendEmail from '../../src/utils/sendEmail' +import { getRepository } from '../../src/entity/utils' +import { ReceivedEmail } from '../../src/entity/received_email' describe('Emails Router', () => { const newsletterEmail = 'fakeUser@omnivore.app' + const from = 'fake from' + const subject = 'fake subject' + const text = 'fake text' + const to = newsletterEmail let user: User let token: string + let receivedEmail: ReceivedEmail before(async () => { // create test user and login @@ -24,6 +31,15 @@ describe('Emails Router', () => { await createTestNewsletterEmail(user, newsletterEmail) token = process.env.PUBSUB_VERIFICATION_TOKEN! + receivedEmail = await getRepository(ReceivedEmail).save({ + user: { id: user.id }, + from, + to, + subject, + text, + html: '', + type: 'non-article', + }) }) after(async () => { @@ -33,11 +49,7 @@ describe('Emails Router', () => { }) describe('forward', () => { - const from = 'from@omnivore.app' - const to = newsletterEmail - const subject = 'test subject' const html = 'test html' - const text = 'test text' beforeEach(async () => { sinon.replace( @@ -61,7 +73,14 @@ describe('Emails Router', () => { const data = { message: { data: Buffer.from( - JSON.stringify({ from, to, subject, html, text }) + JSON.stringify({ + from, + to, + subject, + html, + text, + receivedEmailId: receivedEmail.id, + }) ).toString('base64'), publishTime: new Date().toISOString(), }, @@ -83,7 +102,14 @@ describe('Emails Router', () => { const data = { message: { data: Buffer.from( - JSON.stringify({ from, to, subject, html, text }) + JSON.stringify({ + from, + to, + subject, + html, + text, + receivedEmailId: receivedEmail.id, + }) ).toString('base64'), publishTime: new Date().toISOString(), }, diff --git a/packages/api/test/services/save_newsletter_email.test.ts b/packages/api/test/services/save_newsletter_email.test.ts index 9295024a3..3de0be158 100644 --- a/packages/api/test/services/save_newsletter_email.test.ts +++ b/packages/api/test/services/save_newsletter_email.test.ts @@ -12,15 +12,19 @@ import { getPageByParam } from '../../src/elastic/pages' import nock from 'nock' import { getRepository } from '../../src/entity/utils' import { Subscription } from '../../src/entity/subscription' +import { ReceivedEmail } from '../../src/entity/received_email' describe('saveNewsletterEmail', () => { const fakeContent = 'fake content' const title = 'fake title' const author = 'fake author' + const from = 'fake from' + const text = 'fake text' let user: User let newsletterEmail: NewsletterEmail let ctx: SaveContext + let receivedEmail: ReceivedEmail before(async () => { user = await createTestUser('fakeUser') @@ -30,6 +34,15 @@ describe('saveNewsletterEmail', () => { refresh: true, uid: user.id, } + receivedEmail = await getRepository(ReceivedEmail).save({ + user: { id: user.id }, + from, + to: newsletterEmail.address, + subject: title, + text, + html: '', + type: 'non-article', + }) }) after(async () => { @@ -42,14 +55,14 @@ describe('saveNewsletterEmail', () => { await saveNewsletterEmail( { - from: 'fake from', - text: 'fake text', + from, + text, email: newsletterEmail.address, content: `${fakeContent}`, url, title, author, - receivedEmailId: '', + receivedEmailId: receivedEmail.id, }, newsletterEmail, ctx @@ -66,6 +79,12 @@ describe('saveNewsletterEmail', () => { newsletterEmail: { id: newsletterEmail.id }, }) expect(subscriptions).not.to.be.empty + + // check if the received email was updated + const updatedReceivedEmail = await getRepository(ReceivedEmail).findOneBy({ + id: receivedEmail.id, + }) + expect(updatedReceivedEmail?.type).to.equal('article') }) it('should adds a Newsletter label to that page', async () => { @@ -82,9 +101,9 @@ describe('saveNewsletterEmail', () => { url, title, author, - from: 'fake from', - text: 'fake text', - receivedEmailId: '', + from, + text, + receivedEmailId: receivedEmail.id, }, newsletterEmail, ctx diff --git a/packages/inbound-email-handler/src/index.ts b/packages/inbound-email-handler/src/index.ts index b987178eb..5b214b1dd 100644 --- a/packages/inbound-email-handler/src/index.ts +++ b/packages/inbound-email-handler/src/index.ts @@ -133,8 +133,6 @@ export const inboundEmailHandler = Sentry.GCPFunction.wrapHttpFunction( if (newsletterMessage) { await publishMessage(NEWSLETTER_EMAIL_RECEIVED_TOPIC, { ...newsletterMessage, - text, - from, receivedEmailId, }) return res.status(200).send('newsletter received') From 318a6557a5ae4feb20c9b4de87335739be0e553a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 19 Jan 2023 09:55:49 +0800 Subject: [PATCH 14/16] Email us if a recent email marked as library item --- packages/api/src/resolvers/recent_emails/index.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts index 3eb685544..fa9e258a0 100644 --- a/packages/api/src/resolvers/recent_emails/index.ts +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -13,6 +13,8 @@ import { ReceivedEmail } from '../../entity/received_email' import { saveNewsletterEmail } from '../../services/save_newsletter_email' import { NewsletterEmail } from '../../entity/newsletter_email' import { generateUniqueUrl, parseEmailAddress } from '../../utils/parser' +import { sendEmail } from '../../utils/sendEmail' +import { env } from '../../env' export const recentEmailsResolver = authorized< RecentEmailsSuccess, @@ -101,6 +103,19 @@ export const markEmailAsItemResolver = authorized< newsletterEmail ) + const text = `A recent email marked as a library item + by: ${claims.uid} + from: ${recentEmail.from} + subject: ${recentEmail.subject}` + + // email us to let us know that an email failed to parse as an article + await sendEmail({ + to: env.sender.feedback, + subject: 'A recent email marked as a library item', + text, + from: env.sender.message, + }) + return { success, } From 83c62c65a37c88b090cf694dc3996b7c9c6fa960 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 19 Jan 2023 10:44:08 +0800 Subject: [PATCH 15/16] Add test for saving received email --- .../api/src/resolvers/recent_emails/index.ts | 1 - packages/api/src/routers/svc/emails.ts | 3 +-- .../api/src/services/save_newsletter_email.ts | 1 - .../api/test/resolvers/recent_emails.test.ts | 0 packages/api/test/routers/emails.test.ts | 27 +++++++++++++++++++ .../services/save_newsletter_email.test.ts | 2 -- 6 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 packages/api/test/resolvers/recent_emails.test.ts diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts index fa9e258a0..dad81beef 100644 --- a/packages/api/src/resolvers/recent_emails/index.ts +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -94,7 +94,6 @@ export const markEmailAsItemResolver = authorized< from: recentEmail.from, email: recentEmail.to, title: recentEmail.subject, - text: recentEmail.text, content: recentEmail.html, url: generateUniqueUrl(), author: parseEmailAddress(recentEmail.from).name, diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index e0b855020..10a7d949f 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -41,8 +41,7 @@ function isEmailMessage(data: any): data is EmailMessage { 'to' in data && 'subject' in data && 'html' in data && - 'text' in data && - 'receivedEmailId' in data + 'text' in data ) } diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index bc4bcbfd2..81e604d2c 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -23,7 +23,6 @@ export interface NewsletterMessage { author: string unsubMailTo?: string unsubHttpUrl?: string - text: string receivedEmailId: string } diff --git a/packages/api/test/resolvers/recent_emails.test.ts b/packages/api/test/resolvers/recent_emails.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/api/test/routers/emails.test.ts b/packages/api/test/routers/emails.test.ts index 21486c5e5..1b68043b1 100644 --- a/packages/api/test/routers/emails.test.ts +++ b/packages/api/test/routers/emails.test.ts @@ -13,6 +13,7 @@ import * as sendNotification from '../../src/utils/sendNotification' import * as sendEmail from '../../src/utils/sendEmail' import { getRepository } from '../../src/entity/utils' import { ReceivedEmail } from '../../src/entity/received_email' +import * as jwt from 'jsonwebtoken' describe('Emails Router', () => { const newsletterEmail = 'fakeUser@omnivore.app' @@ -122,4 +123,30 @@ describe('Emails Router', () => { }) }) }) + + describe('create', () => { + const html = 'test html' + const text = 'test text' + const from = 'fake from' + const subject = 'fake subject' + const authToken = jwt.sign(newsletterEmail, process.env.JWT_SECRET || '') + + it('saves the email in the database', async () => { + const data = { + html, + text, + from, + to: newsletterEmail, + subject, + } + const res = await request + .post('/svc/pubsub/emails/save') + .set('Authorization', `${authToken}`) + .send(data) + .expect(200) + + console.log(res.body) + expect(res.body.id).not.to.be.undefined + }) + }) }) diff --git a/packages/api/test/services/save_newsletter_email.test.ts b/packages/api/test/services/save_newsletter_email.test.ts index 3de0be158..e77f64e61 100644 --- a/packages/api/test/services/save_newsletter_email.test.ts +++ b/packages/api/test/services/save_newsletter_email.test.ts @@ -56,7 +56,6 @@ describe('saveNewsletterEmail', () => { await saveNewsletterEmail( { from, - text, email: newsletterEmail.address, content: `${fakeContent}`, url, @@ -102,7 +101,6 @@ describe('saveNewsletterEmail', () => { title, author, from, - text, receivedEmailId: receivedEmail.id, }, newsletterEmail, From a682d87aa69e7994b80f46c9f60beab0398dc7b5 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 19 Jan 2023 12:11:34 +0800 Subject: [PATCH 16/16] Add tests for recent emails resolver --- .../api/src/resolvers/recent_emails/index.ts | 14 +- .../api/test/resolvers/recent_emails.test.ts | 146 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/api/src/resolvers/recent_emails/index.ts b/packages/api/src/resolvers/recent_emails/index.ts index dad81beef..58fccb865 100644 --- a/packages/api/src/resolvers/recent_emails/index.ts +++ b/packages/api/src/resolvers/recent_emails/index.ts @@ -72,18 +72,26 @@ export const markEmailAsItemResolver = authorized< const recentEmail = await getRepository(ReceivedEmail).findOneBy({ id: recentEmailId, user: { id: claims.uid }, + type: 'non-article', }) if (!recentEmail) { + log.info('no recent email', recentEmailId) + return { errorCodes: [MarkEmailAsItemErrorCode.Unauthorized], } } - const newsletterEmail = await getRepository(NewsletterEmail).findOneBy({ - address: recentEmail.to, - user: { id: claims.uid }, + const newsletterEmail = await getRepository(NewsletterEmail).findOne({ + where: { + address: recentEmail.to, + user: { id: claims.uid }, + }, + relations: ['user'], }) if (!newsletterEmail) { + log.info('no newsletter email', recentEmail.to) + return { errorCodes: [MarkEmailAsItemErrorCode.NotFound], } diff --git a/packages/api/test/resolvers/recent_emails.test.ts b/packages/api/test/resolvers/recent_emails.test.ts index e69de29bb..3bae6f7b1 100644 --- a/packages/api/test/resolvers/recent_emails.test.ts +++ b/packages/api/test/resolvers/recent_emails.test.ts @@ -0,0 +1,146 @@ +import 'mocha' +import { expect } from 'chai' +import { User } from '../../src/entity/user' +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import { getRepository } from '../../src/entity/utils' +import { ReceivedEmail } from '../../src/entity/received_email' +import { NewsletterEmail } from '../../src/entity/newsletter_email' + +describe('Recent Emails Resolver', () => { + const username = 'fakeUser' + + let user: User + let authToken: string + let newsletterEmail: NewsletterEmail + let newsletterEmail2: NewsletterEmail + + before(async () => { + // create test user and login + user = await createTestUser(username) + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + authToken = res.body.authToken + + // create test newsletter email + newsletterEmail = await getRepository(NewsletterEmail).save({ + user: { id: user.id }, + address: 'fake email address', + }) + newsletterEmail2 = await getRepository(NewsletterEmail).save({ + user: { id: user.id }, + address: 'fake email address 2', + }) + }) + + after(async () => { + // clean up + await deleteTestUser(user.id) + }) + + describe('recentEmails', () => { + const recentEmailsQuery = ` + query { + recentEmails { + ... on RecentEmailsSuccess { + recentEmails { + id + from + to + subject + text + html + } + } + ... on RecentEmailsError { + errorCodes + } + } + } + ` + let recentEmails: ReceivedEmail[] + + before(async () => { + // create fake emails + const recentEmail = await getRepository(ReceivedEmail).save({ + user: { id: user.id }, + from: 'fake from', + subject: 'fake subject', + text: 'fake text', + html: 'fake html', + to: newsletterEmail.address, + type: 'article', + }) + const recentEmail2 = await getRepository(ReceivedEmail).save({ + user: { id: user.id }, + from: 'fake from 2', + subject: 'fake subject 2', + text: 'fake text 2', + html: 'fake html 2', + to: newsletterEmail2.address, + type: 'non-article', + }) + recentEmails = [recentEmail, recentEmail2] + }) + + it('returns recent emails', async () => { + const res = await graphqlRequest(recentEmailsQuery, authToken).expect(200) + const { recentEmails: results } = res.body.data.recentEmails + + expect(results).to.have.lengthOf(2) + expect(results[0].id).to.eql(recentEmails[1].id) + expect(results[1].id).to.eql(recentEmails[0].id) + }) + }) + + describe('markEmailAsItem', () => { + const markEmailAsItemMutation = (recentEmailId: string) => ` + mutation { + markEmailAsItem(recentEmailId: "${recentEmailId}") { + ... on MarkEmailAsItemSuccess { + success + } + ... on MarkEmailAsItemError { + errorCodes + } + } + } + ` + + let recentEmail: ReceivedEmail + + before(async () => { + // create fake email + recentEmail = await getRepository(ReceivedEmail).save({ + user: { id: user.id }, + from: 'Omnivore Newsletter ', + subject: 'fake subject 3', + text: 'fake text 3', + html: 'fake html 3', + to: newsletterEmail.address, + type: 'non-article', + }) + }) + + after(async () => { + // clean up + await getRepository(ReceivedEmail).delete(recentEmail.id) + }) + + it('marks email as item', async () => { + const resp = await graphqlRequest( + markEmailAsItemMutation(recentEmail.id), + authToken + ) + + expect(resp.body.data.markEmailAsItem.success).to.be.true + + const updatedRecentEmail = await getRepository(ReceivedEmail).findOneBy({ + id: recentEmail.id, + }) + expect(updatedRecentEmail?.type).to.eql('article') + }) + }) +})