Tag: AppleScript

  • Give Your Mac Mail a “Brain”: Automatic Fetching, Summarizing, and Reporting

    Give Your Mac Mail a “Brain”: Automatic Fetching, Summarizing, and Reporting

    Although many email clients or plugins already offer per-email summarization and translation, they often lack the ability to generate a global summary of all emails received that day. So, by combining the built-in Mail app on macOS with the Kouzi agent, I created the following workflow:

    “Use AppleScript to fetch email content from the Mail app, call the Kouzi agent to summarize it, and send the summarized results back to my own inbox—executed automatically every day.”

    The implementation can be broken down into three steps:

    1. Fetching email content
    2. Calling the Kouzi agent to summarize
    3. Setting up automation for scheduled execution

    The final effect: every day, you receive a neatly timed email containing the summarized content of all your unread messages, as shown below.

    Temporary older emails were used for testing, so the content appears dated.

    Fetching email content

    To avoid disrupting the reading flow, I’ve placed the full AppleScript used to fetch email content at the end of the article. Here, I’ll highlight the key ideas:

    1. I specified the iCloud account and the newsletter folder in the script, because that’s where all my subscription emails are collected.
    2. The script retrieves only unread emails and marks them as read after successfully extracting their content.
    3. The extracted content is saved as .txt files in a designated folder, using the email subject as the filename. This keeps data storage simple and decouples it from the later AI-processing steps, making debugging easier.
    4. For each email, the script obtains its message-ID and converts it into a URI of the format message://%3c'messageID'%3e. Clicking this URI launches Mail and opens the corresponding email in a pop-up window—very handy for jumping to the original message after reviewing the summary.

    At the end of this step, we have successfully fetched the content of all unread emails in the designated folder and saved them as text files.

    Using the Kouzi Agent to Summarize Email Content

    I use a JavaScript script to read the files saved in the previous step and call the Kouzi API to generate summaries. Of course, you can use DeepSeek or any other provider’s API. I chose Kouzi mainly for its asynchronous query feature. The full script is also included at the end of the article.

    Script Logic

    1. Read all files in the target directory: the script scans through every file in the specified folder.
    2. Filter for .txt files created today: only files created on the current day with a .txt extension are processed.
    3. Read file content and call the conversation API: for each valid file, the script reads the content and submits it to the Kouzi chat API.
    4. Poll until AI processing completes: using the chat/retrieve endpoint, the script continuously checks the processing status until it becomes completed.
    5. Fetch and organize AI results: through the chat/message/list endpoint, the script retrieves the AI’s response and formats it as HTML, accumulating everything into an allContents variable.
    6. Send an email once all files are processed: after processing all valid files, the script uses nodemailer to send an email containing all summaries generated that day.
    7. Email includes unread count and AI summaries: the subject is the current date, and the body includes the number of unread emails plus all AI-generated summaries.

    Kouzi Agent Setup and Invocation
    Create an agent inside the Kouzi workspace and configure the agent’s prompt. In the address bar of that setup page, you’ll find the Bot_ID, which is used as a parameter when calling the API.

    Kouzi API calls consist of three steps: initiating a conversation, checking the status, and retrieving the result.

    The first step, initiating a conversation, is essentially submitting a processing request to Kouzi.

    Here you need to provide your API Key, Bot_ID, and a custom User_ID. Kouzi will return a Chat_ID and a Conversation_ID as unique identifiers.

    The second step, checking the status, is like asking Kouzi whether the request has finished processing. Only after receiving a completed status should you fetch the result; otherwise, you may end up retrieving incomplete output.

    At this stage, you must provide the Chat_ID and Conversation_ID returned in the previous step.

    The third step, retrieving the result, is where you obtain the final output from the agent. You again supply the Chat_ID and Conversation_ID from step one to fetch the completed response.

    Configure Automation for Scheduled Execution

    With the AppleScript for fetching email content and the JavaScript script for calling the Kouzi agent ready, the next step is to create an automated workflow that runs them regularly.

    Open the Automator app and create a new Calendar Alarm workflow:

    Add a Run AppleScript action, paste in your AppleScript code, and remember to wrap it with the execution block, like this:

    on run {input, parameters}
        -- AppleScript code
    end run

    Add a Run Shell Script action, navigate to the folder containing your script, and execute the JavaScript file:

    cd /path/to/script
    /opt/homebrew/bin/node scriptName.js

    Add a Show Notification action to confirm the workflow has finished running.

    The Automator configuration looks like this:

    After creating it, open the Calendar app and make a new event. Set it to repeat daily, and under the Alert section choose:
    Custom → Open File → Other… → select your Automator workflow.
    This way, it will run automatically each day.

    With this, the entire setup is complete. However, be mindful of privacy and security when using it—it’s best to apply this workflow only to newsletter or public information emails, and avoid uploading private messages containing sensitive personal data.

    AppleScript for Fetching Email Content

    tell application "Mail"
    	-- Get the "newsletter" mailbox under the iCloud account
    	set theAccount to account "iCloud"
    	set theMailbox to mailbox "newsletter" of theAccount
    
    	-- Get all unread emails in the "newsletter" mailbox
    	set unreadMessages to (every message of theMailbox whose read status is false)
    
    	-- Iterate through all unread emails
    	repeat with eachMessage in unreadMessages
    		-- Use the email subject as part of the filename
    		set theSubject to subject of eachMessage
    		-- Get the email content and convert it to plain text
    		set theContent to content of eachMessage
    		set plainTextContent to do shell script "echo " & quoted form of theContent & " | textutil -convert txt -stdin -stdout"
    
    		-- Get the message-ID and generate a URI
    		set messageID to message id of eachMessage
    		set messageURL to "message://" & "%3c" & messageID & "%3e"
    	
    		-- Create a filename by removing illegal characters
    		set cleanSubject to do shell script "echo " & quoted form of theSubject & " | tr -d '\"/:<>?\\|+[]{};=,'"
    		set fileName to (cleanSubject & ".txt")
    	
    		-- Use the specified POSIX path and convert it to AppleScript path format
    		set savePath to POSIX file "path to save txt files"
    	
    		-- Full file path
    		set filePath to (savePath as string) & fileName
    	
    		-- Write the email content to the txt file at the specified path
    		try
    			set fileReference to open for access file filePath with write permission
    			write plainTextContent to fileReference starting at eof
    	
    			-- Append the email URI at the end of the file
    			write return & "emailURL=" & messageURL to fileReference starting at eof
    			close access fileReference
    		on error errMsg
    			close access file filePath
    			display dialog "Error: " & errMsg
    		end try
    	
    		-- Mark the email as read
    		set read status of eachMessage to true
    	end repeat
    end tell
    
    return input

    Script for Calling the Kouzi Agent

    const fs = require('fs');
    const path = require('path');
    const axios = require('axios');
    const nodemailer = require('nodemailer');
    
    const directoryPath = 'Directory path to store text files'; // The directory path to store text files
    const today = new Date().toLocaleDateString('zh-CN'); // Get system local date
    
    let allContents = ''; // Used to store all returned contents from the query API
    let fileCounter = 0; // Used for file counting
    let validFileCount = 0; // Valid file count
    let emailSent = false; // A flag to track whether the email has been sent
    
    // Read files under the specified directory
    fs.readdir(directoryPath, async (err, files) => {
      if (err) {
        console.error('Failed to read directory:', err);
        return;
      }
    
      const filePromises = files.map(file => processFile(file));
      await Promise.all(filePromises);
      checkAllFilesProcessed();
    });
    
    async function processFile(file) {
      const filePath = path.join(directoryPath, file);
    
      try {
        const stats = await fs.promises.stat(filePath);
        const fileCreationDate = stats.birthtime.toLocaleDateString('zh-CN'); // Get file creation date
        if (fileCreationDate === today && path.extname(file) === '.txt') {
          validFileCount++; // Increase valid file count
          const content = await fs.promises.readFile(filePath, 'utf8');
          await callAIAPI(content, file);
        }
      } catch (err) {
        console.error('Error while processing file:', err);
      }
    }
    
    // Use async/await to optimize async handling
    async function callAIAPI(content, fileName) {
      const apiUrl = 'https://api.coze.cn/v3/chat';
      const headers = getHeaders();
      
      // Choose bot_id depending on content length
      const botId = content.length > 32000 ? 'bot_id' : 'bot_id';
      
      const data = {
        bot_id: botId, // Dynamic bot_id
        user_id: '**',
        stream: false,
        auto_save_history: true,
        additional_messages: [
          {
            role: 'user',
            content: content,
            content_type: 'text'
          }
        ]
      };
    
      let attempts = 0;
      const maxAttempts = 5;
    
      while (attempts < maxAttempts) {
        try {
          const response = await axios.post(apiUrl, data, { headers, timeout: 5000 });
          console.log('API response:', response.data);
          const { id, conversation_id } = response.data.data;
          await new Promise(resolve => setTimeout(resolve, 1000)); // Add wait time: 1 second
          await pollConversationStatus(id, conversation_id, fileName);
          break;
        } catch (error) {
          attempts++;
          console.error(`API call error (attempt ${attempts}/${maxAttempts}):`, error);
          if (attempts >= maxAttempts) {
            console.error('Still failed after multiple attempts');
          }
        }
      }
    }
    
    // Extract headers function
    function getHeaders() {
      return {
        'Authorization': 'Bearer api_key',
        'Content-Type': 'application/json'
      };
    }
    
    // Poll conversation detail interface
    async function pollConversationStatus(chat_id, conversation_id, fileName) {
      const retrieveUrl = `https://api.coze.cn/v3/chat/retrieve?chat_id=${chat_id}&conversation_id=${conversation_id}`;
      const headers = getHeaders();
    
      let pollCount = 0;
      const maxPollCount = 120;
    
      return new Promise((resolve, reject) => {
        const intervalId = setInterval(async () => {
          pollCount++;
          if (pollCount > maxPollCount) {
            clearInterval(intervalId);
            reject(new Error(`Polling timeout, chat_id: ${chat_id}, conversation_id: ${conversation_id}`));
            return;
          }
    
          try {
            const response = await axios.get(retrieveUrl, { headers });
            // Cancel printing poll logs
            // console.log('Dialog detail API response:', response.data);
            if (response.data.data.status === 'completed') {
              clearInterval(intervalId);
              await queryAIAPI(chat_id, conversation_id, fileName);
              resolve();
            }
          } catch (error) {
            console.error('Dialog detail API error:', error);
            clearInterval(intervalId);
            reject(error);
          }
        }, 1000); // Poll every second
      });
    }
    
    // Call query interface
    async function queryAIAPI(chat_id, conversation_id, fileName) {
      const queryUrl = `https://api.coze.cn/v3/chat/message/list?chat_id=${chat_id}&conversation_id=${conversation_id}`;
      const headers = getHeaders();
    
      try {
        const response = await axios.get(queryUrl, { headers });
        console.log('Query API response:', response.data);
        const contents = response.data.data
          .filter(item => item.type === 'answer') // Filter items of type 'answer'
          .map(item => item.content)
          .join('\n');
    
        // Read last line of file to get MessageID
        const filePath = path.join(directoryPath, fileName);
        const fileContent = await fs.promises.readFile(filePath, 'utf8');
        const lastLine = fileContent.trim().split('\n').pop();
        const messageId = lastLine.split('=')[1]; // Extract MessageID
    
        fileCounter++; // Increase file count
        console.log(`File count: ${fileCounter}`);
        const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, ""); // Remove extension
        allContents += `<h3><a href="${messageId}">${fileCounter}. ${fileNameWithoutExt}</a></h3><br>${contents.replace(/\n/g, '<br>')}<br><br>`;
      } catch (error) {
        console.error('Query API error:', error);
      }
    }
    
    // Check if all files are processed
    function checkAllFilesProcessed() {
      if (fileCounter === validFileCount && !emailSent) {
        emailSent = true; // Mark email as sent
        sendEmail(allContents); // Call send email function
      }
    }
    
    // Send email
    function sendEmail(contents) {
      const transporter = nodemailer.createTransport({
        host: 'smtp.qq.com',
        port: 465,
        secure: true, // Use SSL
        auth: {
          user: 'Email address', // Your email address
          pass: 'Email authorization code' // Your email authorization code
        }
      });
    
      const mailOptions = {
        from: 'Email address', // Sender email address
        to: 'Email address',    // Recipient email address
        subject: `${today} Daily Email Summary`, // Email subject
        html: `Unread email count: ${validFileCount}<br><br>${contents}` // HTML content
      };
    
      transporter.sendMail(mailOptions, (error, info) => {
        if (error) {
          return console.error('Failed to send email:', error);
        }
        console.log('Email sent:', info.response);
      });
    }