Skip to content

Commit

Permalink
Set up boilerplate for Gmail integration (papercups-io#244)
Browse files Browse the repository at this point in the history
* Set up boilerplate for gmail integration

* Start refactoring a bit

* Remove goth dep

* Add send_message method to gmail module

* Add utility method for decoding base64 encoded email message bodies

* Add tooltip for clarity
  • Loading branch information
reichert621 authored Sep 25, 2020
1 parent db009f3 commit 6628870
Show file tree
Hide file tree
Showing 24 changed files with 724 additions and 41 deletions.
47 changes: 47 additions & 0 deletions assets/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,38 @@ export const fetchSlackAuthorization = async (token = getAccessToken()) => {
.then((res) => res.body.data);
};

export const fetchGmailAuthorization = async (token = getAccessToken()) => {
if (!token) {
throw new Error('Invalid token!');
}

return request
.get(`/api/gmail/authorization`)
.set('Authorization', token)
.then((res) => res.body.data);
};

export type EmailParams = {
recipient: string;
subject: string;
message: string;
};

export const sendGmailNotification = async (
{recipient, subject, message}: EmailParams,
token = getAccessToken()
) => {
if (!token) {
throw new Error('Invalid token!');
}

return request
.post(`/api/gmail/send`)
.send({recipient, subject, message})
.set('Authorization', token)
.then((res) => res.body.data);
};

export const fetchEventSubscriptions = async (token = getAccessToken()) => {
if (!token) {
throw new Error('Invalid token!');
Expand Down Expand Up @@ -478,6 +510,21 @@ export const authorizeSlackIntegration = async (
.then((res) => res.body.data);
};

export const authorizeGmailIntegration = async (
code: string,
token = getAccessToken()
) => {
if (!token) {
throw new Error('Invalid token!');
}

return request
.get(`/api/gmail/oauth`)
.query({code})
.set('Authorization', token)
.then((res) => res.body.data);
};

export const updateWidgetSettings = async (
widgetSettingsParams: WidgetSettingsParams,
token = getAccessToken()
Expand Down
25 changes: 18 additions & 7 deletions assets/src/components/integrations/IntegrationsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ class IntegrationsOverview extends React.Component<Props, State> {

async componentDidMount() {
try {
const {match, location} = this.props;
const {match, location, history} = this.props;
const {search} = location;
const {type} = match.params;

if (type) {
await this.handleIntegrationType(type, search);

history.push('/integrations');
}

const integrations = await Promise.all([
Expand Down Expand Up @@ -70,11 +72,13 @@ class IntegrationsOverview extends React.Component<Props, State> {
};

fetchGmailIntegration = async (): Promise<IntegrationType> => {
const auth = await API.fetchGmailAuthorization();

return {
key: 'gmail',
integration: 'Gmail',
status: 'not_connected',
created_at: null,
integration: 'Gmail (beta)',
status: auth ? 'connected' : 'not_connected',
created_at: auth ? auth.created_at : null,
icon: '/gmail.svg',
};
};
Expand Down Expand Up @@ -109,16 +113,23 @@ class IntegrationsOverview extends React.Component<Props, State> {
};
};

handleIntegrationType = (type: string, query: string = '') => {
handleIntegrationType = async (type: string, query: string = '') => {
const q = qs.parse(query);
const code = q.code ? String(q.code) : null;

if (!code) {
return null;
}

switch (type) {
case 'slack':
const code = String(q.code);

return API.authorizeSlackIntegration(code).catch((err) =>
logger.error('Failed to authorize Slack:', err)
);
case 'gmail':
return API.authorizeGmailIntegration(code).catch((err) =>
logger.error('Failed to authorize Gmail:', err)
);
default:
return null;
}
Expand Down
58 changes: 42 additions & 16 deletions assets/src/components/integrations/IntegrationsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import React from 'react';
import dayjs from 'dayjs';
import {Flex} from 'theme-ui';
import {Box, Flex} from 'theme-ui';
import qs from 'query-string';
import {colors, Button, Table, Tag, Text} from '../common';
import {SLACK_CLIENT_ID} from '../../config';
import {colors, Button, Table, Tag, Text, Tooltip} from '../common';
import {SLACK_CLIENT_ID, isDev} from '../../config';
import {IntegrationType} from './support';

const getSlackAuthUrl = () => {
// NB: when testing locally, update `origin` to an ngrok url
// pointing at localhost:4000 (or wherever your server is running)
const origin = window.location.origin;
const redirect = `${origin}/integrations/slack`;
const q = {
scope:
'incoming-webhook chat:write channels:history channels:manage chat:write.public users:read users:read.email',
user_scope: 'channels:history',
client_id: SLACK_CLIENT_ID,
redirect_uri: redirect,
};
const query = qs.stringify(q);

return `https://slack.com/oauth/v2/authorize?${query}`;
};

const getGmailAuthUrl = () => {
const origin = isDev ? 'http://localhost:4000' : window.location.origin;

return `${origin}/gmail/auth`;
};

const IntegrationsTable = ({
integrations,
}: {
Expand Down Expand Up @@ -60,26 +83,29 @@ const IntegrationsTable = ({
render: (action: any, record: any) => {
const {key, status} = record;
const isConnected = status === 'connected';
// NB: when testing locally, update `origin` to an ngrok url
// pointing at localhost:4000 (or wherever your server is running)
const origin = window.location.origin;
const redirect = `${origin}/integrations/slack`;
const q = {
scope:
'incoming-webhook chat:write channels:history channels:manage chat:write.public users:read users:read.email',
user_scope: 'channels:history',
client_id: SLACK_CLIENT_ID,
redirect_uri: redirect,
};
const query = qs.stringify(q);

switch (key) {
case 'slack':
return (
<a href={`https://slack.com/oauth/v2/authorize?${query}`}>
<a href={getSlackAuthUrl()}>
<Button>{isConnected ? 'Reconnect' : 'Connect'}</Button>
</a>
);
case 'gmail':
return (
<Tooltip
title={
<Box>
Our verification with the Google API is pending, but you can
still link your Gmail account to opt into new features.
</Box>
}
>
<a href={getGmailAuthUrl()}>
<Button>{isConnected ? 'Reconnect' : 'Connect'}</Button>
</a>
</Tooltip>
);
default:
return <Button disabled>Coming soon!</Button>;
}
Expand Down
4 changes: 3 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,14 @@ mailgun_api_key = System.get_env("MAILGUN_API_KEY")
domain = System.get_env("DOMAIN")

if mailgun_api_key != nil and domain != nil do
config :chat_api, ChatApi.Mailer,
config :chat_api, ChatApi.Mailers.Mailgun,
adapter: Swoosh.Adapters.Mailgun,
api_key: mailgun_api_key,
domain: domain
end

config :chat_api, ChatApi.Mailers.Gmail, adapter: Swoosh.Adapters.Gmail

site_id = System.get_env("CUSTOMER_IO_SITE_ID")
customerio_api_key = System.get_env("CUSTOMER_IO_API_KEY")

Expand Down
34 changes: 33 additions & 1 deletion lib/chat_api/emails.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ defmodule ChatApi.Emails do
|> deliver()
end

def send_via_gmail(
to: to,
from: from,
subject: subject,
message: message,
access_token: access_token
) do
Email.generic(
to: to,
from: from,
subject: subject,
message: message
)
|> deliver(access_token: access_token)
end

def get_users_to_email(account_id) do
query =
from(u in User,
Expand Down Expand Up @@ -74,7 +90,7 @@ defmodule ChatApi.Emails do
# TODO: Find a better solution besides try catch probably in config.exs setup an empty mailer that doesn't do anything
try do
if has_valid_to_addresses?(email) do
ChatApi.Mailer.deliver(email)
ChatApi.Mailers.Mailgun.deliver(email)
else
{:warning, "Skipped sending to potentially invalid email: #{inspect(email.to)}"}
end
Expand All @@ -88,6 +104,22 @@ defmodule ChatApi.Emails do
end
end

# TODO: figure out how to clean this up
def deliver(email, access_token: access_token) do
try do
if has_valid_to_addresses?(email) do
ChatApi.Mailers.Gmail.deliver(email, access_token: access_token)
else
{:warning, "Skipped sending to potentially invalid email: #{inspect(email.to)}"}
end
rescue
e ->
IO.puts("Error sending via Gmail: #{e.message}")

{:error, e.message}
end
end

defp disable_validity_check?() do
case System.get_env("DISABLE_EMAIL_VALIDITY_CHECK") do
x when x == "1" or x == "true" -> true
Expand Down
8 changes: 8 additions & 0 deletions lib/chat_api/emails/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ defmodule ChatApi.Emails.Email do

defstruct to_address: nil, message: nil

def generic(to: to, from: from, subject: subject, message: message) do
new()
|> to(to)
|> from(from)
|> subject(subject)
|> text_body(message)
end

# TODO: Move conversation id out the mailer should only care about the message
def new_message_alert(to_address, message, conversation_id) do
link =
Expand Down
Loading

0 comments on commit 6628870

Please sign in to comment.