Gmail Pack: Add ability to attach a file

Hi,

It would be really great to be able to attach a file when sending an email with the Gmail Pack :slight_smile:

I am a freelance developer and I have a table where I store my client’s invoices.

I would like to be able to send them to the client with an action button on the row.

Thank you.

3 Likes

Really liked this idea. It would be very convenient then. Hoping for this one.

Hey there! Thanks so much for reaching out with this request. While it is not currently possible to attach files using the Gmail Pack, we are tracking a feature request for this! I will go ahead and formally add votes for each of you in our feature request tracker.

If anybody else would like their vote formally tracked for this feature request, please reach out to support@coda.io with answers to the following questions:

  1. What value does this new feature unlock? What does this help you accomplish?
  2. On a scale of 1 to 4 (1 = nice to have, 4 = absolutely must have), how badly would you say you need this feature?
  3. How would you describe what you’re trying to build with this feature? (Project tracker, blog, client facing resource, etc.)

Should this feature be built, we’ll be sure to reach out in this thread. We apologize for any inconvenience in the meantime!

4 Likes

Hi @Shaina_Torgerson

Strong vote for this, answers below,

  • In the immediate use case I’m sending payment receipts and acknowledgment letters, but I can think of numerous other similar use cases.
  • 4 out of 4 - I originally had this as 2 out of 4 because I thought I could just do it through make.com/zapier - but I’ve just discovered that this is not possible as Coda does not provide the file to make.com (only the filename), so right now I cannot find any way to work with files externally from a table, which is going to become a serious bottleneck for various workflows - please provide some mechanism to allow this as it seems unnecessarily limiting, which really isn’t the normal MO for Coda.
  • It’s a client interaction resource, sending emails to clients, stakeholders, etc
2 Likes

hi @Evan_Price1 , based on the contribution of @Paul_Danyliuk

you might have something I figured out…

You turn the URL into a hyperlink and this URL you can set as control value for a text box on the page you want to email or you use Format() to have variables filled out.

I tested it today with @joost_mineur to see if the would work towards non code users and it does. Maybe not exactly what you have in mind, but it may help until Coda provides a decent solution.

Cheers, Christiaan

1 Like

Thanks @Christiaan_Huizer

I did find this solution yesterday after writing this comment, and it does work with make.com (and presumably zapier).
My concern is that the hidden _Merge function is now being indicated as deprecated, so I’m quite concerned it will stop working soon.

I found a very good solution that, in addition to being able to attach several files, also allows you to send text as HTML and insert tables in the email

1 Like

Hi, @Fabio_Arbex.
Good to see a new solution for sending Gmail messages.
How does it work? Is it a Coda pack or a Google script or something eles?
This price is one time only or is it a monthly payment?

It uses some packs and also Google Script. The price is paid once. Thanks for your interest

Does it allows to receive attachments? If not, are there any plan?

You can send unlimited emails through Gmail, with rich text and multiple attachments without paying anything more.

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",
    };
  },
});
3 Likes