Judgement from Gmail

Say you’re writing an email, and want to make sure it’s not too spicy/unprofessional - in this world of LLMs you can get judgement whenever you want.

Google, for whatever reason, haven’t added proper Workspace Gemini support to Appscript so I’m using Cerebras for this example, since their inference speed is an order of magnitude faster than anything else and I don’t like spending time on emails

Steps to create

  • Navigate to https://script.google.com/home and click New project
  • Change the project name to something meaningful like IsThisEmailAGoodIdea?
  • Paste the Code.gs into the code editor
  • Click the settings cog on the left
    • tick the “Show ‘appsscript.json’ manifest file in editor”
    • in Script properties create a property CEREBRAS_API_KEY with the value of your Cerebras API key
  • Go back to the editor, and overwrite appsscript.json with the one below
  • Click “Save project to Drive”
  • Click “Deploy” -> “Test Deployments” then “Application(s): Gmail” -> Install

The code

To be clear, not sponsored. Just wanted fast inference so I didn’t have to wait to test this out

This code was mostly generated by claude-sonnet-4-20250514 via Kagi Assistant

Yes the caching strategy is pretty terrible, but good enough for this experiment

Code.gs

/**
 * Gmail Add-on for Professional Email Tone Checker
 * Uses Cerebras Qwen2.5 model via OpenAI-compatible API
 */

function onGmailCompose(e) {
  return createComposeActionCard();
}

function createComposeActionCard() {
  // Check if we have cached results
  const cachedResult = getCachedAnalysis();

  if (cachedResult) {
    return createResultCard(cachedResult, true); // true = minimized
  }

  const card = CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader()
      .setTitle('Professional Tone Checker')
      .setImageUrl('https://www.gstatic.com/images/branding/product/1x/gmail_48dp.png'))
    .addSection(CardService.newCardSection()
      .addWidget(CardService.newButtonSet()
        .addButton(CardService.newTextButton()
          .setText('Check Professional Tone')
          .setOnClickAction(CardService.newAction()
            .setFunctionName('checkEmailTone')))))
    .build();

  return [card];
}

function checkEmailTone(e) {
  try {
    const emailContent = getEmailContent(e);

    if (!emailContent.subject && !emailContent.body) {
      return createErrorCard('Please add content to your email before checking tone.');
    }

    // Create content hash for caching
    const contentHash = createContentHash(emailContent);

    // Check cache first
    let analysis = getCachedAnalysisByHash(contentHash);

    if (!analysis) {
      // Analyze with Cerebras if not cached
      analysis = analyzeWithCerebras(emailContent);

      // Cache the result
      cacheAnalysis(analysis, contentHash);
    }

    return createResultCard(analysis, false); // false = expanded

  } catch (error) {
    console.error('Error checking email tone:', error);
    return createErrorCard('Error analyzing email. Please try again.');
  }
}

function toggleResultView(e) {
  const isMinimized = e.parameters?.minimized === 'true';
  const cachedResult = getCachedAnalysis();

  if (cachedResult) {
    return createResultCard(cachedResult, !isMinimized);
  }

  return createComposeActionCard();
}

function clearCache(e) {
  clearUserCache();
  return createComposeActionCard();
}

function getEmailContent(e) {
  const subject = e.formInput?.subject || e.gmail?.subject || '';
  const body = e.formInput?.body || e.gmail?.body || '';

  return {
    subject: subject,
    body: body
  };
}

function createContentHash(emailContent) {
  const content = `${emailContent.subject}|${emailContent.body}`;
  return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, content)
    .map(byte => (byte + 256) % 256)
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');
}

function cacheAnalysis(analysis, contentHash) {
  const cache = Utilities.getUuid(); // Generate unique cache key per user session
  const cacheData = {
    analysis: analysis,
    contentHash: contentHash,
    timestamp: new Date().getTime()
  };

  try {
    CacheService.getUserCache().put('email_analysis', JSON.stringify(cacheData), 3600); // 1 hour
    CacheService.getUserCache().put('current_hash', contentHash, 3600);
  } catch (error) {
    console.error('Cache error:', error);
  }
}

function getCachedAnalysis() {
  try {
    const cached = CacheService.getUserCache().get('email_analysis');
    if (cached) {
      const cacheData = JSON.parse(cached);
      // Check if cache is less than 1 hour old
      if (new Date().getTime() - cacheData.timestamp < 3600000) {
        return cacheData.analysis;
      }
    }
  } catch (error) {
    console.error('Cache retrieval error:', error);
  }
  return null;
}

function getCachedAnalysisByHash(contentHash) {
  try {
    const cached = CacheService.getUserCache().get('email_analysis');
    const currentHash = CacheService.getUserCache().get('current_hash');

    if (cached && currentHash === contentHash) {
      const cacheData = JSON.parse(cached);
      return cacheData.analysis;
    }
  } catch (error) {
    console.error('Cache hash retrieval error:', error);
  }
  return null;
}

function clearUserCache() {
  try {
    CacheService.getUserCache().removeAll(['email_analysis', 'current_hash']);
  } catch (error) {
    console.error('Cache clear error:', error);
  }
}

function analyzeWithCerebras(emailContent) {
  const prompt = `Analyze this email for professional tone and business appropriateness:

Subject: ${emailContent.subject}
Body: ${emailContent.body}

Provide analysis in this exact JSON format:
{
  "score": [1-10 number],
  "tone": "[brief tone description]",
  "issues": ["issue1", "issue2"],
  "suggestions": ["suggestion1", "suggestion2"],
  "positives": ["positive1", "positive2"]
}

Focus on:
- Professional language usage
- Appropriate formality level
- Clear communication
- Respectful tone
- Business etiquette`;

  try {
    const response = callCerebrasAPI(prompt);
    return parseCerebrasResponse(response);
  } catch (error) {
    console.error('Cerebras API error:', error);
    throw new Error('Failed to analyze email with AI');
  }
}

function callCerebrasAPI(prompt) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('CEREBRAS_API_KEY');

  if (!apiKey) {
    throw new Error('Cerebras API key not configured');
  }

  const url = 'https://api.cerebras.ai/v1/chat/completions';

  const payload = {
    model: 'qwen-3-32b',
    messages: [
      {
        role: 'system',
        content: 'You are a professional communication expert. Analyze emails for business appropriateness and provide constructive feedback in JSON format only.'
      },
      {
        role: 'user',
        content: prompt
      }
    ],
    max_tokens: 1000,
    temperature: 0.7,
    top_p: 0.95
  };

  const options = {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    payload: JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(url, options);

  if (response.getResponseCode() !== 200) {
    const errorText = response.getContentText();
    console.error('API Error Response:', errorText);
    throw new Error(`API request failed: ${response.getResponseCode()}`);
  }

  return JSON.parse(response.getContentText());
}

function parseCerebrasResponse(response) {
  try {
    const content = response.choices[0].message.content.trim();

    let jsonMatch = content.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[0]);
    }

    return parseTextResponse(content);

  } catch (error) {
    console.error('Parse error:', error);
    throw new Error('Failed to parse AI response');
  }
}

function parseTextResponse(text) {
  const result = {
    score: 7,
    tone: 'Professional',
    issues: [],
    suggestions: [],
    positives: []
  };

  const scoreMatch = text.match(/score[:\s]*(\d+)/i);
  if (scoreMatch) {
    result.score = Math.min(10, Math.max(1, parseInt(scoreMatch[1])));
  }

  const toneMatch = text.match(/tone[:\s]*["\']?([^"\'\n]+)["\']?/i);
  if (toneMatch) {
    result.tone = toneMatch[1].trim();
  }

  const issuesSection = text.match(/issues?[:\s]*\[(.*?)\]/is);
  if (issuesSection) {
    result.issues = issuesSection[1].split(',').map(s => s.replace(/["\'\[\]]/g, '').trim()).filter(s => s);
  }

  const suggestionsSection = text.match(/suggestions?[:\s]*\[(.*?)\]/is);
  if (suggestionsSection) {
    result.suggestions = suggestionsSection[1].split(',').map(s => s.replace(/["\'\[\]]/g, '').trim()).filter(s => s);
  }

  const positivesSection = text.match(/positives?[:\s]*\[(.*?)\]/is);
  if (positivesSection) {
    result.positives = positivesSection[1].split(',').map(s => s.replace(/["\'\[\]]/g, '').trim()).filter(s => s);
  }

  return result;
}

function createResultCard(analysis, isMinimized = false) {
  const card = CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader()
      .setTitle('Professional Tone Analysis'));

  // Always show score and basic info
  const mainSection = CardService.newCardSection()
    .addWidget(CardService.newKeyValue()
      .setTopLabel('Professional Score')
      .setContent(`${analysis.score}/10`)
      .setIcon(CardService.Icon.STAR))
    .addWidget(CardService.newKeyValue()
      .setTopLabel('Tone Assessment')
      .setContent(analysis.tone || 'Professional')
      .setIcon(CardService.Icon.DESCRIPTION));

  card.addSection(mainSection);

  if (!isMinimized) {
    // Add detailed sections when expanded
    if (analysis.issues && analysis.issues.length > 0) {
      const issuesSection = CardService.newCardSection()
        .setHeader('⚠️ Areas for Improvement');

      analysis.issues.slice(0, 3).forEach(issue => {
        if (issue && issue.trim()) {
          issuesSection.addWidget(CardService.newTextParagraph()
            .setText(`• ${issue.trim()}`));
        }
      });

      card.addSection(issuesSection);
    }

    if (analysis.suggestions && analysis.suggestions.length > 0) {
      const suggestionsSection = CardService.newCardSection()
        .setHeader('💡 Suggestions');

      analysis.suggestions.slice(0, 3).forEach(suggestion => {
        if (suggestion && suggestion.trim()) {
          suggestionsSection.addWidget(CardService.newTextParagraph()
            .setText(`• ${suggestion.trim()}`));
        }
      });

      card.addSection(suggestionsSection);
    }

    if (analysis.positives && analysis.positives.length > 0) {
      const positivesSection = CardService.newCardSection()
        .setHeader('✅ Positive Aspects');

      analysis.positives.slice(0, 3).forEach(positive => {
        if (positive && positive.trim()) {
          positivesSection.addWidget(CardService.newTextParagraph()
            .setText(`• ${positive.trim()}`));
        }
      });

      card.addSection(positivesSection);
    }
  }

  // Control buttons
  const buttonSection = CardService.newCardSection();
  const buttonSet = CardService.newButtonSet();

  if (isMinimized) {
    buttonSet.addButton(CardService.newTextButton()
      .setText('Show Details')
      .setOnClickAction(CardService.newAction()
        .setFunctionName('toggleResultView')
        .setParameters({minimized: 'true'})));
  } else {
    buttonSet.addButton(CardService.newTextButton()
      .setText('Minimize')
      .setOnClickAction(CardService.newAction()
        .setFunctionName('toggleResultView')
        .setParameters({minimized: 'false'})));
  }

  buttonSet.addButton(CardService.newTextButton()
    .setText('Analyze Again')
    .setOnClickAction(CardService.newAction()
      .setFunctionName('checkEmailTone')))
    .addButton(CardService.newTextButton()
      .setText('Clear Cache')
      .setOnClickAction(CardService.newAction()
        .setFunctionName('clearCache')));

  buttonSection.addWidget(buttonSet);
  card.addSection(buttonSection);

  return card.build();
}

function createErrorCard(message) {
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader()
      .setTitle('Error'))
    .addSection(CardService.newCardSection()
      .addWidget(CardService.newTextParagraph()
        .setText(message))
      .addWidget(CardService.newButtonSet()
        .addButton(CardService.newTextButton()
          .setText('Try Again')
          .setOnClickAction(CardService.newAction()
            .setFunctionName('checkEmailTone')))))
    .build();
}

function testCerebrasAPI() {
  const testEmail = {
    subject: 'Test Email',
    body: 'Hey there! Just wanted to check if we can meet tomorrow. Let me know!'
  };

  try {
    const result = analyzeWithCerebras(testEmail);
    console.log('Test result:', result);
    return result;
  } catch (error) {
    console.error('Test failed:', error);
    return error.toString();
  }
}

appscript.json

{
  "timeZone": "Etc/UTC",
  "dependencies": {
    "enabledAdvancedServices": []
  },
  "exceptionLogging": "NONE",
  "runtimeVersion": "V8",
  "gmail": {
    "name": "Professional Tone Checker",
    "logoUrl": "https://www.gstatic.com/images/branding/product/1x/gmail_48dp.png",
    "contextualTriggers": [
      {
        "unconditional": {},
        "onTriggerFunction": "onGmailCompose"
      }
    ],
    "composeTrigger": {
      "selectActions": [
        {
          "text": "Check Professional Tone",
          "runFunction": "checkEmailTone"
        }
      ],
      "draftAccess": "METADATA"
    }
  },
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
    "https://www.googleapis.com/auth/gmail.addons.current.action.compose",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/gmail.addons.current.message.metadata"
  ]
}

How to use

  • Open a drafted email
  • click the vertical … more options button
  • Select “Check Professional Tone”
    • First time, you’ll be prompted to provide oauth permissions to the appscript script.
  • A card will show up, with guidance about your email

Next steps for users

Tune what exactly you want from the guidance by tweaking the prompt and display details in Code.gs


Copyright © 2025 Richard Finlay Tweed. All rights reserved. All views expressed are my own