0%
November 12, 2023

Send Google Gmail Without Sendgrid

nodejs

Why not Sendgrid?

How easy is Using Sendgrid?

Sendgrid is easy to use, we just need to plug our email information into sendgrid's webpage, then it will generate an API key for us.

Email can then be sent as simply as writing

import sgMail from '@sendgrid/mail';
import { EmailSender } from "../dto/dto";

sgMail.setApiKey(process.env.SENDGRID_API_KEY || "")

const sendMessage: EmailSender = async (props: {
    to: string,
    subject: string,
    text: string,
    html: string
}) => {
    const { html, subject, text, to } = props;

    const msg = {
        to,
        from: "abc@email.com",
        subject,
        text,
        html,
    }

    try {
        await sgMail.send(msg);
    } catch (err) {
        throw new Error(JSON.stringify(err));
    }
}

and I am sure this email-sending feature can be set up within less than 15 minutes with zero knowledge about this service.

Problem of Sendgrid

The problem lies in its pricing (click me). A feature that is supposed to be free (have you ever paid google for sending email?) has a 100 emails/day cap, and uncapping it (still not unlimited) requires at least 19.95 usd (approximately 155.79 hkd) per month.

Problem of Gmail API

The following

  • Complexity of oAuth2 credential authentication and
  • the ease of use of the Google Cloud Console are the only barrier, if we can get around this then there is no reason not to use gmail api (which is free).

Prerequisite: OAuth2 Credential in Json

First you need to create an OAuth2 Credential about your account in json format. Detailed precedures have been included in

under the Create Credentials session.

A gmailService File

Environment Variables

Let's create a gmailService.ts in our service directory, then:

1import fs from "fs";
2import path from "path";
3import process from "process";
4import { authenticate } from "@google-cloud/local-auth";
5import google from "googleapis"
6import { EmailSender, GMailUserJson as GoogleUserJson } from "../dto/dto";
7import nodemailer from "nodemailer"
8
9const SCOPES = ['https://mail.google.com/'];
10
11const {
12    GOOGLE_API_OAUTH2_CREDENTIAL_JSON = "",
13    GOOGLE_API_REQUIRE_LOGIN_FOR_NEW_TOKEN = "",
14    GOOGLE_API_CREDENTIAL_JSON = "",
15} = process.env;
16const OAUTH2_CREDENTIAL_PATH = path.join(process.cwd(), GOOGLE_API_OAUTH2_CREDENTIAL_JSON);
17const REQUIRE_LOGIN_FOR_NEW_TOKEN = GOOGLE_API_REQUIRE_LOGIN_FOR_NEW_TOKEN === "true";
18const CREDENTIALS_PATH = path.join(process.cwd(), GOOGLE_API_CREDENTIAL_JSON);

Let's explain the usage of the 3 environment variables.

  • GOOGLE_API_OAUTH2_CREDENTIAL_JSON Pointing to the oauth2 credentials relative to the root project level. This file must exist and should be obtained from the prerequisite above.
    The oauth2 credentials should look like:
    {
        "installed": {
            "client_id": "em0kcr5.apps.googleusercontent.com",
            "project_id": "gmailapi-123456",
            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
            "token_uri": "https://oauth2.googleapis.com/token",
            "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
            "client_secret": "secret",
            "redirect_uris": [
                "http://localhost"
            ]
        }
    }
  • REQUIRE_LOGIN_FOR_NEW_TOKEN=true (initially)
    • At the first time we run the application we will be asked to authenticate ourself by using authorize function below (line-68).

    • After authentication succeeds, our login information will be saved in a json file saved at GOOGLE_API_CREDENTIAL_JSON.

    • We can set REQUIRE_LOGIN_FOR_NEW_TOKEN=false after the first authentication succeeds given that we have made sure the file pointed by GOOGLE_API_CREDENTIAL_JSON exists.

  • GOOGLE_API_CREDENTIAL_JSON This is the path of the login information relative to the root project level. Initially we don't have this file yet.
    The login credentials should look like:
    {
        "type": "authorized_user",
        "client_id": "em0kcr5.apps.googleusercontent.com",
        "client_secret": "secret",
        "refresh_token": "1//some-string"
    }
Cache Useful Variables

Next let's create two variables that cache the variables we created when launching the application:

19let authClient: google.Auth.OAuth2Client | null = null;
20let loginJson: GoogleUserJson | null = null;
Function to Send Email
21const getCredentialJson = () => {
22    if (loginJson) {
23        return loginJson
24    } else {
25        const content = fs.readFileSync(CREDENTIALS_PATH, { encoding: "utf-8" });
26        const credentials = JSON.parse(content) as GoogleUserJson;
27        loginJson = credentials;
28        return credentials;
29    }
30}
31
32const getAuthClient = () => {
33    if (authClient) {
34        return authClient;
35    } else {
36        try {
37            const content = fs.readFileSync(CREDENTIALS_PATH, { encoding: "utf-8" });
38            const credentials = JSON.parse(content);
39            authClient = new google.Auth.OAuth2Client(
40                credentials.client_id,
41                credentials.client_secret,
42                'http://localhost'
43            );
44            return authClient;
45        } catch (err) {
46            return null;
47        }
48    }
49}
50
51const saveCredentials = (client: google.Auth.OAuth2Client) => {
52    const content = fs.readFileSync(OAUTH2_CREDENTIAL_PATH, { encoding: "utf-8" });
53    const keys = JSON.parse(content);
54    const key = keys.installed || keys.web;
55    const payload = JSON.stringify({
56        type: 'authorized_user',
57        client_id: key.client_id,
58        client_secret: key.client_secret,
59        refresh_token: client.credentials.refresh_token,
60    });
61    fs.writeFileSync(CREDENTIALS_PATH, payload);
62}
63
64/**
65 * Load or request or authorization to call APIs.
66 *
67 */
68async function authorize() {
69    if (REQUIRE_LOGIN_FOR_NEW_TOKEN) {
70        let client = await getAuthClient();
71        if (client) {
72            return client;
73        }
74        client = await authenticate({
75            scopes: SCOPES,
76            keyfilePath: OAUTH2_CREDENTIAL_PATH,
77        });
78        if (client.credentials) {
79            await saveCredentials(client);
80        }
81    }
82}
83
84const getTransporter = async () => {
85    const { client_id, client_secret, refresh_token, type } = getCredentialJson();
86
87    return nodemailer.createTransport({
88        service: "gmail",
89        auth: {
90            type: "OAuth2",
91            user: process.env?.GOOGLE_API_EMAIL_SEND_ACCOUNT || "",
92            clientId: client_id,
93            clientSecret: client_secret,
94            refreshToken: refresh_token
95        }
96    });
97}
98
99const sendMessage: EmailSender = async ({
100    html, subject, text, to
101}) => {
102    const t = await getTransporter();
103    t.sendMail({
104        html, subject, text, to
105    })
106}
107
108export default {
109    authorize,
110    sendMessage
111}
Entrypoint for Google Authentication

Finally:

  • Let's run await gmailService.authorize() before app.listen().
  • Set GOOGLE_API_REQUIRE_LOGIN_FOR_NEW_TOKEN=true for the first time.
  • Turn it off GOOGLE_API_REQUIRE_LOGIN_FOR_NEW_TOKEN=false from the first time onwards.