Search code examples
groovyoauth-2.0gmail-api

GMail API "Delegation denied for [user] when trying to access the email messages/threads of the service account


I have this Katalon Studio codebase, that has a member conversion script, that depends on being able to extract sign-up link from a Gmail account specific for testing. On that Gmail account (let's call it [email protected]), I have the OAuth 2.0 Client IDs and Service Account set up, and have downloaded a gmail-access-credentials.json for the service account.

My SMDEmailUtils class for all this, is defined to be:

public final class SMDEmailUtils {

    private static Gmail _GmailInstance;

    public static String GetMainEmail() {
        if (!GeneralWebUIUtils.GlobalVariableExists('emailID'))
            return "[email protected]";
        return GlobalVariable.emailID.toString();
    }

    public static String CreateEmailFor(String firstName, String lastName) {
        final String[] mainEmailParts = this.GetMainEmail().split('@');

        return "${mainEmailParts[0]}+${firstName}${lastName}@${mainEmailParts[1]}"
                .replaceAll("\'", "");
    }

    public static String ExtractSignUpLink() {
        String link;

        int retryAttempts;

        ActionHandler.Handle({
            link = this.ProcessHTML(this.GetLatestMessageBody(30),
                    "//a[.//div[@class = 'sign-mail-btn-text']]/@href");
        }, { boolean success, ex -> 
            if (!success)
                sleep(1000 * 2**retryAttempts++);
        }, TimeUnit.MINUTES.toSeconds(15))

        return link;
    }

    /**
     * Application name.
     */
    private static final String AppName = "Gmail Message Accessor";
    /**
     * Global instance of the JSON factory.
     */
    private static final JsonFactory JSONFactory = GsonFactory.getDefaultInstance();
    /**
     * Directory to store authorization tokens for this application.
     */
    private static final String TokensDirectoryPath = "tokens";

    /**
     * Global instance of the Scopes required by this quickstart.
     * If modifying these Scopes, delete your previously saved tokens/ folder.
     */
    private static final List<String> Scopes = [GmailScopes.GMAIL_READONLY,];
    private static final String CredentialsFilePath = "./gmail-access-credentials.json";

    /**
     * Creates an authorized Credential object.
     *
     * @param httpTransport The network HTTP Transport.
     * @return An authorized Credential object.
     * @throws IOException If the credentials.json file cannot be found.
     */
    private static Credential getCredentials(final NetHttpTransport httpTransport)
    throws IOException {
        // Load client secrets.
        InputStream is = new FileInputStream(this.CredentialsFilePath);
        if (is == null) {
            throw new FileNotFoundException("Resource not found: " + this.CredentialsFilePath);
        }
        GoogleClientSecrets clientSecrets =
                GoogleClientSecrets.load(this.JSONFactory, new InputStreamReader(is));

        // Build flow and trigger user authorization request.
        GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
                httpTransport, this.JSONFactory, clientSecrets, this.Scopes)
                .setDataStoreFactory(new FileDataStoreFactory(new java.io.File(this.TokensDirectoryPath)))
                .setAccessType("offline")
                .build();
        LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
        return new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
    }

    public static Gmail GetGmailInstance() {
        if (this._GmailInstance == null) {
            // Build a new authorized API client service.
            final NetHttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
            this._GmailInstance = new Gmail.Builder(httpTransport, this.JSONFactory, getCredentials(httpTransport))
                    .setApplicationName(this.AppName)
                    .build();
        }
        return this._GmailInstance;
    }

    public static String GetLatestMessageBody(int timeOut) {
        return this.getContent(this.GetLatestMessage(timeOut));
    }

    public static Message GetLatestMessage(int timeOut) {
        // get the latest thread list
        ListThreadsResponse response = this.HandleRequest({
            return this.GetGmailInstance()
                    .users()
                    .threads()
                    .list(this.GetMainEmail())
                    .setQ("is:unread newer_than:1d")
                    .setIncludeSpamTrash(true)
                    .execute();
        },
        { ListThreadsResponse res -> return !res.getThreads().isEmpty() },
        timeOut);

        return response.getThreads()
                .collect({ Thread thread ->
                    return this.GetGmailInstance()
                            .users()
                            .threads()
                            .get(this.GetMainEmail(), thread.getId())
                            .execute()
                }).max { Thread thread -> thread.getMessages().last().getInternalDate() }
                .getMessages()
                .last();
    }

    /**
     * Copied from https://stackoverflow.com/a/58286921
     * @param message
     * @return
     */
    private static String getContent(Message message) {
        StringBuilder stringBuilder = new StringBuilder();
        try {
            getPlainTextFromMessageParts(message.getPayload().getParts(), stringBuilder);
            // NOTE: updated by Mike Warren, this was adapted for message that contain URLs in its body
            return new String(Base64.getUrlDecoder().decode(stringBuilder.toString()),
                    StandardCharsets.UTF_8);
        } catch (UnsupportedEncodingException e) {
            // NOTE: updated by Mike Warren
            Logger.getGlobal().severe("UnsupportedEncoding: ${e.toString()}");
            return message.getSnippet();
        }
    }

    /**
     * Copied from https://stackoverflow.com/a/58286921
     * @param messageParts
     * @param stringBuilder
     */
    private static void getPlainTextFromMessageParts(List<MessagePart> messageParts, StringBuilder stringBuilder) {
        for (MessagePart messagePart : messageParts) {
            // NOTE: updated by Mike Warren
            if (messagePart.getMimeType().startsWith("text/")) {
                stringBuilder.append(messagePart.getBody().getData());
            }

            if (messagePart.getParts() != null) {
                getPlainTextFromMessageParts(messagePart.getParts(), stringBuilder);
            }
        }
    }

    public static GenericJson HandleRequest(Closure<GenericJson> onDoRequest, Closure<Boolean> onCheckResponse, int timeOut) {
        long startTime = System.currentTimeSeconds();

        int exponent = 0;

        while (System.currentTimeSeconds() < startTime + timeOut) {
            GenericJson response = onDoRequest();
            if (onCheckResponse(response))
                return response;

            // wait some time to try again, exponential backoff style
            sleep(1000 * 2**exponent++);
        }

        return null;
    }

    /**
     * **NOTE**: forked from https://stackoverflow.com/a/2269464/2027839 , and then refactored
     * 
     * Processes HTML, using XPath
     * 
     * @param html
     * @param xpath
     * @return the result 
     */
    public static String ProcessHTML(String html, String xpath) {

        final String properHTML = this.ToProperHTML(html);

        final Element document = DocumentBuilderFactory.newInstance()
                .newDocumentBuilder()
                .parse(new ByteArrayInputStream( properHTML.bytes ))
                .documentElement;
        return XPathFactory.newInstance()
                .newXPath()
                .evaluate( xpath, document );
    }

    private static String ToProperHTML(String html) {
        // SOURCE: https://stackoverflow.com/a/19125599/2027839
        String properHTML = html.replaceAll( "(&(?!amp;))", "&amp;" );

        if (properHTML.contains('<!DOCTYPE html'))
            return properHTML;


        return """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
    <head></head>
    <body>
        ${properHTML}
    </body>
</html>
""";
    }
}

I am tryna hit the [email protected] inbox for the messages/threads.

However, when I hit the SMDEmailUtils.ExtractSignUpLink(), I face some 403 error, after sign-in from the OAuth Consent Screen, and the message is :

Delegation denied for [my_email]

I have added [my_email] as a test user on the OAuth Consent Screen set-up Test Users step.

When I use "me" in the place of this.GetMainEmail(), it works but it accesses my email inbox instead of [email protected] .

What should I do to remedy this, and get this working?

NOTE: This was working, as written, up until I faced some invalid_grant issue. I deleted the token, tried to re-create it, and I seem to be facing this issue, and it feels like I can do nothing about it...


Solution

  • It turns out that what I was tryna do was called delegation, and after thinking long about it, there was no need for it (through the Katalon Studio GlobalVariables (or something similar), end user can set their own main email. This was intentional design decision waay back when I was working on this for the first time).

    Delegation here is a YAGNI, and only makes sense for Google Workspace users. But, my main email and my personal email are just their own separate users, not on any Workspace.

    Hence, the solution here is to just delete the StoredCredential from the tokens folder, run the user test case, and this time, handle the OAuth Consent Screen, manually, via the Chrome profile for the main email.