Creating robust internal tools is a critical endeavor for any engineering organization aiming to streamline workflows, enhance productivity, and automate repetitive tasks. Google Workspace Add-ons, powered by Google Apps Script, offer a powerful way to build custom integrations directly into Gmail, Calendar, Drive, and other Workspace applications. This comprehensive walkthrough will guide you through the entire process of creating, testing, and deploying an internal Workspace Add-on for your organization.
Unlike standalone web applications, Workspace Add-ons provide a context-aware user experience directly within Google application interfaces, minimizing context switching and improving user adoption. Apps Script, Google’s serverless JavaScript-based development platform, handles the backend without requiring you to manage servers, authentication flows, or complex infrastructure. By the end of this guide, you’ll have built a functional add-on and understand how to deploy it organization-wide.
What You’ll Build
In this walkthrough, we’ll create a Customer Information Add-on for Gmail that automatically fetches customer data when you open an email. This practical example demonstrates key concepts you can apply to any internal tool:
- Context-aware UI that responds to Gmail events
- External API integration
- Professional card-based interface
- Organization-wide deployment
Prerequisites
Before starting, ensure you have:
- Google Workspace Account: You need a Google Workspace (formerly G Suite) account, not a personal Gmail account
- Admin Access: To deploy organization-wide, you’ll need Google Workspace Admin privileges (or coordinate with your admin)
- JavaScript Knowledge: Familiarity with modern JavaScript (ES6+)
- Access to Apps Script: Navigate to script.google.com and verify you can create a new project
Note: For testing during development, admin access isn’t required - you can test add-ons in your own account.
Step 1: Create Your First Apps Script Project
Let’s start by creating a new Apps Script project and setting up the basic structure.
1.1 Initialize the Project
- Navigate to script.google.com
- Click “New project” in the top left
- Rename your project to “Customer Info Add-on” by clicking “Untitled project”
- You’ll see a default
Code.gsfile with amyFunction()template
1.2 Set Up the Manifest
The manifest file (appsscript.json) is the configuration brain of your add-on. It defines metadata, OAuth scopes, and event triggers.
- In your Apps Script editor, click the gear icon (⚙️) for “Project Settings”
- Check “Show ‘appsscript.json’ manifest file in editor”
- Return to the “Editor” tab - you’ll now see
appsscript.jsonin your file list - Replace the contents of
appsscript.jsonwith:
{
"timeZone": "America/New_York",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
"https://www.googleapis.com/auth/script.external_request"
],
"gmail": {
"name": "Customer Info",
"logoUrl": "https://www.gstatic.com/images/branding/product/1x/apps_script_48dp.png",
"contextualTriggers": [
{
"unconditional": {},
"onTriggerFunction": "onGmailMessageOpen"
}
],
"homepageTrigger": {
"runFunction": "onHomepage"
}
}
}
Key Configuration Elements:
runtimeVersion: "V8": Ensures you’re using the modern V8 JavaScript runtime with ES6+ supportoauthScopes: Defines what permissions your add-on needsgmail.addons.current.message.readonly: Read the currently open emailscript.external_request: Make HTTP requests to external APIs
contextualTriggers: Defines when your add-on appears (here, on any opened email)homepageTrigger: Function called when add-on is opened without specific context
Step 2: Build the User Interface with Card Service
Now let’s create the actual add-on logic. Replace the contents of Code.gs with the following code:
/**
* Homepage shown when add-on is opened without email context
*/
function onHomepage() {
const card = CardService.newCardBuilder()
.setHeader(
CardService.newCardHeader()
.setTitle('Customer Info Add-on')
.setSubtitle('Open an email to see customer details')
)
.addSection(
CardService.newCardSection()
.addWidget(
CardService.newTextParagraph()
.setText('This add-on displays customer information when you open an email.')
)
.addWidget(
CardService.newTextParagraph()
.setText('Open any email to see it in action!')
)
)
.build();
return card;
}
/**
* Triggered when user opens an email in Gmail
*/
function onGmailMessageOpen(e) {
// Extract email data from the event object
const messageId = e.gmail.messageId;
const accessToken = e.gmail.accessToken;
// Get the message using Gmail API
const message = getCurrentMessage(messageId, accessToken);
// Extract customer identifier from email
// For this example, we'll look for customer ID in subject or from address
const customerId = extractCustomerId(message);
// Fetch customer data (simulated - replace with your actual API)
const customerData = fetchCustomerData(customerId);
// Build and return the UI card
return buildCustomerCard(customerData);
}
/**
* Get current Gmail message details
*/
function getCurrentMessage(messageId, accessToken) {
const url = `https://www.googleapis.com/gmail/v1/users/me/messages/${messageId}`;
const response = UrlFetchApp.fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
return JSON.parse(response.getContentText());
}
/**
* Extract customer ID from email
* In real implementation, you'd have logic to parse the email
*/
function extractCustomerId(message) {
// Look for customer ID in subject line (format: [CID:12345])
const headers = message.payload.headers;
const subject = headers.find(h => h.name === 'Subject')?.value || '';
const cidMatch = subject.match(/\[CID:(\d+)\]/);
if (cidMatch) {
return cidMatch[1];
}
// Fallback: extract from sender email domain
const from = headers.find(h => h.name === 'From')?.value || '';
const emailMatch = from.match(/(\w+)@company\.com/);
if (emailMatch) {
return emailMatch[1];
}
return 'unknown';
}
/**
* Fetch customer data from your internal API
* This is simulated - replace with actual API call
*/
function fetchCustomerData(customerId) {
// In production, you'd call your actual CRM API:
// const response = UrlFetchApp.fetch(`https://your-crm.com/api/customers/${customerId}`, {
// headers: { 'Authorization': 'Bearer YOUR_API_TOKEN' }
// });
// return JSON.parse(response.getContentText());
// Simulated data for demonstration
return {
id: customerId,
name: 'Acme Corporation',
status: 'Active',
tier: 'Enterprise',
accountManager: 'Jane Smith',
lastContact: '2025-11-28',
totalRevenue: '$125,000',
supportTickets: 3
};
}
/**
* Build the UI card displaying customer information
*/
function buildCustomerCard(customer) {
const card = CardService.newCardBuilder();
// Header
card.setHeader(
CardService.newCardHeader()
.setTitle(`Customer: ${customer.name}`)
.setSubtitle(`ID: ${customer.id} | ${customer.tier}`)
);
// Customer Details Section
const detailsSection = CardService.newCardSection()
.setHeader('Account Details');
detailsSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Status')
.setContent(customer.status)
.setIcon(customer.status === 'Active' ?
CardService.Icon.CONFIRMATION_NUMBER_ICON :
CardService.Icon.DESCRIPTION)
);
detailsSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Account Manager')
.setContent(customer.accountManager)
.setIcon(CardService.Icon.PERSON)
);
detailsSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Last Contact')
.setContent(customer.lastContact)
.setIcon(CardService.Icon.CLOCK)
);
card.addSection(detailsSection);
// Metrics Section
const metricsSection = CardService.newCardSection()
.setHeader('Key Metrics');
metricsSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Total Revenue')
.setContent(customer.totalRevenue)
.setIcon(CardService.Icon.DOLLAR)
);
metricsSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Open Support Tickets')
.setContent(String(customer.supportTickets))
.setIcon(CardService.Icon.EMAIL)
);
card.addSection(metricsSection);
// Action Buttons Section
const actionsSection = CardService.newCardSection();
actionsSection.addWidget(
CardService.newButtonSet()
.addButton(
CardService.newTextButton()
.setText('View in CRM')
.setOpenLink(CardService.newOpenLink()
.setUrl(`https://crm.example.com/customers/${customer.id}`)
.setOpenAs(CardService.OpenAs.FULL_SIZE)
.setOnClose(CardService.OnClose.NOTHING))
)
.addButton(
CardService.newTextButton()
.setText('Create Ticket')
.setOnClickAction(
CardService.newAction()
.setFunctionName('createSupportTicket')
.setParameters({customerId: customer.id})
)
)
);
card.addSection(actionsSection);
return card.build();
}
/**
* Handler for "Create Ticket" button
*/
function createSupportTicket(e) {
const customerId = e.parameters.customerId;
// In production, create ticket via API
// For demo, show success message
const notification = CardService.newActionResponseBuilder()
.setNotification(
CardService.newNotification()
.setText(`Support ticket created for customer ${customerId}`)
)
.build();
return notification;
}
This code creates a complete add-on with:
- Homepage: Shown when opened without email context
- Email context trigger: Automatically displays when email is opened
- Customer data extraction: Parses customer ID from email
- API integration: Fetches data (simulated, easily replaceable)
- Professional UI: Uses Card Service widgets for clean presentation
- Interactive buttons: Actions that link to external systems
Note: The image below is a placeholder. In your organization’s documentation, consider creating an actual architecture diagram showing the flow between Gmail → Apps Script → External APIs → Card UI.
Step 3: Testing Your Add-on
Before deploying to your organization, thoroughly test the add-on in your personal environment.
3.1 Test Deployment
- In the Apps Script editor, click Deploy → Test deployments
- Click Install to install the add-on for yourself only
- Open Gmail in a new tab
- Click on any email
- Look for your add-on icon in the right sidebar
- Click it to see your add-on in action
Troubleshooting Test Deployments:
- Add-on not appearing? Wait 1-2 minutes for Gmail to refresh, then reload the page
- Errors displayed? Check the Apps Script execution logs: View → Executions
- Authorization required? Click “Authorize” and grant necessary permissions
3.2 Debugging Tips
Apps Script provides excellent debugging capabilities:
// Add logging throughout your code
function onGmailMessageOpen(e) {
console.log('Event data:', JSON.stringify(e));
const messageId = e.gmail.messageId;
console.log('Processing message:', messageId);
try {
const message = getCurrentMessage(messageId, e.gmail.accessToken);
console.log('Message data:', JSON.stringify(message));
const customerId = extractCustomerId(message);
console.log('Extracted customer ID:', customerId);
const customerData = fetchCustomerData(customerId);
console.log('Customer data:', JSON.stringify(customerData));
return buildCustomerCard(customerData);
} catch (error) {
console.error('Error:', error);
return buildErrorCard(error.message);
}
}
/**
* Display error in user-friendly card
*/
function buildErrorCard(errorMessage) {
return CardService.newCardBuilder()
.setHeader(
CardService.newCardHeader()
.setTitle('Error')
)
.addSection(
CardService.newCardSection()
.addWidget(
CardService.newTextParagraph()
.setText(`An error occurred: ${errorMessage}`)
)
)
.build();
}
Viewing Logs:
- In Apps Script editor: View → Executions
- Click on any execution to see its logs
- Logs show console.log() output, errors, and execution time
Step 4: Understanding OAuth Scopes and Security
Security is critical for internal add-ons. Let’s understand how OAuth scopes work.
Common OAuth Scopes
| Scope | Purpose | Risk Level |
|---|---|---|
gmail.addons.current.message.readonly | Read currently opened email | Low |
gmail.addons.current.message.action | Compose emails from add-on | Medium |
gmail.readonly | Read all emails in mailbox | High |
gmail.modify | Modify emails (labels, etc.) | High |
script.external_request | Call external APIs | Medium |
spreadsheets | Access Google Sheets | Medium |
Security Best Practices:
- Request minimum scopes: Only request permissions you actually need
- Use
.current.message.*scopes: These are safer than full mailbox access - Validate all inputs: Never trust data from emails or users
- Sanitize API responses: Don’t display untrusted HTML content
- Use HTTPS for external APIs: Always encrypt data in transit
- Store secrets securely: Use Script Properties, never hardcode API keys
Storing API Keys Securely
Never hardcode API keys in your script. Use Properties Service instead:
/**
* Set up your API key (run once, manually)
*/
function setupApiKey() {
const scriptProperties = PropertiesService.getScriptProperties();
scriptProperties.setProperty('CRM_API_KEY', 'your-actual-api-key-here');
scriptProperties.setProperty('CRM_API_URL', 'https://your-crm.com/api');
}
/**
* Use the API key in your code
*/
function fetchCustomerData(customerId) {
const scriptProperties = PropertiesService.getScriptProperties();
const apiKey = scriptProperties.getProperty('CRM_API_KEY');
const apiUrl = scriptProperties.getProperty('CRM_API_URL');
const response = UrlFetchApp.fetch(`${apiUrl}/customers/${customerId}`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) {
throw new Error('Failed to fetch customer data');
}
return JSON.parse(response.getContentText());
}
To set properties:
- Copy the
setupApiKey()function into your script - Update with your actual API credentials
- Select
setupApiKeyfrom the function dropdown - Click Run
- Delete or comment out the function after running
Step 5: Deploying Organization-Wide
Once tested, deploy the add-on for your entire organization.
5.1 Create Organization Deployment
- In Apps Script editor: Deploy → New deployment
- Click gear icon → Add-on
- Configure deployment:
- Description: “Customer Info Add-on for internal use”
- Version: “Version 1”
- Execute as: “User accessing the add-on” (recommended)
- Who has access: Select “Anyone within your organization”
- Click Deploy
- Copy the Deployment ID (you’ll need this for admin console)
5.2 Configure in Google Workspace Admin Console
Now publish the add-on through the Admin Console:
- Navigate to admin.google.com
- Go to Apps → Google Workspace Marketplace apps
- Click Add app → Add custom app
- Paste your Deployment ID
- Review permissions and click Add
- Choose installation settings:
- Install for everyone: Automatic installation for all users
- Available to install: Users can install from sidebar
- Click Continue and Finish
5.3 Installation Propagation
- Changes take up to 24 hours to propagate fully
- Most users see updates within 1-2 hours
- Users may need to refresh Gmail or re-login
Verification:
- Have test users check their Gmail add-on sidebar
- Check Admin Console reports for installation status
- Monitor Apps Script execution logs for usage
Step 6: Advanced Features
6.1 Adding User Configuration
Let users customize the add-on behavior:
/**
* Show configuration card on homepage
*/
function onHomepage() {
const userProperties = PropertiesService.getUserProperties();
const autoShow = userProperties.getProperty('autoShow') === 'true';
const card = CardService.newCardBuilder()
.setHeader(
CardService.newCardHeader()
.setTitle('Customer Info Add-on')
.setSubtitle('Configure your preferences')
)
.addSection(
CardService.newCardSection()
.setHeader('Settings')
.addWidget(
CardService.newSelectionInput()
.setType(CardService.SelectionInputType.CHECK_BOX)
.setFieldName('autoShow')
.addItem('Automatically show customer info', 'true', autoShow)
)
.addWidget(
CardService.newButtonSet()
.addButton(
CardService.newTextButton()
.setText('Save Settings')
.setOnClickAction(
CardService.newAction()
.setFunctionName('saveSettings')
)
)
)
)
.build();
return card;
}
/**
* Save user preferences
*/
function saveSettings(e) {
const userProperties = PropertiesService.getUserProperties();
const autoShow = e.formInput.autoShow ? e.formInput.autoShow[0] : 'false';
userProperties.setProperty('autoShow', autoShow);
return CardService.newActionResponseBuilder()
.setNotification(
CardService.newNotification()
.setText('Settings saved successfully')
)
.build();
}
6.2 Caching for Performance
Reduce API calls and improve response time with caching:
/**
* Fetch customer data with caching
*/
function fetchCustomerData(customerId) {
const cache = CacheService.getUserCache();
const cacheKey = `customer_${customerId}`;
// Try to get from cache first
const cached = cache.get(cacheKey);
if (cached) {
console.log('Returning cached data for:', customerId);
return JSON.parse(cached);
}
// Not in cache, fetch from API
console.log('Fetching fresh data for:', customerId);
const scriptProperties = PropertiesService.getScriptProperties();
const apiKey = scriptProperties.getProperty('CRM_API_KEY');
const apiUrl = scriptProperties.getProperty('CRM_API_URL');
const response = UrlFetchApp.fetch(`${apiUrl}/customers/${customerId}`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) {
throw new Error('Failed to fetch customer data');
}
const data = JSON.parse(response.getContentText());
// Cache for 10 minutes (600 seconds)
cache.put(cacheKey, JSON.stringify(data), 600);
return data;
}
Cache Benefits:
- Faster response times (no API latency)
- Reduced API quota usage
- Better reliability if external API is temporarily down
Cache Limitations:
- Maximum 100KB per item
- 1MB total per user
- Data expires after specified time or 6 hours max
6.3 Supporting Multiple Workspace Apps
Extend your add-on to work in Calendar, Drive, or Docs:
{
"timeZone": "America/New_York",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
"https://www.googleapis.com/auth/calendar.addons.execute",
"https://www.googleapis.com/auth/drive.addons.metadata.readonly",
"https://www.googleapis.com/auth/script.external_request"
],
"addOns": {
"common": {
"name": "Customer Info",
"logoUrl": "https://www.gstatic.com/images/branding/product/1x/apps_script_48dp.png",
"homepageTrigger": {
"runFunction": "onHomepage"
}
},
"gmail": {
"contextualTriggers": [
{
"unconditional": {},
"onTriggerFunction": "onGmailMessageOpen"
}
]
},
"calendar": {
"eventOpenTrigger": {
"runFunction": "onCalendarEventOpen"
}
}
}
}
Then add handlers for each app:
/**
* Calendar event handler
*/
function onCalendarEventOpen(e) {
const eventId = e.calendar.id;
const attendees = e.calendar.attendees || [];
// Find customers from attendee list
const customers = attendees
.map(a => extractCustomerId({payload: {headers: [{name: 'From', value: a.email}]}}))
.filter(id => id !== 'unknown');
if (customers.length === 0) {
return buildNoCustomersCard();
}
// Show first customer
const customerData = fetchCustomerData(customers[0]);
return buildCustomerCard(customerData);
}
function buildNoCustomersCard() {
return CardService.newCardBuilder()
.setHeader(
CardService.newCardHeader()
.setTitle('Customer Info')
)
.addSection(
CardService.newCardSection()
.addWidget(
CardService.newTextParagraph()
.setText('No customer contacts found in this event.')
)
)
.build();
}
Step 7: Production Best Practices
7.1 Error Handling and Resilience
Production add-ons need robust error handling:
/**
* Wrapper with comprehensive error handling
*/
function onGmailMessageOpen(e) {
try {
// Validate event object
if (!e || !e.gmail || !e.gmail.messageId) {
throw new Error('Invalid event object');
}
const messageId = e.gmail.messageId;
const accessToken = e.gmail.accessToken;
// Get message with retry logic
const message = getMessageWithRetry(messageId, accessToken);
const customerId = extractCustomerId(message);
// Validate customer ID
if (!customerId || customerId === 'unknown') {
return buildNoCustomerCard();
}
const customerData = fetchCustomerDataSafe(customerId);
return buildCustomerCard(customerData);
} catch (error) {
console.error('Error in onGmailMessageOpen:', error);
logError(error, e);
return buildErrorCard(error.message);
}
}
/**
* Retry logic for API calls
*/
function getMessageWithRetry(messageId, accessToken, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return getCurrentMessage(messageId, accessToken);
} catch (error) {
console.log(`Attempt ${i + 1} failed:`, error.message);
if (i === maxRetries - 1) throw error;
Utilities.sleep(1000 * Math.pow(2, i)); // Exponential backoff
}
}
}
/**
* Safe customer data fetch with fallback
*/
function fetchCustomerDataSafe(customerId) {
try {
return fetchCustomerData(customerId);
} catch (error) {
console.error('Failed to fetch customer data:', error);
// Return minimal fallback data
return {
id: customerId,
name: 'Unknown Customer',
status: 'Data unavailable',
error: true
};
}
}
/**
* Log errors to spreadsheet for monitoring
* Setup: Create a Google Sheet, get its ID from the URL, and store it:
* PropertiesService.getScriptProperties().setProperty('LOG_SHEET_ID', 'your-sheet-id-here');
*/
function logError(error, context) {
try {
const scriptProperties = PropertiesService.getScriptProperties();
const sheetId = scriptProperties.getProperty('LOG_SHEET_ID');
if (!sheetId) {
console.warn('LOG_SHEET_ID not configured - skipping error logging to sheet');
return;
}
const sheet = SpreadsheetApp.openById(sheetId).getActiveSheet();
sheet.appendRow([
new Date(),
error.message,
error.stack || '',
JSON.stringify(context)
]);
} catch (logError) {
console.error('Failed to log error:', logError);
}
}
7.2 Performance Optimization
Key optimization strategies:
- Minimize API Calls: Batch requests when possible
- Cache Aggressively: Use CacheService for frequently accessed data
- Lazy Load: Only fetch data when actually needed
- Optimize Payload: Request only fields you need from APIs
/**
* Optimized Gmail message fetch - only get headers
*/
function getCurrentMessage(messageId, accessToken) {
const url = `https://www.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From`;
const response = UrlFetchApp.fetch(url, {
headers: { 'Authorization': `Bearer ${accessToken}` },
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) {
throw new Error(`Gmail API error: ${response.getResponseCode()}`);
}
return JSON.parse(response.getContentText());
}
/**
* Batch fetch multiple customers
*/
function fetchMultipleCustomers(customerIds) {
const cache = CacheService.getUserCache();
const uncached = [];
const results = {};
// Check cache first
customerIds.forEach(id => {
const cached = cache.get(`customer_${id}`);
if (cached) {
results[id] = JSON.parse(cached);
} else {
uncached.push(id);
}
});
// Batch fetch uncached
if (uncached.length > 0) {
const scriptProperties = PropertiesService.getScriptProperties();
const apiUrl = scriptProperties.getProperty('CRM_API_URL');
const apiKey = scriptProperties.getProperty('CRM_API_KEY');
const response = UrlFetchApp.fetch(`${apiUrl}/customers/batch`, {
method: 'post',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
payload: JSON.stringify({ ids: uncached })
});
const batchData = JSON.parse(response.getContentText());
// Cache and store results
batchData.forEach(customer => {
cache.put(`customer_${customer.id}`, JSON.stringify(customer), 600);
results[customer.id] = customer;
});
}
return results;
}
7.3 Monitoring and Analytics
Track add-on usage and performance:
/**
* Log usage metrics
* Setup: Create a Google Sheet for metrics with columns: Timestamp, User, Action, Metadata
* Get the sheet ID from URL and store:
* PropertiesService.getScriptProperties().setProperty('METRICS_SHEET_ID', 'your-sheet-id-here');
*/
function logUsage(action, metadata = {}) {
const userEmail = Session.getActiveUser().getEmail();
const timestamp = new Date();
const logEntry = {
timestamp: timestamp.toISOString(),
user: userEmail,
action: action,
metadata: metadata
};
// Log to spreadsheet
try {
const scriptProperties = PropertiesService.getScriptProperties();
const sheetId = scriptProperties.getProperty('METRICS_SHEET_ID');
if (!sheetId) {
console.warn('METRICS_SHEET_ID not configured - skipping metrics logging');
return;
}
const sheet = SpreadsheetApp.openById(sheetId)
.getSheetByName('Usage Logs');
if (!sheet) {
console.error('Sheet "Usage Logs" not found in metrics spreadsheet');
return;
}
sheet.appendRow([
timestamp,
userEmail,
action,
JSON.stringify(metadata)
]);
} catch (error) {
console.error('Failed to log usage:', error);
}
}
/**
* Enhanced message open handler with metrics
*/
function onGmailMessageOpen(e) {
const startTime = Date.now();
try {
const result = processEmailOpen(e);
logUsage('message_open', {
duration: Date.now() - startTime,
success: true
});
return result;
} catch (error) {
logUsage('message_open', {
duration: Date.now() - startTime,
success: false,
error: error.message
});
throw error;
}
}
7.4 Version Management
Maintain multiple versions for testing and rollback:
- Semantic Versioning: Use version numbers like 1.0.0, 1.1.0, 2.0.0
- Test Deployments: Always test new versions before org-wide rollout
- Gradual Rollout: Deploy to pilot group first
- Keep Previous Version: Don’t delete old deployments immediately
Version Deployment Strategy:
Version 1.0.0 (Production)
├─ Deploy to: Everyone
└─ Status: Stable
Version 1.1.0 (Beta)
├─ Deploy to: Beta testers group
└─ Status: Testing
Version 1.2.0 (Development)
├─ Deploy to: Test deployments only
└─ Status: In development
Step 8: Troubleshooting Common Issues
Issue 1: Add-on Not Appearing
Symptoms: Add-on doesn’t show in Gmail sidebar
Solutions:
- Wait up to 24 hours for propagation
- Check Admin Console installation status
- Verify user is in correct organizational unit
- Clear browser cache and reload Gmail
- Try incognito mode
- Check if add-on is enabled in Gmail settings
Issue 2: Authorization Errors
Symptoms: “Authorization required” or scope errors
Solutions:
// Verify scopes in appsscript.json
{
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
"https://www.googleapis.com/auth/script.external_request"
]
}
- Ensure all required scopes are listed
- Re-authorize: Remove add-on and reinstall
- Check Admin Console for restricted scopes
- Verify script hasn’t been disabled by admin
Issue 3: External API Calls Failing
Symptoms: Timeout or connection errors
Solutions:
function makeExternalApiCall(url) {
// Get API credentials securely
const scriptProperties = PropertiesService.getScriptProperties();
const apiToken = scriptProperties.getProperty('EXTERNAL_API_TOKEN');
if (!apiToken) {
throw new Error('API token not configured');
}
try {
const response = UrlFetchApp.fetch(url, {
headers: {
'Authorization': `Bearer ${apiToken}`
},
muteHttpExceptions: true,
validateHttpsCertificates: true
});
const code = response.getResponseCode();
if (code === 429) {
throw new Error('Rate limit exceeded - try again later');
}
if (code >= 400) {
throw new Error(`API error: ${code} - ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
- Check API endpoint is HTTPS
- Verify API key/token is valid
- Check Apps Script quotas: script.google.com/home/usersettings
- Implement retry logic with exponential backoff
- Check if domain needs whitelisting in Workspace Admin
Issue 4: Slow Performance
Symptoms: Add-on takes >5 seconds to load
Solutions:
- Implement caching (see Section 6.2)
- Reduce payload size from external APIs
- Optimize image loading (use lower resolution)
- Minimize Card complexity (fewer widgets)
- Profile execution time:
function profileExecution() {
const steps = {};
steps.start = Date.now();
const message = getCurrentMessage();
steps.getMessage = Date.now();
const customerId = extractCustomerId(message);
steps.extractId = Date.now();
const data = fetchCustomerData(customerId);
steps.fetchData = Date.now();
const card = buildCustomerCard(data);
steps.buildCard = Date.now();
// Log timing breakdown
console.log('Timing breakdown:');
console.log('Get message:', steps.getMessage - steps.start, 'ms');
console.log('Extract ID:', steps.extractId - steps.getMessage, 'ms');
console.log('Fetch data:', steps.fetchData - steps.extractId, 'ms');
console.log('Build card:', steps.buildCard - steps.fetchData, 'ms');
console.log('Total:', steps.buildCard - steps.start, 'ms');
return card;
}
Issue 5: Quota Exceeded Errors
Apps Script Quotas (Free workspace accounts):
- Email sends: 100/day
- URL fetches: 20,000/day
- Execution time: 6 minutes/execution
- Triggers: 20 concurrent
Solutions:
- Implement aggressive caching
- Batch API requests
- Optimize execution paths
- Consider upgrading to paid workspace
- Monitor quota usage in Apps Script dashboard
Understanding the Apps Script Execution Environment
The V8 Runtime
Apps Script uses Google’s V8 JavaScript engine (same as Chrome and Node.js), providing:
Modern JavaScript Features:
// ES6+ features fully supported
const fetchData = async (id) => {
const response = await UrlFetchApp.fetch(`https://api.example.com/data/${id}`);
return JSON.parse(response.getContentText());
};
// Destructuring
const {customerId, companyName} = extractCustomerInfo(message);
// Template literals
const url = `${baseUrl}/customers/${customerId}/details`;
// Spread operator
const allData = {...basicInfo, ...additionalDetails};
// Arrow functions
const customerIds = emails.map(e => extractId(e)).filter(id => id !== null);
Key Characteristics:
| Aspect | Details |
|---|---|
| Runtime | V8 (Chrome/Node.js engine) |
| JavaScript Version | ES6+ (ECMAScript 2015+) |
| Execution Model | Serverless, ephemeral containers |
| State Persistence | Use PropertiesService or external storage |
| Cold Starts | Initial execution after inactivity is slower |
| Quotas | Per-user and per-day limits apply |
Serverless Considerations
Statelessness: Each execution is independent
// ❌ DON'T: Global variables reset between executions
let cachedData = null;
function getData() {
if (!cachedData) {
cachedData = fetchData(); // This won't persist!
}
return cachedData;
}
// ✓ DO: Use CacheService for persistence
function getData() {
const cache = CacheService.getUserCache();
let data = cache.get('myData');
if (!data) {
data = fetchData();
cache.put('myData', JSON.stringify(data), 600);
} else {
data = JSON.parse(data);
}
return data;
}
Advanced Architecture Patterns
Pattern 1: Multi-Source Data Aggregation
Combine data from multiple sources:
function onGmailMessageOpen(e) {
const message = getCurrentMessage(e.gmail.messageId, e.gmail.accessToken);
const customerId = extractCustomerId(message);
// Fetch from multiple sources in parallel
const results = fetchMultipleSources(customerId);
return buildAggregatedCard(results);
}
function fetchMultipleSources(customerId) {
// Apps Script doesn't support true parallelism, but this pattern
// minimizes sequential delays
const sources = {
crm: null,
support: null,
billing: null,
analytics: null
};
try {
sources.crm = fetchFromCRM(customerId);
} catch (e) {
console.error('CRM fetch failed:', e);
}
try {
sources.support = fetchFromSupport(customerId);
} catch (e) {
console.error('Support fetch failed:', e);
}
try {
sources.billing = fetchFromBilling(customerId);
} catch (e) {
console.error('Billing fetch failed:', e);
}
try {
sources.analytics = fetchFromAnalytics(customerId);
} catch (e) {
console.error('Analytics fetch failed:', e);
}
return sources;
}
function buildAggregatedCard(data) {
const card = CardService.newCardBuilder();
card.setHeader(
CardService.newCardHeader()
.setTitle('Customer Dashboard')
);
// Add section for each data source that succeeded
if (data.crm) {
card.addSection(buildCRMSection(data.crm));
}
if (data.support) {
card.addSection(buildSupportSection(data.support));
}
if (data.billing) {
card.addSection(buildBillingSection(data.billing));
}
if (data.analytics) {
card.addSection(buildAnalyticsSection(data.analytics));
}
return card.build();
}
Pattern 2: Workflow Automation
Automate multi-step processes:
/**
* Handle button click to create support ticket
*/
function createSupportTicket(e) {
const customerId = e.parameters.customerId;
const customerEmail = e.parameters.customerEmail;
try {
// Step 1: Create ticket in support system
const ticket = createTicketInSystem(customerId);
// Step 2: Log activity in CRM
logActivityInCRM(customerId, 'support_ticket_created', ticket.id);
// Step 3: Send notification email
sendTicketNotification(customerEmail, ticket);
// Step 4: Update UI
return CardService.newActionResponseBuilder()
.setNotification(
CardService.newNotification()
.setText(`Ticket #${ticket.id} created successfully`)
)
.setStateChanged(true)
.build();
} catch (error) {
return CardService.newActionResponseBuilder()
.setNotification(
CardService.newNotification()
.setText(`Failed to create ticket: ${error.message}`)
)
.build();
}
}
function sendTicketNotification(email, ticket) {
GmailApp.sendEmail(
email,
`Support Ticket #${ticket.id} Created`,
`Your support ticket has been created.\n\nTicket ID: ${ticket.id}\nStatus: ${ticket.status}\n\nView ticket: ${ticket.url}`
);
}
Pattern 3: Progressive Enhancement
Start with basic functionality, add features progressively:
function buildCustomerCard(customer) {
const card = CardService.newCardBuilder();
// Level 1: Basic info (always available)
card.setHeader(
CardService.newCardHeader()
.setTitle(customer.name || 'Unknown Customer')
);
const basicSection = CardService.newCardSection()
.setHeader('Basic Information');
if (customer.id) {
basicSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Customer ID')
.setContent(customer.id)
);
}
card.addSection(basicSection);
// Level 2: Enhanced data (if available)
if (customer.accountManager) {
const teamSection = CardService.newCardSection()
.setHeader('Team');
teamSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Account Manager')
.setContent(customer.accountManager)
.setIcon(CardService.Icon.PERSON)
);
card.addSection(teamSection);
}
// Level 3: Real-time data (if available)
if (customer.liveMetrics) {
const metricsSection = CardService.newCardSection()
.setHeader('Live Metrics');
metricsSection.addWidget(
CardService.newKeyValue()
.setTopLabel('Active Users')
.setContent(String(customer.liveMetrics.activeUsers))
);
card.addSection(metricsSection);
}
// Level 4: Actions (if permissions allow)
if (hasPermission('create_tickets')) {
const actionsSection = CardService.newCardSection();
actionsSection.addWidget(
CardService.newButtonSet()
.addButton(
CardService.newTextButton()
.setText('Create Support Ticket')
.setOnClickAction(
CardService.newAction()
.setFunctionName('createSupportTicket')
.setParameters({
customerId: customer.id,
customerEmail: customer.email
})
)
)
);
card.addSection(actionsSection);
}
return card.build();
}
Production Deployment Checklist
Before deploying to your organization, ensure:
Testing Complete
- Tested in test deployment with multiple users
- Verified all error paths work correctly
- Confirmed performance is acceptable (<3s load time)
- Tested with different email types
Security Review
- Minimum OAuth scopes requested
- API keys stored in Properties (not hardcoded)
- Input validation implemented
- External API calls use HTTPS
- Error messages don’t expose sensitive data
Monitoring Setup
- Error logging configured
- Usage metrics collection active
- Admin notification for critical errors
- Dashboard for monitoring adoption
Documentation
- User guide created
- Admin deployment guide written
- Troubleshooting section documented
- Support contact provided
Compliance
- Privacy policy reviewed
- Data handling documented
- Retention policies defined
- Access controls configured
Rollout Plan
- Pilot group identified (5-10 users)
- Feedback mechanism established
- Timeline for org-wide rollout set
- Rollback procedure documented
Conclusion
You’ve now built a complete, production-ready Google Workspace Add-on from scratch. This walkthrough covered:
✓ Setup: Creating Apps Script projects and manifest configuration
✓ Development: Building UIs with Card Service and implementing business logic
✓ Integration: Connecting to external APIs securely
✓ Testing: Comprehensive debugging and validation
✓ Deployment: Organization-wide rollout through Admin Console
✓ Production: Error handling, monitoring, performance optimization
✓ Advanced Patterns: Multi-source aggregation, workflow automation, progressive enhancement
Key Takeaways
- Start Simple: Begin with basic functionality, add features iteratively
- Security First: Always use minimum OAuth scopes and secure credential storage
- Cache Aggressively: Reduce API calls and improve performance with caching
- Handle Errors Gracefully: Provide fallbacks and helpful error messages
- Monitor Usage: Track metrics to understand adoption and issues
- Test Thoroughly: Use test deployments before org-wide rollout
Next Steps
Now that you have a working add-on:
- Customize the example to your organization’s needs
- Connect to your actual internal APIs and data sources
- Expand to Calendar, Drive, or other Workspace apps
- Share your success with stakeholders
- Iterate based on user feedback
Resources
- Apps Script Documentation: developers.google.com/apps-script
- Card Service Reference: developers.google.com/apps-script/reference/card-service
- Workspace Add-ons: developers.google.com/workspace/add-ons
- Admin SDK: developers.google.com/admin-sdk
- Workspace Marketplace: workspace.google.com/marketplace
Internal Google Workspace Add-ons represent a powerful way to extend your organization’s productivity tools with custom functionality that perfectly fits your workflows. With the foundation you’ve built here, you’re ready to create sophisticated internal tools that will streamline operations and delight your users.