DEV Community

Explorer
Explorer

Posted on

📬 Lazy Approval in Joget: Complete Workflows via Email Polling (BeanShell)

Overview
Allowing users to approve or reject workflow requests directly from their email client—without logging into the portal—massively accelerates turnaround times. This implementation, commonly known as Lazy Approval, uses a comprehensive BeanShell script to poll an IMAPS inbox, parse incoming replies for intent (e.g., "Approved" or "Rejected"), authenticate the sender, and programmatically complete the corresponding Joget workflow assignment.

How It Works
The script acts as an automated, secure bridge between your mail server and the Joget Workflow Manager API:

  1. Secure Connection: It reads connection properties (host, port, credentials) from a dedicated listener configuration form (#form.j_ave_listener...#) and establishes a secure connection via the IMAPS protocol.
  2. Message Polling: It retrieves unread emails using Flag.SEEN, false, parses plain text, HTML, or multipart contents using Jsoup, and extracts the clean message body.
  3. Intent & ID Extraction: Using regex patterns, the script identifies the target Process ID from the subject line (e.g., looking for ID:...) and evaluates keywords in the body (APPROVE, REJECT) while filtering out standard email signatures or history blocks.
  4. Impersonation & Execution: It maps the sender's email address back to a valid Joget user account via the Directory Manager, temporarily binds that username to the current execution thread using setCurrentThreadUser, and calls workflowManager.assignmentComplete so the audit trail accurately records the true approver.

Where to Use in Joget
To deploy this automated polling mechanism, follow these standard navigation paths:

  • 💡 For Scheduled Workflow Polling: Workflow Builder --> Design Process --> Add Tool --> BeanShell Tool (configure this tool to run on a recurring SLA deadline or automated system trigger).
  • 💡 For Custom Background Schedulers: System Backend --> Manage Plugins --> Custom Scheduler (pointing the logic to pull from your listener configuration form).

Full Code

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.mail.BodyPart;
import javax.mail.Flags;
import javax.mail.Flags.Flag;
import javax.mail.Folder;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.Session;
import javax.mail.Store;
import javax.mail.event.MessageCountAdapter;
import javax.mail.event.MessageCountEvent;
import javax.mail.internet.InternetAddress;
import javax.mail.Address;
import javax.mail.search.FlagTerm;

import com.sun.mail.imap.IMAPFolder;
import com.sun.mail.imap.IMAPStore;

import org.joget.commons.util.LogUtil;
import org.joget.commons.util.StringUtil;
import org.joget.apps.app.model.AppDefinition;
import org.joget.apps.app.model.PackageActivityForm;
import org.joget.apps.app.service.AppUtil;
import org.joget.apps.app.service.AppService;
import org.joget.apps.form.model.FormData;
import org.joget.directory.model.User;
import org.joget.directory.model.service.DirectoryManager;
import org.joget.workflow.model.WorkflowAssignment;
import org.joget.workflow.model.service.WorkflowManager;
import org.joget.workflow.model.service.WorkflowUserManager;
import org.joget.plugin.base.ApplicationPlugin;
import org.joget.plugin.base.Plugin;
import org.joget.plugin.base.PluginManager;
import org.joget.plugin.property.model.PropertyEditable;
import org.joget.apps.form.model.FormRow;
import org.joget.apps.form.model.FormRowSet;
import org.apache.commons.lang3.StringUtils;

import java.sql.*;
import java.util.Calendar;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import javax.activation.DataHandler;
import javax.activation.DataSource;
import javax.activation.FileDataSource;
import javax.mail.Transport;
import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
import javax.mail.Authenticator;
import javax.mail.PasswordAuthentication;
import java.util.ArrayList;
import java.lang.String;
import java.util.Arrays;
import java.util.Scanner;
import org.apache.commons.lang3.ArrayUtils;
import java.util.stream.*; 

public FormRowSet activitiesLog = new FormRowSet();

public void pollEmail() {
  Properties properties = new Properties();
  if ("true".equals("#form.j_ave_listener.debug_imap?java#")) {
    properties.put("mail.debug", "true");
  }
  properties.put("mail.store.protocol", "imaps");
  properties.put("mail.imaps.host", "#form.j_ave_listener.host?java#");
  properties.put("mail.imaps.port", "#form.j_ave_listener.port?java#");
  properties.put("mail.imaps.timeout",(#form.j_ave_listener.timeout?java#*1000));

    Session session = Session.getInstance(properties);
    IMAPStore store = null;
    Folder inbox = null;

    int messageCount = 0;
    String errors = "";
    try {
        debug("Connect to IMAP for #form.j_ave_listener.email?java#");
        store = (IMAPStore) session.getStore("imap");
        store.connect("#form.j_ave_listener.email?java#","#form.j_ave_listener.password?java#");

        debug("IMAP connected for #form.j_ave_listener.email?java#");

        inbox = (IMAPFolder) store.getFolder("#form.j_ave_listener.folder?java#");
        if (null == inbox) {
            throw new Exception(" no  folder [#form.j_ave_listener.folder?java#] for user [#form.j_ave_listener.email?java#] on host [#form.j_ave_listener.host?java#]");
        }

        // opening found folder in read/write
        inbox.open(Folder.READ_WRITE);

        messageCount = inbox.getUnreadMessageCount();
        debug("Unread Messages: " + messageCount);
        debug("Filter email by exact subject text: #form.j_ave_listener.subject_filter?java#");
        debug("Filter email by regex subject text: #form.j_ave_listener.subject_pattern?java#");

        int count = 0;

        Message[] messages = inbox.search(new FlagTerm(new Flags(Flags.Flag.SEEN), false));
        for (Message message : messages) {
            try {
                if (count > #form.j_ave_listener.max_email?java#) {
                    debug("Email checking reach max email per check(#form.j_ave_listener.max_email?java#).");
                    break;
                }

                // get email details
                String subject = message.getSubject();
                if(!matchFilter(subject)) {
                    continue;
                }

                Object[] fromCollection = message.getFrom();
                String sender = fromCollection[0].toString();

                String content = "";
                String data =  getTextFromMessage(message);
                content=data;
                debug("multipart content: " + data);

                debug("--- Mail " + count + ": " + subject + " --- ");
                debug("Sender: " + sender);
                debug("Mail Content: " + content);

                // reset read flag
                message.setFlag(Flags.Flag.SEEN, true);
                content=content.replaceAll("<[^>]*>", "");
                content=content.replaceAll("[^}]*}", "");

                debug("Mail Content after removing HTML: " + content);
                // check for approval message
                parseEmail(sender, subject, content);

                count++;
            } catch (MessagingException e) {
                errors += e.getMessage() + "\n";
                LogUtil.error("App: eas - Poll Email tool", e, e.getMessage());
            }
        }

    } catch (Exception e) {
        errors += e.getMessage() + "\n";
        LogUtil.error("App: eas - Poll Email tool", e, "");
    } finally {
        try {
            if (inbox != null && inbox.isOpen()) {
                inbox.close(false);
            }
        } catch (Exception e) {
            // ignore
        }

        try {
            if (store != null && store.isConnected()) {
                store.close();
            }
        } catch (Exception e) {
            // ignore
        }
    }
    if (!errors.isEmpty()) {
        addError(messageCount, errors);
    }
    storeFormData("j_ave_log", "log", activitiesLog);
}

public void parseEmail(String sender, String subject, String content) {
    String processId = null;
    String activityId = null;
    Map variables = new HashMap();
    FormData formData = new FormData();
    String[] approvedcond={"Approved","approved"};
    String[] rejectedcond={"Rejected","rejected"};

     Pattern r = Pattern.compile("ID:(.*)");

      // For Process id
      Matcher m = r.matcher(subject);
      if (m.find( )) {
         String result=m.group(1);
         debug("processId =" + result);
         processId=result.trim();
      }else{
         System.out.println("NO Process id Found");
      }
      String[] contentParts=content.split("From:");
      String content1 = contentParts[0];
      content1 = content1.replace("_","");
      content1 = content1.toUpperCase();
      debug("Mail Content After splitting: "+content1);

       String value="";

       String[] ApproveKey = {"APPROVE","APPROVED","DONE","FINE","OKAY"};
       String[] RejectKey = {"REJECT","DECLINE","DECLINED","DENIED","DENY","REJECTED"};

       if(content1.indexOf("PAYMENT DETAILS")!=-1){
        content1=content1.substring(0,content1.indexOf("PAYMENT DETAILS"));
       }
       else if(content1.indexOf("SYSTEM ADMIN-JOGET")!=-1){
        content1=content1.substring(0,content1.indexOf("SYSTEM ADMIN-JOGET"));
       }

        for (String element: ApproveKey){
           if(content1.contains(element)){
            if(content1.contains("NOT APPROVE")|| content1.contains("NOT APPROVED")||content1.contains("REJECT")|| content1.contains("REJECTED")){
                value="Rejected";
                break;
            }else{
            value="Approved";
            break;}
           }else{
            for (String element1: RejectKey){
                if(content1.contains(element1)){
                 value="Rejected";
                 break;
                }else{
                    value="invalid response";
                }
             }
            }
        }

                    debug("approvalStatus="+value);
      formData.addRequestParameterValues("approvalStatus", new String[] {value});

      // For Approver Name
      User user = null;

    InternetAddress ia = new InternetAddress(sender);
    String email = ia.getAddress();
    DirectoryManager directoryManager = (DirectoryManager)AppUtil.getApplicationContext().getBean("directoryManager");
    Collection users = directoryManager.getUserList(email, null, null, 0, 1);
    if (!users.isEmpty()) {
        user = (User)users.iterator().next();
        String ApproverName=user.getFirstName() + " " + user.getLastName();
       variables.put("approverName",ApproverName); 
       debug("Approver Name Is "+ApproverName);
    }

    if(value=="Approved"){
       variables.put("LazyApprovalStatus","Approved by Lazy Approval"); 
    }else if (value=="Rejected"){
        variables.put("LazyApprovalStatus","Rejected By Lazy Approval");  
      }

    if (processId != null || activityId != null) {
        completeActivity(sender, processId, activityId, formData, variables, subject, content);
    }
}

public boolean matchFilter(String subject) {
    String filter = "#form.j_ave_listener.subject_filter?java#";
    if (!filter.isEmpty() && !filter.startsWith("#form.j_ave_listener.subject_filter")) {
        try {
            debug("Subject filter for ("+subject+").");
            if ("true".equals("#form.j_ave_listener.subject_filter_regex?java#")) {
                if (subject.matches("#form.j_ave_listener.subject_filter?java#")) {
                    debug("Found a match using regex subject filter");
                    return true;
                }
            } else if (subject.contains("#form.j_ave_listener.subject_filter?java#")) {
                debug("Found a match using exact subject filter");
                return true;
            } else {
                debug("No match found on subject filter.");
            }
        } catch (Exception ex) {
            debug("Subject filter error for ("+subject+"). " + ex.getMessage());
        }
        return false;
    } else {
        return true;
    }
}

public void completeActivity(String sender, String processId, String activityId, FormData formData, Map variables, String subject, String message) {
    String username = getUsername(sender);
     debug("Inside Complete Activity Method");
     debug("Username"+username);
    if (username != null) {
        AppService appService = (AppService)AppUtil.getApplicationContext().getBean("appService");
        WorkflowManager workflowManager = (WorkflowManager)AppUtil.getApplicationContext().getBean("workflowManager");
        WorkflowUserManager workflowUserManager = (WorkflowUserManager)AppUtil.getApplicationContext().getBean("workflowUserManager");
        String currentUsername = workflowUserManager.getCurrentUsername();
        debug("currentUserName: "+currentUsername);
        try {
            // set current user
            workflowUserManager.setCurrentThreadUser(username);

            // find assignment
            WorkflowAssignment assignment = null;
            if (activityId != null) {
            debug("is activity id null");
                assignment = workflowManager.getAssignment(activityId);
            }
            if (processId != null) {
                debug("is process id null");
                assignment = workflowManager.getAssignmentByProcess(processId);
                debug("Assignment ="+ assignment);
                debug("process id ="+ processId);
            }

            if (assignment != null) {
                AppDefinition currentAppDef = AppUtil.getCurrentAppDefinition();

                try {
                    String assignmentId = assignment.getActivityId();
                    AppDefinition appDef = appService.getAppDefinitionForWorkflowActivity(assignmentId);

                    activityId = assignment.getActivityId();
                    processId = assignment.getProcessId();

                    //if has form data to submit
                    if (!formData.getRequestParams().isEmpty()) {
                        PackageActivityForm activityForm = appService.viewAssignmentForm(appDef, assignment, formData, "", "");
                        if (activityForm != null && activityForm.getForm() != null) {
                            debug("Submit Form for assignment: " + assignmentId + " " + formData.getRequestParams());
                            appService.submitForm(activityForm.getForm(), formData, false);
                        }
                    }

                    debug("assignmentComplete: " + assignmentId + " " + variables);
                    if (!assignment.isAccepted()) {
                        workflowManager.assignmentAccept(assignmentId);
                    }
                    workflowManager.assignmentComplete(assignmentId, variables);

                    sendAutoReply(sender, subject);
                } finally {
                    AppUtil.setCurrentAppDefinition(currentAppDef);
                }

                addActivityLog(sender, processId, activityId, subject, message, variables, formData.getRequestParams());
            } else {
                debug("assignment not found for process(" + processId + ") or activityId(" + activityId + ")");
            }

        } finally {
            workflowUserManager.setCurrentThreadUser(currentUsername);
        }
    } else {
        debug("No user found for sender : " + sender);
    }
}

public String getUsername(String sender) {
    User user = null;
    InternetAddress ia = new InternetAddress(sender);
    String email = ia.getAddress();
    DirectoryManager directoryManager = (DirectoryManager)AppUtil.getApplicationContext().getBean("directoryManager");
    Collection users = directoryManager.getUserList(email, null, null, 0, 1);
    if (!users.isEmpty()) {
        user = (User)users.iterator().next();
        return user.getUsername();
    }
    return null;
}

public void sendAutoReply(String sender, String subject) {
    if ("true".equals("#form.j_ave_listener.auto_reply?java#")) {
        sendEmail(sender, "Re: " + subject, "#form.j_ave_listener.smtp_message?java#");
    }
}

public void sendEmail(String email, String subject, String message) {
    PluginManager pluginManager = (PluginManager) AppUtil.getApplicationContext().getBean("pluginManager");
    Plugin plugin = pluginManager.getPlugin("org.joget.apps.app.lib.EmailTool");
    Map propertiesMap = new HashMap();
    propertiesMap.put("pluginManager", pluginManager);

    propertiesMap.put("host", "#form.j_ave_listener.smtp_host?java#");
    propertiesMap.put("port", "#form.j_ave_listener.smtp_port?java#");
    propertiesMap.put("from", "#form.j_ave_listener.smtp_email?java#");
    propertiesMap.put("username", "#form.j_ave_listener.smtp_email?java#");
    propertiesMap.put("password", "#form.j_ave_listener.smtp_password?java#");
    propertiesMap.put("security", "#form.j_ave_listener.smtp_security?java#");
    propertiesMap.put("toSpecific", email);
    propertiesMap.put("subject", subject);
    propertiesMap.put("message", message);

    ApplicationPlugin emailTool = (ApplicationPlugin) plugin;
    ((PropertyEditable) emailTool).setProperties(propertiesMap);
    emailTool.execute(propertiesMap);
}

public void addError(int messageCount, String errors) {
    FormRowSet rows = new FormRowSet();
    FormRow row = new FormRow();
    row.setProperty("listener_id", "#form.j_ave_listener.uid?java#");
    row.setProperty("count", Integer.toString(messageCount));
    row.setProperty("errors", errors);
    Date now = new Date();
    row.setDateCreated(now);
    row.setDateModified(now);
    rows.add(row);

    storeFormData("j_ave_log_error", "errorLog", rows);
}

public void addActivityLog(String sender, String processId, String activityId, String subject, String message, Map variables, Map formData) {
    FormRow row = new FormRow();
    row.setProperty("listener_id", "#form.j_ave_listener.uid?java#");
    row.setProperty("sender", sender);
    row.setProperty("processId", processId);
    row.setProperty("activityId", activityId);
    row.setProperty("subject", subject);
    row.setProperty("message", message);
    row.setProperty("variables", "Variables: \n" + variables.toString() + "\n\nData: \n" + formData.toString());

    Date now = new Date();
    row.setDateCreated(now);
    row.setDateModified(now);

    activitiesLog.add(row);
}

public void storeFormData(String tableName, String formId, FormRowSet rows) {
    AppService appService = (AppService) AppUtil.getApplicationContext().getBean("appService");
    appService.storeFormData(formId, tableName, rows, null);
}

public void debug(String msg) {
    if ("true".equals("#form.j_ave_listener.debug?java#")) {
        LogUtil.info("App: Approval Via Email System", msg);
    }
}

public String getTextFromMessage(Message message) throws MessagingException, IOException {
    String result = "";
    debug("message is "+message.getContentType());
    if (message.isMimeType("text/plain")) {
        result = message.getContent().toString();
    } else if (message.isMimeType("multipart/*")) {
        MimeMultipart mimeMultipart = (MimeMultipart) message.getContent();
        result = getTextFromMimeMultipart(mimeMultipart);
    }
    else if (message.isMimeType("text/html")) {
           String html = (String)message.getContent();
           result = result + "\n" + org.jsoup.Jsoup.parse(html).text();
            debug("text/html "+message.getContentType());
        }
    return result;
}

public String getTextFromMimeMultipart(MimeMultipart mimeMultipart)  throws MessagingException, IOException{
    String result = "";
    int count = mimeMultipart.getCount();
    debug("Total count is "+count.toString());
    for (int i = 0; i < count; i++) {
        BodyPart bodyPart = mimeMultipart.getBodyPart(i);
        if (bodyPart.isMimeType("text/plain")) {
           result = result + "\n" + bodyPart.getContent();
            debug("text/plain => "+bodyPart.getContentType());
           break; 
        } 
        else if (bodyPart.isMimeType("text/html")) {
           String html = (String)bodyPart.getContent();
           result = result + "\n" + org.jsoup.Jsoup.parse(html).text();
            debug("text/html "+bodyPart.getContentType());
        }
        else if (bodyPart.getContent() instanceof MimeMultipart){
             debug("Multipart "+bodyPart.getContentType());
             result = result + getTextFromMimeMultipart((MimeMultipart)bodyPart.getContent());
        }
    }
    return result;
  }

pollEmail();

Enter fullscreen mode Exit fullscreen mode

Example Use Cases

  • 💡 Executive Requisitions: C-level executives completing urgent purchase order approvals via a quick "Approved" reply directly from their mobile devices.
  • 💡 Field Service Sign-offs: Off-site team managers confirming task completions without needing to launch a corporate VPN or log into the web interface.
  • 💡 Helpdesk Escalations: Support agents resolving or reopening tracked items straight from their email correspondence.

Customization Tips

  • ⚙️ Keyword Dictionaries: Adjust the ApproveKey and RejectKey string arrays in the parseEmail method to include custom local phrases or organizational shorthand (e.g., "AGREED", "PROCEED").
  • ⚙️ Truncation Rules: Ensure custom email footers, legal disclaimers, or ongoing thread titles (like "PAYMENT DETAILS") are properly accounted for in the string truncation logic so they do not trigger false rejections.
  • ⚙️ Thread Context Reset: Always guarantee that workflowUserManager.setCurrentThreadUser(currentUsername) executes inside the finally block to securely revert thread ownership after completing the assignment.

Key Benefits

  • Frictionless UX: Completely removes portal login friction, dramatically reducing task stalling.
  • Accurate Auditing: By dynamically resolving the sender's address to their registered Joget username, the system enforces compliance and maintains accurate approval records.
  • Robust Content Parsing: Handles plain text, rich HTML, and complex multipart MIME attachments seamlessly using Jsoup.

🛡️ Security Note
Because this implementation authenticates users based on their incoming email headers, your organization's mail server must enforce strict SPF/DKIM validation to prevent sender address spoofing. Maintain generic mapping placeholders as shown (#form.j_ave_listener...#) to prevent hardcoding plain-text credentials in your scripts.

Final Thoughts
Integrating email-driven actions shifts your workflow engine from a passive queue into an active, responsive platform. Mastering this programmatic bridge ensures critical business transactions continue moving forward effortlessly.

Top comments (0)