What?

Associate an incoming supplier reply with a Purchase Order so it appears in that order's Emails related list.

Why?

The outgoing email is already associated correctly. The supplier's reply appears in the Contact's email history, but is missing from the Purchase Order. The association can also fail when the reply contains inline images.

How?

Read the reply from the Contact, download and re-upload its inline images, replace their references in the HTML, then associate the email with the Purchase Order.

1. Set up the connection

  • Replace YOUR_CRM_CONNECTION with the client's CRM connection name. It needs access to emails, the relevant modules and file uploads.
  • Change https://www.zohoapis.eu if the client uses another data centre.
  • For the automation, replace YOUR_PO_REFERENCE_FIELD with the Purchase Order reference field's API name.

2. Test the association

Pass the supplier's Contact ID and the destination Purchase Order ID as String arguments. This test uses the first email returned for the Contact; check that it is the reply you want.

string standalone.fn_PurchaseOrders_AssociateContactEmail(String p_ContactID,String p_PurchaseOrderID)
{
v_ApiBase = "https://www.zohoapis.eu/crm/v8";
v_ContactID = ifnull(p_ContactID,"").trim();
v_PurchaseOrderID = ifnull(p_PurchaseOrderID,"").trim();
if(v_ContactID == "" || v_PurchaseOrderID == "")
{
	return "Supply both the Contact ID and target Purchase Order ID.";
}
// Fetch the first email from the Contact.
r_Emails = invokeurl
[
	url :v_ApiBase + "/Contacts/" + v_ContactID + "/Emails"
	type :GET
	connection:"YOUR_CRM_CONNECTION"
];
l_Emails = ifnull(r_Emails.get("Emails"),List());
if(l_Emails.isEmpty())
{
	return "No source email returned: " + r_Emails.toString();
}
m_Email = l_Emails.get(0);
v_MessageID = m_Email.get("message_id");
m_EmailOwner = ifnull(m_Email.get("owner"),Map());
v_EmailUserID = m_EmailOwner.get("id");
if(v_MessageID == null || v_EmailUserID == null)
{
	return "The source email is missing its message ID or owner ID.";
}
// Fetch the original HTML using the owner of the selected email.
m_EmailViewParameters = Map();
m_EmailViewParameters.put("user_id",v_EmailUserID);
r_Content = invokeurl
[
	url :v_ApiBase + "/Contacts/" + v_ContactID + "/Emails/" + v_MessageID
	type :GET
	parameters:m_EmailViewParameters
	connection:"YOUR_CRM_CONNECTION"
];
l_EmailContent = ifnull(r_Content.get("Emails"),List());
if(l_EmailContent.isEmpty())
{
	return "No email content returned: " + r_Content.toString();
}
m_EmailDetails = l_EmailContent.get(0);
v_EmailContent = ifnull(m_EmailDetails.get("content"),"");
if(v_EmailContent == "")
{
	return "The source email has no content.";
}
// Normalize the source email's backslash image references.
v_EmailContent = v_EmailContent.replaceAll("crm[^A-Za-z0-9]+img_id:","crm/img_id:",false);
// Collect distinct inline image IDs from quoted HTML attributes.
// Handles both single and double quotes, including repeated images.
l_InlineImageIDs = List();
l_ImageParts = v_EmailContent.toList("crm/img_id:");
v_PartIndex = 0;
for each  v_ImagePart in l_ImageParts
{
	if(v_PartIndex > 0)
	{
		v_EndIndex = v_ImagePart.indexOf("\"");
		v_SingleQuoteIndex = v_ImagePart.indexOf("'");
		if(v_SingleQuoteIndex >= 0)
		{
			if(v_EndIndex < 0 || v_SingleQuoteIndex < v_EndIndex)
			{
				v_EndIndex = v_SingleQuoteIndex;
			}
		}
		if(v_EndIndex <= 0)
		{
			return "Could not extract an inline image ID from the source HTML.";
		}
		v_ImageID = v_ImagePart.subString(0,v_EndIndex);
		if(l_InlineImageIDs.contains(v_ImageID) == false)
		{
			l_InlineImageIDs.add(v_ImageID);
		}
	}
	v_PartIndex = v_PartIndex + 1;
}
info "Distinct inline images found: " + l_InlineImageIDs.size();
for each  v_ImageID in l_InlineImageIDs
{
	// 1. Download the original inline image.
	m_ImageDownloadParameters = Map();
	m_ImageDownloadParameters.put("message_id",v_MessageID);
	m_ImageDownloadParameters.put("user_id",v_EmailUserID);
	m_ImageDownloadParameters.put("id",v_ImageID);
	r_InlineImageFile = invokeurl
	[
		url :v_ApiBase + "/Contacts/" + v_ContactID + "/Emails/actions/download_inline_images"
		type :GET
		parameters:m_ImageDownloadParameters
		connection:"YOUR_CRM_CONNECTION"
	];
	if(r_InlineImageFile.isFile() == false)
	{
		return "Inline image download failed. Image ID: " + v_ImageID;
	}
	// 2. Upload as an inline image to ZFS.
	// files sends multipart/form-data; do not add the JSON header here.
	r_InlineImageFile.setParamName("file");
	r_Upload = invokeurl
	[
		url :v_ApiBase + "/files?type=inline"
		type :POST
		files:r_InlineImageFile
		connection:"YOUR_CRM_CONNECTION"
	];
	l_UploadResults = ifnull(r_Upload.get("data"),List());
	if(l_UploadResults.isEmpty())
	{
		return "Inline image upload failed: " + r_Upload.toString();
	}
	m_UploadResult = l_UploadResults.get(0);
	if(m_UploadResult.get("code") != "SUCCESS")
	{
		return "Inline image upload failed: " + m_UploadResult.toString();
	}
	m_UploadDetails = ifnull(m_UploadResult.get("details"),Map());
	v_ZFSFileID = ifnull(m_UploadDetails.get("id"),"");
	if(v_ZFSFileID == "")
	{
		return "The inline image upload returned no file ID: " + r_Upload.toString();
	}
	// 3. Replace every occurrence using the new ZFS ID.
	// Include the closing quote to avoid matching part of another ID.
	v_OldImageReference = "crm/img_id:" + v_ImageID;
	v_NewImageReference = "crm/img_id:" + v_ZFSFileID;
	v_EmailContent = v_EmailContent.replaceAll(v_OldImageReference + "\"",v_NewImageReference + "\"",true);
	v_EmailContent = v_EmailContent.replaceAll(v_OldImageReference + "'",v_NewImageReference + "'",true);
}
// Build the association payload after replacing all inline images.
m_AssociationEmail = Map();
m_AssociationEmail.put("from",m_Email.get("from"));
m_AssociationEmail.put("to",m_Email.get("to"));
m_AssociationEmail.put("subject",m_Email.get("subject"));
m_AssociationEmail.put("content",v_EmailContent);
m_AssociationEmail.put("mail_format","html");
m_AssociationEmail.put("date_time",m_Email.get("time"));
m_AssociationEmail.put("sent",m_Email.get("sent"));
// Zoho documents this field as the original email header's Message-ID.
m_AssociationEmail.put("original_message_id",v_MessageID);
l_AssociationEmails = List();
l_AssociationEmails.add(m_AssociationEmail);
m_AssociationRequest = Map();
m_AssociationRequest.put("Emails",l_AssociationEmails);
m_JsonHeaders = Map();
m_JsonHeaders.put("Content-Type","application/json");
// Associate the email with the Purchase Order.
r_Associate = invokeurl
[
	url :v_ApiBase + "/Purchase_Orders/" + v_PurchaseOrderID + "/actions/associate_email"
	type :POST
	parameters:m_AssociationRequest.toString()
	headers:m_JsonHeaders
	connection:"YOUR_CRM_CONNECTION"
];
info r_Associate;
return r_Associate.toString();
}

3. Run it when a reply arrives

Create an email workflow for Contacts » Incoming email » Received. Add fn_PurchaseOrders_AssociateSupplierReplies and map:

  • p_Subject (String): the incoming email's subject.
  • p_ContactID (Integer): the supplier Contact's record ID.

The subject selects the order using the configured PO reference, for example PO - DEMO - JOB-DEMO. Adapt the subject parsing to the client's format.

The function checks the first 10 emails returned for the Contact and processes received messages only. It makes no requests for older pages.

Set v_EnableJobFallback to false for Purchase Orders only. Set v_EnableTextFallback to false to prevent a labelled text copy when HTML association fails.

void automation.fn_PurchaseOrders_AssociateSupplierReplies(String p_Subject,Int p_ContactID)
{
	v_BaseURL = "https://www.zohoapis.eu/crm/v8";
	v_POReferenceField = "YOUR_PO_REFERENCE_FIELD";
	v_EnableJobFallback = true;
	v_EnableTextFallback = true;
	l_Attempts = {1,2};
	m_JsonHeaders = Map();
	m_JsonHeaders.put("Content-Type","application/json");
	v_TargetModule = "";
	v_TargetRecordID = "";
	v_AmbiguousTarget = false;
	v_POReference = "";
	v_JobReference = "";
	v_Subject = ifnull(p_Subject,"").toUpperCase().trim();
	if(p_ContactID != null && v_Subject != "")
	{
		v_ContactID = p_ContactID.toString();
		v_POPosition = v_Subject.indexOf("PO - ");
		if(v_POPosition >= 0)
		{
			v_Remainder = v_Subject.subString(v_POPosition + 5);
			l_ReferenceParts = v_Remainder.toList(" - ");
			if(l_ReferenceParts.size() > 0)
			{
				v_POReference = l_ReferenceParts.get(0).trim();
				if(v_POReference.startsWith("JOB-"))
				{
					v_POReference = "";
				}
			}
		}
		v_JobPosition = v_Subject.indexOf("JOB-");
		if(v_JobPosition >= 0)
		{
			l_JobParts = v_Subject.subString(v_JobPosition).toList(" ");
			if(l_JobParts.size() > 0)
			{
				v_JobReference = l_JobParts.get(0).trim();
			}
		}
		// Resolve the PO first, then optionally check the Job reference.
		for each v_LookupAttempt in l_Attempts
		{
			if(v_TargetRecordID == "" && v_AmbiguousTarget == false && v_POReference != "")
			{
				try
				{
					l_SearchResults = zoho.crm.searchRecords("Purchase_Orders","(" + v_POReferenceField + ":equals:" + v_POReference + ")",1,100,Map(),"YOUR_CRM_CONNECTION");
					l_ExactMatches = List();
					for each m_SearchResult in l_SearchResults
					{
						if(ifnull(m_SearchResult.get(v_POReferenceField),"").toString().toUpperCase() == v_POReference)
						{
							l_ExactMatches.add(m_SearchResult);
						}
					}
					if(l_ExactMatches.size() == 1 && l_SearchResults.size() < 100)
					{
						v_TargetModule = "Purchase_Orders";
						v_TargetRecordID = l_ExactMatches.get(0).get("id").toString();
					}
					else if(l_ExactMatches.size() > 1 || l_SearchResults.size() >= 100)
					{
						v_AmbiguousTarget = true;
					}
				}
				catch(v_LookupError)
				{
					v_TargetRecordID = "";
				}
			}
		}
		for each v_LookupAttempt in l_Attempts
		{
			if(v_EnableJobFallback && v_TargetRecordID == "" && v_AmbiguousTarget == false && v_JobReference != "")
			{
				try
				{
					l_SearchResults = zoho.crm.searchRecords("Sales_Orders","(Subject:equals:" + v_JobReference + ")",1,100,Map(),"YOUR_CRM_CONNECTION");
					l_ExactMatches = List();
					for each m_SearchResult in l_SearchResults
					{
						l_SubjectTokens = ifnull(m_SearchResult.get("Subject"),"").toUpperCase().replaceAll("[^A-Z0-9_-]"," ",false).toList(" ");
						if(l_SubjectTokens.contains(v_JobReference))
						{
							l_ExactMatches.add(m_SearchResult);
						}
					}
					if(l_ExactMatches.size() == 1 && l_SearchResults.size() < 100)
					{
						v_TargetModule = "Sales_Orders";
						v_TargetRecordID = l_ExactMatches.get(0).get("id").toString();
					}
					else if(l_ExactMatches.size() > 1 || l_SearchResults.size() >= 100)
					{
						v_AmbiguousTarget = true;
					}
				}
				catch(v_LookupError)
				{
					v_TargetRecordID = "";
				}
			}
		}
	}
	if(v_TargetRecordID != "")
	{
		v_EmailEndpoint = v_BaseURL + "/Contacts/" + v_ContactID + "/Emails";
		l_ProcessedMessageIDs = List();
		l_PermanentErrors = {"OAUTH_SCOPE_MISMATCH","NO_PERMISSION","AUTHORIZATION_FAILED","INVALID_TOKEN","INVALID_MODULE","INVALID_URL_PATTERN"};
		v_ListReady = false;
		for each v_ListAttempt in l_Attempts
		{
			if(v_ListReady == false)
			{
				try
				{
					r_EmailList = invokeurl
					[
						url :v_EmailEndpoint
						type :GET
						connection:"YOUR_CRM_CONNECTION"
					];
					if(r_EmailList.get("Emails") != null)
					{
						v_ListReady = true;
					}
				}
				catch(v_ListError)
				{
					v_ListReady = false;
				}
			}
		}
		if(v_ListReady)
		{
			// The Emails API returns ten emails; do not request another page.
			l_EmailSummaries = r_EmailList.get("Emails");
			for each m_EmailSummary in l_EmailSummaries
			{
				try
				{
					v_MessageID = ifnull(m_EmailSummary.get("message_id"),"");
					if(m_EmailSummary.get("sent") == false && v_MessageID != "" && l_ProcessedMessageIDs.contains(v_MessageID) == false)
					{
						l_ProcessedMessageIDs.add(v_MessageID);
						m_EmailOwner = ifnull(m_EmailSummary.get("owner"),Map());
						v_EmailOwnerID = ifnull(m_EmailOwner.get("id"),"");
						m_ViewParameters = Map();
						if(v_EmailOwnerID != "")
						{
							m_ViewParameters.put("user_id",v_EmailOwnerID);
						}
						l_EmailDetails = List();
						for each v_ViewAttempt in l_Attempts
						{
							if(l_EmailDetails.isEmpty())
							{
								try
								{
									r_EmailDetails = invokeurl
									[
										url :v_EmailEndpoint + "/" + v_MessageID
										type :GET
										parameters:m_ViewParameters
										connection:"YOUR_CRM_CONNECTION"
									];
									l_EmailDetails = ifnull(r_EmailDetails.get("Emails"),List());
								}
								catch(v_ViewError)
								{
									l_EmailDetails = List();
								}
							}
						}
						v_ProcessEmail = false;
						if(l_EmailDetails.isEmpty() == false)
						{
							m_EmailDetails = l_EmailDetails.get(0);
							v_EmailContent = ifnull(m_EmailDetails.get("content"),"");
							v_EmailSubject = ifnull(m_EmailDetails.get("subject"),m_EmailSummary.get("subject"));
							v_EmailDateTime = ifnull(m_EmailDetails.get("sent_time"),m_EmailSummary.get("time"));
							m_EmailSender = ifnull(m_EmailDetails.get("from"),m_EmailSummary.get("from"));
							l_EmailRecipients = ifnull(m_EmailDetails.get("to"),m_EmailSummary.get("to"));
							if(ifnull(m_EmailDetails.get("sent"),false) == false && m_EmailSender != null && l_EmailRecipients != null && v_EmailDateTime != null)
							{
								v_ProcessEmail = true;
							}
						}
						if(v_ProcessEmail)
						{
							if(v_EmailContent == "")
							{
								v_EmailContent = "<p>The source email body was empty.</p>";
							}
							v_EmailContent = v_EmailContent.replaceAll("crm[^A-Za-z0-9]+img_id:","crm/img_id:",false);
							l_ImageIDs = List();
							v_PartNumber = 0;
							for each v_ImagePart in v_EmailContent.toList("crm/img_id:")
							{
								if(v_PartNumber > 0)
								{
									v_QuotePosition = v_ImagePart.indexOf("\"");
									v_SingleQuotePosition = v_ImagePart.indexOf("'");
									if(v_SingleQuotePosition >= 0 && (v_QuotePosition < 0 || v_SingleQuotePosition < v_QuotePosition))
									{
										v_QuotePosition = v_SingleQuotePosition;
									}
									if(v_QuotePosition > 0)
									{
										v_ImageID = v_ImagePart.subString(0,v_QuotePosition);
										if(l_ImageIDs.contains(v_ImageID) == false)
										{
											l_ImageIDs.add(v_ImageID);
										}
									}
								}
								v_PartNumber = v_PartNumber + 1;
							}
							for each v_ImageID in l_ImageIDs
							{
								v_NewImageID = "";
								for each v_ImageAttempt in l_Attempts
								{
									if(v_NewImageID == "")
									{
										try
										{
											m_ImageParameters = Map();
											m_ImageParameters.put("message_id",v_MessageID);
											m_ImageParameters.put("user_id",v_EmailOwnerID);
											m_ImageParameters.put("id",v_ImageID);
											r_InlineImageFile = invokeurl
											[
												url :v_EmailEndpoint + "/actions/download_inline_images"
												type :GET
												parameters:m_ImageParameters
												connection:"YOUR_CRM_CONNECTION"
											];
											if(r_InlineImageFile.isFile())
											{
												r_InlineImageFile.setParamName("file");
												r_ImageUpload = invokeurl
												[
													url :v_BaseURL + "/files?type=inline"
													type :POST
													files:r_InlineImageFile
													connection:"YOUR_CRM_CONNECTION"
												];
												l_UploadResults = ifnull(r_ImageUpload.get("data"),List());
												if(l_UploadResults.isEmpty() == false)
												{
													m_UploadResult = l_UploadResults.get(0);
													if(m_UploadResult.get("code") == "SUCCESS")
													{
														m_UploadDetails = ifnull(m_UploadResult.get("details"),Map());
														v_NewImageID = ifnull(m_UploadDetails.get("id"),"");
													}
												}
											}
										}
										catch(v_ImageError)
										{
											v_NewImageID = "";
										}
									}
								}
								if(v_NewImageID != "")
								{
									for each v_Quote in {"\"","'"}
									{
										v_EmailContent = v_EmailContent.replaceAll("crm/img_id:" + v_ImageID + v_Quote,"crm/img_id:" + v_NewImageID + v_Quote,true);
									}
								}
							}
							// Associate using the source message ID.
							m_AssociationEmail = Map();
							m_AssociationEmail.put("from",m_EmailSender);
							m_AssociationEmail.put("to",l_EmailRecipients);
							m_AssociationEmail.put("subject",v_EmailSubject);
							m_AssociationEmail.put("mail_format","html");
							m_AssociationEmail.put("date_time",v_EmailDateTime);
							m_AssociationEmail.put("sent",false);
							m_AssociationEmail.put("original_message_id",v_MessageID);
							v_AssociationDone = false;
							v_PermanentFailure = false;
							for each v_ContentMode in {"original","text"}
							{
								if(v_AssociationDone == false && v_PermanentFailure == false && (v_ContentMode == "original" || v_EnableTextFallback))
								{
									v_AttemptContent = v_EmailContent;
									if(v_ContentMode == "text")
									{
										v_TextContent = v_EmailContent.replaceAll("(?is)<(script|style)[^>]*>.*?</(script|style)>","",false);
										v_TextContent = v_TextContent.replaceAll("(?i)<br[^>]*>","\n",false).replaceAll("(?i)</(p|div|li)>","\n",false);
										v_TextContent = v_TextContent.replaceAll("<[^>]*>","",false).replaceAll("<","&lt;",true).replaceAll(">","&gt;",true);
										v_AttemptContent = "<p><em>Text copy: the original formatting or inline images could not be associated.</em></p><pre>" + v_TextContent + "</pre>";
									}
									m_AssociationEmail.put("content",v_AttemptContent);
									l_AssociationEmails = List();
									l_AssociationEmails.add(m_AssociationEmail);
									m_AssociationRequest = Map();
									m_AssociationRequest.put("Emails",l_AssociationEmails);
									for each v_AssociationAttempt in l_Attempts
									{
										if(v_AssociationDone == false && v_PermanentFailure == false)
										{
											try
											{
												r_Association = invokeurl
												[
													url :v_BaseURL + "/" + v_TargetModule + "/" + v_TargetRecordID + "/actions/associate_email"
													type :POST
													parameters:m_AssociationRequest.toString()
													headers:m_JsonHeaders
													connection:"YOUR_CRM_CONNECTION"
												];
												m_AssociationResult = r_Association;
												l_AssociationResults = ifnull(r_Association.get("Emails"),List());
												if(l_AssociationResults.isEmpty() == false)
												{
													m_AssociationResult = l_AssociationResults.get(0);
												}
												v_AssociationCode = ifnull(m_AssociationResult.get("code"),"UNKNOWN");
												if(v_AssociationCode == "SUCCESS" || v_AssociationCode == "DUPLICATE_DATA")
												{
													v_AssociationDone = true;
												}
												else if(l_PermanentErrors.contains(v_AssociationCode))
												{
													v_PermanentFailure = true;
												}
											}
											catch(v_AssociationError)
											{
												v_AssociationDone = false;
											}
										}
									}
								}
							}
						}
					}
				}
				catch(v_EmailError)
				{
					v_ProcessEmail = false;
				}
			}
		}
	}
}

Receive a test reply, then open the Purchase Order's Emails related list.

API references: Get Emails (10 per call), Associate Email, Download Inline Images, Upload Files.