In September of 2005, I wrote a blog post named “Sending an e-mail to users whose password is about to expire“. Written in VBScript, it was one of my most popular blog posts of all time. I still have clients of mine that use it and I get occaisional email questions regarding it.
However, it is certainly showing its age!
There are other solutions available now, for free. However, the other solutions don’t meet all of my needs. (As always, I encourage you to choose the solution that best meets your needs.)
In my case, I need to be able to support:
-
Fast and efficient searching of Active Directory
-
Support for Fine Grained Password Policies (FGPPs, also known as Password Settings Objects or PSOs)
-
Authenticated SMTP
-
SSL/TLS SMTP
-
Report to Administrative User only (via SMTP)
-
Report to Administrative User only (via console)
-
Report to end-user (via SMTP)
The script in this blog post meets all of those needs. And, it is now written in PowerShell instead of VBScript.
I also chose to use the .Net Framework (System.DirectoryServices) for access to Active Directory (as well as some ADSI), instead of using the Active Directory PowerShell cmdlets. This makes it possible to execute the script on pretty-much any domain-joined computer, instead of one that requires RSAT-ADDS to be installed. It also avoids some weirdness around certain values returned by the AD cmdlets not matching older cmdlets or the AD itself.
This script is designed to work with PowerShell v2.0. The only PowerShell v2.0 feature is use of the Send-MailMessage cmdlet and using splatting to call Send-MailMessage. If you need this on PowerShell v1.0, you must just write a replacement for Send-MailMessage (use System.Net.Mail – it’s not a big deal).
The script will work on any domain functional level (DFL). The DFL is relevant to whether Fine-Grained Password Policies (FGPP, also known as Password Settings Objects – PSOs) are in use or not. FGPPs can be used when the domain level is at Windows 2008 or higher.
Coming in at 767 lines, this is the longest single PowerShell script I believe I’ve posted. But it’s well documented and hopefully self-descriptive. There are some fairly advanced capabilities demonstrated in this script, so you may find it worthwhile to study it a bit. If you have questions, let me know.
###
### Send-MailToUsersWithExpiringPasswords
###
### The top third of the script is data acquisition (and well documented).
### The bottom two-thirds is simple email-sending and report writing.
###
### This is PowerShell v1 compatible EXCEPT for using Send-MailMessage. You can
### easily replace that using System.Net.Mail if you wish.
###
### Parameter information:
### daysForEmail - how many days before a password expires should a user receive warning emails
### adminEmail - the administrator's email address
### adminEmailOnly - do not send email to users, only report to the administrator
### SMTPfrom - the From address for the SMTP message(s)
### SMTPserver - the server to be used for sending the SMTP message(s)
### SMTPuser - if credentials are required, the user for authenticating to the SMTP server
### SMTPpassword - if credentials are required, the password for the SMTPuser
### anr - instead of searching all users, only search for users matching the specified ANR string
### SMTPuseSSL - use an SSL/TLS connection, not a clear-text SMTP connection
### Quiet - if NOT set, a copy of the admin report is dumped to the pipeline as text
### DontSendEmail - Email is never sent to either users or admin
###
Param(
[int]$daysForEmail = 14,
[string]$adminEmail = "michael@smithcons.com",
[switch]$adminEmailOnly,
[string]$SMTPfrom,
[string]$SMTPserver,
[string]$SMTPuser,
[string]$SMTPpassword,
[string]$anr,
[switch]$SMTPuseSSL,
[switch]$Quiet,
[switch]$DontSendEmail
)
### Using Set-StrictMode helps protect against wonky errors that get caught by the
### compiler in compiled languages. Specifically (from the helpfile for the cmdlet):
### -- Prohibits references to uninitialized variables (including uninitialized
### variables in strings).
### -- Prohibits references to non-existent properties of an object.
### -- Prohibits function calls that use the syntax for calling methods.
### -- Prohibits a variable without a name (${}).
###
### However, using strict mode means that extra care has to be taken when using
### hashtables and property value collections. You see that in this script every
### time you see the Item() accessor method being used.
Set-StrictMode -Version 2.0
### For information about ANR, see "Ambiguous Name Resolution" in
### http://technet.microsoft.com/en-us/library/cc978014.aspx
$domainObject = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$domainName = $domainObject.Name
$domainRoot = "LDAP://" + $domainName
$domainADSI = [ADSI]$domainRoot
$domainMode = $domainADSI.'msDS-Behavior-Version' ## Windows2008Domain is 3.
### domainMode is a PITA. System.DirectoryServices.ActiveDirectory.Domain.DomainMode and
### Microsoft.ActiveDirectory.Management.ADDomainMode have different values for the same
### enums!
###
### The first type is returned by System.DirectoryServices, the second type by the
### Get-ADDomain PowerShell cmdlet.
###
### That is why I ignore both of those potential access methods and use ADSI to access the
### value directly from the domain object. For specific information about those values, see:
### http://msdn.microsoft.com/en-us/library/cc223742(v=prot.10).aspx
[System.Int64]$Script:MaxPasswordAge = 0
function GetMaximumPasswordAge
{
###
### GetMaximumPasswordAge
###
### Retrieve the maximum password age that is set on the domain object. This is
### normally set by the "Default Domain Policy".
###
if( $Script:MaxPasswordAge )
{
### Cache the value so that it only has to be retrieved once, converted
### to an int64 once, and converted to days once. Win-win-win.
return $Script:MaxPasswordAge
}
### Dealing with ADSI unfortunately also means dealing with COM objects.
### Using ConvertLargeIntegerToInt64 takes the COM object and converts
### it into a native .Net type.
[System.Int64]$Script:MaxPasswordAge = $domainADSI.ConvertLargeIntegerToInt64( $domainADSI.maxPwdAge.Value )
### Convert to days
### there are 86,400 seconds per day (24 * 60 * 60)
### there are 10,000,000 nanoseconds per second
[System.Int64]$Script:MaxPasswordAge = ( -$Script:MaxPasswordAge / ( 86400 * 10000000 ) )
return [System.Int64]$Script:MaxPasswordAge
}
function newSecurePassword( [string]$password )
{
###
### newSecurePassword
###
### Take the normal string password provided and turn it into a
### secure string that can be used to set credentials.
###
### In PowerShell v2.0, this can be done with ConvertTo-SecureString.
### That cmdlet isn't available in v1.0 though.
###
$secure = New-Object System.Security.SecureString
$password.ToCharArray() |% { $secure.AppendChar( $_ ) }
return $secure
}
function newPSCredential( [string]$username, [string]$password )
{
###
### newPSCredential
###
### Create a new PSCredential object containing the provided
### username and plain-text password.
###
$pass = newSecurePassword $password
$cred = New-Object System.Management.Automation.PSCredential( $username, $pass )
$pass = $null
return $cred
}
###
### We need to find all those user's who:
### Are normal users 0x00000200 ADS_UF_NORMAL_ACCOUNT
### Are not disabled 0x00000002 ADS_UF_ACCOUNTDISABLE
### Do not have "password never expires" 0x00010000 ADS_UF_DONT_EXPIRE_PASSWD
### Do not have "no password required" 0x00000020 ADS_UF_PASSWD_NOTREQD
###
### Once we have the users, determine whether the user has a PSO
### by examining msDS-PSOApplied (if the domain mode of the executing domain
### is at Windows2008Domain or higher).
###
### If the user has a PSO, load the PSO (and store it to a hashtable)
### and evaluate the user's password against the PSO.
###
### If the user does not have a PSO, evalute the user's password
### against the Default Domain Policy Maximum Password Age (which
### is found by the function GetMaximumPasswordAge above).
###
### The most efficient way to find users is to do an LDAP search. But building the
### proper search isn't that easy. We need a number of filters:
###
### objectCategory=Person
### (userAccountControl & ADS_UF_NORMAL_ACCOUNT) <> 0
### (userAccountControl & ADS_UF_ACCOUNTDISABLE) == 0
### (userAccountControl & ADS_UF_DONT_EXPIRE_PASSWD) == 0
### (userAccountControl & ADS_UF_PASSWD_NOTREQD) == 0
###
### Since userAccountControl is a BIT-FLAG attribute (meaning that individuals bits
### of the value control these options) then we need to be able to do a bit-wise
### LDAP search. It's important to realize that with a bit-wise search, the result
### of a particular filter is either not-one (!1, which is false) or not-zero (!0,
### which is true).
###
### So the next step in building our query is to use the LDAP bit-wise AND filter:
###
### 1.2.840.113556.1.4.803
###
### This is used to do a bit-wise AND of the attribute on the left to the value
### on the right. For example:
###
### attribute:1.2.840.113556.1.4.803:=1024
###
### This means a bit-wise AND is done of the value of the attribute to the value
### 1024 (which is 0x400 in hexadecimal). If the result of that bit-wise AND is
### zero, then the value of the filter is false. If the result is non-zero, then
### the value of the filter is true. Putting a "!" in front of a false result
### makes the result true. Putting a "!" in front of a true result, makes it false.
###
### A combination of these two techniques makes it possible to scan for zero and
### non-zero bits (that is, those which are set to one and those which are set to
### zero).
###
### LDAP also supports a bit-wise OR filter, using the special value of:
###
### 1.2.840.113556.1.4.804
###
### Given the presence of AND and OR filters, it is possible to build very complex
### combination filters.
###
### A combination filter is built up of individual filters combined with either a
### logical OR ("|") or a logical AND ("&") and surrounded by parentheses.
###
### (&
### (objectCategory=Person)
### (userAccountControl:1.2.840.113556.1.4.803:=512)
### (!userAccountControl:1.2.840.113556.1.4.803:=2)
### (!userAccountControl:1.2.840.113556.1.4.803:=65536)
### (!userAccountControl:1.2.840.113556.1.4.803:=32)
### )
###
### So, in pseudo-C code this is:
###
### if( ( objectCategory == Person ) AND
### ( ( userAccountControl & ADS_UF_NORMAL_ACCOUNT ) != 0 ) AND
### ( ( userAccountControl & ADS_UF_ACCOUNTDISABLE ) == 0 ) AND
### ( ( userAccountControl & ADS_UF_DONT_EXPIRE_PASSWD ) == 0 ) AND
### ( ( userAccountControl & ADS_UF_PASSWD_NOTREQD ) == 0 ) )
### {
### ### we've got a matching user!
### }
###
$ldapFilter = "(&" +
"(objectCategory=Person)" +
"(userAccountControl:1.2.840.113556.1.4.803:=512)" +
"(!userAccountControl:1.2.840.113556.1.4.803:=2)" +
"(!userAccountControl:1.2.840.113556.1.4.803:=65536)" +
"(!userAccountControl:1.2.840.113556.1.4.803:=32)"
if( $anr )
{
###
### using an ANR subquery allows us to reduce the result set from the LDAP query
###
$ldapFilter += "(anr=$anr)"
}
$ldapFilter += ")"
###
### build the LDAP search
###
$directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
$directorySearcher.PageSize = 1000
$directorySearcher.SearchRoot = $domainRoot
$directorySearcher.SearchScope = "subtree"
$directorySearcher.Filter = $ldapFilter
###
### load the properties we want
###
$directorySearcher.PropertiesToLoad.Add( "displayName" ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "mail" ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "pwdLastSet" ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "sAMAccountName" ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "userAccountControl" ) | Out-Null
if( $domainMode -ge 3 )
{
### this attribute is only valid on Windows2008Domain and above
$directorySearcher.PropertiesToLoad.Add( "msDS-PSOApplied" ) | Out-Null
}
$users = $directorySearcher.FindAll()
###
### All the data is in $users (or will be paged into $users).
### Build the necessary reports and emails.
###
$crnl = "`r`n"
$script:adminReport = ""
function line
{
foreach( $arg in $args )
{
$script:adminReport += $arg + $crnl
}
}
$now = Get-Date
$maximumPasswordAge = GetMaximumPasswordAge
line ( "Admin Report - Send Mail to Users with Expiring Passwords - Run Date/Time: " + $now.ToString() )
line " "
line "Parameters:"
line " Days warning for sending email: $daysForEmail"
line " Administrator email: $adminEmail"
if( $DontSendEmail )
{
line " Email will not be sent to either the administrator email or to user's email"
}
elseif( $adminEmailOnly )
{
line " Only the administrator will be sent email"
}
if( ![System.String]::IsNullOrEmpty( $SMTPfrom ) )
{
line " SMTP From address: $SMTPfrom"
}
if( ![System.String]::IsNullOrEmpty( $SMTPserver ) )
{
line " SMTP server: $SMTPserver"
}
if( ![System.String]::IsNullOrEmpty( $SMTPuser ) )
{
line " SMTP user: $SMTPuser"
}
if( ![System.String]::IsNullOrEmpty( $SMTPpassword ) )
{
line " SMTP password: $SMTPpassword"
}
if( $SMTPuseSSL )
{
line " UseSSL with SMTP is set"
}
line " Maximum Password Age in Default Domain Policy = $maximumPasswordAge"
line " Domain Mode $($domainObject.DomainMode.ToString())"
line ( " User count = " + $users.Count.ToString() )
if( [System.String]::IsNullOrEmpty( $SMTPserver ) -and [System.String]::IsNullOrEmpty( $PSEmailServer ) -and !$DontSendEmail )
{
Write-Error "No email server was specified via the SMTPserver parameter or the `$PSEmailServer environment variable."
exit
}
if( ![System.String]::IsNullOrEmpty( $SMTPuser ) -and [System.String]::IsNullOrEmpty( $SMTPpassword ) )
{
Write-Error "No SMTPpassword was specified."
exit
}
if( ![System.String]::IsNullOrEmpty( $SMTPpassword ) -and [System.String]::IsNullOrEmpty( $SMTPuser ) )
{
Write-Error "No SMTPuser was specified."
exit
}
if( [System.String]::IsNullOrEmpty( $SMTPfrom ) )
{
$SMTPfrom = "Password Administrator "
}
###
### some of the bitflags attached to the userAccountControl attribute
###
$ADS_UF_NORMAL_ACCOUNT = 0x00200
$ADS_UF_ACCOUNTDISABLE = 0x00002
$ADS_UF_DONT_EXPIRE_PASSWD = 0x10000
$ADS_UF_PASSWD_NOTREQD = 0x00020
$psoCache = @{}
foreach( $user in $users )
{
###
### we spend some time being pretty careful dealing with the properties. this
### allows us to verify we get the attributes we want, and for those that were
### not present, we can establish reasonable defaults.
###
line " "
$propertyBag = $user.properties
if( !$propertybag )
{
line "error! null propertybag!"
continue
}
$mail = ""
$disp = ""
$sam = ""
$pls = 0
$pso = 0
$uac = 0
$pwdX = $null
$dispObj = $propertyBag.Item( 'displayname' )
if( $dispObj -and ( $dispObj.Count -gt 0 ) )
{
$disp = $dispObj.Item( 0 )
if( !$disp ) { $disp = "" }
}
$dispObj = $null
### line "displayname = $disp"
$samObj = $propertyBag.Item( 'samaccountname' )
if( $samObj -and ( $samObj.Count -gt 0 ) )
{
$sam = $samObj.Item( 0 )
if( !$sam ) { $sam = "" }
}
else
{
$sam = ""
}
$samObj = $null
### line "sam = $sam"
$uacObj = $propertyBag.Item( 'useraccountcontrol' )
if( $uacObj -and ( $uacObj.Count -gt 0 ) )
{
$uac = $uacObj.Item( 0 )
}
else
{
line "no uac for $sam, assumed 0x200"
$uac = $ADS_UF_NORMAL_ACCOUNT
}
$uacObj = $null
### line "uac = $uac"
$plsObj = $propertyBag.Item( 'pwdlastset' )
if( $plsObj -and ( $plsObj.Count -gt 0 ) )
{
$pls = $plsObj.Item( 0 )
}
else
{
### this can be a normal occurence if the password has never been set
$pls = 0
}
$plsObj = $null
### line "pls = $pls"
line ( $sam.PadRight( 21 ) + $pls.ToString().PadRight( 21 ) + $uac.ToString().PadRight( 8 ) + '0x' + $uac.ToString('x').PadRight( 8 ) )
if( $pls -eq 0 )
{
line ( " " * 8 + "The password has never been set for this user, skipped" )
continue
}
[System.Int64]$localMaxAge = $maximumPasswordAge
if( $domainMode -ge 3 )
{
###
### PSOs can be created on Server 2003, but they don't work properly until
### the domain mode is Windows2008Domain or higher.
###
### So if we find a PSO and the domain mode is Windows2008Domain or higher,
### we first determine whether we've seen this PSO before. If we have seen
### the PSO before, then we've stored the msDS-MaximumPasswordAge value for
### the PSO into a hash table for quick retrieval. If we have not seen the
### PSO before, then we use ADSI to load the PSO and retrieve the value for
### msDS-MaximumPasswordAge and then cache the value for future access. By
### using a cache, we only have to access Active Directory to obtain values
### once per PSO, leading to a significant performance improvement compared
### to using the native cmdlets or S.DS.
###
$psoObj = $propertyBag.Item( 'msds-psoapplied' )
if( $psoObj -and ( $psoObj.Count -gt 0 ) )
{
$pso = $psoObj.Item( 0 )
### line ( "PSO object/name" + $pso )
if( $psoCache.Item( $pso ) )
{
[System.Int64]$localMaxAge = $psoCache.Item( $pso )
### line ( " " * 8 + "Accessed PSO from cache = " + $pso )
}
else
{
$psoADSI = [ADSI]( "LDAP://" + $pso )
$ageOBJ = $psoADSI.'msDS-MaximumPasswordAge'
[System.Int64]$localMaxAge = $psoADSI.ConvertLargeIntegerToInt64( $ageOBJ.Value )
### Convert to days
### there are 86,400 seconds per day (24 * 60 * 60)
### there are 10,000,000 nanoseconds per second
$localMaxAge = ( -$localMaxAge / ( 86400 * 10000000 ) )
$psoCache.$pso = $localMaxAge
### line ( " " * 8 + "Stored PSO to cache = " + $pso )
$ageOBJ = $null
$psoADSI = $null
### line "localMaxAge = $localMaxAge"
}
}
else
{
### completely normal to not have a PSO, in that case, use the maxPwdAge
### from the default domain policy.
$pso = $null
### line "pso is null"
}
$psoObj = $null
}
### In an Exchange environment, the 'mail' attribute contains the primary SMTP
### address for a user. In a non-Exchange environment, that should also be true,
### but no system process validates it. We do presume that the mail address is
### valid, if present.
$mailObj = $propertyBag.Item( 'mail' )
if( $mailObj -and ( $mailObj.Count -gt 0 ) )
{
$mail = $mailObj.Item( 0 )
}
else
{
$mail = ''
}
if( 0 )
{
### The conditions reported on below cannot occur based on our LDAP filter
### But they helped me develop and test the LDAP filter. :-)
if( $uac -band $ADS_UF_NORMAL_ACCOUNT )
{
"`t`tNormal account"
}
else
{
"`t`tNot a normal account"
}
if( $uac -band $ADS_UF_ACCOUNTDISABLE )
{
"`t`tAccount disabled"
}
else
{
"`t`tAccount enabled"
}
if( $uac -band $ADS_UF_DONT_EXPIRE_PASSWD )
{
"`t`tPassword doesn't expire"
}
else
{
"`t`tPassword expires"
}
if( $uac -band $ADS_UF_PASSWD_NOTREQD )
{
"`t`tPassword not required"
}
else
{
"`t`tPassword required"
}
}
### If we get here, $pls is non-zero.
###
### $pls comes to us in FileTime format (the number of 100 nansecond ticks
### since Jan 1, 1601). So it must be converted to DateTime and adjusted
### for the normal clock in order for us to do our arithmetic on it. For
### more information about FileTime, see:
### msdn.microsoft.com/en-us/library/windows/desktop/ms724290(v=vs.85).aspx
$date = [DateTime]$pls
$passwordLastSet = $date.AddYears( 1600 ).ToLocalTime()
$passwordExpires = $passwordLastSet.AddDays( $localMaxAge )
line ( " " * 8 + "The password was last set on " + $passwordLastSet.ToString() )
if( $now -gt $passwordExpires )
{
line ( " " * 8 + "The password already expired on $($passwordExpires.ToString()). No email will be sent.")
continue
}
else
{
line ( " " * 8 + "The password will expire on " + $passwordExpires.ToString() )
}
$difference = $passwordExpires - $now
$days = $difference.Days.ToString()
$hours = $difference.Hours.ToString()
$minutes = $difference.Minutes.ToString()
line ( " " * 8 + "(This is in $days days, $hours hours, $minutes minutes)" )
if( $difference.Days -le $daysForEmail )
{
if( [System.String]::IsNullOrEmpty( $mail ) -and !$adminEmailOnly )
{
line ( " " * 8 + "Oops - user doesn't have an email address." )
continue
}
if( $DontSendEmail )
{
line ( " " * 8 + "Oops - we aren't supposed to send an email." )
continue
}
line ( " " * 8 + "This user will be sent an email for password change." )
$hash = @{}
$hash.To = $adminEmail
$hash.Priority = 'High'
$hash.From = $SMTPfrom
if( ![System.String]::IsNullOrEmpty( $disp ) )
{
$hash.Subject = "Warning! The network password for $disp ($sam) is about to expire."
}
else
{
$hash.Subject = "Warning! The network password for $sam is about to expire."
}
if( !$adminEmailOnly -and ![System.String]::IsNullOrEmpty( $mail ) )
{
$hash.CC = $mail
}
if( ![System.String]::IsNullOrEmpty( $SMTPserver ) )
{
$hash.SmtpServer = $SMTPserver
}
elseif( ![System.String]::IsNullOrEmpty( $PSEmailServer ) )
{
$hash.SmtpServer = $PSEmailServer
}
###
### Send-MailMessage will default to using $PSEmailServer when no other SMTP server is specified.
### We checked earlier to ensure that at least one of those was specified.
###
if( ![System.String]::IsNullOrEmpty( $SMTPuser ) )
{
###
### If SMTPuser is specified then SMTPpassword is also specified.
### We checked earlier to make certain that if one was specified,
### then both were specified.
###
$hash.Credential = newPSCredential $SMTPuser $SMTPpassword
}
if( $SMTPuseSSL )
{
$hash.UseSSL = $true
}
$bodyHeader = @"
WARNING!
"@
$bodyHeader += "`r`nFor network user id: "
if( ![System.String]::IsNullOrEmpty( $disp ) )
{
$bodyHeader += $disp + " (" + $sam + ")"
}
else
{
$bodyHeader += $sam
}
$hash.Body = @"
$bodyHeader
Your password is about to expire in $days days, $hours hours, $minutes minutes.
Please change it now!
Thank you,
Your System Administrator
"@
###
### This is V2.0 function. I should replace it with something v1.0 compatible.
### (splatting is also V2.0 only, which is why I'm lazy and didn't replace it.)
###
Send-MailMessage @hash
$hash = $null
}
}
###
### Invidual report emails complete.
### Now send summaries to the console and/or to the administrator email address.
###
line " "
if( !$quiet )
{
###
### If $quiet is NOT set, then dump the report to the console, as well
### as sending the email to the administrator.
###
$script:adminReport
}
if( !$DontSendEmail )
{
$hash = @{}
$hash.To = $adminEmail
$hash.Priority = 'High'
$hash.From = $SMTPfrom
$hash.Subject = "Admin Report - Send Mail to Users with Expiring Passwords - Run Date/Time: " + $now.ToString()
$hash.Body = $adminReport
###
### Send-MailMessage will default to using $PSEmailServer when no other SMTP server is specified.
### We checked earlier to ensure that at least one of those was specified.
###
if( ![System.String]::IsNullOrEmpty( $SMTPserver ) )
{
$hash.SmtpServer = $SMTPserver
}
elseif( ![System.String]::IsNullOrEmpty( $PSEmailServer ) )
{
$hash.SmtpServer = $PSEmailServer
}
if( ![System.String]::IsNullOrEmpty( $SMTPuser ) )
{
###
### If SMTPuser is specified then SMTPpassword is also specified.
### We checked earlier to make certain that if one was specified,
### then both were specified.
###
$hash.Credential = newPSCredential $SMTPuser $SMTPpassword
}
if( $SMTPuseSSL )
{
$hash.UseSSL = $true
}
Send-MailMessage @hash
$hash = $null
}
###
### Clean up a bit.
###
$now = $null
$users = $null
$psoCache = $null
$directorySearch = $null
$domainADSI = $null
$domainObject = $null
$script:adminreport = $null
### Done.
Until next time…
If there are things you would like to see written about, please let me know.
Follow me on twitter! – @EssentialExch