This script helps you to monitor message flow in a NoSpamProxy environment using a PRTG custom PowerShell sensor.
This custom sensor contains the following five channels:
The default interval is five minutes. But you might want to change the interval as needed for your environment.
These channels can easily be modified and additional channels can be added as well.
NoSpamProxy is a powerful anti-spam gateway solution providing additonal functionality like centralized S/MIME and PGP encryption for on-premises and Exchange Online deployments.
PRTG is a industry standard system monitoring solution.
The script itself does not take any additional attributes and is called by PRTG probe.
To verify your setup, you easily execute the PowerShell script. It returns a Xml result.
PS C:\Scripts> .\Get-NoSpamProxyPrtgData.ps1 <prtg> <result> <channel>In/Out Success</channel> <value>0</value> <unit>Count</unit> </result> <result> <channel>Inbound Success</channel> <value>0</value> <unit>Count</unit> </result> <result> <channel>Outbound Success</channel> <value>0</value> <unit>Count</unit> </result> <result> <channel>Inbound PermanentlyBlocked</channel> <value>0</value> <unit>Count</unit> </result> <result> <channel>Inbound DeliveryPending</channel> <value>0</value> <unit>Count</unit> <limitmaxwarning>10</limitmaxwarning> <limitmode>1</limitmode> </result> </prtg>
The PRTG channel configuration
The following screenshot shows PRTG example graphs.
The custom PowerShell script must be saved to the following location of the PRTG probe:
[INSTALLPATH]\PRTG Network Monitor\Custom Sensors\EXEXML
Ensure to have the PowerShell execution policy set correctly. Otherwise the PRTG service won't be able to execute the PowerShell script.
Ensure that the service account used by the PRTG probe has access to the script and is a member of the NoSpamProxy Monitoring Administrators security group.
Additional credits go to Brian Addicks, https://github.com/brianaddicks/prtgshell
This is a wrap-up of an older post that had originally been published on my former website.
Even though that this post focusses on Exchange 2010 transport agents, you will get an understand on what is required to create an Exchange 2013/2016 aka Version 15 transport agent.
Writing your own transport agent for Exchange 2010 is not really complicated. With a Visual Studio C# Class project you are ready to go.
The follow picture shows the Visual Studio Solution as it has been used for the Message Modifier Solution.
Besides the C# class the solution contains the following Powershell script to simplify development and deployment:
The transport agent intercepts a message from a given sender address and performs the following actions:
// AttachmentModify // ---------------------------------------------------------- // Example for intercepting email messages in an Exchange 2010 transport queue // // The example intercepts messages sent from a configurable email address(es) // and checks the mail message for attachments have filename in to format // // WORKBOOK_{GUID} // // Changing the filename of the attachments makes it easier for the information worker // to identify the reports in the emails and in the file system as well. // Copyright (c) Thomas Stensitzki // ---------------------------------------------------------- using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Xml; // the lovely Exchange using Microsoft.Exchange.Data.Transport; using Microsoft.Exchange.Data.Transport.Smtp; using Microsoft.Exchange.Data.Transport.Email; using Microsoft.Exchange.Data.Transport.Routing; namespace SFTools.Messaging.AttachmentModify { #region Message Modifier Factory /// <summary> /// Message Modifier Factory /// </summary> public class MessageModifierFactory : RoutingAgentFactory { /// <summary> /// Instance of our transport agent configuration /// This is for a later implementation /// </summary> private MessageModifierConfig messageModifierConfig = new MessageModifierConfig(); /// <summary> /// Returns an instance of the agent /// </summary> /// <param name="server">The SMTP Server</param> /// <returns>The Transport Agent</returns> public override RoutingAgent CreateAgent(SmtpServer server) { return new MessageModifier(messageModifierConfig); } } #endregion #region Message Modifier Routing Agent /// <summary> /// The Message Modifier Routing Agent for modifying an email message /// </summary> public class MessageModifier : RoutingAgent { // The agent uses the fileLock object to synchronize access to the log file private object fileLock = new object(); /// <summary> /// The current MailItem the transport agent is handling /// </summary> private MailItem mailItem; /// <summary> /// This context to allow Exchange to continue processing a message /// </summary> private AgentAsyncContext agentAsyncContext; /// <summary> /// Transport agent configuration /// </summary> private MessageModifierConfig messageModifierConfig; /// <summary> /// Constructor for the MessageModifier class /// </summary> /// <param name="messageModifierConfig">Transport Agent configuration</param> public MessageModifier(MessageModifierConfig messageModifierConfig) { // Set configuration this.messageModifierConfig = messageModifierConfig; // Register an OnRoutedMessage event handler this.OnRoutedMessage += OnRoutedMessageHandler; } /// <summary> /// Event handler for OnRoutedMessage event /// </summary> /// <param name="source">Routed Message Event Source</param> /// <param name="args">Queued Message Event Arguments</param> void OnRoutedMessageHandler(RoutedMessageEventSource source, QueuedMessageEventArgs args) { lock (fileLock) { try { this.mailItem = args.MailItem; this.agentAsyncContext = this.GetAgentAsyncContext(); // Get the folder for accessing the config file string dllDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); // Fetch the from address from the current mail item RoutingAddress fromAddress = this.mailItem.FromAddress; Boolean boWorkbookFound = false; // We just want to modifiy subjects when we modified an attachement first #region External Receive Connector Example // CHeck first, if the mail item does have a ReceiveConnectorName property first to prevent ugly things to happen if (mailItem.Properties.ContainsKey("Microsoft.Exchange.Transport.ReceiveConnectorName")) { // This is just an example, if you want to do something with a mail item which has been received via a named external receive connector if (mailItem.Properties["Microsoft.Exchange.Transport.ReceiveConnectorName"].ToString().ToLower() == "externalreceiveconnectorname") { // do something fancy with the email } } #endregion RoutingAddress catchAddress; // Check, if we have any email addresses configured to look for if (this.messageModifierConfig.AddressMap.Count > 0) { // Now lets check, if the sender address can be found in the dictionary if (this.messageModifierConfig.AddressMap.TryGetValue(fromAddress.ToString().ToLower(), out catchAddress)) { // Sender address found, now check if we have attachments to handle if (this.mailItem.Message.Attachments.Count != 0) { // Get all attachments AttachmentCollection attachments = this.mailItem.Message.Attachments; // Modify each attachment for (int count = 0; count < this.mailItem.Message.Attachments.Count; count++) { // Get attachment Attachment attachment = this.mailItem.Message.Attachments[count]; // We will only transform attachments which start with "WORKBOOK_" if (attachment.FileName.StartsWith("WORKBOOK_")) { // Create a new filename for the attachment // [MODIFIED SUBJECT]-[NUMBER].[FILEEXTENSION] String newFileName = MakeValidFileName(string.Format("{0}-{1}{2}", ModifiySubject(this.mailItem.Message.Subject.Trim()), count + 1, Path.GetExtension(attachment.FileName))); // Change the filename of the attachment this.mailItem.Message.Attachments[count].FileName = newFileName; // Yes we have changed the attachment. Therefore we want to change the subject as well. boWorkbookFound = true; } } // Have changed any attachments? if (boWorkbookFound) { // Then let's change the subject as well this.mailItem.Message.Subject = ModifiySubject(this.mailItem.Message.Subject); } } } } } catch (System.IO.IOException ex) { // oops Debug.WriteLine(ex.ToString()); this.agentAsyncContext.Complete(); } finally { // We are done this.agentAsyncContext.Complete(); } } // Return to pipeline return; } /// <summary> /// Build a new subject, if the first 10 chars of the original subject are a valid date. /// We muste transform the de-DE format dd.MM.yyyy to yyyyMMdd for better sortability in the email client. /// </summary> /// <param name="MessageSubject">The original subject string</param> /// <returns>The modified subject string, if modification was possible</returns> private static string ModifiySubject(string MessageSubject) { string newSubject = String.Empty; if (MessageSubject.Length >= 10) { string dateCheck = MessageSubject.Substring(0, 10); DateTime dt = new DateTime(); try { // Check if we can parse the datetime if (DateTime.TryParse(dateCheck, out dt)) { // lets fetch the subject starting at the 10th character string subjectRight = MessageSubject.Substring(10).Trim(); // build a new subject newSubject = string.Format("{0:yyyyMMdd} {1}", dt, subjectRight); } } finally { // do nothing } } return newSubject; } /// <summary> /// Replace invalid filename chars with an underscore /// </summary> /// <param name="name">The filename to be checked</param> /// <returns>The sanitized filename</returns> private static string MakeValidFileName(string name) { string invalidChars = Regex.Escape(new string(Path.GetInvalidFileNameChars())); string invalidRegExStr = string.Format(@"[{0}]+", invalidChars); return Regex.Replace(name, invalidRegExStr, "_"); } } #endregion #region Message Modifier Configuration /// <summary> /// Message Modifier Configuration class /// </summary> public class MessageModifierConfig { /// <summary> /// The name of the configuration file. /// </summary> private static readonly string configFileName = "SFTools.MessageModify.Config.xml"; /// <summary> /// Point out the directory with the configuration file (= assembly location) /// </summary> private string configDirectory; /// <summary> /// The filesystem watcher to monitor configuration file updates. /// </summary> private FileSystemWatcher configFileWatcher; /// <summary> /// The from address /// </summary> private Dictionary<string, RoutingAddress> addressMap; /// <summary> /// Whether reloading is ongoing /// </summary> private int reLoading = 0; /// <summary> /// The mapping between domain to catchall address. /// </summary> public Dictionary<string, RoutingAddress> AddressMap { get { return this.addressMap; } } /// <summary> /// Constructor /// </summary> public MessageModifierConfig() { // Setup a file system watcher to monitor the configuration file this.configDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); this.configFileWatcher = new FileSystemWatcher(this.configDirectory); this.configFileWatcher.NotifyFilter = NotifyFilters.LastWrite; this.configFileWatcher.Filter = configFileName; this.configFileWatcher.Changed += new FileSystemEventHandler(this.OnChanged); // Create an initially empty map this.addressMap = new Dictionary<string, RoutingAddress>(); // Load the configuration this.Load(); // Now start monitoring this.configFileWatcher.EnableRaisingEvents = true; } /// <summary> /// Configuration changed handler. /// </summary> /// <param name="source">Event source.</param> /// <param name="e">Event arguments.</param> private void OnChanged(object source, FileSystemEventArgs e) { // Ignore if load ongoing if (Interlocked.CompareExchange(ref this.reLoading, 1, 0) != 0) { Trace.WriteLine("load ongoing: ignore"); return; } // (Re) Load the configuration this.Load(); // Reset the reload indicator this.reLoading = 0; } /// <summary> /// Load the configuration file. If any errors occur, does nothing. /// </summary> private void Load() { // Load the configuration XmlDocument doc = new XmlDocument(); bool docLoaded = false; string fileName = Path.Combine(this.configDirectory, MessageModifierConfig.configFileName); try { doc.Load(fileName); docLoaded = true; } catch (FileNotFoundException) { Trace.WriteLine("Configuration file not found: {0}", fileName); } catch (XmlException e) { Trace.WriteLine("XML error: {0}", e.Message); } catch (IOException e) { Trace.WriteLine("IO error: {0}", e.Message); } // If a failure occured, ignore and simply return if (!docLoaded || doc.FirstChild == null) { Trace.WriteLine("Configuration error: either no file or an XML error"); return; } // Create a dictionary to hold the mappings Dictionary<string, RoutingAddress> map = new Dictionary<string, RoutingAddress>(100); // Track whether there are invalid entries bool invalidEntries = false; // Validate all entries and load into a dictionary foreach (XmlNode node in doc.FirstChild.ChildNodes) { if (string.Compare(node.Name, "domain", true, CultureInfo.InvariantCulture) != 0) { continue; } XmlAttribute domain = node.Attributes["name"]; XmlAttribute address = node.Attributes["address"]; // Validate the data if (domain == null || address == null) { invalidEntries = true; Trace.WriteLine("Reject configuration due to an incomplete entry. (Either or both domain and address missing.)"); break; } if (!RoutingAddress.IsValidAddress(address.Value)) { invalidEntries = true; Trace.WriteLine(String.Format("Reject configuration due to an invalid address ({0}).", address)); break; } // Add the new entry string lowerDomain = domain.Value.ToLower(); map[lowerDomain] = new RoutingAddress(address.Value); Trace.WriteLine(String.Format("Added entry ({0} -> {1})", lowerDomain, address.Value)); } // If there are no invalid entries, swap in the map if (!invalidEntries) { Interlocked.Exchange<Dictionary<string, RoutingAddress>>(ref this.addressMap, map); Trace.WriteLine("Accepted configuration"); } } } #endregion }
When you encounter a situation where the transport queues are filling up or you just want to get messages out of a transport queue for further usage, you have to export the messages to the file system.
With Exchange 2007 you could easily utilize the Export-Message cmdlet to export suspended messages from a transport queue:
Get-Message -Queue MYSERVER\29489 | ?{$_.Status -eq "Suspended"} | Export-Message -Path D:\MessageExport
With Exchange Server 2010 or newer the Path attribute had been removed from the Export-Message cmdlet. The cmdlet now returns a binary object that needs to be assembled to a readable text message.
You can only export suspended messages, as the transport service might take precedence on non suspended messages. You can either suspend the queue or suspend single messages. The following example for Exchange Server 2010 or newer suspends the messages, but not the queue itself.
Identify the queue holding messages to be exported
Beispiel: Auflistung der Warteschlage auf Server MCMEP01
Get-Queue -Server MCMEP01 Identity DeliveryType Status MessageCount Velocity RiskLevel OutboundIPPool NextHopDomain -------- ------------ ------ ------------ -------- --------- -------------- ------------- MCMEP01\18 SmtpDeliv... Ready 0 0 Normal 0 MXEDB19 MCMEP01\23 SmtpDeliv... Ready 0 0 Normal 0 MXEDB08 MCMEP01\24 SmtpDeliv... Ready 0 0 Normal 0 MXEDB01 MCMEP01\25 SmartHost... Ready 3 0 Normal 0 [10.10.11.1] MCMEP01\53 SmtpDeliv... Ready 0 0 Normal 0 MXEDB03 MCMEP01\Submission Undefined Ready 512 0 Normal 0 Submission MCMEP01\Shadow\3 ShadowRed... Ready 2 0 Normal 0 MCMEP04.mcsmemail.de MCMEP01\Shadow\4 ShadowRed... Ready 2 0 Normal 0 MCMEP03.mcsmemail.de MCMEP01\Shadow\5 ShadowRed... Ready 3 0 Normal 0 MCMEP02.mcsmemail.de MCMEP01\Shadow\6 ShadowRed... Ready 2 0 Normal 0 MCMEP07.mcsmemail.de MCMEP01\Shadow\15 ShadowRed... Ready 1 0 Normal 0 MCMEP08.mcsmemail.de
Suspend all Messages in Queue MCMEP01\Submission
Get-Queue MCMEP01\Submission | Get-Message ResultSize Unlimited | Suspend-Message
Fetch all messages to an array and export all messages to the local file system. You can either export all messages by just enumerating the messages or by using the message subject as the file name. Using the message subject posed the risk that the subject might contain a character that is not allowed for file names.
$array = @(Get-Message -Queue MCMEP01\Submission -ResultSize Unlimited | ?{$_.Status -eq "Suspened"}) $array | ForEach-Object {$m++;Export-Message $_.Identity | AssembleMessage -Path ("E:\Export\"+$m+".eml")} $array | ForEach-Object {$m++;$filename="E:\Export\"+$m+"_"+$_.subject+".eml"; Export-Message $_.identity | AssembleMessage -path $filename}
The messages exported to the local file system can now be copied to the Exchange Transport Replay folder, which exists on each Exchange Server having an Exchange 2010 Hub Transport role or Exchange 2013/2016 Mailbox role.
After successful export of all suspended messages you want to delete the suspended messages from the queue. Ensure to use -WithNDR $false as otherwise all senders will receive a Non Delivery Report (NDR).
Get-Queue MCMEP01\Submission | Get-Message -ResultSize Unlimited | ?{$_.Status -eq "Suspened"} | Remove-Message -WithNDR $false
Messages saved to the Replay folder will be picked up the transport service. When a messages is picked up, the file extension changes to .TMP. You will not be able to delete a file at this point as the file is locked by the transport service. After a message file has been processed successfully, the file is deleted by the transport service. If there is any issue with the message file the file extension will change to .BAD.
I was involved in a troubleshooting request for a hybrid mail flow issue. Before I take a closer look at the issue, let's talk about the hybrid setup.
A managed service provider runs separated on-premises Exchange Organizations for various clients. Also, the service provider runs it's own Exchange Organization in a hybrid setup with Exchange Online (EXO) utilizing centralized mail flow. Let's name the managed service provider Varunagroup, using the primary domain varunagroup.de.
The on-premises IT-Infrastructure consists of the following email components:
The following diagram illustrates the setup and the expected mail flow.
Let's name one of the clients Setebos AG, using setebos-ag.com as their primary domain.
Varunagroup's IT department activated journaling in Exchange Online, using an on-premises Journaling mailbox. After a few days, an IT administrator checked the inbox folder for journaling messages and journaling reports. The journaling inbox did not contain messages of Varounagroup senders or recipients only, but messages from client sender domains, e.g., setebos-ag.com.
In reality, the mail flow from on-premises to external recipients from any of the local Exchange organizations looked like shown in this diagram.
Why does the Variangoup journaling mailbox contain messages from Setebos senders sent to external recipients?
We choose a single message for troubleshooting purposes, originating from the Setebos.com domain, sent to a non-Varunagroup recipient.
The interesting piece of information is row 6.
You see that EXO resolves the target mail exchanger via DNS. The target is another Microsoft 365 tenant as we see an xxx.mail.protection.outlook.com host.
When checking the on-premises mail gateway connection log, we found the distracting information that the gateway resolved the target mail exchanger as xxx.mail.protection.outlook.com.
As a next step, we checked the extended message tracking log using the new Exchange Admin Center. We created a new custom query with the following search criteria:
When you troubleshoot connection issues with Exchange Online, always select the extended report. You'll receive the report as a CSV file attachment. Use the Data tab in Excel to import the CSV file. Do not access the content by simply clicking the received file attachment.
The interesting information is stored in the custom_data column for row source=SMTP and event_id=RECEIVE.
S:ProxyHop1=HE1EUR01FT049.mail.protection.outlook.com(10.152.0.221); S:ProxyHop2=AM0PRxxCAxxxx.outlook.office365.com(2603:10a6:208:fa::40); S:InboundConnectorData=Name=Inbound from [EXCHANGE ORG GUID]; ConnectorType=OnPremises; TenantId=[VARUNAGROUP GUID]; S:InboundTlsDetails=TLS=SP_PROT_TLS1_2_SERVER [...]; S:CorrelationId=d9ac6a10-8de9-4308-4205-07d865e8909b; S:MimeParts=Att/Emb/MPt:0/0/1; S:MessageValue=MediumHigh; S:Replication=AM6PRxxxxMBxxxx; S:FirstForestHop=AM0PRxxxxMBxxxx.eurprd03.prod.outlook.com; S:FromEntity=HybridOnPrem; S:Oorg=varunagroup.de; S:ProxiedClientIPAddress=81.173.212.44; S:ProxiedClientHostname=mx01.varunagroup.de; S:DeliveryPriority=Normal; S:AccountForest=EURPRxxAxxx.PROD.OUTLOOK.COM
The information in line 3 shows the actual name of the configured Varunagroup inbound connector, as shown in the Exchange Online connector configuration. The message did not enter the Varunagroup EXO tenant due to a mysterious connection, it was received by the dedicated inbound connector, configured by HCW.
The key to this question is the TLS certificate used by the centralized email gateway and the TLS common name filtering in Exchange Online.
The wildcard name *.varunagroup.de resulted in a matching string comparison for the incoming TLS common names of mx01.varunagroup.de and mx02.varunagroup.de. At the same time, the inbound connector matched the Edge Transport TLS certificate smtpo365.varunagroup.de.
Nobody knew, how the inbound connector configuration got "changed" to the wildcard name or for how long that configuration resulted in outbound messages from customer domains routed via the service provider tenant.
The solution contains two configurations.
The TLS common name behavior is by design and described in this blog post as FAQ #6(b). As a customer, you identify this as a misbehaving SMTP receive connector. But as described in the blog post, this is by design.
It is required that you understand the inbound routing behavior of Exchange Online if you have complicated outbound routing requirements. The blog post provides detailed information on how Office 365 inbound routing works and what you should be aware of.
Links
Enjoy Exchange Online.
This scripts helps to suspend all messages in an Exchange transport queue and to export all suspended messages to a given target folder.
The script uses the AssembleMessage cmdlet to properly export queued messages as .eml files.
Optionally, all exported messages can be removed from the transport queue.
This script requires the GlobalFunctions module for logging.
# EXAMPLE 1 # Export messages from queue MCMEP01\45534 to D:\ExportedMessages and do not delete messages after export .\Export-MessageQueue -Queue MCMEP01\45534 -Path D:\ExportedMessages # EXAMPLE 2 # Export messages from queue MCMEP01\45534 to D:\ExportedMessages and delete messages after export .\Export-MessageQueue -Queue MCMEP01\45534 -Path D:\ExportedMessages -DeleteAfterExport
As always: Test and familiarize yourself with the script in a test or development environment.
Microsoft Docs provides detailed documentation on Exchange Server 2016 mail flow and the transport pipeline. That article helps you to
The detailed diagram showing the Exchange Server 2016 transport pipeline in the TechNet documentation does not show the TCP ports being used by the Exchange Server 2016 components.
The following diagram is an updated version of the original diagram showing the TCP ports being used by
By default Exchange Server implements the following receive connectors
Cross-server SMTP communication occurs on either TCP 2525 or TCP 475.
Enjoy Exchange Server!
This script deletes user from the NoSpamProxy NoSpamProxyAddressSynchronization database table [Usermanagement].[User] table that have not been removed by the NoSpamProxy Active Directory synchronization job.
The script was developed due to a process flaw in how Active Directory accounts are handled as part of a leaver process. So this script does not fix a software bug, but a process glitch.
Due to the Active Directory account process the accounts still exist in Active Directory and are synchronized to the NoSpamProxyAddressSynchronization database.
When executed without the -Delete parameter all identified users are wirtten the log file only.
# EXAMPLE 1 # Check for Active Directory existance of all users stored in NoSpamProxy database. Do NOT delete any users from the database. .\Remove-NspUsers.ps1 # EXAMPLE 2 # Delete users from NoSpamProxy database hosted on SQL instance MYNSPSERVER\SQLEXPRESS that do NOT exist in Active Directory. .\Remove-NspUsers.ps1 -Delete -SqlServerInstance MYNSPSERVER\SQLEXPRESS