I’ve been using Zapier as an intermediary to get around this, but after we ran out of zaps one month, it caused some problems. So, I got Claude to create a Gmail pack with a send email button that supports attaching files from Coda file columns. I’ll send the code to the Coda team to see if they will implement the feature if some of the work is done. But if they don’t, for anyone else looking for this feature, here is the pack code. You’ll need to create a Google Cloud Project and do the setup there for it to work, but there is loads of info about how to do that out there, and it’s dead easy.
Disclaimer: I’m not a coder; this code is fully AI-generated, but it works.
import * as coda from "@codahq/packs-sdk";
// ======================== INITIALISATION ==========================
export const pack = coda.newPack();
// Configure OAuth2 Authentication for Gmail
pack.setUserAuthentication({
type: coda.AuthenticationType.OAuth2,
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes: [
"https://www.googleapis.com/auth/gmail.send", // For sending emails
"https://www.googleapis.com/auth/userinfo.email", // For connection name
],
additionalParams: {
access_type: "offline", // Essential for refresh tokens
prompt: "consent", // Forces consent screen to ensure refresh token
include_granted_scopes: "true", // Maintains previously granted scopes
},
// Display the connected email account
getConnectionName: async function (context) {
try {
const response = await context.fetcher.fetch({
method: "GET",
url: "https://www.googleapis.com/oauth2/v2/userinfo",
cacheTtlSecs: 0,
});
const userInfo = response.body;
return userInfo.email || "Gmail Account";
} catch (error) {
console.error("Error fetching user info:", error);
return "Gmail Account";
}
},
});
// Declare the network domain
pack.addNetworkDomain('googleapis.com');
// ======================== HELPER FUNCTIONS ==========================
// Helper function to handle API requests with proper error handling
async function makeGmailApiRequest(context: coda.ExecutionContext, url: string, method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE" = "GET", body?: any) {
try {
const response = await context.fetcher.fetch({
method: method,
url: url,
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
cacheTtlSecs: 0, // Disable caching by default
});
return response.body;
} catch (error) {
// Let Coda attempt automatic token refresh by re-throwing 401 errors unchanged
if (coda.StatusCodeError.isStatusCodeError(error) && error.statusCode === 401) {
throw error; // Re-throw the original 401 error so Coda can refresh the token
}
// Handle rate limiting
if (error.statusCode === 429) {
throw new coda.UserVisibleError("Gmail API rate limit reached. Please try again in a few moments.");
}
// Handle other errors
console.error("API request error:", error);
throw new coda.UserVisibleError(`Gmail API error: ${error.message || "Unknown error occurred"}`);
}
}
// MIME type mappings (centralized)
const MIME_MAPPINGS = {
// Images
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
// Documents
'application/pdf': '.pdf',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.ms-excel': '.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-powerpoint': '.ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
// Text & Other
'text/plain': '.txt',
'text/html': '.html',
'text/css': '.css',
'text/javascript': '.js',
'application/json': '.json',
'application/xml': '.xml',
'application/zip': '.zip',
'text/csv': '.csv',
};
// Helper function to get file extension from MIME type
function getExtensionFromMimeType(mimeType: string): string {
return MIME_MAPPINGS[mimeType.toLowerCase() as keyof typeof MIME_MAPPINGS] || '.bin';
}
// Helper function to get MIME type from file extension
function getMimeTypeFromExtension(extension: string): string | null {
const extLower = extension.toLowerCase();
for (const [mime, ext] of Object.entries(MIME_MAPPINGS)) {
if (ext === `.${extLower}`) {
return mime;
}
}
return null;
}
// ======================== SEND EMAIL ACTION ==========================
// Helper function to create MIME message with attachments
async function createMimeMessage(
context: coda.ExecutionContext,
to: string[],
cc: string[],
bcc: string[],
subject: string,
body: string,
attachments: any[],
attachmentsFilenames: string[] = []
): Promise<string> {
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
// Build email headers
const headers = [
`To: ${to.join(", ")}`,
...(cc.length > 0 ? [`Cc: ${cc.join(", ")}`] : []),
...(bcc.length > 0 ? [`Bcc: ${bcc.join(", ")}`] : []),
`Subject: ${subject}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"",
""
].join("\r\n");
// Start building the MIME message
let mimeMessage = headers;
// Add text body
mimeMessage += `--${boundary}\r\n`;
mimeMessage += `Content-Type: text/plain; charset="UTF-8"\r\n`;
mimeMessage += `Content-Transfer-Encoding: 7bit\r\n\r\n`;
mimeMessage += `${body}\r\n\r\n`;
// Add attachments
if (attachments && attachments.length > 0) {
for (let i = 0; i < attachments.length; i++) {
const attachment = attachments[i];
// Handle different file formats from Coda
let fileUrl: string;
let fileName: string;
let mimeType: string;
if (typeof attachment === 'string') {
// Coda passed a URL string directly
fileUrl = attachment;
// Use attachment filename if provided, otherwise extract from URL
if (attachmentsFilenames.length > 0 && attachmentsFilenames[i]) {
fileName = attachmentsFilenames[i];
} else {
const urlParts = fileUrl.split('/');
fileName = urlParts[urlParts.length - 1] || `attachment_${i + 1}`;
}
// Don't add .bin yet - we'll determine the correct extension after checking headers
mimeType = "application/octet-stream";
} else if (attachment && typeof attachment === 'object') {
// Coda passed a file object
if (!attachment.url) {
throw new coda.UserVisibleError(`File at position ${i + 1} is missing URL. Please ensure the file is properly uploaded to Coda.`);
}
fileUrl = attachment.url;
// Use attachment filename if provided, otherwise use object's name
if (attachmentsFilenames.length > 0 && attachmentsFilenames[i]) {
fileName = attachmentsFilenames[i];
} else {
fileName = attachment.name || `attachment_${i + 1}`;
}
mimeType = attachment.type || attachment.mimeType || "application/octet-stream";
} else {
console.error(`Invalid attachment at index ${i}:`, attachment);
throw new coda.UserVisibleError(`Invalid attachment at position ${i + 1}. Please ensure all files are properly selected.`);
}
// Validate URL format
if (!fileUrl.startsWith('http://') && !fileUrl.startsWith('https://')) {
throw new coda.UserVisibleError(`Invalid file URL for "${fileName}". Please ensure the file is properly uploaded to Coda.`);
}
try {
// Fetch the file content from Coda
const fileResponse = await context.fetcher.fetch({
method: "GET",
url: fileUrl,
isBinaryResponse: true,
cacheTtlSecs: 0,
disableAuthentication: true, // Don't send Gmail credentials to codahosted.io
});
// Check if we got a response
if (!fileResponse || !fileResponse.body) {
throw new Error("No response body received");
}
// Get the correct MIME type from response headers
const responseHeaders = fileResponse.headers || {};
const contentType = responseHeaders['content-type'] || responseHeaders['Content-Type'];
if (contentType) {
mimeType = Array.isArray(contentType) ? contentType[0].split(';')[0].trim() : contentType.split(';')[0].trim(); // Remove any parameters like charset
}
// If still generic mime type, try to guess from filename extension
if (mimeType === "application/octet-stream") {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const guessedMime = getMimeTypeFromExtension(ext);
if (guessedMime) {
mimeType = guessedMime;
}
}
// Generate proper filename with correct extension (only if not using attachment filename)
if (typeof attachment === 'string' && (!attachmentsFilenames.length || !attachmentsFilenames[i])) {
if (!fileName.includes('.') || fileName.endsWith('.bin')) {
const extension = getExtensionFromMimeType(mimeType);
// If it's just a hash or ends with .bin, create a new name
if (!fileName.includes('.')) {
fileName = `attachment_${i + 1}${extension}`;
} else if (fileName.endsWith('.bin')) {
fileName = fileName.replace(/\.bin$/, extension);
}
}
}
console.log(`Attachment ${i + 1}: ${fileName} (${mimeType})`);
// Convert to base64
const base64Content = Buffer.from(fileResponse.body).toString("base64");
// Add attachment to MIME message
mimeMessage += `--${boundary}\r\n`;
mimeMessage += `Content-Type: ${mimeType}; name="${fileName}"\r\n`;
mimeMessage += `Content-Disposition: attachment; filename="${fileName}"\r\n`;
mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`;
// Add base64 content in chunks (76 chars per line as per MIME spec)
const chunks = base64Content.match(/.{1,76}/g) || [];
mimeMessage += chunks.join("\r\n");
mimeMessage += "\r\n\r\n";
} catch (error) {
console.error(`Failed to attach file ${fileName}:`, error);
throw new coda.UserVisibleError(`Failed to attach file "${fileName}". Error: ${error.message || "Unknown error"}`);
}
}
}
// Close the MIME message
mimeMessage += `--${boundary}--`;
return mimeMessage;
}
pack.addFormula({
name: "SendEmail",
description: "Send an email with optional attachments.",
parameters: [
coda.makeParameter({
type: coda.ParameterType.StringArray,
name: "to",
description: "Email recipients (To)",
}),
coda.makeParameter({
type: coda.ParameterType.String,
name: "subject",
description: "Email subject",
}),
coda.makeParameter({
type: coda.ParameterType.String,
name: "body",
description: "Email body",
}),
coda.makeParameter({
type: coda.ParameterType.StringArray,
name: "cc",
description: "CC recipients",
optional: true,
suggestedValue: [],
}),
coda.makeParameter({
type: coda.ParameterType.StringArray,
name: "bcc",
description: "BCC recipients",
optional: true,
suggestedValue: [],
}),
coda.makeParameter({
type: coda.ParameterType.FileArray,
name: "attachments",
description: "Files to attach to the email",
optional: true,
}),
coda.makeParameter({
type: coda.ParameterType.StringArray,
name: "attachmentsFilenames",
description: "Attachment filenames (optional). Use thisRow.Files.ToText().Split(',') to preserve original names. If provided, must match the number of attachments.",
optional: true,
}),
],
resultType: coda.ValueType.Object,
schema: coda.makeObjectSchema({
properties: {
id: { type: coda.ValueType.String },
threadId: { type: coda.ValueType.String },
labelIds: { type: coda.ValueType.Array, items: { type: coda.ValueType.String } },
status: { type: coda.ValueType.String },
},
}),
isAction: true,
cacheTtlSecs: 0,
execute: async function ([to, subject, body, cc, bcc, attachments, attachmentsFilenames], context) {
// Validate recipients
if (!to || to.length === 0) {
throw new coda.UserVisibleError("At least one recipient is required");
}
// Handle attachments - ensure it's an array
const fileAttachments = attachments || [];
const fileNames = attachmentsFilenames || [];
// Validate attachment filenames if provided
if (fileNames.length > 0 && fileNames.length !== fileAttachments.length) {
throw new coda.UserVisibleError(`Number of attachment filenames (${fileNames.length}) must match number of attachments (${fileAttachments.length})`);
}
// Debug logging (minimal)
console.log(`Processing ${fileAttachments.length} attachment(s)`);
if (fileAttachments.length > 0) {
// Validate attachment structure
for (let i = 0; i < fileAttachments.length; i++) {
const attachment = fileAttachments[i];
if (!attachment) {
throw new coda.UserVisibleError(`Attachment at position ${i + 1} is empty. Please ensure all files are properly selected.`);
}
// Validate based on whether it's a string (URL) or object
if (typeof attachment === 'string') {
if (!attachment.startsWith('http://') && !attachment.startsWith('https://')) {
throw new coda.UserVisibleError(`Invalid attachment URL at position ${i + 1}. Please ensure the file is properly uploaded to Coda.`);
}
} else if (typeof attachment === 'object') {
if (!('url' in attachment)) {
throw new coda.UserVisibleError(`Attachment at position ${i + 1} is missing URL. Please ensure the file is properly uploaded to Coda.`);
}
} else {
throw new coda.UserVisibleError(`Invalid attachment format at position ${i + 1}. Expected file URL or file object.`);
}
}
// Note: We can't easily check total attachment size without downloading them first
// Gmail will reject if over 25MB limit during send
}
// Create the MIME message
const mimeMessage = await createMimeMessage(
context,
to,
cc || [],
bcc || [],
subject,
body,
fileAttachments,
fileNames
);
// Convert to base64 for Gmail API
const encodedMessage = Buffer.from(mimeMessage).toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
// Send the email
const url = "https://www.googleapis.com/gmail/v1/users/me/messages/send";
const response = await makeGmailApiRequest(context, url, "POST", {
raw: encodedMessage,
});
return {
id: response.id,
threadId: response.threadId,
labelIds: response.labelIds || [],
status: "sent",
};
},
});