├── .DS_Store
├── LICENSE
├── README.md
├── attachment-archiver
├── README.md
├── code.js
└── rules_template.png
├── calendar-clone
├── README.md
└── code.js
├── calendar-invite-decline
├── README.md
└── code.js
├── gmail-flatten-labels
├── README.md
└── code.js
├── google-chat-updates-bot
├── README.md
├── code.js
├── examplepost.png
└── examplepost2.png
├── ip-range-monitor
├── README.md
├── code.js
├── exampleemail.png
└── examplepost.png
└── user-alias-report
└── code.js
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdw353/google-workspace-apps-script-toolbox/e3921ec0021b0562dbcef3b5ab9f2d6f18c1c486/.DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Google Workspace Apps Script Toolbox
2 |
3 | A collection of scripts for Google Workspace admins and users who leverage Workspace applications.
4 |
5 | ## Scripts
6 | * [Google Chat Updates Bot](google-chat-updates-bot)
7 | * [IP Range Monitor](ip-range-monitor)
8 | * [User Alias Report](user-alias-report)
9 | * [Calendar Clone](calendar-clone)
10 | * [Attachment Archiver](attachment-archiver)
11 | * [Gmail Flatten Labels](gmail-flatten-labels)
--------------------------------------------------------------------------------
/attachment-archiver/README.md:
--------------------------------------------------------------------------------
1 | # Attachment Archiver
2 |
3 | ## What it does
4 | Automatically intercept emails to the active user's inbox and based on configured rules, route message attachments to a specified Google Drive folder. Sheet based configuration allows for updates without needing to change any code.
5 |
6 | ## Why you would use it
7 | Remove email from any workflow where users process files from senders external to the organization and do not need the ability to communicate in response. Leveraging this script allows for the creation of a centralized email address that can be utilized by multiple teams.
8 |
9 | ## How it works
10 | The script first looks to a linked Google Sheet for the rules it should process. For each rule (processed in order), it queries the active user's Gmail inbox to find unread message threads that match the rule criteria.
11 |
12 | Threads that are found to match the query will have their first message's attachments saved to Drive and deposited in a location defined by the rule.
13 |
14 | After a thread is processed, it is marked as read and archived, which prevents it from being queried again in the future. If a thread becomes unread and added back to the inbox (manually or by replies), it will be processed again.
15 |
16 | ## Rules
17 | Rules are contained in a Google Sheet and contain six attributes (that must be listed in this order):
18 | - Rule Description: A nice name for the rule being executed.
19 | - Destination ID: The Drive directory ID that should parent where the files are placed.
20 | - Trigger Type: The criteria that the message will be evaluated against to determine if the rule fits.
21 | - Trigger Keyword: The keyword that the trigger is looking to match.
22 | - Subdirectory: Whether or not files should be placed directly into the destination folder or placed in a subdirectory.
23 | - Domain View: Whether or not everyone in the domain should get view only permission to a file if they have the link.
24 |
25 | Feel free to make a copy of this [template rule sheet](https://docs.google.com/spreadsheets/d/15KfB7d7zxDaJvptfWlDezPh7CUzMgPgT8pFfy7gkL0w), which looks like:
26 | 
27 |
28 | ### Trigger Types
29 | Triggers are the criteria by which a message will be evaluated to see if it matches a rule.
30 | - POSTFIX: Looking for a specific keyword appended with + to the end of an email address.
31 | - SENDER: Looking for a specific message sender.
32 | - SUBJECT: Looking for a specific keyword in the message subject.
33 |
34 | ### Subdirectory Options
35 | Subdirectory options control how attachments are placed into the destination Drive folder.
36 | - NONE: Store the files directly in the destination Drive folder provided.
37 | - DATE: Store the files in a subidrectory by date (YYYY-MM-DD). This is the date the script is run, not the date of the message.
38 | - SENDER: Store the files in a subdirectory by the message sender.
39 |
40 | ### Domain View Options
41 | The domain view setting allows for additional permissions to be set on each file.
42 | - FALSE: No additional permissions will be granted to the entire domain (if they have the link). Only those with inherited permissions will have access.
43 | - TRUE: Each file will be granted view only permission to the entire domain (if they have the link).
44 |
45 | ## Scopes and Configuration
46 |
47 | ### Requested Scopes
48 | In order for the script to work, it must be initialized and granted the necessary permissions in order to take actions on behalf of the signed-in user:
49 | - Apps Script (https://www.googleapis.com/auth/script.scriptapp)
50 | - Drive (https://www.googleapis.com/auth/drive)
51 | - Gmail (https://mail.google.com)
52 | - Sheets (https://www.googleapis.com/auth/spreadsheets)
53 |
54 | ### Configuration
55 | There are 4 variables in the code itself that may require modification before execution:
56 | - MAX_EMAILS: the maximum size of the Gmail search (default 20).
57 | - TRIGGER_MINUTES: how often the script runs and checks for new messages (default 15).
58 | - RULES_SHEET_ID: the ID of the Google Sheet where the Rules can be found.
59 | - RULES_SHEET_NAME: the name of the workbook tab in the Sheet where the rules can be found.
60 |
61 | ## Usage
62 | This application consists of two components: an Apps Script project and a Google Sheet. The script is generally expected to be initalized once via manual execution, followed by triggered execution every X minutes. The Sheet can be updated with rules without needing to modify the script.
63 |
64 | ### Sheet creation and setup
65 | - Make a copy of this [template rule sheet](https://docs.google.com/spreadsheets/d/15KfB7d7zxDaJvptfWlDezPh7CUzMgPgT8pFfy7gkL0w) OR create a new Google Sheet with 6 columns and a header matching the Rules attributes listed above.
66 | - Rename the worksheet to 'Rules' or a name of your choosing. This will be required for the script's configuration.
67 | - Make a note of the script ID found in the URL. This will be required for the script's configuration.
68 | - Optionally create data validation rules for the Trigger Type and Subdirectory values that map to the attributes listed above.
69 | - Create your rules.
70 | - Note that any Drive folders listed in a rule must be shared with the account that will execute the script and own the Gmail inbox.
71 |
72 | ### Script creation and initilization
73 | - Create a new Google Apps Script project. Name it 'Attachment Archiver' or a name of your choosing.
74 | - Copy and paste the code from this repository into the Code.gs script.
75 | - At minimum, modify the following configuration fields: RULES_SHEET_ID and RULES_SHEET_NAME.
76 | - Run the initialize() function. Authorize the required scopes.
77 | - Send a test email to the inbox that matches your configured rules and manually run processRules(). Debug statements will provide insights on matches and actions.
78 |
79 | ## Watchpoints
80 | - The script is currently configured to process 20 messages every 15 minutes for each rule. 20 messages every 15 minutes is 1,920 messages per day. However, [Apps Script does have limits](https://developers.google.com/apps-script/guides/services/quotas#current_limitations). If Apps Script does time out, missed messages will be picked up in the next scan. If you require more throughput, consider using an additional user account or moving to App Engine.
81 | - Rules are executed in order. If a message is caught by an earlier rule, it will not be picked up by a later rule. Consider placing broad rules (e.g. by sender) later in the list. Earlier rules also have less of a chance of timing out than later rules.
82 | - Only the first message in a thread is processed. Replies, even with attachments, will be ignored; however, the first message will be processed again (creating duplicates). New submissions should always be sent as a new message.
83 | - The user account that is going to be executing the script (and own the Gmail inbox) must be granted Editor permission on any Drive folder (destination ID) to which it is going to deposit files.
84 | - Gmail has a [max attachment size](https://support.google.com/a/answer/1366776). For workflows with files greater than this limit, other methods should be employed.
85 | - There is a behavioral difference between the way the Gmail UI and Gmail API processes queries, particularly the ones used in this script for the SENDER rule. The UI query will match the user's account and any associated alises (if they are a Google account), whereas the API will not. When testing your SENDER rules, you may see more results returned when using the web interface than when you query the API.
86 |
--------------------------------------------------------------------------------
/attachment-archiver/code.js:
--------------------------------------------------------------------------------
1 | /*
2 | Attachment Archiver
3 |
4 | Automatically intercept emails to the active user's inbox and, based on rules,
5 | route message attachments to a specified Google Drive folder. Once messages
6 | are processed, they are marked as read and archived.
7 |
8 | Script links up with a Google Sheet to interpret and process rules, which can
9 | be added without updating the script. Template Sheet can be found at:
10 | https://docs.google.com/spreadsheets/d/15KfB7d7zxDaJvptfWlDezPh7CUzMgPgT8pFfy7gkL0w
11 |
12 | Triggers include:
13 | - POSTFIX: Looking for a specific keyword appended with + to the end of an email address.
14 | - SENDER: Looking for a specific message sender.
15 | - SUBJECT: Looking for a specific keyword in the message subject.
16 |
17 | Drive storage actions include:
18 | - NONE: Store the files directly in the destination folder.
19 | - DATE: Store the files in a subdirectory of the destination by date.
20 | - SENDER: Store the files in a subdirectory of the destination by sender.
21 |
22 | Drive domain view permissions include:
23 | - FALSE: do not share individual files with the domain, only inherited permissions will gain access.
24 | - TRUE: apply domain wide view permissions to each file.
25 | */
26 |
27 | /*
28 | Script Configuration
29 |
30 | Modify the below variables to match requirements. All are required.
31 | MAX_EMAILS: the size of the Gmail search
32 | TRIGGER_MINUTES: how often the script runs and checks for new messages
33 | RULES_SHEET_ID = the ID of the Google Sheet with configured Rules
34 | RULES_SHEET_NAME = the name of the workbook tab on the Sheet that contains the Rules
35 | */
36 | const MAX_EMAILS = 20;
37 | const TRIGGER_MINUTES = 15;
38 | const RULES_SHEET_ID = '1ZRgf5IpMTY3CI_bDC-2zAWiV3j82vvOI6ghJD8OXeyo';
39 | const RULES_SHEET_NAME = 'Rules';
40 |
41 | /*
42 | Public Functions
43 | */
44 | function initialize() {
45 | resetTriggers_();
46 | processRules();
47 | }
48 |
49 | function processRules() {
50 | let rules = getRules_();
51 | rules.forEach(function(rule) {
52 | let query = buildQueryForTrigger_(rule);
53 | Logger.log(`${rule.description}: ${query}`);
54 | if (query) {
55 | let gmailThreads = GmailApp.search(query, 0, MAX_EMAILS);
56 | if (gmailThreads.length > 0) {
57 | Logger.log(`${rule.description}: ${gmailThreads.length} thread(s) were found`);
58 | gmailThreads.forEach(function(thread) {
59 | processNewThread_(thread, rule);
60 | });
61 | } else {
62 | Logger.log(`${rule.description}: no matching threads found`);
63 | }
64 | } else {
65 | Logger.log(`${rule.description}: ${rule.triggerType} is not a known trigger type`);
66 | }
67 | });
68 | }
69 |
70 | /*
71 | Private Functions
72 | */
73 |
74 | function buildQueryForTrigger_(rule) {
75 | let query = 'has:attachment label:unread label:inbox ';
76 | let activeUserComponents = getActiveUser_().split('@');
77 | switch(rule.triggerType) {
78 | case 'POSTFIX':
79 | query += `to:(${activeUserComponents[0]}+${rule.triggerKeyword}@${activeUserComponents[1]})`;
80 | break;
81 | case 'SENDER':
82 | query += `from:(${rule.triggerKeyword})`;
83 | break;
84 | case 'SUBJECT':
85 | query += `subject:("${rule.triggerKeyword}")`;
86 | break;
87 | default:
88 | query = null;
89 | }
90 | return query;
91 | }
92 |
93 | function processNewThread_(thread, rule) {
94 | let attachments = thread.getMessages()[0].getAttachments();
95 | let driveId = determineDestinationDriveId_(rule, thread);
96 | attachments.forEach(function(attachment) {
97 | writeBlobToDrive_(attachment.copyBlob(), attachment.getName(), driveId, rule.domainView);
98 | });
99 | thread.markRead();
100 | thread.moveToArchive();
101 | }
102 |
103 | function determineDestinationDriveId_(rule, thread) {
104 | let driveId;
105 | switch(rule.subdirectory) {
106 | case 'SENDER':
107 | driveId = findOrCreateDirectory_(rule.destinationId, thread.getMessages()[0].getFrom());
108 | break;
109 | case 'DATE':
110 | driveId = findOrCreateDirectory_(rule.destinationId, getCurrentDate_());
111 | break;
112 | default:
113 | driveId = rule.destinationId;
114 | }
115 | return driveId;
116 | }
117 |
118 | function findOrCreateDirectory_(parentId, folderName) {
119 | let childId;
120 | let folders = DriveApp.getFolderById(parentId).getFoldersByName(folderName);
121 | while (folders.hasNext()) {
122 | childId = folders.next().getId();
123 | break;
124 | }
125 | if (!childId) {
126 | childId = DriveApp.getFolderById(parentId).createFolder(folderName).getId();
127 | }
128 | return childId;
129 | }
130 |
131 | function getRules_() {
132 | let sheet = getSheet_();
133 | let ruleList = sheet.getRange(2, 1, sheet.getLastRow(), 6).getValues();
134 | let rulesObjects = [];
135 | ruleList.forEach(function(rule) {
136 | if (rule[0]) {
137 | rulesObjects.push(getRuleObject_(rule));
138 | }
139 | });
140 | return rulesObjects;
141 | }
142 |
143 | function writeBlobToDrive_(blob, name, drive_id, domainView) {
144 | let file = DriveApp.createFile(blob);
145 | file.setName(name);
146 | if (domainView === true){
147 | file.setSharing(DriveApp.Access.DOMAIN_WITH_LINK, DriveApp.Permission.VIEW);
148 | }
149 | file.getParents().next().removeFile(file);
150 | DriveApp.getFolderById(drive_id).addFile(file);
151 | Logger.log(`${name} written to Drive folder ${drive_id}`);
152 | }
153 |
154 | function getRuleObject_(rule) {
155 | return {
156 | description: rule[0],
157 | destinationId: rule[1],
158 | triggerType: rule[2],
159 | triggerKeyword: rule[3],
160 | subdirectory: rule[4],
161 | domainView: rule[5]
162 | };
163 | }
164 |
165 | function getSheet_() {
166 | return SpreadsheetApp.openById(RULES_SHEET_ID).getSheetByName(RULES_SHEET_NAME);
167 | }
168 |
169 | function getCurrentDate_() {
170 | return Utilities.formatDate(new Date(), 'EST', 'yyyy-MM-dd');
171 | }
172 |
173 | function getActiveUser_() {
174 | return Session.getActiveUser().toString();
175 | }
176 |
177 | function resetTriggers_() {
178 | var triggers = ScriptApp.getProjectTriggers();
179 | triggers.forEach(function(trigger) {
180 | ScriptApp.deleteTrigger(trigger);
181 | });
182 |
183 | ScriptApp.newTrigger('processRules')
184 | .timeBased()
185 | .everyMinutes(TRIGGER_MINUTES)
186 | .create();
187 | }
--------------------------------------------------------------------------------
/attachment-archiver/rules_template.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdw353/google-workspace-apps-script-toolbox/e3921ec0021b0562dbcef3b5ab9f2d6f18c1c486/attachment-archiver/rules_template.png
--------------------------------------------------------------------------------
/calendar-clone/README.md:
--------------------------------------------------------------------------------
1 | TODO
--------------------------------------------------------------------------------
/calendar-clone/code.js:
--------------------------------------------------------------------------------
1 | // [SOURCE_CALENDAR_ID] and [DESTINATION_CALENDAR_ID]
2 | // For a primary calendar, this will be something like user@gmail.com.
3 | // For a secondary calendar, this will be something like xyz@group.calendar.google.com
4 |
5 | const SOURCE = 'YOUR_EMAIL_ADDRESS_GOES_HERE';
6 | const DESTINATION = 'SECONDARY_CALENDAR_ID@group.calendar.google.com';
7 | const NUM_DAYS = 14;
8 |
9 | function main() {
10 | const startDate = new Date();
11 | const endDate = new Date(startDate.getTime() + (NUM_DAYS * 86400 * 1000));
12 | Logger.log(`Start: ${startDate.toDateString()} | End: ${endDate.toDateString()}`);
13 |
14 | clearDestinationEvents_({ startDate: startDate, endDate: endDate });
15 | cloneEvents_({ startDate: startDate, endDate: endDate });
16 | }
17 |
18 | function clearDestinationEvents_({ startDate, endDate } = {}) {
19 | const destinationEvents = CalendarApp.getCalendarById(DESTINATION).getEvents(startDate, endDate);
20 |
21 | (destinationEvents.length > 0) ? Logger.log(`Deleting events...`) : Logger.log(`No events to delete...`);
22 |
23 | destinationEvents.forEach(function (event) {
24 | Logger.log(`${event.getStartTime().toDateString()} | ${event.getTitle()}`);
25 | event.deleteEvent();
26 | });
27 | }
28 |
29 | function cloneEvents_({ startDate, endDate } = {}) {
30 | const sourceEvents = CalendarApp.getCalendarById(SOURCE).getEvents(startDate, endDate);
31 | const destination = CalendarApp.getCalendarById(DESTINATION);
32 |
33 | (sourceEvents.length > 0) ? Logger.log(`Cloning events...`) : Logger.log(`No events to clone...`);
34 |
35 | sourceEvents.forEach((sourceEvent) => {
36 | const status = sourceEvent.getMyStatus();
37 | const title = sourceEvent.getTitle();
38 | const startTime = sourceEvent.getStartTime();
39 | const isAllDayEvent = sourceEvent.isAllDayEvent();
40 |
41 | const isAccepted = !status || status !== status.NO;
42 | const isNotWorkingLocation = !isEventWorkingLocation_({ title: title, isAllDayEvent: isAllDayEvent });
43 |
44 | if (isAccepted && isNotWorkingLocation) {
45 | Logger.log(`CLONED: ${startTime.toDateString()} | ${title}`);
46 | let newEvent = destination.createEvent(
47 | title,
48 | startTime,
49 | sourceEvent.getEndTime(),
50 | {
51 | description: sourceEvent.getDescription(),
52 | location: sourceEvent.getLocation()
53 | }
54 | );
55 | if (sourceEvent.getColor()) {
56 | newEvent.setColor(sourceEvent.getColor());
57 | }
58 | } else {
59 | Logger.log(`SKIPPED: ${startTime.toDateString()} | ${title}`);
60 | }
61 | });
62 | }
63 |
64 | // Dash count is purely based on Google office naming conventions
65 | function isEventWorkingLocation_({ title, isAllDayEvent } = {}) {
66 | const dashMatch = title.match(/-/g);
67 | const dashCount = dashMatch ? dashMatch.length : 0;
68 | const isHome = title.trim() == "Home";
69 | const hasOffice = title.includes("(Office)");
70 |
71 | return (isAllDayEvent && (isHome || (dashCount == 2 && hasOffice)));
72 | }
73 |
--------------------------------------------------------------------------------
/calendar-invite-decline/README.md:
--------------------------------------------------------------------------------
1 | //TODO
--------------------------------------------------------------------------------
/calendar-invite-decline/code.js:
--------------------------------------------------------------------------------
1 | const SOURCE = 'YOUR_EMAIL_ADDRESS';
2 | const LOG_ID = `YOUR_GOOGLE_DOC_ID`;
3 | const STATUS = CalendarApp.GuestStatus.INVITED;
4 | const NUM_DAYS = 21;
5 | const DECLINE_HOURS = {
6 | AFTER: 20,
7 | BEFORE: 7
8 | };
9 | const ALLOW_USERS = [
10 | "LDAP@EMAIL.COM",
11 | "LDAP2@EMAIL.COM"
12 | ];
13 | const REJECT_TITLES = [
14 | "CALENDAR_EVENT_NAMES_GO_HERE_1",
15 | "CALENDAR_EVENT_NAMES_GO_HERE_2",
16 | ];
17 |
18 | function main() {
19 | const startDate = new Date();
20 | const endDate = new Date(startDate.getTime() + (NUM_DAYS * 86400 * 1000));
21 | Logger.log(`Start: ${startDate.toDateString()} | End: ${endDate.toDateString()}`);
22 |
23 | const events = getEventsToDecline_({ startDate: startDate, endDate: endDate });
24 | if (events.length > 0) deleteEvents_({ events });
25 | }
26 |
27 | function getEventsToDecline_({ startDate, endDate }) {
28 | const sourceEvents = CalendarApp.getCalendarById(SOURCE).getEvents(startDate, endDate);
29 | const rejectEvents = sourceEvents.filter((event) => isRejected_({ event: event }));
30 | Logger.log(`Events to delete: ${rejectEvents.length}`)
31 | return rejectEvents;
32 | }
33 |
34 | function isRejected_({ event } = {}) {
35 | const creator = event.getCreators()[0];
36 | const status = event.getMyStatus();
37 | const title = event.getTitle();
38 | const startTime = event.getStartTime();
39 | const rejection = determineRejection_({ status: status, title: title, startTime: startTime, creator: creator });
40 | Logger.log(`${startTime.toDateString()} | ${title} | ${creator} | ${status} | ${rejection}`);
41 | return rejection;
42 | }
43 |
44 | function determineRejection_({ status, title, startTime, creator } = {}) {
45 | const startFail = startTime.getHours() >= DECLINE_HOURS.AFTER || startTime.getHours() < DECLINE_HOURS.BEFORE;
46 | const statusFail = status == STATUS;
47 | const titleFail = REJECT_TITLES.includes(title);
48 | const creatorFail = !ALLOW_USERS.includes(creator);
49 | return creatorFail && statusFail && (startFail || titleFail);
50 | }
51 |
52 | function deleteEvents_({ events } = {}) {
53 | const body = getLogBody_();
54 |
55 | Logger.log(`Deleting events...`)
56 | events.forEach((event) => {
57 | const logString = `DELETED: ${event.getStartTime().toDateString()} | ${event.getTitle()} | ${event.getCreators()[0]}`;
58 | Logger.log(logString);
59 | if (body) body.insertParagraph(0, logString);
60 | event.deleteEvent();
61 | });
62 |
63 | if (body) {
64 | const now = new Date();
65 | body.insertParagraph(0, now.toString());
66 | }
67 | }
68 |
69 | function getLogBody_({ } = {}) {
70 | try {
71 | return DocumentApp.openById(LOG_ID).getBody();
72 | } catch (e) {
73 | Logger.log(e);
74 | return null;
75 | }
76 | }
77 |
78 | function logEvent_({ event } = {}) {
79 | Logger.log(`Event: ${event.getTitle()}`);
80 | Logger.log(`Status: ${event.getMyStatus()}`);
81 | Logger.log(`Start: ${event.getStartTime()}`);
82 | Logger.log(`Creators: ${event.getCreators()}`);
83 | }
84 |
--------------------------------------------------------------------------------
/gmail-flatten-labels/README.md:
--------------------------------------------------------------------------------
1 | //TODO
--------------------------------------------------------------------------------
/gmail-flatten-labels/code.js:
--------------------------------------------------------------------------------
1 | // Query max is 500. Batch max is 100.
2 | const THREADS_PER_QUERY = 100; // Max 500
3 | const DELETE_EMPTY_CHILD = true;
4 |
5 | // This is the nice name of the label, not the query name.
6 | // For example, if your label is 'Parent Label', use
7 | // 'Parent Label' not 'label:parent-label'.
8 | const LABEL_TO_FLATTEN = 'My Label';
9 |
10 | function processLabels() {
11 | const pLabel = GmailApp.getUserLabelByName(LABEL_TO_FLATTEN);
12 | Logger.log(`Flattening to parent label: ${pLabel.getName()}`);
13 |
14 | const children = GmailApp.getUserLabels().filter(
15 | label => label.getName().includes(LABEL_TO_FLATTEN + '/'));
16 | Logger.log(`Child labels identified: ${children.length}`);
17 |
18 | children.forEach(function(cLabel) {
19 | const labelName = cLabel.getName();
20 | Logger.log(`Processing label: ${labelName}`);
21 |
22 | let threadsProcessed = 0;
23 | do {
24 | let threads = cLabel.getThreads(0, THREADS_PER_QUERY);
25 | Logger.log(`Threads identified: ${threads.length}`);
26 |
27 | if (threads.length === 0) {
28 | Logger.log(`Finished processing label: ${labelName}`);
29 | Logger.log(`Total threads processed: ${threadsProcessed}`);
30 | break;
31 | }
32 |
33 | // Prioritize batch for results <= 100.
34 | if (threads.length > 100) {
35 | processSerial_(pLabel, cLabel, threads);
36 | } else {
37 | processBatch_(pLabel, cLabel, threads);
38 | }
39 |
40 | threadsProcessed += threads.length;
41 | } while (true);
42 |
43 | if (DELETE_EMPTY_CHILD) {
44 | cLabel.deleteLabel();
45 | Logger.log(`${labelName} deleted.`)
46 | }
47 | });
48 | }
49 |
50 | function processBatch_(pLabel, cLabel, threads) {
51 | pLabel.addToThreads(threads);
52 | cLabel.removeFromThreads(threads);
53 | Logger.log(`Batch flattened.`);
54 | }
55 |
56 | function processSerial_(plabel, cLabel, threads) {
57 | for (let i = 0; i < threads.length; i++) {
58 | threads[i].addLabel(plabel);
59 | threads[i].removeLabel(cLabel);
60 | if ((i + 1) % 20 === 0 || (i + 1) === threads.length) {
61 | Logger.log(`Flattened ${i + 1} of ${threads.length} threads.`);
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/google-chat-updates-bot/README.md:
--------------------------------------------------------------------------------
1 | # Google Chat Updates Bot
2 |
3 | ## What it does
4 | Keep up to date with any feed by having new posts published to a Google Chat room using Apps Script and Webhooks. Feeds included in this example are various official Google blogs.
5 |
6 | ### Included Feeds
7 | - [Google Workspace Updates](https://workspaceupdates.googleblog.com/)
8 | - [Chrome Releases Blog](https://chromereleases.googleblog.com)
9 | - [Cloud Blog: Training and Certifications](https://cloud.google.com/blog/topics/training-certifications)
10 | - [Google Developers Blog](https://developers.googleblog.com/)
11 |
12 | ### End result
13 |
14 |
15 |
16 | ## Why you would use it
17 | Bring information from feeds directly into your Google Chat room, where members can comment or react directly inline with the content. This might include product updates regarding Google Workspace, code updates from a repository, or news from relevant sources.
18 |
19 | ## How it works
20 | The script is deployed as a single Apps Script project and file, set to run on a timed trigger. The script requests data from various configured feeds, which are then evaluated as to whether or not they've been seen before by the script. New updates are then broadcast out to configured webooks. Local storage is used to keep track of which posts have been previously seen.
21 |
22 | ## Scopes and Configuration
23 |
24 | ### Requested Scopes
25 | In order for the script to work, it must be initialized and granted the necessary permissions in order to take actions on behalf of the signed-in user:
26 | - Apps Script (https://www.googleapis.com/auth/script.scriptapp)
27 | - Apps Script (https://www.googleapis.com/auth/script.external_request)
28 |
29 | ### Configuration
30 | There are 7 variables in the code itself that may require modification before execution:
31 | - MAX_INIT_UPDATES: when initializing the script, how many initial posts tosend to a room
32 | - MAX_CONTENT.CHARS: the length of the article summary that is included in theChat card
33 | - MAX_CONTENT.UPDATES: the number of new updates to send. Generally does not need to be updated
34 | - TRIGGER_INTERVAL_HOURS: how often the script will check for updates
35 | - NOTIFY_HOURS.START: hour of the day after which notifications can be sent (local time)
36 | - NOTIFY_HOURS.END: hour of the day after which no notifications should be sent (local time)
37 | - NOTIFY_WEEKEND: whether the script should run on the weekend (local time)
38 |
39 | ### Extensibility
40 | This script was made to handle the various formats of the supported Google blogs. However, it can easily be extended to support other feed formats or webhook platforms.
41 | - FEED_FORMAT: a type of feed input, requring a `name` and a `parseFunction`
42 | - WEBHOOK_PLATFORMS: a type of webook output, requring a `name` and a `viewFunction`
43 |
44 | ## Usage
45 | This application consists of a single Apps Script project and file. The script is expected to be initalized once via manual execution, followed by triggered execution every X hours. One will also need to set up a webhook in Google Chat (or any other configured channel).
46 |
47 | ### Create a webhook in Google Chat
48 | 1. In a Google Chat room, select 'Configure webhooks'.
49 | 2. If one already exists, select 'Add another', otherwise...
50 | 3. Enter a name for your webhook. You may want to use 'Google Workspace Updates', or something reflective of the feeds that are being used.
51 | 4. Enter an icon for your webhook. You may want to use [this image](https://lh3.googleusercontent.com/proxy/Avi9GdfQQrgH3Iyy7f92yR4NElOpiq46VzMwnCWAFJRvj_GU_r2f2aUdKDNiQfchDKg50O2jj445ohIY_TuGoGyDGWVZVcedIMAwuM7eKX88ymDx40A=s88-c).
52 | 5. Save the webhook.
53 | 6. Once saved, copy the webhook URL. You'll need this when you setup the script.
54 |
55 | ### Script creation and initilization
56 | 1. Create a new Google Apps Script project. Name it 'Google Chat Updates Bot' or a name of your choosing.
57 | 2. Copy and paste the code from [code.js](code.js) into Code.gs within the Apps Script project.
58 | 3. Update the configuration directly in the code. At minimum, replace YOUR_WEBHOOK_URL_GOES_HERE with your webhook's URL.
59 | 4. Run `initializeScript()` and authorize the required scopes.
60 | 5. Run logScriptProperties() to verify local storage is working properly.
61 | 6. Check your Google Chat room for your first update.
--------------------------------------------------------------------------------
/google-chat-updates-bot/code.js:
--------------------------------------------------------------------------------
1 | /*
2 | Google Chat Updates Bot
3 | github.com/jdw353
4 |
5 | TL;DR:
6 | Keep up to date with any feed by having new posts published to a Google Chat
7 | room using Apps Script and Webhooks. Feeds included in this example are
8 | various official Google blogs.
9 |
10 | How it works:
11 | - The script is deployed as a single Apps Script project and file.
12 | - initializeScript() is run first, which requests scope authorization, clears
13 | local storage, adds a timer trigger, and kicks off the primary workflow.
14 | - executeUpdateWorkflow() will then pull down records for each configured feed
15 | and determine whether or not it has been before by the script (using local
16 | storage). If it has been seen, it is skipped. If it's new, it is queued for
17 | posting. Local storage is updated accordingly.
18 | - New posts are then published to any subscribed Chat room using Webhooks.
19 |
20 | Script Configuration:
21 | Modify the below variables to match requirements. All are required, but most
22 | can be left as default.
23 | - WEBHOOKS: the destination for any published posts. You must provide a
24 | webhook URL in the YOUR_WEBHOOK_URL_GOES_HERE space
25 | - MAX_CONTENT.CHARS: the length of the article summary that is included in the
26 | Chat card
27 | - MAX_CONTENT.UPDATES: the number of new updates to send. Generally does not
28 | need to be updated
29 | - MAX_INIT_UPDATES: when initializing the script, how many initial posts to
30 | send to a room
31 | - TRIGGER_INTERVAL_HOURS: how often the script will check for updates
32 | - NOTIFY_HOURS.START: hour of the day after which notifications can be sent (local time)
33 | - NOTIFY_HOURS.END: hour of the day after which no notifications should be sent (local time)
34 | - NOTIFY_WEEKEND: whether the script should run on the weekend (local time)
35 |
36 | Script Extension
37 | This script was made to handle the various formats of the supported Google
38 | blogs. However, it can easily be extended to support other feed formats or
39 | webhook platforms.
40 | - FEED_FORMAT: a type of feed input, requring a name and a
41 | parseFunction
42 | - WEBHOOK_PLATFORMS: a type of webook output, requring a name and
43 | a viewFunction
44 | */
45 |
46 | // Importing utility to help with URI parsing.
47 | eval(UrlFetchApp.fetch('https://rawgit.com/medialize/URI.js/gh-pages/src/URI.js').getContentText());
48 |
49 | const TRIGGER_INTERVAL_HOURS = 1;
50 | const NOTIFY_WEEKEND = false;
51 | const MAX_INIT_UPDATES = 1;
52 | const MAX_CONTENT = {
53 | CHARS: 300,
54 | UPDATES: 20 // Cannot be greater than 25 for RSS.
55 | };
56 | const NOTIFY_HOURS = {
57 | START: 9,
58 | END: 17
59 | };
60 |
61 | const FEED_FORMAT = {
62 | FB_XML: {
63 | name: 'Feed Burner XML',
64 | parseFunction: parseFBXmlIntoUpdateObject_,
65 | },
66 | GOOGLE_BLOG_RSS: {
67 | name: 'Google Blog RSS',
68 | parseFunction: parseGoogleBlogRssIntoUpdateObject_,
69 | }
70 | };
71 |
72 | const WEBHOOK_PLATFORMS = {
73 | GOOGLE_CHAT: {
74 | name: 'Google Chat',
75 | viewFunction: buildGoogleChatViewV2_,
76 | }
77 | };
78 |
79 | // Replace YOUR_WEBHOOK_URL_GOES_HERE with a webhook URL from Google Chat.
80 | const WEBHOOKS = {
81 | ADMIN_ROOM: {
82 | name: 'Google Workspace Admin Room',
83 | type: WEBHOOK_PLATFORMS.GOOGLE_CHAT,
84 | url: 'YOUR_WEBHOOK_URL_GOES_HERE'
85 | },
86 | };
87 |
88 | // Determine which feeds get broadcast to which webhooks by modifying the `webhooks` attribute for each feed.
89 | const FEEDS = {
90 | WORKSPACE_UPDATES: {
91 | format: FEED_FORMAT.FB_XML,
92 | title: 'Google Workspace Updates',
93 | subtitle: 'workspaceupdates.googleblog.com',
94 | source: 'https://feeds.feedburner.com/GoogleAppsUpdates',
95 | logo: 'https://fonts.gstatic.com/s/i/productlogos/googleg/v6/web-512dp/logo_googleg_color_1x_web_512dp.png',
96 | cta: 'READ MORE',
97 | filters: ['What’s changing', 'Quick launch summary'],
98 | webhooks: [WEBHOOKS.ADMIN_ROOM]
99 | },
100 | CHROME_RELEASES: {
101 | format: FEED_FORMAT.FB_XML,
102 | title: 'Chrome Releases',
103 | subtitle: 'chromereleases.googleblog.com',
104 | source: 'https://feeds.feedburner.com/GoogleChromeReleases',
105 | logo: 'https://fonts.gstatic.com/s/i/productlogos/chrome/v6/web-512dp/logo_chrome_color_1x_web_512dp.png',
106 | cta: 'READ MORE',
107 | filters: ['Hi everyone!'],
108 | webhooks: [WEBHOOKS.ADMIN_ROOM]
109 | },
110 | GCP_TRAINING: {
111 | format: FEED_FORMAT.GOOGLE_BLOG_RSS,
112 | title: 'GCP Training & Certification',
113 | subtitle: 'cloudblog.withgoogle.com',
114 | source:
115 | 'https://cloudblog.withgoogle.com/topics/training-certifications/rss/',
116 | logo:
117 | 'https://fonts.gstatic.com/s/i/productlogos/google_cloud/v8/web-512dp/logo_google_cloud_color_1x_web_512dp.png',
118 | cta: 'READ MORE',
119 | filters: [],
120 | webhooks: [WEBHOOKS.ADMIN_ROOM]
121 | },
122 | DEVELOPERS: {
123 | format: FEED_FORMAT.FB_XML,
124 | title: 'Google Developers',
125 | subtitle: 'developers.googleblog.com',
126 | source: 'https://feeds.feedburner.com/GDBcode',
127 | logo:
128 | 'https://fonts.gstatic.com/s/i/productlogos/google_developers/v7/web-512dp/logo_google_developers_color_1x_web_512dp.png',
129 | cta: 'READ MORE',
130 | filters: [],
131 | webhooks: [WEBHOOKS.ADMIN_ROOM]
132 | }
133 | };
134 |
135 | /**
136 | * Public Functions
137 | */
138 |
139 | function initializeScript() {
140 | // Ensures the script is in a default state.
141 | clearProperties_();
142 |
143 | // Clears and initiates a single daily trigger.
144 | resetTriggers_();
145 |
146 | // Kicks off first fetch of feed updates. Set a flag to true that is only
147 | // modified here to alert that this is the first time the function is running.
148 | executeUpdateWorkflow(null, true);
149 |
150 | // Logs the storage for manual validation.
151 | logScriptProperties();
152 | }
153 |
154 | function executeUpdateWorkflow(trigger, initialization) {
155 | // Skip execution if we're not initializing or outside of notification hours.
156 | if (!(initialization || isValidExecutionWindow_())) {
157 | return;
158 | }
159 |
160 | // If a feed is not configured to send to a webhook, no need to fetch updates.
161 | Object.keys(FEEDS).filter(feed => FEEDS[feed].webhooks.length > 0).forEach(function(feed) {
162 | try {
163 | let feedUpdates = fetchLatestUpdates_(feed);
164 | let newUpdates = checkForNewUpdates_(feed, feedUpdates);
165 | if (newUpdates) {
166 | // The initialization flag is only set when this function is called from
167 | // initializeScript(). In order to not spam a webook, the results get
168 | // trimmed to the MAX_INIT_UPDATES amount if the number of updates is
169 | // larger.
170 | if (initialization && (newUpdates.length > MAX_INIT_UPDATES)) {
171 | newUpdates.length = MAX_INIT_UPDATES;
172 | }
173 | sendUpdatesToWebhooks_(feed, newUpdates);
174 | }
175 | } catch (err) {
176 | Logger.log(err);
177 | }
178 | });
179 | }
180 |
181 | function logScriptProperties() {
182 | let feedUpdates = PropertiesService.getScriptProperties().getProperties();
183 | Object.keys(feedUpdates).forEach(function(id) {
184 | let updates = JSON.parse(feedUpdates[id]);
185 | Logger.log(`Feed: ${id}`);
186 | Logger.log(updates);
187 | });
188 | }
189 |
190 | /**
191 | * Workflow (Private) Functions
192 | */
193 |
194 | function isValidExecutionWindow_() {
195 | let date = new Date();
196 | let validDay = (NOTIFY_WEEKEND || !(date.getDay() === 0 || date.getDay() === 6));
197 | let validHour = (date.getHours() >= NOTIFY_HOURS.START && date.getHours() < NOTIFY_HOURS.END);
198 | Logger.log(`${Session.getScriptTimeZone()}: valid day (${validDay}), valid hour (${validHour})`);
199 | return (validDay && validHour);
200 | }
201 |
202 | function fetchLatestUpdates_(feed) {
203 | let updates = [];
204 |
205 | let results =
206 | UrlFetchApp.fetch(FEEDS[feed].source, {muteHttpExceptions: true});
207 |
208 | if (results.getResponseCode() !== 200) {
209 | Logger.log(results.message);
210 | return null;
211 | }
212 |
213 | updates = FEEDS[feed].format.parseFunction(feed, results.getContentText());
214 |
215 | // Cap the number of updates that are processed and stored.
216 | let recordsToRemove = updates.length - MAX_CONTENT.UPDATES;
217 | if (recordsToRemove > 0) {
218 | updates.splice(-recordsToRemove, recordsToRemove);
219 | }
220 |
221 | return updates;
222 | }
223 |
224 | function checkForNewUpdates_(feed, feedUpdates) {
225 | if (!feedUpdates) {
226 | return [];
227 | }
228 |
229 | let newUpdates = [];
230 | let latestUpdates = [];
231 | let properties = PropertiesService.getScriptProperties().getProperties();
232 | let existingUpdates = properties[feed] ? JSON.parse(properties[feed]) : [];
233 |
234 | // For each update, determine if we've seen it before based on the URL path's
235 | // existence in the script's storage. If we haven't seen it yet, store the
236 | // update for use later in broadcasting. URL paths tend to not change when posts
237 | // are updated, whereas the IDs tend to change on updates, hence using them.
238 | feedUpdates.forEach(function(update) {
239 | const path = getURLPath_(update.link);
240 | latestUpdates.push(path);
241 | if (existingUpdates.indexOf(path) === -1) {
242 | newUpdates.push(update);
243 | }
244 | });
245 |
246 | // Write the latest updates to storage, including any new ones.
247 | properties[feed] = JSON.stringify(latestUpdates);
248 | PropertiesService.getScriptProperties().setProperties(properties, true);
249 |
250 | Logger.log(`${feed}: ${newUpdates.length} new updates were found.`);
251 | return newUpdates;
252 | }
253 |
254 | function sendUpdatesToWebhooks_(feed, newUpdates) {
255 | FEEDS[feed].webhooks.forEach(function(webhook) {
256 | newUpdates.forEach(function(update) {
257 | let updateView = webhook.type.viewFunction(FEEDS[feed], update);
258 | postUpdate_(webhook.url, updateView);
259 | });
260 | });
261 | }
262 |
263 | function postUpdate_(url, updateView) {
264 | try {
265 | let options = {
266 | 'contentType': 'application/json; charset=UTF-8',
267 | 'method': 'post',
268 | 'payload': JSON.stringify(updateView),
269 | 'followRedirects': true,
270 | 'muteHttpExceptions': true
271 | };
272 | return UrlFetchApp.fetch(url, options);
273 | } catch (err) {
274 | Logger.log(err);
275 | }
276 | }
277 |
278 | function parseFBXmlIntoUpdateObject_(feed, feedXml) {
279 | let updates = [];
280 | let document = XmlService.parse(feedXml);
281 | let atom = XmlService.getNamespace('http://www.w3.org/2005/Atom');
282 | let entries = document.getRootElement().getChildren('entry', atom);
283 |
284 | entries.forEach(function(entry) {
285 | let id = entry.getChild('id', atom).getText();
286 | let publishedDate = entry.getChild('published', atom).getText();
287 | let title = entry.getChild('title', atom).getText();
288 | let content = entry.getChild('content', atom).getText();
289 | let linkOptions = entry.getChildren('link', atom);
290 | let link = '';
291 | for (let i = 0; i < linkOptions.length; i++) {
292 | if (linkOptions[i].getAttribute('rel').getValue() === 'alternate') {
293 | link = linkOptions[i].getAttribute('href').getValue();
294 | break;
295 | }
296 | }
297 | updates.push(
298 | buildUpdateObject_(feed, id, publishedDate, title, content, link));
299 | });
300 |
301 | return updates;
302 | }
303 |
304 | function parseGoogleBlogRssIntoUpdateObject_(feed, feedXml) {
305 | let updates = [];
306 | let document = XmlService.parse(feedXml);
307 | let entries =
308 | document.getRootElement().getChild('channel').getChildren('item');
309 |
310 | entries.forEach(function(entry) {
311 | let guid = entry.getChild('guid').getText();
312 | let publishedDate = entry.getChild('pubDate').getText();
313 | let title = entry.getChild('title').getText();
314 | let description = entry.getChild('description').getText();
315 | let link = entry.getChild('link').getText();
316 | updates.push(buildUpdateObject_(
317 | feed, guid, publishedDate, title, description, link));
318 | });
319 |
320 | return updates;
321 | }
322 |
323 | function buildUpdateObject_(feed, id, date, title, content, link) {
324 | // Remove some special characters and any filters that are specific to a feed.
325 | let finalContent = content.replace(/<[^>]+>/g, '');
326 | FEEDS[feed].filters.forEach(function(filter) {
327 | finalContent = finalContent.replace(filter, '');
328 | });
329 |
330 | let lastSpaceIndex = finalContent.substring(0, MAX_CONTENT.CHARS).lastIndexOf(' ');
331 | let snippet = finalContent.substring(0, lastSpaceIndex).trim();
332 | let fullContent = finalContent.substring(lastSpaceIndex, finalContent.length).trim();
333 |
334 | return {
335 | feed: feed,
336 | id: id,
337 | date: new Date(date).toDateString(),
338 | title: title,
339 | snippet: snippet + '...',
340 | fullContent: (fullContent.length > 0) ? '...' + fullContent : '',
341 | link: link
342 | };
343 | }
344 |
345 | function getURLPath_(link) {
346 | return URI(link).path();
347 | }
348 |
349 | function clearProperties_() {
350 | PropertiesService.getScriptProperties().deleteAllProperties();
351 | }
352 |
353 | function resetTriggers_() {
354 | // First clear all the triggers.
355 | let triggers = ScriptApp.getProjectTriggers();
356 | triggers.forEach(function(trigger) {
357 | ScriptApp.deleteTrigger(trigger);
358 | });
359 |
360 | // Then initialize a single daily trigger.
361 | ScriptApp.newTrigger('executeUpdateWorkflow')
362 | .timeBased()
363 | .everyHours(TRIGGER_INTERVAL_HOURS)
364 | .create();
365 | }
366 |
367 | function buildGoogleChatViewV1_(feed, update) {
368 | return {
369 | 'cards': [{
370 | 'header': {
371 | 'title': feed.title,
372 | 'subtitle': feed.subtitle,
373 | 'imageUrl': feed.logo,
374 | },
375 | 'sections': [
376 | {
377 | 'widgets': [
378 | {'textParagraph': {'text': `${update.title}`}},
379 | {'textParagraph': {'text': update.snippet}}
380 | ]
381 | },
382 | {
383 | 'widgets': [{
384 | 'buttons': [{
385 | 'textButton': {
386 | 'text': feed.cta,
387 | 'onClick': {'openLink': {'url': update.link}}
388 | }
389 | }]
390 | }]
391 | }
392 | ]
393 | }]
394 | };
395 | }
396 |
397 | function buildGoogleChatViewV2_(feed, update) {
398 | return {
399 | 'cardsV2': [{
400 | 'cardId': update.id,
401 | 'card': {
402 | 'header': {
403 | 'title': feed.title,
404 | 'subtitle': feed.subtitle,
405 | 'imageUrl': feed.logo,
406 | 'imageType': 'CIRCLE',
407 | 'imageAltText': 'Logo for the feed'
408 | },
409 | 'sections': [
410 | {
411 | 'header': '',
412 | 'collapsible': false,
413 | 'uncollapsibleWidgetsCount': 1,
414 | 'widgets': [
415 | {'textParagraph': {'text': `${update.title}`}},
416 | ]
417 | },
418 | {
419 | 'header': '',
420 | 'collapsible': update.fullContent.length > 0,
421 | 'uncollapsibleWidgetsCount': 1,
422 | 'widgets': [
423 | {'textParagraph': {'text': update.snippet}},
424 | {'textParagraph': {'text': update.fullContent}}
425 | ]
426 | },
427 | {
428 | 'header': '',
429 | 'collapsible': false,
430 | 'uncollapsibleWidgetsCount': 0,
431 | 'widgets': [
432 | {
433 | 'buttonList': {
434 | 'buttons': [
435 | {
436 | 'text': feed.cta,
437 | 'onClick': {'openLink': {'url': update.link}}
438 | }
439 | ]
440 | }
441 | }
442 | ]
443 | }
444 | ],
445 | }
446 | }]
447 | }
448 | }
449 |
--------------------------------------------------------------------------------
/google-chat-updates-bot/examplepost.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdw353/google-workspace-apps-script-toolbox/e3921ec0021b0562dbcef3b5ab9f2d6f18c1c486/google-chat-updates-bot/examplepost.png
--------------------------------------------------------------------------------
/google-chat-updates-bot/examplepost2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdw353/google-workspace-apps-script-toolbox/e3921ec0021b0562dbcef3b5ab9f2d6f18c1c486/google-chat-updates-bot/examplepost2.png
--------------------------------------------------------------------------------
/ip-range-monitor/README.md:
--------------------------------------------------------------------------------
1 | # Google IP Range Monitor
2 |
3 | Google maintains a global infrastructure, which grows dynamically to accommodate
4 | increasing demand. Google services use a large range of IP addresses, which
5 | often change.
6 |
7 | As a result, there are some instances where Google Workspace customers will want to
8 | monitor and know when changes are made to the list of IP addresses.
9 |
10 | _Google IP Range Monitor_ provides a method that Google Workspace Administrators, Network
11 | Admins, or Google Partners can follow in order to set up an automated Apps
12 | Script project that will notify them via email when changes are made to Google’s
13 | IP ranges.
14 |
15 | This is an updated version of
16 | [Google Netblock Monitor](https://github.com/GoogleCloudPlatform/professional-services/tree/master/tools/netblock-monitor).
17 |
18 | ## Requested Scopes
19 |
20 | In order for the script to work, it must be initialized and granted necessary
21 | permissions in order to take actions on behalf of the signed-in user. The
22 | following scopes are required for the IP Range Monitor to function:
23 |
24 | - Gmail (https://mail.google.com)
25 | - Apps Script (https://www.googleapis.com/auth/script.external_request)
26 | - Apps Script (https://www.googleapis.com/auth/script.scriptapp)
27 |
28 | ## Configuration and Usage
29 |
30 | The script is generally expected to be initalized once via manual execution,
31 | followed by triggered execution on a daily basis. Additionally, a few helper
32 | functions are provided to help with ongoing visibility of the tool's execution.
33 |
34 | ### Configuration
35 |
36 | There are 5 variables in the code itself that may require modification before
37 | execution:
38 |
39 | - DISTRIBUTION: enable or disable the available channels for updates, which
40 | are via email or Google Chat (wehbook).
41 | - DISTRIBUTION_LIST: an array of strings that includes the email addresses
42 | that will receive notifications. Mandatory to update.
43 | - DAILY_TRIGGER_HOUR: the hour of the day that the trigger will run, defaulted
44 | to 8am. Optional to update.
45 | - DAILY_TRIGGER_TZ: the time zone that the trigger will run in, defaulted to
46 | America/New_York. Optional to update.
47 | - CHAT_WEBHOOK_URL: the URL of the webhook established in a Google Chat room.
48 |
49 | ### Usage
50 |
51 | - Create a new Google Apps Script project. Name it "Google IP Range Monitor".
52 | - Copy and paste the code from this repository into the Code.gs script.
53 | - You must modify the DISTRIBUTION and DISTRIBUTION_LIST/CHAT_WEBHOOK_URL
54 | variables accordingly. Modifying DAILY_TRIGGER_HOUR and DAILY_TRIGGER_TZ is
55 | optional.
56 | - Run the initializeMonitor function. Authorize the required scopes.
57 | - Validate via Logs that initial values are populated.
58 |
59 | ## End Results
60 | 
61 | 
62 |
63 | ## Helpful links
64 |
65 | - [Google Netblock Monitor](https://www.cloudconnect.goog/docs/DOC-33011)
66 | - [Google's IP Ranges](http://www.gstatic.com/ipranges/goog.json)
67 | - [IP address ranges for outbound SMTP](https://support.google.com/a/answer/60764)
68 | - [Overview of Google Apps Script](https://developers.google.com/apps-script/overview)
69 |
--------------------------------------------------------------------------------
/ip-range-monitor/code.js:
--------------------------------------------------------------------------------
1 | /*
2 | Google IP Range Monitor
3 |
4 | Polls gstatic.com/ipranges/goog.json for information on various
5 | Google IP ranges. Reports back changes.
6 |
7 | A comparison of current blocks is done against previously known IP ranges
8 | stored in Apps Script properties. If/when IP ranges are found to be added or
9 | removed, an email is generated with the specific details.
10 | */
11 |
12 |
13 | /**
14 | * Script Configuration
15 | *
16 | * Modify the below variables to match requirements.
17 | * [REQ] DISTRIBUTION: whether updates to go email, Chat, or both.
18 | * [REQ] DISTRIBUTION_LIST: include emails that will receive notifications.
19 | * [OPT] DAILY_TRIGGER_HOUR is the hour when the script should run each day.
20 | * [OPT] DAILY_TRIGGER_TZ is the timezone that maps to the hour.
21 | */
22 | /** @enum {boolean} */
23 | var DISTRIBUTION = {CHAT: false, EMAIL: true};
24 | /** @const {!Array} */
25 | var DISTRIBUTION_LIST =
26 | ['email@domain.com', 'email2@domain.com', 'email3@domain.com'];
27 | /** @const {number} */
28 | var DAILY_TRIGGER_HOUR = 8;
29 | /** @const {string} */
30 | var DAILY_TRIGGER_TZ = 'America/New_York';
31 |
32 | /**
33 | * Google IP Range Configuration
34 | */
35 | /** @const {string} */
36 | var GOOGLE_IP_RANGES = 'https://www.gstatic.com/ipranges/goog.json';
37 |
38 | /**
39 | * Email Configuration
40 | */
41 | /** @const {string} */
42 | var EMAIL_SUBJECT = 'Google IP Range Changes Detected';
43 | /** @const {string} */
44 | var EMAIL_HTML_BODY = 'Action | Type | ' +
45 | 'Range |
%CHANGE_RECORDS%
';
46 | /** @enum {string} */
47 | var ChangeRecordFormat = {
48 | HTML: '%ACTION% | %IPTYPE% | %IP% |
',
49 | PLAIN: 'Action: %ACTION% IP Type: %IPTYPE% ' +
50 | 'IP Range: %IP%\n'
51 | };
52 |
53 | /**
54 | * Google Chat Configuration
55 | *
56 | * Modify the below variables to match requirements.
57 | * [REQ] CHAT_WEBHOOK_URL: the URL provied by Chat for the webhook, with inputs
58 | * required for {space}, {key}, and {token}.
59 | */
60 | /** @const {string} */
61 | var CHAT_WEBHOOK_URL = 'https://chat.googleapis.com/v1/spaces/{space}' +
62 | '/messages?key={key}' +
63 | '&token={token}';
64 |
65 | /**
66 | * Script Objects
67 | */
68 | /** @enum {string} */
69 | var ChangeAction = {ADD: 'add', REMOVE: 'remove'};
70 | /** @enum {string} */
71 | var ScriptProperty = {PREFIX: 'prefixes', SYNC: 'syncToken'};
72 |
73 | /**
74 | * ChangeRecord object that details relevant info when a range is changed.
75 | * @typedef {{action:!ChangeAction, ipType:string, ip:string}}
76 | */
77 | var ChangeRecord;
78 |
79 |
80 |
81 | /**
82 | * Public Functions
83 | */
84 |
85 | /**
86 | * Initializes the Apps Script project by ensuring that the prompt for
87 | * permissions occurs before the trigger is set, script assets (triggers,
88 | * properties) start in a clean state, data is populated, and an email is sent
89 | * containing the current state of Google's IP ranges.
90 | */
91 | function initializeMonitor() {
92 | // Ensures the script is in a default state.
93 | clearProperties_();
94 | // Clears and initiates a single daily trigger.
95 | resetTriggers_();
96 | // Kicks off first fetch of IPs for storage. This will generate an email.
97 | executeUpdateWorkflow();
98 | // Logs the storage for manual validation.
99 | logScriptProperties();
100 | }
101 |
102 | /**
103 | * Kicks off the workflow to fetch the ranges, analyzes/stores the results,
104 | * and emails any changes.
105 | */
106 | function executeUpdateWorkflow() {
107 | try {
108 | var ipRanges = getGoogleIpRanges_();
109 | var isDataUpdated = determineIfDataIsUpdated_(ipRanges.syncToken);
110 | if (!isDataUpdated) {
111 | Logger.log('Data has not been updated since last check.');
112 | return;
113 | }
114 | var prefixMap = mapIpPrefixes_(ipRanges.prefixes);
115 | var ipRangeChanges = getIPRangeChanges_(prefixMap);
116 | setNewDataUpdatedTime_(ipRanges.syncToken);
117 | if (ipRangeChanges.length) {
118 | if (DISTRIBUTION.EMAIL) {
119 | emailChanges_(ipRangeChanges);
120 | }
121 | if (DISTRIBUTION.CHAT) {
122 | postToWebhook_(ipRangeChanges);
123 | }
124 | Logger.log('Changes found: %s', ipRangeChanges.length);
125 | } else {
126 | Logger.log('No changes found.');
127 | }
128 | } catch (err) {
129 | Logger.log(err);
130 | }
131 | }
132 |
133 | /**
134 | * Writes the contents of the script's properties to logs for manual inspection.
135 | */
136 | function logScriptProperties() {
137 | Logger.log(
138 | 'Last Updated: ' +
139 | PropertiesService.getScriptProperties().getProperty(ScriptProperty.SYNC));
140 | var knownPrefixes = PropertiesService.getScriptProperties().getProperty(
141 | ScriptProperty.PREFIX);
142 | if (knownPrefixes != null) {
143 | knownPrefixes = JSON.parse(knownPrefixes);
144 | Object.keys(knownPrefixes).forEach(function(ip) {
145 | Logger.log('IP: %s Type: %s', ip, knownPrefixes[ip]);
146 | });
147 | }
148 | }
149 |
150 |
151 | /**
152 | * Workflow (Private) Functions
153 | */
154 | /**
155 | * Queries for Google's IP ranges from the canonical source and returns them.
156 | * @private
157 | * @return {!Object} IP range object.
158 | */
159 | function getGoogleIpRanges_() {
160 | var result = UrlFetchApp.fetch(GOOGLE_IP_RANGES, {muteHttpExceptions: true});
161 | return JSON.parse(result.getContentText());
162 | }
163 |
164 | /**
165 | * Simply checks to see whether the data in the latest fetch is different than
166 | * the last data stored in storage by comparing the syncToken (timestamp).
167 | * It's possible that there can be a mismatch but no prefixes have changed.
168 | * @param {string} syncToken A timestamp of when the list was last updated.
169 | * @return {bool} Whether or not the dates are different.
170 | */
171 | function determineIfDataIsUpdated_(syncToken) {
172 | var previousSyncToken =
173 | PropertiesService.getScriptProperties().getProperty(ScriptProperty.SYNC);
174 |
175 | if (previousSyncToken == null) {
176 | return true;
177 | }
178 |
179 | return (syncToken !== previousSyncToken);
180 | }
181 |
182 | /**
183 | * Converts an array of prefix key/value pairs into a map of ip/ip type.
184 | * @param {!Array} prefixes The list of IP prefixes and type.
185 | * @return {Object} ipPrefixMap Key value map of an IP address
186 | * to ip address type.
187 | */
188 | function mapIpPrefixes_(prefixes) {
189 | var ipPrefixMap = {};
190 |
191 | prefixes.forEach(function(prefix) {
192 | var type = Object.keys(prefix)[0];
193 | var ipPrefix = prefix[type];
194 | ipPrefixMap[ipPrefix] = type.replace('Prefix', '');
195 | });
196 |
197 | return ipPrefixMap;
198 | }
199 |
200 | /**
201 | * Compares the new IP prefixes to the known items in storage.
202 | * @private
203 | * @param {!Object} prefixMap A key value map of an IP
204 | * address to ip address type (ipv4 or ipv6).
205 | * e.g. {'64.233.160.0/19': 'ipv4'}
206 | * @return {!Array} List of ChangeRecord(s) representing
207 | * detected changes and whether the action should be to add or remove them.
208 | */
209 | function getIPRangeChanges_(prefixMap) {
210 | if (!prefixMap) {
211 | return [];
212 | }
213 |
214 | var changes = [];
215 | var newPrefixes = {};
216 | var oldPrefixes = PropertiesService.getScriptProperties().getProperty(
217 | ScriptProperty.PREFIX);
218 | oldPrefixes = (oldPrefixes == null) ? {} : JSON.parse(oldPrefixes);
219 |
220 | // First check to see which previous IPs still exist. Keep those that are,
221 | // and remove those that no longer exist.
222 | Object.keys(oldPrefixes).forEach(function(previousIP) {
223 | if (prefixMap.hasOwnProperty(previousIP)) {
224 | newPrefixes[previousIP] = oldPrefixes[previousIP];
225 | } else {
226 | changes.push(getChangeRecord_(
227 | ChangeAction.REMOVE, oldPrefixes[previousIP], previousIP));
228 | }
229 | });
230 |
231 | // Then check to see which current IPs didn't exist previously and add them.
232 | Object.keys(prefixMap).forEach(function(currentIP) {
233 | if (!oldPrefixes[currentIP]) {
234 | changes.push(
235 | getChangeRecord_(ChangeAction.ADD, prefixMap[currentIP], currentIP));
236 | newPrefixes[currentIP] = prefixMap[currentIP];
237 | }
238 | });
239 |
240 | // Replace the existing list of IPs and types (within script storage)
241 | // with the current state.
242 | PropertiesService.getScriptProperties().setProperty(
243 | ScriptProperty.PREFIX, JSON.stringify(newPrefixes));
244 |
245 | return changes;
246 | }
247 |
248 | /**
249 | * Updates the script's stored syncToken with the one from the latest data.
250 | * @param {string} syncToken A timestamp of when the data was last updated.
251 | */
252 | function setNewDataUpdatedTime_(syncToken) {
253 | PropertiesService.getScriptProperties().setProperty(
254 | ScriptProperty.SYNC, syncToken);
255 | }
256 |
257 |
258 | /**
259 | * Generates an email that includes a formatted display of all changes.
260 | * @private
261 | * @param {!Array} changeRecords List of detected changes.
262 | */
263 | function emailChanges_(changeRecords) {
264 | var changePlain = '';
265 | var changeHTML = '';
266 |
267 | changeRecords.forEach(function(changeRecord) {
268 | changePlain +=
269 | formatChangeForEmail_(changeRecord, ChangeRecordFormat.PLAIN);
270 | changeHTML += formatChangeForEmail_(changeRecord, ChangeRecordFormat.HTML);
271 | });
272 |
273 | GmailApp.sendEmail(
274 | DISTRIBUTION_LIST.join(', '), EMAIL_SUBJECT, changePlain,
275 | // The HTML formatted records, represented as table rows (), need to
276 | // be inserted into the table (), along with the table headers
277 | // ().
278 | {'htmlBody': EMAIL_HTML_BODY.replace('%CHANGE_RECORDS%', changeHTML)});
279 | }
280 |
281 | /**
282 | * Generates an Chat post that includes a formatted display of all changes.
283 | * @private
284 | * @param {!Array} changeRecords List of detected changes.
285 | */
286 | function postToWebhook_(changeRecords) {
287 | var recordView = formatChangeForChat_(changeRecords);
288 | try {
289 | var options = {
290 | 'contentType': 'application/json; charset=UTF-8',
291 | 'method': 'post',
292 | 'payload': JSON.stringify(recordView),
293 | 'followRedirects': true,
294 | 'muteHttpExceptions': true
295 | };
296 | UrlFetchApp.fetch(CHAT_WEBHOOK_URL, options);
297 | } catch (err) {
298 | Logger.log(err);
299 | }
300 | }
301 |
302 |
303 | /**
304 | * Helper Functions
305 | */
306 |
307 | /**
308 | * Creates and returns a record object that reflects changes in ranges.
309 | * @private
310 | * @param {!ChangeAction} action The type change that occurred.
311 | * @param {!IpType} ipType The type of IP address.
312 | * @param {string} ip The IP range.
313 | * @return {!ChangeRecord} Change record object.
314 | */
315 | function getChangeRecord_(action, ipType, ip) {
316 | return {action: action, ipType: ipType, ip: ip};
317 | }
318 |
319 | /**
320 | * Creates a formatted record of an change based on a template.
321 | * @private
322 | * @param {!ChangeRecord} changeRecord Record representing a prefix change.
323 | * @param {!ChangeRecordFormat} emailChangeFormat HTML or PLAIN.
324 | * @return {string} - Formatted change that includes the values.
325 | */
326 | function formatChangeForEmail_(changeRecord, emailChangeFormat) {
327 | return emailChangeFormat.replace('%ACTION%', changeRecord.action)
328 | .replace('%IPTYPE%', changeRecord.ipType)
329 | .replace('%IP%', changeRecord.ip);
330 | }
331 |
332 | /**
333 | * Creates a formatted record of an change formatted for Hangouts Chat.
334 | * @private
335 | * @param {!Array} changeRecords List of detected changes.
336 | * @return {!Object} - Structured Hangouts Chat card object.
337 | */
338 | function formatChangeForChat_(changeRecords) {
339 | var sections = [];
340 |
341 | changeRecords.forEach(function(changeRecord) {
342 | sections.push({
343 | 'widgets': [
344 | {'textParagraph': {'text': 'Action: ' + changeRecord.action}},
345 | {'textParagraph': {'text': 'Type: ' + changeRecord.ipType}},
346 | {'textParagraph': {'text': 'Range: ' + changeRecord.ip}},
347 | ]
348 | });
349 | });
350 |
351 | //
352 | return {
353 | 'cards': [{
354 | 'header': {
355 | 'title': 'Google IP Range Monitor',
356 | 'subtitle': sections.length + ' changes were detected',
357 | 'imageUrl':
358 | 'http://www.stickpng.com/assets/images/5847f9cbcef1014c0b5e48c8.png',
359 | },
360 | 'sections': sections
361 | }]
362 | };
363 | }
364 |
365 |
366 |
367 | /**
368 | * Clears all Apps Script internal storage.
369 | * @private
370 | */
371 | function clearProperties_() {
372 | PropertiesService.getScriptProperties().deleteAllProperties();
373 | }
374 |
375 | /**
376 | * Resets script tiggers by clearing them and adding a single daily trigger.
377 | * @private
378 | */
379 | function resetTriggers_() {
380 | // First clear all the triggers.
381 | var triggers = ScriptApp.getProjectTriggers();
382 | triggers.forEach(function(trigger) {
383 | ScriptApp.deleteTrigger(trigger);
384 | });
385 |
386 | // Then initialize a single daily trigger.
387 | ScriptApp.newTrigger('executeUpdateWorkflow')
388 | .timeBased()
389 | .atHour(DAILY_TRIGGER_HOUR)
390 | .everyDays(1)
391 | .inTimezone(DAILY_TRIGGER_TZ)
392 | .create();
393 | }
394 |
--------------------------------------------------------------------------------
/ip-range-monitor/exampleemail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdw353/google-workspace-apps-script-toolbox/e3921ec0021b0562dbcef3b5ab9f2d6f18c1c486/ip-range-monitor/exampleemail.png
--------------------------------------------------------------------------------
/ip-range-monitor/examplepost.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdw353/google-workspace-apps-script-toolbox/e3921ec0021b0562dbcef3b5ab9f2d6f18c1c486/ip-range-monitor/examplepost.png
--------------------------------------------------------------------------------
/user-alias-report/code.js:
--------------------------------------------------------------------------------
1 | /*
2 | G Suite User Alias Report
3 |
4 | Configuration:
5 | - YOUR_PRIMARY_DOMAIN_HERE: Replace with your G Suite primary domain.
6 | - YOUR_SHEET_ID_HERE: Replace with the ID of a Google Sheet.
7 |
8 | */
9 |
10 | var USER_OPTIONS = {
11 | domain: 'YOUR_PRIMARY_DOMAIN_HERE',
12 | customer: 'my_customer',
13 | maxResults: 500,
14 | projection: 'basic',
15 | viewType: 'domain_public',
16 | orderBy: 'email'
17 | };
18 |
19 | var OUTPUT_SHEET_ID = 'YOUR_SHEET_ID_HERE';
20 |
21 | function main() {
22 | var users = fetchDomainUsers_();
23 | writeHeaderToSheet_();
24 | for (var i = 0; i < users.length; i++) {
25 | writeUserToSheet_(users[i]);
26 | }
27 | }
28 |
29 | function fetchDomainUsers_() {
30 | var users = [];
31 | var pageCount = 1;
32 | do {
33 | var response = AdminDirectory.Users.list(USER_OPTIONS);
34 | response.users.forEach(function(user) {
35 | users.push({'name': user.name.fullName, 'emails': user.emails});
36 | });
37 |
38 | // For domains with many users, the results are paged.
39 | if (response.nextPageToken) {
40 | USER_OPTIONS.pageToken = response.nextPageToken;
41 | }
42 |
43 | Logger.log('Page ' + pageCount + ': ' + response.users.length + ' users.');
44 | pageCount++;
45 | } while (response.nextPageToken);
46 | return users;
47 | }
48 |
49 | function writeHeaderToSheet_() {
50 | var sheet = SpreadsheetApp.openById(OUTPUT_SHEET_ID).getSheets()[0];
51 | sheet.getRange(sheet.getLastRow() + 1, 1, 1, 3).setValues([['User Name', 'Email Address', 'Is Primary?']]);
52 | }
53 |
54 | function writeUserToSheet_(user) {
55 | var records = [];
56 | if (user.emails.length > 0) {
57 | for (var i = 0; i < user.emails.length; i++) {
58 | records.push([user.name, user.emails[i].address, (user.emails[i].primary === true)]);
59 | }
60 | }
61 |
62 | var sheet = SpreadsheetApp.openById(OUTPUT_SHEET_ID).getSheets()[0];
63 | sheet.getRange(sheet.getLastRow() + 1, 1, records.length, 3).setValues(records);
64 | }
--------------------------------------------------------------------------------
|