I would like to open the compose UI and be able to update the draft subject / recipients / and CC all at the same time, not in multiple operations.
The sample code Google gives you doesn't work out of the box, you need to correct the errors. Here is the working code.
/**
* Compose trigger function that fires when the compose UI is
* requested. Builds and returns a compose UI for inserting images.
*
* #param {event} e The compose trigger event object. Not used in
* this example.
* #return {Card[]}
*/
function startApp(e) {
return [buildComposeCard()];
}
/**
* Build a card to display interactive buttons to allow the user to
* update the subject, and To, Cc, Bcc recipients.
*
* #return {Card}
*/
function buildComposeCard() {
var card = CardService.newCardBuilder();
var cardSection = CardService.newCardSection().setHeader('Update email');
cardSection.addWidget(
CardService.newTextButton()
.setText('Update subject')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateSubjectAction')));
cardSection.addWidget(
CardService.newTextButton()
.setText('Update To recipients')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateToRecipientsAction')));
cardSection.addWidget(
CardService.newTextButton()
.setText('Update Cc recipients')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateCcRecipientsAction')));
cardSection.addWidget(
CardService.newTextButton()
.setText('Update Bcc recipients')
.setOnClickAction(CardService.newAction()
.setFunctionName('applyUpdateBccRecipientsAction')));
return card.addSection(cardSection).build();
}
/**
* Updates the subject field of the current email when the user clicks
* on "Update subject" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateSubjectAction() {
// Get the new subject field of the email.
// This function is not shown in this example.
var subject = ['this is a subject'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftSubjectAction(CardService.newUpdateDraftSubjectAction()
.addUpdateSubject(subject))
.build();
return response;
}
/**
* Updates the To recipients of the current email when the user clicks
* on "Update To recipients" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateToRecipientsAction() {
// Get the new To recipients of the email.
// This function is not shown in this example.
var toRecipients = ['johhny.appleseed#gmail.com'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftToRecipientsAction(CardService.newUpdateDraftToRecipientsAction()
.addUpdateToRecipients(toRecipients))
.build();
return response;
}
/**
* Updates the Cc recipients of the current email when the user clicks
* on "Update Cc recipients" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateCcRecipientsAction() {
// Get the new Cc recipients of the email.
// This function is not shown in this example.
var ccRecipients = ['big.blue#montana.com'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftCcRecipientsAction(CardService.newUpdateDraftCcRecipientsAction()
.addUpdateCcRecipients(ccRecipients))
.build();
return response;
}
/**
* Updates the Bcc recipients of the current email when the user clicks
* on "Update Bcc recipients" in the compose UI.
*
* Note: This is not the compose action that builds a compose UI, but
* rather an action taken when the user interacts with the compose UI.
*
* #return {UpdateDraftActionResponse}
*/
function applyUpdateBccRecipientsAction() {
// Get the new Bcc recipients of the email.
// This function is not shown in this example.
var bccRecipients = ['spacer#gmail.com'];
var response = CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftBccRecipientsAction(CardService.newUpdateDraftBccRecipientsAction()
.addUpdateBccRecipients(bccRecipients))
.build();
return response;
}
When that code is opened, it looks like this compose UI display.
However
these links only work one at a time and close the screen. You have to perform all the actions in multiple moves. I would like it to be able to perform more then one action at a time.
For example, if I click "Update Subject", the action works and the compose UI screen closes, but I don't want to open the add-on a second time to add the CC email address.
I believe your goal as follows.
You want to run these functions of applyUpdateSubjectAction(), applyUpdateToRecipientsAction(), applyUpdateCcRecipientsAction() and applyUpdateBccRecipientsAction() by one click at the dialog.
In this case, how about the following modification?
Modified script:
Please modify buildComposeCard() as follows.
function buildComposeCard() {
var card = CardService.newCardBuilder();
var cardSection = CardService.newCardSection().setHeader('Update email');
cardSection.addWidget(CardService.newTextButton().setText('Update email').setOnClickAction(CardService.newAction().setFunctionName('applyUpdateEmail')));
return card.addSection(cardSection).build();
}
And, please add the following function.
function applyUpdateEmail() {
var subject = ['this is a subject'];
var toRecipients = ['johhny.appleseed#gmail.com'];
var ccRecipients = ['big.blue#montana.com'];
var bccRecipients = ['spacer#gmail.com'];
return CardService.newUpdateDraftActionResponseBuilder()
.setUpdateDraftSubjectAction(CardService.newUpdateDraftSubjectAction().addUpdateSubject(subject))
.setUpdateDraftToRecipientsAction(CardService.newUpdateDraftToRecipientsAction().addUpdateToRecipients(toRecipients))
.setUpdateDraftCcRecipientsAction(CardService.newUpdateDraftCcRecipientsAction().addUpdateCcRecipients(ccRecipients))
.setUpdateDraftBccRecipientsAction(CardService.newUpdateDraftBccRecipientsAction().addUpdateBccRecipients(bccRecipients))
.build();
}
In this modification, you can see "Update email" at the opened dialog. When you click it, addUpdateSubject, addUpdateToRecipients, addUpdateCcRecipients and addUpdateBccRecipients are run.
Note:
When you modified the Google Apps Script, please modify the deployment as new version. By this, the modified script is reflected to the add-on. Please be careful this.
References:
Class UpdateDraftActionResponse
Class UpdateDraftActionResponseBuilder
Related
I try to create my mail merge with this example.
However, I don't want to send all emails immediately after using the script - I'd like to create scheduled messages with dates specified in a column (Scheduled time).
The script from the example:
// To learn how to use this script, refer to the documentation:
// https://developers.google.com/apps-script/samples/automations/mail-merge
/*
Copyright 2022 Martin Hawksey
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
https://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.
*/
/**
* #OnlyCurrentDoc
*/
/**
* Change these to match the column names you are using for email
* recipient addresses and email sent column.
*/
const RECIPIENT_COL = "Recipient";
const EMAIL_SENT_COL = "Email Sent";
/**
* Creates the menu item "Mail Merge" for user to run scripts on drop-down.
*/
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('Mail Merge')
.addItem('Send Emails', 'sendEmails')
.addToUi();
}
/**
* Sends emails from sheet data.
* #param {string} subjectLine (optional) for the email draft message
* #param {Sheet} sheet to read data from
*/
function sendEmails(subjectLine, sheet=SpreadsheetApp.getActiveSheet()) {
// option to skip browser prompt if you want to use this code in other projects
if (!subjectLine){
subjectLine = Browser.inputBox("Mail Merge",
"Type or copy/paste the subject line of the Gmail " +
"draft message you would like to mail merge with:",
Browser.Buttons.OK_CANCEL);
if (subjectLine === "cancel" || subjectLine == ""){
// If no subject line, finishes up
return;
}
}
// Gets the draft Gmail message to use as a template
const emailTemplate = getGmailTemplateFromDrafts_(subjectLine);
// Gets the data from the passed sheet
const dataRange = sheet.getDataRange();
// Fetches displayed values for each row in the Range HT Andrew Roberts
// https://mashe.hawksey.info/2020/04/a-bulk-email-mail-merge-with-gmail-and-google-sheets-solution-evolution-using-v8/#comment-187490
// #see https://developers.google.com/apps-script/reference/spreadsheet/range#getdisplayvalues
const data = dataRange.getDisplayValues();
// Assumes row 1 contains our column headings
const heads = data.shift();
// Gets the index of the column named 'Email Status' (Assumes header names are unique)
// #see http://ramblings.mcpher.com/Home/excelquirks/gooscript/arrayfunctions
const emailSentColIdx = heads.indexOf(EMAIL_SENT_COL);
// Converts 2d array into an object array
// See https://stackoverflow.com/a/22917499/1027723
// For a pretty version, see https://mashe.hawksey.info/?p=17869/#comment-184945
const obj = data.map(r => (heads.reduce((o, k, i) => (o[k] = r[i] || '', o), {})));
// Creates an array to record sent emails
const out = [];
// Loops through all the rows of data
obj.forEach(function(row, rowIdx){
// Only sends emails if email_sent cell is blank and not hidden by a filter
if (row[EMAIL_SENT_COL] == ''){
try {
const msgObj = fillInTemplateFromObject_(emailTemplate.message, row);
// See https://developers.google.com/apps-script/reference/gmail/gmail-app#sendEmail(String,String,String,Object)
// If you need to send emails with unicode/emoji characters change GmailApp for MailApp
// Uncomment advanced parameters as needed (see docs for limitations)
GmailApp.sendEmail(row[RECIPIENT_COL], msgObj.subject, msgObj.text, {
htmlBody: msgObj.html,
// bcc: 'a.bbc#email.com',
// cc: 'a.cc#email.com',
from: 'sender#gmail.com',
name: 'name of the sender',
// replyTo: 'a.reply#email.com',
// noReply: true, // if the email should be sent from a generic no-reply email address (not available to gmail.com users)
attachments: emailTemplate.attachments,
inlineImages: emailTemplate.inlineImages
});
// Edits cell to record email sent date
out.push([new Date()]);
} catch(e) {
// modify cell to record error
out.push([e.message]);
}
} else {
out.push([row[EMAIL_SENT_COL]]);
}
});
// Updates the sheet with new data
sheet.getRange(2, emailSentColIdx+1, out.length).setValues(out);
/**
* Get a Gmail draft message by matching the subject line.
* #param {string} subject_line to search for draft message
* #return {object} containing the subject, plain and html message body and attachments
*/
function getGmailTemplateFromDrafts_(subject_line){
try {
// get drafts
const drafts = GmailApp.getDrafts();
// filter the drafts that match subject line
const draft = drafts.filter(subjectFilter_(subject_line))[0];
// get the message object
const msg = draft.getMessage();
// Handles inline images and attachments so they can be included in the merge
// Based on https://stackoverflow.com/a/65813881/1027723
// Gets all attachments and inline image attachments
const allInlineImages = draft.getMessage().getAttachments({includeInlineImages: true,includeAttachments:false});
const attachments = draft.getMessage().getAttachments({includeInlineImages: false});
const htmlBody = msg.getBody();
// Creates an inline image object with the image name as key
// (can't rely on image index as array based on insert order)
const img_obj = allInlineImages.reduce((obj, i) => (obj[i.getName()] = i, obj) ,{});
//Regexp searches for all img string positions with cid
const imgexp = RegExp('<img.*?src="cid:(.*?)".*?alt="(.*?)"[^\>]+>', 'g');
const matches = [...htmlBody.matchAll(imgexp)];
//Initiates the allInlineImages object
const inlineImagesObj = {};
// built an inlineImagesObj from inline image matches
matches.forEach(match => inlineImagesObj[match[1]] = img_obj[match[2]]);
return {message: {subject: subject_line, text: msg.getPlainBody(), html:htmlBody},
attachments: attachments, inlineImages: inlineImagesObj };
} catch(e) {
throw new Error("Oops - can't find Gmail draft");
}
/**
* Filter draft objects with the matching subject linemessage by matching the subject line.
* #param {string} subject_line to search for draft message
* #return {object} GmailDraft object
*/
function subjectFilter_(subject_line){
return function(element) {
if (element.getMessage().getSubject() === subject_line) {
return element;
}
}
}
}
/**
* Fill template string with data object
* #see https://stackoverflow.com/a/378000/1027723
* #param {string} template string containing {{}} markers which are replaced with data
* #param {object} data object used to replace {{}} markers
* #return {object} message replaced with data
*/
function fillInTemplateFromObject_(template, data) {
// We have two templates one for plain text and the html body
// Stringifing the object means we can do a global replace
let template_string = JSON.stringify(template);
// Token replacement
template_string = template_string.replace(/{{[^{}]+}}/g, key => {
return escapeData_(data[key.replace(/[{}]+/g, "")] || "");
});
return JSON.parse(template_string);
}
/**
* Escape cell data to make JSON safe
* #see https://stackoverflow.com/a/9204218/1027723
* #param {string} str to escape JSON special characters from
* #return {string} escaped string
*/
function escapeData_(str) {
return str
.replace(/[\\]/g, '\\\\')
.replace(/[\"]/g, '\\\"')
.replace(/[\/]/g, '\\/')
.replace(/[\b]/g, '\\b')
.replace(/[\f]/g, '\\f')
.replace(/[\n]/g, '\\n')
.replace(/[\r]/g, '\\r')
.replace(/[\t]/g, '\\t');
};
}
Is it possible to set sending date with the GmaillApp.sendEmail function? I wasn't able to find a way in the documentation.
If not, is there another way to achieve this with Google Sheets? Any suggestion will be appreciated.
Scheduling with Google Apps Script is possible using Triggers.
Click Triggers on left menu.
Then "Add Trigger" button and select the function and time to run automatically.
To run your function with an specific date from your cell, see this other question: Run trigger at specific date and time
Documentation: https://developers.google.com/apps-script/reference/script/clock-trigger-builder
Hope it helps.
Yes, almost anything is possible with AppScript.
I suggest you read the sheet into an array and then create a loop that compares the current time to the scheduled time. If they are equal (may not be able to get it to the exact second, minutes likely would be better), sends the email and removes the element from the array. The loop continues until the array is empty.
I've successfully made my GAS for tweet with OAuth1. It's known that OAuth1 GAS library is deprecated, so I'm trying to migrate to OAuth2 library.
I saw a few changes, but I don't get the correct way to authorize my request with this.
The main questions I have right now are:
Bearer Token replace in OAuth2 to key&access tokens in OAuth1?
I don't need key&access to authorize rquest? I'm base on example of the Google developers's site itself
For more clarity, I put the code, extracted from Google developers's site, adapted for my propouses:
// Call this function just once, to initialize the OAuth client.
function initializeOAuthClient() {
if (typeof OAuth2 === 'undefined') {
var libUrl = 'https://developers.google.com/google-ads/scripts/docs/examples/oauth20-library';
throw Error('OAuth2 library not found. Please take a copy of the OAuth2 ' +
'library from ' + libUrl + ' and append to the bottom of this script.');
}
var tokenUrl = 'https://api.twitter.com/oauth2/token';
authUrlFetch = OAuth2.withClientCredentials(
tokenUrl, CONSUMER_KEY, CONSUMER_SECRET);
}
function sendTweet(status) {
var service = accessProtectedResource(SERVICE_UPDATE_URL, "post");
if (service.hasAccess()) {
var url = 'https://api.twitter.com/1.1/statuses/update.json?include_entities=true&status=' + percentEncode(status);
var response = service.fetch(url);
//var result = JSON.parse(response.getContentText());
return response;
}
}
/**
* Attempts to access a non-Google API using a constructed service
* object.
*
* If your add-on needs access to non-Google APIs that require OAuth,
* you need to implement this method. You can use the OAuth1 and
* OAuth2 Apps Script libraries to help implement it.
*
* #param {String} url The URL to access.
* #param {String} method_opt The HTTP method. Defaults to GET.
* #param {Object} headers_opt The HTTP headers. Defaults to an empty
* object. The Authorization field is added
* to the headers in this method.
* #return {HttpResponse} the result from the UrlFetchApp.fetch() call.
*/
function accessProtectedResource(url, method_opt, headers_opt) {
var service = getOAuthService();
var maybeAuthorized = service.hasAccess();
if (maybeAuthorized) {
// A token is present, but it may be expired or invalid. Make a
// request and check the response code to be sure.
// Make the UrlFetch request and return the result.
var accessToken = service.getAccessToken();
var method = method_opt || 'post';
var headers = headers_opt || {};
headers['Authorization'] =
Utilities.formatString('Bearer %s', accessToken);
var resp = UrlFetchApp.fetch(url, {
'headers': headers,
'method' : method,
'muteHttpExceptions': true, // Prevents thrown HTTP exceptions.
});
var code = resp.getResponseCode();
if (code >= 200 && code < 300) {
return resp.getContentText("utf-8"); // Success
} else if (code == 401 || code == 403) {
// Not fully authorized for this action.
maybeAuthorized = false;
} else {
// Handle other response codes by logging them and throwing an
// exception.
console.error("Backend server error (%s): %s", code.toString(),
resp.getContentText("utf-8"));
throw ("Backend server error: " + code);
}
}
if (!maybeAuthorized) {
// Invoke the authorization flow using the default authorization
// prompt card.
CardService.newAuthorizationException()
.setAuthorizationUrl(service.getAuthorizationUrl())
.setResourceDisplayName("Display name to show to the user")
.throwException();
}
}
/**
* Create a new OAuth service to facilitate accessing an API.
* This example assumes there is a single service that the add-on needs to
* access. Its name is used when persisting the authorized token, so ensure
* it is unique within the scope of the property store. You must set the
* client secret and client ID, which are obtained when registering your
* add-on with the API.
*
* See the Apps Script OAuth2 Library documentation for more
* information:
* https://github.com/googlesamples/apps-script-oauth2#1-create-the-oauth2-service
*
* #return A configured OAuth2 service object.
*/
function getOAuthService() {
return OAuth2.createService('SERVICE_NAME')
.setAuthorizationBaseUrl('SERVICE_AUTH_URL')
.setTokenUrl('SERVICE_AUTH_TOKEN_URL')
.setClientId('CLIENT_ID')
.setClientSecret('CLIENT_SECRET')
.setScope('SERVICE_SCOPE_REQUESTS')
.setCallbackFunction('authCallback')
.setCache(CacheService.getUserCache())
.setPropertyStore(PropertiesService.getScriptProperties())
}
/**
* Boilerplate code to determine if a request is authorized and returns
* a corresponding HTML message. When the user completes the OAuth2 flow
* on the service provider's website, this function is invoked from the
* service. In order for authorization to succeed you must make sure that
* the service knows how to call this function by setting the correct
* redirect URL.
*
* The redirect URL to enter is:
* https://script.google.com/macros/d/<Apps Script ID>/usercallback
*
* See the Apps Script OAuth2 Library documentation for more
* information:
* https://github.com/googlesamples/apps-script-oauth2#1-create-the-oauth2-service
*
* #param {Object} callbackRequest The request data received from the
* callback function. Pass it to the service's
* handleCallback() method to complete the
* authorization process.
* #return {HtmlOutput} a success or denied HTML message to display to
* the user. Also sets a timer to close the window
* automatically.
*/
function authCallback(callbackRequest) {
var authorized = getOAuthService().handleCallback(callbackRequest);
if (authorized) {
return HtmlService.createHtmlOutput(
'Success! <script>setTimeout(function() { top.window.close() }, 1);</script>');
} else {
return HtmlService.createHtmlOutput('Denied');
}
}
/**
* Unauthorizes the non-Google service. This is useful for OAuth
* development/testing. Run this method (Run > resetOAuth in the script
* editor) to reset OAuth to re-prompt the user for OAuth.
*/
function resetOAuth() {
getOAuthService().reset();
}
function main() {
try {
let result = sendTweet("Este va a ser un gran día!\n https://www.instagram.com/amos_oficialba/");
Logger.log("Resultado: " + result);
}
catch(err) {
console.log(err["stack"]);
}
}
Native support was removed from OAuthConfig, but that does not prevent your app to make an OAuth 1 request to external APIs. The open source library OAuth1 for Apps Script was created as a replacement in case you were using OAuthConfig before.
To Tweet from Google Apps Script with the OAuth1 for Apps Script library:
You need to setup the callback URL in your Twitter Developer portal. When using this library, the callback URL will always be in the format https://script.google.com/macros/s/YOUR_SCRIPT_ID/usercallback. You will need to replace YOUR_SCRIPT_ID with, well, your script's ID.
In Google Apps Script, go to the File menu and select Project properties. Take a note of your script ID.
In the Twitter Developer portal, select your app, then click Edit under Authentication settings.
Add the callback URL, then click Save when done:
Back in Google Apps Script, select the Resources menu, then click Libraries.
In the Libraries window, import the OAuth1 library by typing its ID 1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s, then click Add.
Select the latest version (18 at the time of writing)
Once done, use this script to setup a valid Tweet request. Replace CONSUMER_KEY and CONSUMER_SECRET with the API key and secret for your app; replace TOKEN, and TOKEN_SECRET with your user's access token and access token secret.
var CONSUMER_KEY = 'your consumer key';
var CONSUMER_SECRET = 'your consumer secret';
var TOKEN = 'your access token';
var TOKEN_SECRET = 'your access token secret';
/**
* Authorizes and makes a request to the Twitter API.
*/
function run() {
var service = getService();
Logger.log(service.getCallbackUrl())
if (service.hasAccess()) {
var url = 'https://api.twitter.com/1.1/statuses/update.json';
var payload = {
status: 'just setting up my google apps script'
};
var response = service.fetch(url, {
method: 'post',
payload: payload
});
var result = JSON.parse(response.getContentText());
Logger.log(JSON.stringify(result, null, 2));
} else {
var authorizationUrl = service.authorize();
Logger.log('Open the following URL and re-run the script: %s',
authorizationUrl);
}
}
function doGet() {
return HtmlService.createHtmlOutput(ScriptApp.getService().getUrl());
}
/**
* Reset the authorization state, so that it can be re-tested.
*/
function reset() {
var service = getService();
service.reset();
}
/**
* Configures the service.
*/
function getService() {
return OAuth1.createService('Twitter')
// Set the endpoint URLs.
.setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
.setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
.setAuthorizationUrl('https://api.twitter.com/oauth/authorize')
// Set the consumer key and secret.
.setConsumerKey(CONSUMER_KEY)
.setConsumerSecret(CONSUMER_SECRET)
// Set your user's access token key and secret
.setAccessToken(TOKEN, TOKEN_SECRET)
.setCallbackFunction('authCallback')
}
/**
* Handles the OAuth callback.
*/
function authCallback(request) {
var service = getService();
var authorized = service.handleCallback(request);
if (authorized) {
return HtmlService.createHtmlOutput('Success!');
} else {
return HtmlService.createHtmlOutput('Denied');
}
}
Alternatively, you can use Google's own OAuth 1 replacement script to sign OAuth 1 requests. You can find an example of usage in the Google Ads script page.
When I try to activate my add-on for a message in the SPAM folder it tells me "Spam and suspicious messages can’t be used for recommended content or actions." How do I make it work?
function getContextualAddOn(event) {
var message = getCurrentMessage(event);
var card = createCard(message);
return [card.build()];
}
/**
* Retrieves the current message given an action event object.
* #param {Event} event Action event object
* #return {Message}
*/
function getCurrentMessage(event) {
var accessToken = event.messageMetadata.accessToken;
var messageId = event.messageMetadata.messageId;
GmailApp.setCurrentMessageAccessToken(accessToken);
return GmailApp.getMessageById(messageId);
}
function createCard(message) {
var emailFrom = message.getHeader("return-path-1");
var card = CardService.newCardBuilder();
card.setHeader(CardService.newCardHeader().setTitle("Forward e-mail"));
var statusSection = CardService.newCardSection();
statusSection.addWidget(CardService.newTextParagraph()
.setText("<b>Sender: </b>" + emailFrom ));
card.addSection(statusSection);
var formArea = CardService.newCardSection();
var widget = CardService.newTextInput()
.setFieldName("forwardTo")
.setTitle("To:");
formArea.addWidget(widget);
card.addSection(formArea);
return card;
}
Gmail add-ons cannot currently handle emails inside the SPAM folder.
There is an open Feature Request in Issue Tracker regarding this functionality:
Allow GMAIL AddOns To Process emails inside SPAM folder
I'd suggest you to star this issue, in order to prioritize it and to keep track of any updates.
Workarounds:
Move the emails outside the SPAM folder, forward them, or what have you, before interacting with them through the add-on.
I have a problem with running Google picker API.
I'm trying to make a custom menu function for my Google spreadsheet that I can choose a file with (obtain file ID on Google Drive) and then my apps-script will automatically rename it according to result in some cell in sheet and then it will also move the file to a proper folder on Google Drive. I think I'm able to do everything mentioned above except to run Google picker API.
To do task described above I think I need Google Picker API. Please correct me if I'm wrong!!! I just need a dialog window which will let me choose a file and let give me file ID of the chosen file.
So basically I have followed instructions for File-open dialogs here and here. I've tried it many many times. I've tried every help I could find on youtube or google, but the result is still the same:
When I open my spreadsheet, I see my new custom menu, I open it and click on Start which runs the script. The script opens a blank window with "Select a file". After a while I get this statement:
The statement means "Server IP address n-utbhwlxa4witl3o3ev4uschzd37oxgge2qt73xy-0lu-script.googleusercontent.com was not found" (Address looks like Client ID, but it's definiteliy not the ID i see in web console.)
I've enabled Google Picker Api and Google drive API in developers console, I've also copied API_KEY to the code. I think I've done everything described in instuctions right. But I've done nothing more. I'm afraid that I'm missing some basic step which is obvious for skilled developer, but I've no clue. (Like if I have to specify Authorized JavaScript origins for OAuth 2.0 client IDs etc.)
Anybody any idea or direction, please?
EDIT:
My .gs code is (copy-pasted from https://developers.google.com/apps-script/guides/dialogs):
/**
* Creates a custom menu in Google Sheets when the spreadsheet opens.
*/
function onOpen() {
SpreadsheetApp.getUi().createMenu('Picker')
.addItem('Start', 'showPicker')
.addToUi();
}
/**
* Displays an HTML-service dialog in Google Sheets that contains client-side
* JavaScript code for the Google Picker API.
*/
function showPicker() {
var html = HtmlService.createHtmlOutputFromFile('filepicker.html')
.setWidth(600)
.setHeight(425)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
SpreadsheetApp.getUi().showModalDialog(html, 'Select a file');
}
/**
* Gets the user's OAuth 2.0 access token so that it can be passed to Picker.
* This technique keeps Picker from needing to show its own authorization
* dialog, but is only possible if the OAuth scope that Picker needs is
* available in Apps Script. In this case, the function includes an unused call
* to a DriveApp method to ensure that Apps Script requests access to all files
* in the user's Drive.
*
* #return {string} The user's OAuth 2.0 access token.
*/
function getOAuthToken() {
DriveApp.getRootFolder();
return ScriptApp.getOAuthToken();
}
and my html code (also copy-pasted from the same source) is as follows (I have not changed anything except Developers key):
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script>
// IMPORTANT: Replace the value for DEVELOPER_KEY with the API key obtained
// from the Google Developers Console.
var DEVELOPER_KEY = 'I put my own key here';
var DIALOG_DIMENSIONS = {width: 600, height: 425};
var pickerApiLoaded = false;
/**
* Loads the Google Picker API.
*/
function onApiLoad() {
gapi.load('picker', {'callback': function() {
pickerApiLoaded = true;
}});
}
/**
* Gets the user's OAuth 2.0 access token from the server-side script so that
* it can be passed to Picker. This technique keeps Picker from needing to
* show its own authorization dialog, but is only possible if the OAuth scope
* that Picker needs is available in Apps Script. Otherwise, your Picker code
* will need to declare its own OAuth scopes.
*/
function getOAuthToken() {
google.script.run.withSuccessHandler(createPicker)
.withFailureHandler(showError).getOAuthToken();
}
/**
* Creates a Picker that can access the user's spreadsheets. This function
* uses advanced options to hide the Picker's left navigation panel and
* default title bar.
*
* #param {string} token An OAuth 2.0 access token that lets Picker access the
* file type specified in the addView call.
*/
function createPicker(token) {
if (pickerApiLoaded && token) {
var picker = new google.picker.PickerBuilder()
// Instruct Picker to display only spreadsheets in Drive. For other
// views, see https://developers.google.com/picker/docs/#otherviews
.addView(google.picker.ViewId.SPREADSHEETS)
// Hide the navigation panel so that Picker fills more of the dialog.
.enableFeature(google.picker.Feature.NAV_HIDDEN)
// Hide the title bar since an Apps Script dialog already has a title.
.hideTitleBar()
.setOAuthToken(token)
.setDeveloperKey(DEVELOPER_KEY)
.setCallback(pickerCallback)
.setOrigin(google.script.host.origin)
// Instruct Picker to fill the dialog, minus 2 pixels for the border.
.setSize(DIALOG_DIMENSIONS.width - 2,
DIALOG_DIMENSIONS.height - 2)
.build();
picker.setVisible(true);
} else {
showError('Unable to load the file picker.');
}
}
/**
* A callback function that extracts the chosen document's metadata from the
* response object. For details on the response object, see
* https://developers.google.com/picker/docs/result
*
* #param {object} data The response object.
*/
function pickerCallback(data) {
var action = data[google.picker.Response.ACTION];
if (action == google.picker.Action.PICKED) {
var doc = data[google.picker.Response.DOCUMENTS][0];
var id = doc[google.picker.Document.ID];
var url = doc[google.picker.Document.URL];
var title = doc[google.picker.Document.NAME];
document.getElementById('result').innerHTML =
'<b>You chose:</b><br>Name: <a href="' + url + '">' + title +
'</a><br>ID: ' + id;
} else if (action == google.picker.Action.CANCEL) {
document.getElementById('result').innerHTML = 'Picker canceled.';
}
}
/**
* Displays an error message within the #result element.
*
* #param {string} message The error message to display.
*/
function showError(message) {
document.getElementById('result').innerHTML = 'Error: ' + message;
}
</script>
</head>
<body>
<div>
<button onclick='getOAuthToken()'>Select a file</button>
<p id='result'></p>
</div>
<script src="https://apis.google.com/js/api.js?onload=onApiLoad"></script>
</body>
</html>
Fortunately, Amit Agarwal wrote a tutorial on this blog about using Picker API in Apps Script.
Here's a snippet:
function onOpen() {
SpreadsheetApp.getUi().createMenu('Google Picker')
.addItem('Choose Folder', 'showPicker')
.addToUi();
}
/**
* Displays an HTML-service dialog in Google Sheets that contains client-side
* JavaScript code for the Google Picker API.
*/
function showPicker() {
var html = HtmlService.createHtmlOutputFromFile('Picker.html')
.setWidth(600)
.setHeight(425)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
SpreadsheetApp.getUi().showModalDialog(html, 'Select Folder');
}
function getOAuthToken() {
DriveApp.getRootFolder();
return ScriptApp.getOAuthToken();
}
I have created simple Gmail addon, now I'm struggling with below strategies.
After installing the addon, when first inbox is opened, we ask basic info input from users, when he opens second mail we won't ask basic info details again.
How I can handle this?
I have tried property service but no luck.
Update
var MAX_THREADS = 5;
/**
* Returns the array of cards that should be rendered for the current
* e-mail thread. The name of this function is specified in the
* manifest 'onTriggerFunction' field, indicating that this function
* runs every time the add-on is started.
*
* #param {Object} e data provided by the Gmail UI.
* #returns {Card[]}
*/
function buildAddOn(e) {
// Activate temporary Gmail add-on scopes.
//Logger.log('E', Session.getActiveUser());
var accessToken = e.messageMetadata.accessToken;
GmailApp.setCurrentMessageAccessToken(accessToken);
var userProperties = PropertiesService.getUserProperties();
var Token = userProperties.getProperty('Token');
Logger.log('Token value:',typeof Token);
if(Token != null ){
var messageId = e.messageMetadata.messageId;
var senderData = extractSenderData(messageId);
var cards = [];
// Build a card for each recent thread from this email's sender.
if (senderData.recents.length > 0) {
senderData.recents.forEach(function(threadData) {
cards.push(buildRecentThreadCard(senderData.email, threadData));
});
} else {
// Present a blank card if there are no recent threads from
// this sender.
cards.push(CardService.newCardBuilder()
.setHeader(CardService.newCardHeader()
.setTitle('No recent threads from this sender')).build());
}
return cards;
}
else{
var cards = []
var login_card = build_login_card()
cards.push(login_card);
return cards;
}
}
/**
* This function builds a set of data about this sender's presence in your
* inbox.
*
* #param {String} messageId The message ID of the open message.
* #return {Object} a collection of sender information to display in cards.
*/
function extractSenderData(messageId) {
// Use the Gmail service to access information about this message.
var mail = GmailApp.getMessageById(messageId);
var threadId = mail.getThread().getId();
var senderEmail = extractEmailAddress(mail.getFrom());
var recentThreads = GmailApp.search('from:' + senderEmail);
var recents = [];
// Retrieve information about up to 5 recent threads from the same sender.
recentThreads.slice(0,MAX_THREADS).forEach(function(thread) {
if (thread.getId() != threadId && ! thread.isInChats()) {
recents.push({
'subject': thread.getFirstMessageSubject(),
'count': thread.getMessageCount(),
'link': 'https://mail.google.com/mail/u/0/#inbox/' + thread.getId(),
'lastDate': thread.getLastMessageDate().toDateString()
});
}
});
var senderData = {
"email": senderEmail,
'recents': recents
};
return senderData;
}
/**
* Given the result of GmailMessage.getFrom(), extract only the email address.
* getFrom() can return just the email address or a string in the form
* "Name <myemail#domain>".
*
* #param {String} sender The results returned from getFrom().
* #return {String} Only the email address.
*/
function extractEmailAddress(sender) {
var regex = /\<([^\#]+\#[^\>]+)\>/;
var email = sender; // Default to using the whole string.
var match = regex.exec(sender);
if (match) {
email = match[1];
}
return email;
}
/**
* Builds a card to display information about a recent thread from this sender.
*
* #param {String} senderEmail The sender email.
* #param {Object} threadData Infomation about the thread to display.
* #return {Card} a card that displays thread information.
*/
function buildRecentThreadCard(senderEmail, threadData) {
var card = CardService.newCardBuilder();
card.setHeader(CardService.newCardHeader().setTitle(threadData.subject));
var section = CardService.newCardSection()
.setHeader("<font color=\"#1257e0\">Recent thread</font>");
section.addWidget(CardService.newTextParagraph().setText(threadData.subject));
section.addWidget(CardService.newKeyValue()
.setTopLabel('Sender')
.setContent(senderEmail));
section.addWidget(CardService.newKeyValue()
.setTopLabel('Number of messages')
.setContent(threadData.count.toString()));
section.addWidget(CardService.newKeyValue()
.setTopLabel('Last updated')
.setContent(threadData.lastDate.toString()));
var threadLink = CardService.newOpenLink()
.setUrl(threadData.link)
.setOpenAs(CardService.OpenAs.FULL_SIZE);
var button = CardService.newTextButton()
.setText('Open Thread')
.setOpenLink(threadLink);
section.addWidget(CardService.newButtonSet().addButton(button));
card.addSection(section);
return card.build();
}
function build_login_card(){
var card = CardService.newCardBuilder();
card.setHeader(CardService.newCardHeader().setTitle("Login Here"));
var userProperties = PropertiesService.getUserProperties();
var Token = userProperties.setProperty('Token',"Token");
return card.build()
}
According to comments, the primary issue here is that uninstalling the add-on does not remove bits from the UserProperties datastore, and thus if the add-on is re-installed, the add-on configuration steps cannot be performed again.
Generic Add-on Solution
If this were not a Gmail add-on, this could be simply solved with a time-based trigger, since per documentation:
Add-on triggers will stop firing in any of the following situations:
- If the add-on is uninstalled by the user
- If the add-on is disabled in a document (if it is re-enabled, the trigger will become operational again)
- If the developer unpublishes the add-on or submits a broken version to the add-on store
I.e., you would simply write the day's date to a key (e.g. LAST_SEEN) in PropertiesService#UserProperties every day, and then check both TOKEN and LAST_SEEN when deciding which card to display.
Gmail Add-on Solution:
Per Gmail add-on documentation, you cannot create or use Apps Script simple / installable triggers in a Gmail add-on. So, the easiest implementation of the solution is not available. However, we can still use this approach, by moving the region in which that LAST_SEEN key is updated.
Right now (2018 March), the only available trigger for a Gmail add-on is the contextual trigger unconditional:
Currently, the only contextual trigger type available is unconditional, which triggers for all emails regardless of content.
So, if you bind to this contextual trigger, while your add-on is installed it will run a function whenever the user opens an email. The solution is then for your contextually triggered function to include the snippet
PropertiesService.getUserProperties().setProperty("LAST_SEEN", String(new Date().getTime()));
If you have other stuff to do in your contextually triggered function, that code will be uninfluenced by this addition. If you don't have a contextually triggered function, then you'd want to return [] (an empty card stack) in order to avoid showing any UI.
To use this new property, in your buildAddon(e) method you want to query for this value in addition to the TOKEN property you are using:
var userProps = PropertiesService.getUserProperties().getAll();
var Token = userProps["Token"];
var lastSeen = userProps["LAST_SEEN"] || 0; // If found, will be milliseconds since epoch.
var absence = new Date().getTime() - lastSeen; // Time in ms since last use of add-on.
if (Token == null || absence > /* some duration you choose */ ) {
// New install, or user has started using app again.
return [build_login_card()];
} else {
// User is still using add-on, so do normal stuff.
}
This is obviously not a perfect solution (i.e. an uninstall contextual trigger would be much better), but can help detect lack-of-use situations
There are rate limits on how often you can write to PropertiesService. If you have speedy/"power" users, they might trip quotas.
Could combine CacheService and PropertiesService to handle frequent reads in a "short" session (of up to 6hr since last storage into cache).
Robert , have you tried caching the user input ?
I do caching in the event handler.
function onDomainChange(e){
var cache = CacheService.getScriptCache();
Logger.log(e.formInput);
cache.put('domain',e.formInput.domain);
Logger.log(cache.get('domain'));
}
Refer cache docs