Skip to content
Merged
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
182 changes: 182 additions & 0 deletions ai/email-classifier/Cards.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Triggered when the add-on is opened from the Gmail homepage.
*
* @param {!Object} e - The event object.
* @returns {!Card} - The homepage card.
*/
function onHomepageTrigger(e) {
return buildHomepageCard();
}

/**
* Builds the main card displayed on the Gmail homepage.
*
* @returns {!Card} - The homepage card.
*/
function buildHomepageCard() {
// Create a new card builder
const cardBuilder = CardService.newCardBuilder();

// Create a card header
const cardHeader = CardService.newCardHeader();
cardHeader.setImageUrl('https://fonts.gstatic.com/s/i/googlematerialicons/label_important/v20/googblue-24dp/1x/gm_label_important_googblue_24dp.png');
cardHeader.setImageStyle(CardService.ImageStyle.CIRCLE);
cardHeader.setTitle("Email Classifier");

// Add the header to the card
cardBuilder.setHeader(cardHeader);

// Create a card section
const cardSection = CardService.newCardSection();

// Create buttons for generating sample emails and analyzing sentiment
const buttonSet = CardService.newButtonSet();

// Create "Classify emails" button
const classifyButton = createFilledButton({
text: 'Classify emails',
functionName: 'main',
color: '#007bff',
icon: 'new_label'
});
buttonSet.addButton(classifyButton);

// Create "Create Labels" button
const createLabelsButtton = createFilledButton({
text: 'Create labels',
functionName: 'createLabels',
color: '#34A853',
icon: 'add'
});

// Create "Remove Labels" button
const removeLabelsButtton = createFilledButton({
text: 'Remove labels',
functionName: 'removeLabels',
color: '#FF0000',
icon: 'delete'
});

if (labelsCreated()) {
buttonSet.addButton(removeLabelsButtton);
} else {
buttonSet.addButton(createLabelsButtton);
}

// Add the button set to the section
cardSection.addWidget(buttonSet);

// Add the section to the card
cardBuilder.addSection(cardSection);

// Build and return the card
return cardBuilder.build();
}

/**
* Creates a filled text button with the specified text, function, and color.
*
* @param {{text: string, functionName: string, color: string, icon: string}} options
* - text: The text to display on the button.
* - functionName: The name of the function to call when the button is clicked.
* - color: The background color of the button.
* - icon: The material icon to display on the button.
* @returns {!TextButton} - The created text button.
*/
function createFilledButton({text, functionName, color, icon}) {
// Create a new text button
const textButton = CardService.newTextButton();

// Set the button text
textButton.setText(text);

// Set the action to perform when the button is clicked
const action = CardService.newAction();
action.setFunctionName(functionName);
action.setLoadIndicator(CardService.LoadIndicator.SPINNER);
textButton.setOnClickAction(action);

// Set the button style to filled
textButton.setTextButtonStyle(CardService.TextButtonStyle.FILLED);

// Set the background color
textButton.setBackgroundColor(color);

textButton.setMaterialIcon(CardService.newMaterialIcon().setName(icon));

return textButton;
}

/**
* Creates a notification response with the specified text.
*
* @param {string} notificationText - The text to display in the notification.
* @returns {!ActionResponse} - The created action response.
*/
function buildNotificationResponse(notificationText) {
// Create a new notification
const notification = CardService.newNotification();
notification.setText(notificationText);

// Create a new action response builder
const actionResponseBuilder = CardService.newActionResponseBuilder();

// Set the notification for the action response
actionResponseBuilder.setNotification(notification);

// Build and return the action response
return actionResponseBuilder.build();
}

/**
* Creates a card to display the spreadsheet link.
*
* @param {string} spreadsheetUrl - The URL of the spreadsheet.
* @returns {!ActionResponse} - The created action response.
*/
function showSpreadsheetLink(spreadsheetUrl) {
const updatedCardBuilder = CardService.newCardBuilder();

updatedCardBuilder.setHeader(CardService.newCardHeader().setTitle('Sheet generated!'));

const updatedSection = CardService.newCardSection()
.addWidget(CardService.newTextParagraph()
.setText('Click to open the sheet:')
)
.addWidget(CardService.newTextButton()
.setText('Open Sheet')
.setOpenLink(CardService.newOpenLink()
.setUrl(spreadsheetUrl)
.setOpenAs(CardService.OpenAs.FULL_SCREEN) // Opens in a new browser tab/window
.setOnClose(CardService.OnClose.NOTHING) // Does nothing when the tab is closed
)
)
.addWidget(CardService.newTextButton() // Optional: Add a button to go back or refresh
.setText('Go Back')
.setOnClickAction(CardService.newAction()
.setFunctionName('onHomepageTrigger')) // Go back to the initial state
);

updatedCardBuilder.addSection(updatedSection);
const newNavigation = CardService.newNavigation().updateCard(updatedCardBuilder.build());

return CardService.newActionResponseBuilder()
.setNavigation(newNavigation) // This updates the current card in the UI
.build();
}
133 changes: 133 additions & 0 deletions ai/email-classifier/ClassifyEmail.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Constructs the prompt for classifying an email.
*
* @param {string} subject The subject of the email.
* @param {string} body The body of the email.
* @returns {string} The prompt for classifying an email.
*/
const classifyEmailPrompt = (subject, body) => `
Objective: You are an AI assistant tasked with classifying email threads. Analyze the entire email thread provided below and determine the single most appropriate classification label. Your response must conform to the provided schema.

**Classification Labels & Descriptions:**

* **needs-response**: The sender is explicitly or implicitly expecting a **direct, communicative reply** from me (${ME}) to answer a question, acknowledge receipt of information, confirm understanding, or continue a conversation. **Prioritize this label if the core expectation is purely a written or verbal communication back to the sender.**
* **action-required**: The email thread requires me (${ME}) to perform a **distinct task, make a formal decision, provide a review leading to approval/rejection, or initiate a process that results in a demonstrable change or outcome.** This label is for actions *beyond* just sending a reply, such as completing a document, setting up a meeting, approving a request, delegating a task, or performing a delegated duty.
* **for-your-info**: The email thread's primary purpose is to convey information, updates, or announcements. No immediate action or direct reply is expected or required from me (${ME}); the main purpose is for me to be informed and aware. This includes both routine 'FYI' updates and critical announcements where my role is to comprehend, not act or respond.

**Evaluation Criteria - Consider the following:**

* **Sender's Intent & My Role:** What does the sender want me (${ME}) to do, say, or know?
* **Direct Requests:** Are there explicit questions or calls to action addressed to me (${ME})?
* **Distinguishing Action vs. Response:**
* If the email primarily asks for a *verbal or written communication* (e.g., answering a specific question, providing feedback, confirming receipt, giving thoughts, and is directly addressed to me (${ME})), it's likely \`needs-response\`.
* If the email requires me to *perform a specific task or make a formal decision that goes beyond simply communicating* (e.g., completing a document, scheduling, approving a request, delegating, implementing a change), it's likely \`action-required\`.
* **Urgency/Deadlines:** Are there time-sensitive elements mentioned?
* **Last Message Focus:** Give slightly more weight to the content of the most recent messages in the thread.
* **Keywords:**
* Look for terms like "answer," "reply to," "your thoughts on," "confirm," "acknowledge" for \`needs-response\`.
* Look for terms like "complete," "approve," "review and approve," "sign," "process," "set up," "delegate" for \`action-required\`.
* Look for terms like "FYI," "update," "announcement," "read," "info" for \`for-information\`.
* **Overall Significance:** Is the topic critical or routine, influencing the *type* of information being conveyed?

**Input:** Email message content
Subject: ${subject}

--- Email Thread Messages ---
${body}
--- End of Email Thread ---

**Output:** Return the single best classification and a brief justification.
Format: JSON object with '[Classification]', and '[Reason]'
`.trim();

/**
* Classifies an email based on its subject and messages.
*
* @param {string} subject The subject of the email.
* @param {!Array<!GmailMessage>} messages An array of Gmail messages.
* @returns {!Object} The classification object.
*/
function classifyEmail(subject, messages) {
const body = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
body.push(`Message ${i + 1}:`);
body.push(`From: ${message.getFrom()}`);
body.push(`To:${message.getTo()}`);
body.push('Body:');
body.push(message.getPlainBody());
body.push('---');
}

// Prepare the request payload
const payload = {
contents: [
{
role: "user",
parts: [
{
text: classifyEmailPrompt(subject, body.join('\n'))
}
]
}
],
generationConfig: {
temperature: 0,
topK: 1,
topP: 0.1,
seed: 37,
maxOutputTokens: 1024,
responseMimeType: "application/json",
// Expected response format for simpler parsing.
responseSchema: {
type: "object",
properties: {
classification: {
type: "string",
enum: Object.keys(classificationLabels),
},
reason: {
type: 'string'
}
}
}
}
};

// Prepare the request options
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${ScriptApp.getOAuthToken()}`
},
contentType: 'application/json',
muteHttpExceptions: true, // Set to true to inspect the error response
payload: JSON.stringify(payload)
};

// Make the API request
const response = UrlFetchApp.fetch(API_URL, options);

// Parse the response. There are two levels of JSON responses to parse.
const parsedResponse = JSON.parse(response.getContentText());
const text = parsedResponse.candidates[0].content.parts[0].text;
const classification = JSON.parse(text);
return classification;
}

67 changes: 67 additions & 0 deletions ai/email-classifier/Code.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Main function to process emails, classify them, and update a spreadsheet.
* This function searches for unread emails in the inbox from the last 7 days,
* classifies them based on their subject and content, adds labels to the emails,
* creates draft responses for emails that need a response, and logs the
* classification results in a spreadsheet.
* @return {string} The URL of the spreadsheet.
*/
function main() {
// Calculate the date 7 days ago
const today = new Date();
const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);

// Create a Sheet
const headers = ['Subject', 'Classification', 'Reason'];
const spreadsheet = createSheetWithHeaders(headers);

// Format the date for the Gmail search query (YYYY/MM/DD)
// Using Utilities.formatDate ensures correct formatting based on script
// timezone
const formattedDate = Utilities.formatDate(
sevenDaysAgo, Session.getScriptTimeZone(), 'yyyy/MM/dd');

// Construct the search query
const query = `is:unread after:${formattedDate} in:inbox`;
console.log('Searching for emails with query: ' + query);

// Search for threads matching the query
// Note: GmailApp.search() returns threads where *at least one* message
// matches
const threads = GmailApp.search(query);
createLabels();

for (const thread of threads) {
const messages = thread.getMessages();
const subject = thread.getFirstMessageSubject();
const {classification, reason} = classifyEmail(subject, messages);
console.log(`Classification: ${classification}, Reason: ${reason}`);

thread.addLabel(classificationLabels[classification].gmailLabel);

if (classification === 'needs-response') {
const draft = draftEmail(subject, messages);
thread.createDraftReplyAll(null, {htmlBody: draft});
}

addDataToSheet(spreadsheet, hyperlink(thread), classification, reason);
}

return showSpreadsheetLink(spreadsheet.getUrl());
}
Loading
Loading