Last active
September 21, 2025 16:28
-
-
Save LukasGibb/09ab097ae208ea55eb624b9648498c27 to your computer and use it in GitHub Desktop.
Google Apps Script: Auto-Forward Emails to Xero
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Google Apps Script: Auto-Forward Emails to Xero | |
| * Author: Lukas Gibb (Written with help from ChatGPT o1) | |
| * Email: lg@cloudjourneyman.com | |
| * Credit for idea: Reddit User 'zfa' (https://www.reddit.com/user/zfa/) mentioned this idea here: | |
| * https://www.reddit.com/r/GMail/comments/hk4gc1/comment/fwrcghc/ | |
| * | |
| * Purpose: | |
| * Xero (accounting tool) allows PDF invoices to be forwarded manually to create draft invoices. | |
| * Doing this one-by-one can be time consuming and tedious so it would be great to just set up filters | |
| * to do it however Xero do not currently allow a method to click the verification link or retrieve | |
| * the code that GMail sends to verify you are allowed to forward email to your xerofiles.com address. | |
| * This means you cannot automatically forward emails to Xero as they come in. With this script you | |
| * can save some time by adding a label to the emails you want to forward (manually or with a filter) | |
| * and then forwarding them all by running this script. | |
| * | |
| * Description: | |
| * This script automatically forwards Gmail messages that meet the following criteria to Xero: | |
| * 1. They have a specific label (eg. "ForwardToXero"). | |
| * 2. They contain at least one PDF attachment. | |
| * 3. The message is currently in the Inbox. | |
| * | |
| * The script then: | |
| * - Removes the ForwardToXero label. | |
| * - Applies an "Accounts Payable" label (configurable). | |
| * - Archives the thread. | |
| * - Sends a summary report via email listing forwarded/skipped messages. | |
| * | |
| * Setup & Usage: | |
| * 1. Create labels "ForwardToXero" and "Reference/- Accounts Payable" in Gmail. | |
| * 2. Add the "ForwardToXero" label (manually or via a filter) to messages you want forwarded to Xero. | |
| * 3. Update the configuration constants (DRY_RUN, XERO_EMAIL, REPORT_RECIPIENT) as needed. | |
| * 4. Go to https://script.google.com/home, create a new project and copy this script in and save it. | |
| * 5. Run the script. Check the execution log and your email for the forwarding report. | |
| * 6. If DRY_RUN = true, it's only a simulation. Set DRY_RUN = false to actually forward. | |
| * | |
| * Notes: | |
| * - Uses message.forward(recipient, options) to preserve the conversation thread. | |
| * - Embeds a custom HTML block that includes the original From/Date/Subject/To in the forwarded mail. | |
| * - If you have aliases set up in your gmail. The script will look for the original "To:" address | |
| * in your valid aliases to determine the "from" address when forwarding. | |
| * - If no matching alias is found in the "To:" field, the message is skipped. | |
| * - If no PDF is found, the message is skipped. | |
| * - If the message is not in the Inbox, it is skipped. | |
| * | |
| * Support: | |
| * - For further customization or issues, see Google Apps Script docs or contact the script author. | |
| */ | |
| function forwardInvoicesToXero() { | |
| // If DRY_RUN is true, we simulate actions without actually forwarding. | |
| var DRY_RUN = true; | |
| // Configuration | |
| var FORWARD_LABEL = "ForwardToXero"; | |
| var ACCOUNTS_PAYABLE_LABEL = "Reference/- Accounts Payable"; | |
| var XERO_EMAIL = "your.xero.address@xerofiles.com"; | |
| var REPORT_RECIPIENT = "youraccount@gmail.com"; | |
| Logger.log("Starting script execution..."); | |
| // Retrieve all valid alias addresses (including primary) | |
| var validAliases = GmailApp.getAliases(); | |
| validAliases.push(Session.getActiveUser().getEmail()); | |
| Logger.log("Valid 'From' aliases: " + JSON.stringify(validAliases, null, 2)); | |
| var gmail = GmailApp; | |
| var label = gmail.getUserLabelByName(FORWARD_LABEL); | |
| var accountsPayableLabel = gmail.getUserLabelByName(ACCOUNTS_PAYABLE_LABEL); | |
| // Fail early if labels are missing | |
| if (!label || !accountsPayableLabel) { | |
| var missingLabels = []; | |
| if (!label) missingLabels.push(`"${FORWARD_LABEL}"`); | |
| if (!accountsPayableLabel) missingLabels.push(`"${ACCOUNTS_PAYABLE_LABEL}"`); | |
| var errorMessage = `Error: Missing labels: ${missingLabels.join(", ")}. Please create them.`; | |
| Logger.log(errorMessage); | |
| gmail.sendEmail(REPORT_RECIPIENT, "Xero Forwarding Script Error", errorMessage); | |
| return; | |
| } | |
| Logger.log(`Labels found: "${FORWARD_LABEL}", "${ACCOUNTS_PAYABLE_LABEL}"`); | |
| // Retrieve threads with the ForwardToXero label | |
| var threads = label.getThreads(); | |
| Logger.log(`Total threads with label "${FORWARD_LABEL}": ${threads.length}`); | |
| // Exit early if no threads found - don't send report | |
| if (threads.length === 0) { | |
| Logger.log("No threads found with the specified label. Exiting without sending report."); | |
| return; | |
| } | |
| var forwardedEmails = []; | |
| var skippedEmails = []; | |
| var actionTaken = DRY_RUN ? "SIMULATED" : "FORWARDED"; | |
| for (var i = 0; i < threads.length; i++) { | |
| var thread = threads[i]; | |
| var messages = thread.getMessages(); | |
| Logger.log(`Processing thread ${i + 1} with ${messages.length} message(s)...`); | |
| for (var j = 0; j < messages.length; j++) { | |
| var message = messages[j]; | |
| // Basic info for reporting | |
| var emailLink = `https://mail.google.com/mail/u/0/#inbox/${message.getId()}`; | |
| var formattedDate = Utilities.formatDate(message.getDate(), Session.getScriptTimeZone(), "yyyy-MM-dd"); | |
| var subject = message.getSubject(); | |
| var fromAddress = message.getFrom(); | |
| // For the forwarded info block, let's do a more verbose date/time | |
| var dateString = Utilities.formatDate(message.getDate(), Session.getScriptTimeZone(), "EEE, dd MMM yyyy 'at' HH:mm"); | |
| // We'll sanitize the fromAddress for HTML (in case of angle brackets) | |
| var sanitizedFrom = fromAddress | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">"); | |
| // Extract a display name for the 'From' address in the final report | |
| var senderNameMatch = fromAddress.match(/^(.*?)(?: <[^>]+>)?$/); | |
| var senderName = senderNameMatch && senderNameMatch[1] ? senderNameMatch[1] : "No Name"; | |
| // The message was originally sent to (possibly multiple addresses) | |
| // We'll see if ANY of them are in validAliases. | |
| var toField = message.getTo(); // e.g. "Jane Doe <jane@mydomain.com>, info@mydomain.com" | |
| // We'll also sanitize the toField for display | |
| var sanitizedTo = toField | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">"); | |
| // Attempt to find a match in validAliases. | |
| var fromAlias = null; | |
| // If there are multiple recipients, we split them. | |
| var possibleRecipients = toField.split(/,/).map(function(r){ | |
| return r.trim(); | |
| }); | |
| // For each recipient, see if we can parse out the address and match it to an alias. | |
| for (var r = 0; r < possibleRecipients.length; r++) { | |
| var rec = possibleRecipients[r]; | |
| // Attempt to parse the actual email out of something like "Name <email@domain>". | |
| var match = rec.match(/<([^>]+)>/); | |
| var recAddress = match ? match[1] : rec; | |
| // If this address is one of the valid aliases, we pick it. | |
| if (validAliases.includes(recAddress)) { | |
| fromAlias = recAddress; | |
| break; | |
| } | |
| } | |
| Logger.log(`Checking message ${j + 1}: Subject: "${subject}", From: "${fromAddress}"; Found alias in To: "${fromAlias}"`); | |
| if (message.isInInbox()) { | |
| var attachments = message.getAttachments(); | |
| Logger.log(`Message has ${attachments.length} attachment(s).`); | |
| // Check for PDF attachments | |
| var hasPDF = attachments.some(function(att) { | |
| var contentType = att.getContentType(); | |
| var fileName = att.getName().toLowerCase(); | |
| var isPDF = contentType.includes("pdf") || fileName.endsWith(".pdf"); | |
| Logger.log(`Checking attachment: "${att.getName()}" - MIME Type: ${contentType}, Is PDF: ${isPDF}`); | |
| return isPDF; | |
| }); | |
| Logger.log(`Final PDF detection result: ${hasPDF}`); | |
| if (hasPDF) { | |
| // If we found no matching alias in the To: field, skip. | |
| if (!fromAlias) { | |
| var reasonAlias = "No matching Gmail alias found in To: " + toField; | |
| Logger.log(reasonAlias); | |
| skippedEmails.push({ | |
| date: formattedDate, | |
| senderName: senderName, | |
| fromAddress: fromAddress, | |
| subject: subject, | |
| reason: reasonAlias, | |
| link: emailLink | |
| }); | |
| continue; | |
| } | |
| try { | |
| if (!DRY_RUN) { | |
| Logger.log(`Forwarding email using from=${fromAlias} to ${XERO_EMAIL}...`); | |
| // We'll build a custom HTML block with the original headers | |
| // Then append the original message HTML. | |
| var forwardedInfoBlock = [ | |
| "Forwarded from 'Auto-Forward-Invoice-to-Xero' Google Apps Script<br><br>", | |
| "---------- Forwarded message ---------<br>", | |
| `From: ${sanitizedFrom}<br>`, | |
| `Date: ${dateString}<br>`, | |
| `Subject: ${subject.replace(/</g, "<").replace(/>/g, ">")}<br>`, | |
| `To: ${sanitizedTo}<br><br>` | |
| ].join(""); | |
| var customHtmlBody = forwardedInfoBlock + message.getBody(); | |
| // Use message.forward(recipient, options) - preserves the thread | |
| message.forward(XERO_EMAIL, { | |
| from: fromAlias, | |
| subject: "Fwd: " + subject, | |
| body: "(Your client does not support HTML)", // fallback text | |
| htmlBody: customHtmlBody, | |
| attachments: attachments | |
| }); | |
| // Remove the ForwardToXero label, add ACCOUNTS_PAYABLE_LABEL, move thread to archive | |
| thread.removeLabel(label); | |
| thread.addLabel(accountsPayableLabel); | |
| thread.moveToArchive(); | |
| } else { | |
| Logger.log(`SIMULATED: Would have forwarded email from ${fromAlias} to ${XERO_EMAIL}.`); | |
| } | |
| // Record successful forward | |
| forwardedEmails.push({ | |
| date: formattedDate, | |
| senderName: senderName, | |
| fromAddress: fromAddress, | |
| subject: subject, | |
| link: emailLink | |
| }); | |
| } catch (error) { | |
| Logger.log(`Error forwarding email: ${error.message}`); | |
| skippedEmails.push({ | |
| date: formattedDate, | |
| senderName: senderName, | |
| fromAddress: fromAddress, | |
| subject: subject, | |
| reason: `Failed to forward email - ${error.message}`, | |
| link: emailLink | |
| }); | |
| } | |
| } else { | |
| // No PDF found | |
| skippedEmails.push({ | |
| date: formattedDate, | |
| senderName: senderName, | |
| fromAddress: fromAddress, | |
| subject: subject, | |
| reason: "No PDF attachment found", | |
| link: emailLink | |
| }); | |
| Logger.log("Skipping this message: No PDF attachment found."); | |
| } | |
| } else { | |
| // Not in Inbox | |
| skippedEmails.push({ | |
| date: formattedDate, | |
| senderName: senderName, | |
| fromAddress: fromAddress, | |
| subject: subject, | |
| reason: "Not in Inbox", | |
| link: emailLink | |
| }); | |
| Logger.log("Skipping this message: Not in inbox."); | |
| } | |
| } | |
| } | |
| // Build the final report | |
| var reportSubject = `Xero Forwarding Report (${actionTaken} Mode)`; | |
| var reportBody = `<p>The following emails were ${actionTaken}:</p><ul>` + | |
| forwardedEmails.map(function(email) { | |
| return `<li><a href="${email.link}" target="_blank">${email.date}</a> | <a href="mailto:${email.fromAddress}">${email.senderName}</a> | ${email.subject}</li>`; | |
| }).join("") + `</ul>`; | |
| if (skippedEmails.length > 0) { | |
| reportBody += `<p>The following emails were skipped:</p><ul>` + | |
| skippedEmails.map(function(email) { | |
| return `<li><a href="${email.link}" target="_blank">${email.date}</a> | <a href="mailto:${email.fromAddress}">${email.senderName}</a> | ${email.subject} | Reason: ${email.reason}</li>`; | |
| }).join("") + `</ul>`; | |
| } else { | |
| reportBody += `<p>No emails were skipped.</p>`; | |
| } | |
| // Send the report | |
| GmailApp.sendEmail( | |
| REPORT_RECIPIENT, | |
| reportSubject, | |
| "", | |
| { | |
| htmlBody: reportBody, | |
| noReply: true | |
| } | |
| ); | |
| Logger.log(reportBody); | |
| Logger.log("Script execution complete."); | |
| } |
Author
excellent script.
Suggest adding after line 87:
// Exit early if no threads found - don't send report
if (threads.length === 0) {
Logger.log("No threads found with the specified label. Exiting without sending report.");
return;
}
(before I made this change I was getting an empty report email every hour!)
Author
excellent script. Suggest adding after line 87:
// Exit early if no threads found - don't send report if (threads.length === 0) { Logger.log("No threads found with the specified label. Exiting without sending report."); return; }(before I made this change I was getting an empty report email every hour!)
Thanks @ajlowndes. I've added that improvement now. I only run it manually as needed at this stage so this issue hadn't come up. Sounds like you set up a trigger and got it running hourly. Hope it's working well for you. :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey Tom, I'm glad it was helpful for you.
I have added your suggestion to the gist. Thanks for taking the time to give me that feedback.