Skip to content

Commit 9f468eb

Browse files
authored
feat: Email Classifier (#541)
1 parent c9b7745 commit 9f468eb

File tree

9 files changed

+920
-0
lines changed

9 files changed

+920
-0
lines changed

ai/email-classifier/Cards.gs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Triggered when the add-on is opened from the Gmail homepage.
19+
*
20+
* @param {!Object} e - The event object.
21+
* @returns {!Card} - The homepage card.
22+
*/
23+
function onHomepageTrigger(e) {
24+
return buildHomepageCard();
25+
}
26+
27+
/**
28+
* Builds the main card displayed on the Gmail homepage.
29+
*
30+
* @returns {!Card} - The homepage card.
31+
*/
32+
function buildHomepageCard() {
33+
// Create a new card builder
34+
const cardBuilder = CardService.newCardBuilder();
35+
36+
// Create a card header
37+
const cardHeader = CardService.newCardHeader();
38+
cardHeader.setImageUrl('https://fonts.gstatic.com/s/i/googlematerialicons/label_important/v20/googblue-24dp/1x/gm_label_important_googblue_24dp.png');
39+
cardHeader.setImageStyle(CardService.ImageStyle.CIRCLE);
40+
cardHeader.setTitle("Email Classifier");
41+
42+
// Add the header to the card
43+
cardBuilder.setHeader(cardHeader);
44+
45+
// Create a card section
46+
const cardSection = CardService.newCardSection();
47+
48+
// Create buttons for generating sample emails and analyzing sentiment
49+
const buttonSet = CardService.newButtonSet();
50+
51+
// Create "Classify emails" button
52+
const classifyButton = createFilledButton({
53+
text: 'Classify emails',
54+
functionName: 'main',
55+
color: '#007bff',
56+
icon: 'new_label'
57+
});
58+
buttonSet.addButton(classifyButton);
59+
60+
// Create "Create Labels" button
61+
const createLabelsButtton = createFilledButton({
62+
text: 'Create labels',
63+
functionName: 'createLabels',
64+
color: '#34A853',
65+
icon: 'add'
66+
});
67+
68+
// Create "Remove Labels" button
69+
const removeLabelsButtton = createFilledButton({
70+
text: 'Remove labels',
71+
functionName: 'removeLabels',
72+
color: '#FF0000',
73+
icon: 'delete'
74+
});
75+
76+
if (labelsCreated()) {
77+
buttonSet.addButton(removeLabelsButtton);
78+
} else {
79+
buttonSet.addButton(createLabelsButtton);
80+
}
81+
82+
// Add the button set to the section
83+
cardSection.addWidget(buttonSet);
84+
85+
// Add the section to the card
86+
cardBuilder.addSection(cardSection);
87+
88+
// Build and return the card
89+
return cardBuilder.build();
90+
}
91+
92+
/**
93+
* Creates a filled text button with the specified text, function, and color.
94+
*
95+
* @param {{text: string, functionName: string, color: string, icon: string}} options
96+
* - text: The text to display on the button.
97+
* - functionName: The name of the function to call when the button is clicked.
98+
* - color: The background color of the button.
99+
* - icon: The material icon to display on the button.
100+
* @returns {!TextButton} - The created text button.
101+
*/
102+
function createFilledButton({text, functionName, color, icon}) {
103+
// Create a new text button
104+
const textButton = CardService.newTextButton();
105+
106+
// Set the button text
107+
textButton.setText(text);
108+
109+
// Set the action to perform when the button is clicked
110+
const action = CardService.newAction();
111+
action.setFunctionName(functionName);
112+
action.setLoadIndicator(CardService.LoadIndicator.SPINNER);
113+
textButton.setOnClickAction(action);
114+
115+
// Set the button style to filled
116+
textButton.setTextButtonStyle(CardService.TextButtonStyle.FILLED);
117+
118+
// Set the background color
119+
textButton.setBackgroundColor(color);
120+
121+
textButton.setMaterialIcon(CardService.newMaterialIcon().setName(icon));
122+
123+
return textButton;
124+
}
125+
126+
/**
127+
* Creates a notification response with the specified text.
128+
*
129+
* @param {string} notificationText - The text to display in the notification.
130+
* @returns {!ActionResponse} - The created action response.
131+
*/
132+
function buildNotificationResponse(notificationText) {
133+
// Create a new notification
134+
const notification = CardService.newNotification();
135+
notification.setText(notificationText);
136+
137+
// Create a new action response builder
138+
const actionResponseBuilder = CardService.newActionResponseBuilder();
139+
140+
// Set the notification for the action response
141+
actionResponseBuilder.setNotification(notification);
142+
143+
// Build and return the action response
144+
return actionResponseBuilder.build();
145+
}
146+
147+
/**
148+
* Creates a card to display the spreadsheet link.
149+
*
150+
* @param {string} spreadsheetUrl - The URL of the spreadsheet.
151+
* @returns {!ActionResponse} - The created action response.
152+
*/
153+
function showSpreadsheetLink(spreadsheetUrl) {
154+
const updatedCardBuilder = CardService.newCardBuilder();
155+
156+
updatedCardBuilder.setHeader(CardService.newCardHeader().setTitle('Sheet generated!'));
157+
158+
const updatedSection = CardService.newCardSection()
159+
.addWidget(CardService.newTextParagraph()
160+
.setText('Click to open the sheet:')
161+
)
162+
.addWidget(CardService.newTextButton()
163+
.setText('Open Sheet')
164+
.setOpenLink(CardService.newOpenLink()
165+
.setUrl(spreadsheetUrl)
166+
.setOpenAs(CardService.OpenAs.FULL_SCREEN) // Opens in a new browser tab/window
167+
.setOnClose(CardService.OnClose.NOTHING) // Does nothing when the tab is closed
168+
)
169+
)
170+
.addWidget(CardService.newTextButton() // Optional: Add a button to go back or refresh
171+
.setText('Go Back')
172+
.setOnClickAction(CardService.newAction()
173+
.setFunctionName('onHomepageTrigger')) // Go back to the initial state
174+
);
175+
176+
updatedCardBuilder.addSection(updatedSection);
177+
const newNavigation = CardService.newNavigation().updateCard(updatedCardBuilder.build());
178+
179+
return CardService.newActionResponseBuilder()
180+
.setNavigation(newNavigation) // This updates the current card in the UI
181+
.build();
182+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Constructs the prompt for classifying an email.
19+
*
20+
* @param {string} subject The subject of the email.
21+
* @param {string} body The body of the email.
22+
* @returns {string} The prompt for classifying an email.
23+
*/
24+
const classifyEmailPrompt = (subject, body) => `
25+
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.
26+
27+
**Classification Labels & Descriptions:**
28+
29+
* **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.**
30+
* **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.
31+
* **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.
32+
33+
**Evaluation Criteria - Consider the following:**
34+
35+
* **Sender's Intent & My Role:** What does the sender want me (${ME}) to do, say, or know?
36+
* **Direct Requests:** Are there explicit questions or calls to action addressed to me (${ME})?
37+
* **Distinguishing Action vs. Response:**
38+
* 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\`.
39+
* 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\`.
40+
* **Urgency/Deadlines:** Are there time-sensitive elements mentioned?
41+
* **Last Message Focus:** Give slightly more weight to the content of the most recent messages in the thread.
42+
* **Keywords:**
43+
* Look for terms like "answer," "reply to," "your thoughts on," "confirm," "acknowledge" for \`needs-response\`.
44+
* Look for terms like "complete," "approve," "review and approve," "sign," "process," "set up," "delegate" for \`action-required\`.
45+
* Look for terms like "FYI," "update," "announcement," "read," "info" for \`for-information\`.
46+
* **Overall Significance:** Is the topic critical or routine, influencing the *type* of information being conveyed?
47+
48+
**Input:** Email message content
49+
Subject: ${subject}
50+
51+
--- Email Thread Messages ---
52+
${body}
53+
--- End of Email Thread ---
54+
55+
**Output:** Return the single best classification and a brief justification.
56+
Format: JSON object with '[Classification]', and '[Reason]'
57+
`.trim();
58+
59+
/**
60+
* Classifies an email based on its subject and messages.
61+
*
62+
* @param {string} subject The subject of the email.
63+
* @param {!Array<!GmailMessage>} messages An array of Gmail messages.
64+
* @returns {!Object} The classification object.
65+
*/
66+
function classifyEmail(subject, messages) {
67+
const body = [];
68+
for (let i = 0; i < messages.length; i++) {
69+
const message = messages[i];
70+
body.push(`Message ${i + 1}:`);
71+
body.push(`From: ${message.getFrom()}`);
72+
body.push(`To:${message.getTo()}`);
73+
body.push('Body:');
74+
body.push(message.getPlainBody());
75+
body.push('---');
76+
}
77+
78+
// Prepare the request payload
79+
const payload = {
80+
contents: [
81+
{
82+
role: "user",
83+
parts: [
84+
{
85+
text: classifyEmailPrompt(subject, body.join('\n'))
86+
}
87+
]
88+
}
89+
],
90+
generationConfig: {
91+
temperature: 0,
92+
topK: 1,
93+
topP: 0.1,
94+
seed: 37,
95+
maxOutputTokens: 1024,
96+
responseMimeType: "application/json",
97+
// Expected response format for simpler parsing.
98+
responseSchema: {
99+
type: "object",
100+
properties: {
101+
classification: {
102+
type: "string",
103+
enum: Object.keys(classificationLabels),
104+
},
105+
reason: {
106+
type: 'string'
107+
}
108+
}
109+
}
110+
}
111+
};
112+
113+
// Prepare the request options
114+
const options = {
115+
method: 'POST',
116+
headers: {
117+
'Authorization': `Bearer ${ScriptApp.getOAuthToken()}`
118+
},
119+
contentType: 'application/json',
120+
muteHttpExceptions: true, // Set to true to inspect the error response
121+
payload: JSON.stringify(payload)
122+
};
123+
124+
// Make the API request
125+
const response = UrlFetchApp.fetch(API_URL, options);
126+
127+
// Parse the response. There are two levels of JSON responses to parse.
128+
const parsedResponse = JSON.parse(response.getContentText());
129+
const text = parsedResponse.candidates[0].content.parts[0].text;
130+
const classification = JSON.parse(text);
131+
return classification;
132+
}
133+

ai/email-classifier/Code.gs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Main function to process emails, classify them, and update a spreadsheet.
19+
* This function searches for unread emails in the inbox from the last 7 days,
20+
* classifies them based on their subject and content, adds labels to the emails,
21+
* creates draft responses for emails that need a response, and logs the
22+
* classification results in a spreadsheet.
23+
* @return {string} The URL of the spreadsheet.
24+
*/
25+
function main() {
26+
// Calculate the date 7 days ago
27+
const today = new Date();
28+
const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
29+
30+
// Create a Sheet
31+
const headers = ['Subject', 'Classification', 'Reason'];
32+
const spreadsheet = createSheetWithHeaders(headers);
33+
34+
// Format the date for the Gmail search query (YYYY/MM/DD)
35+
// Using Utilities.formatDate ensures correct formatting based on script
36+
// timezone
37+
const formattedDate = Utilities.formatDate(
38+
sevenDaysAgo, Session.getScriptTimeZone(), 'yyyy/MM/dd');
39+
40+
// Construct the search query
41+
const query = `is:unread after:${formattedDate} in:inbox`;
42+
console.log('Searching for emails with query: ' + query);
43+
44+
// Search for threads matching the query
45+
// Note: GmailApp.search() returns threads where *at least one* message
46+
// matches
47+
const threads = GmailApp.search(query);
48+
createLabels();
49+
50+
for (const thread of threads) {
51+
const messages = thread.getMessages();
52+
const subject = thread.getFirstMessageSubject();
53+
const {classification, reason} = classifyEmail(subject, messages);
54+
console.log(`Classification: ${classification}, Reason: ${reason}`);
55+
56+
thread.addLabel(classificationLabels[classification].gmailLabel);
57+
58+
if (classification === 'needs-response') {
59+
const draft = draftEmail(subject, messages);
60+
thread.createDraftReplyAll(null, {htmlBody: draft});
61+
}
62+
63+
addDataToSheet(spreadsheet, hyperlink(thread), classification, reason);
64+
}
65+
66+
return showSpreadsheetLink(spreadsheet.getUrl());
67+
}

0 commit comments

Comments
 (0)