Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/khaki-friends-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@openfn/language-googlesheets': minor
'@openfn/language-googledrive': minor
'@openfn/language-gmail': minor
---

Add support for Google Service Account credential
37 changes: 33 additions & 4 deletions packages/gmail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,25 @@ sendMessage({

This will send an email with two plain attachments and one ZIP archive containing two files.

# Acquiring an access token
# Authentication

This adaptor supports two authentication methods: **OAuth2** and **Service Account**.

## OAuth2

**On app.openfn.org:** Click **Sign in with Gmail** in the credential setup form. Authentication is handled for you and no manual token management is needed.

**Using the OpenFn CLI locally:** Use the [gcloud CLI](https://cloud.google.com/sdk/docs/install) to print a temporary access token:

```bash
gcloud auth print-access-token
```

The Gmail adaptor implicitly uses the Gmail account of the Google account that is used to authenticate the application.

Allowing the Gmail adaptor to access a Gmail account is a multi-step process.

## Create an OAuth 2.0 client ID
### Create an OAuth 2.0 client ID

Follow the instructions are found here:
https://support.google.com/googleapi/answer/6158849
Expand All @@ -272,7 +284,7 @@ https://support.google.com/googleapi/answer/6158849
- Click "Create"
- On the resulting popup screen, find and click "DOWNLOAD JSON" and save this file to a secure location.

## Retrieve an access token
### Retrieve an access token

- Navigate to [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/).
- Find *Step 1 Select & authorize APIs*:
Expand All @@ -295,7 +307,7 @@ https://support.google.com/googleapi/answer/6158849
- *Refresh token* and *Access token* will be populated briefly before the interface automatically advances to *Step 3 Configure request to API*. To view the *Access token*, return to *Step 2 Exchange authorization code for tokens*.
- The *Access token* is valid for 1 hour. You may enable **Auto-refresh the token before it expires** or manually refresh it using the **Refresh access token** button.

## Configure OpenFn CLI to find the access token
### Configure OpenFn CLI to find the access token

The Gmail adaptor looks for the access token in the configuration section under `access_token`.

Expand All @@ -317,3 +329,20 @@ Example configuration using a workflow:
]
}
```

## Service Account

Service accounts allow the adaptor to access Gmail without user interaction,
making them well-suited for automated workflows.

> **Important:** Gmail service account access requires **Google Workspace**.
> It does **not** work with personal Gmail accounts (`@gmail.com`). If your
> organisation uses personal Google accounts, use the OAuth2 method instead.

To create a service account and JSON key file, follow the
[Google Cloud service account documentation](https://cloud.google.com/iam/docs/service-accounts-create).
Your OpenFn credential requires the `client_email` and `private_key` fields from the downloaded JSON key file. You can also optionally provide a `subject` field for impersonation -- see [Google's domain-wide delegation guide](https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority) for details.

For enabling domain-wide delegation and authorising the required Gmail API
scopes, see the
[Google Workspace domain-wide delegation guide](https://support.google.com/a/answer/162106).
45 changes: 40 additions & 5 deletions packages/gmail/configuration-schema.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$comment": "OAuth2",
"type": "object",
"properties": {
"access_token": {
"title": "Access Token",
"type": "string",
"description": "Your Gmail access token",
"description": "Your Gmail OAuth2 access token",
"writeOnly": true,
"minLength": 1,
"examples": [
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjlGWERwYmhax0rZNRlQyU3ZRdVhoODQ2WVR3RUlCdyIsI"
]
},
"private_key": {
"title": "Private Key",
"type": "string",
"description": "RSA private key from the Google service account JSON key file",
"writeOnly": true,
"minLength": 1,
"examples": [
"-----BEGIN RSA PRIVATE KEY-----\nMIIEow..."
]
},
"client_email": {
"title": "Client Email",
"type": "string",
"description": "Service account email address (e.g. my-app@project-id.iam.gserviceaccount.com)",
"minLength": 1,
"examples": [
"my-app@project-id.iam.gserviceaccount.com"
]
},
"subject": {
"title": "Subject Email",
"type": "string",
"description": "Gmail user to impersonate via domain-wide delegation (e.g. user@yourdomain.com). Required for service account access to Gmail.",
"examples": [
"user@yourdomain.com"
]
}
},
"type": "object",
"additionalProperties": true,
"required": ["access_token"]
"oneOf": [

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am worried this will break on lightning 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josephjclark would love you're input on this, I think lightning is expecting required filed not "oneOf".
Should we have have two different schema maybe 🤷🏽 ? But again lightning need to support both of those schema

{
"$comment": "OAuth2",
"required": ["access_token"]
},
{
"$comment": "Service Account",
"required": ["private_key", "client_email"]
}
],
"additionalProperties": true
}
1 change: 1 addition & 0 deletions packages/gmail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"deep-eql": "4.1.1",
"mocha": "^10.8.2",
"rimraf": "3.0.2",
"sinon": "^19.0.4",
"undici": "^7.24.7"
},
"repository": {
Expand Down
7 changes: 6 additions & 1 deletion packages/gmail/src/Adaptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,19 @@ export function execute(...operations) {
};

return state => {
const isServiceAccount =
state.configuration?.private_key && state.configuration?.client_email;

return commonExecute(
createConnection,
...operations,
removeConnection
)({
...initialState,
...state,
configuration: normalizeOauthConfig(state.configuration),
configuration: isServiceAccount
? state.configuration
: normalizeOauthConfig(state.configuration),
});
};
}
Expand Down
57 changes: 40 additions & 17 deletions packages/gmail/src/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function getMessageResult(userId, messageId) {

export function getContentIndicators(
defaultContentRequests = [],
contentRequests = []
contentRequests = [],
) {
const contentIndicators = contentRequests.map(getContentIndicator);
const contentNames = new Set(contentIndicators.map(({ name }) => name));
Expand Down Expand Up @@ -68,7 +68,7 @@ function getContentIndicator(contentRequest) {

if (!contentIndicator.type) {
console.error(
`Unable to determine desired content type: ${contentRequest}`
`Unable to determine desired content type: ${contentRequest}`,
);
throw new Error('No desired content type provided.');
}
Expand Down Expand Up @@ -127,7 +127,7 @@ export async function buildAndSendMessage(message) {
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; filename="${file}"`,
'',
attachment.content
attachment.content,
);
}

Expand Down Expand Up @@ -183,11 +183,34 @@ async function parseArchiveAttachment(attachment) {
};
}

export function createConnection(state) {
const { access_token } = state.configuration;

const auth = new google.auth.OAuth2();
auth.credentials = { access_token };
export async function createConnection(state) {
const {
access_token,
private_key,
client_email,
subject,
scopes = [],
} = state.configuration;

const mandatoryScopes = [
'https://mail.google.com/',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'openid',
];
let auth;
if (private_key && client_email) {
auth = new google.auth.JWT({
email: client_email,
key: private_key,
scopes: [...mandatoryScopes, ...scopes],
subject,
});
await auth.authorize();
} else {
auth = new google.auth.OAuth2();
auth.credentials = { access_token };
}

gmail = google.gmail({ version: 'v1', auth });

Expand All @@ -202,19 +225,19 @@ export function removeConnection(state) {
async function getFileFromArchiveFromAttachment(message, desiredContent) {
const attachmentResult = await getAttachmentResult(
message,
desiredContent.archive
desiredContent.archive,
);

return await extractFileFromArchiveAttachment(
attachmentResult,
desiredContent
desiredContent,
);
}

async function getFileFromAttachment(message, desiredContent) {
const attachmentResult = await getAttachmentResult(
message,
desiredContent.file
desiredContent.file,
);

return await extractFileFromAttachment(attachmentResult, desiredContent);
Expand Down Expand Up @@ -250,7 +273,7 @@ async function extractFileFromArchiveAttachment(attachment, desiredContent) {

if (!attachment.data) {
console.error(
`Data not found in the archive attachment for: ${attachment.expression}`
`Data not found in the archive attachment for: ${attachment.expression}`,
);
return null;
}
Expand All @@ -259,7 +282,7 @@ async function extractFileFromArchiveAttachment(attachment, desiredContent) {
const zip = await JSZip.loadAsync(compressedBuffer);

const filename = Object.keys(zip.files).find(name =>
isExpressionMatch(name, desiredContent.file)
isExpressionMatch(name, desiredContent.file),
);

if (!filename) {
Expand All @@ -286,7 +309,7 @@ async function extractFileFromAttachment(attachment, desiredContent) {

if (!attachment.data) {
console.error(
`Data not found in the file attachment for: ${attachment.expression}`
`Data not found in the file attachment for: ${attachment.expression}`,
);
return null;
}
Expand All @@ -303,11 +326,11 @@ async function extractFileFromAttachment(attachment, desiredContent) {

function getBodyFromMessage(message, desiredContent) {
const bodyPart = message.parts?.find(
part => part.mimeType === 'multipart/alternative'
part => part.mimeType === 'multipart/alternative',
);

const textBodyPart = bodyPart?.parts.find(
part => part.mimeType === 'text/plain'
part => part.mimeType === 'text/plain',
);

const textBody = textBodyPart?.body?.data;
Expand All @@ -324,7 +347,7 @@ function getBodyFromMessage(message, desiredContent) {

function getValueFromMessageHeader(message, desiredContent) {
const header = message.headers?.find(
h => h.name.toLowerCase() === desiredContent.type
h => h.name.toLowerCase() === desiredContent.type,
);

if (!header) {
Expand Down
4 changes: 2 additions & 2 deletions packages/gmail/test/Adaptor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('sendMessage', () => {
let mockGmail;
let sendStub;

beforeEach(() => {
beforeEach(async () => {
originalGmail = google.gmail;

const mockResponse = {
Expand All @@ -36,7 +36,7 @@ describe('sendMessage', () => {

google.gmail = () => mockGmail;

createConnection({
await createConnection({
configuration: {
access_token: 'mock-access-token',
},
Expand Down
Loading
Loading