Blog
All Blog Posts | Next Post | Previous Post
TMS WEB Core and More with Andrew:
Notifications via E-Mail with XData/Indy
Tuesday, August 1, 2023
There could be any number of events that a user of a TMS
WEB Core app might want to be notified about. Perhaps an update to content found within the app. A
notification about a recent login. Or maybe the user wants to receive a report summarizing some aspect of the
data found within the app. Could be any of a thousand things. How do we send them such a notification? And
what are our choices for communication channels? There are almost as many options. In this post, we're going to
show how to send e-mails, where we'll be using a TMS XData server and Indy to take care of the hardest parts.
Why E-Mail?
Although there are other communication channels we might use, like SMS, in-app notifications, browser-based notifications, or even specific products like Slack, e-mail is perhaps still the most common way to send messages to our users. It isn't
nearly as dominant as it was before the arrival of modern mobile devices. But in a corporate environment, it is
likely that all users of a given line-of-business app have access to e-mail, even though this is no longer a
certainty. So we'll be sure to cover other options in future posts. For now, e-mail remains prevalent enough
that it is still a workable solution for many scenarios.
One of those scenarios involves providing a login or other authorization as part of an app registration
mechanism. Generally speaking, we can assume that when someone signs up for an account within an app, the
email they provide will be unique to them. If we send them a confirmation code, and they can provide it back to us,
we've essentially validated their e-mail address and confirmed that they can receive subsequent notifications
sent to the same address. None of this is guaranteed, of course, but it works well enough that this has been a
common way of authenticating users for a very long time. Their e-mail address is used as a way to
validate that they are who they say they are during subsequent interactions.
More recently, trends have moved toward other methods to accomplish the same thing, such as partnering up with
the likes of Microsoft, Google, or Apple, and using their platforms to provide some measure of authentication
without having access to an individual's e-mail address. This is important in situations where users simply do
not want to receive unsolicited e-mails or reveal any more of their identity than is absolutely required. Sometimes it's a trust issue. Sometimes it's an issue of wanting to simplify or expedite the login or
registration process. While beneficial and important, this isn't really the group of users we're concerned with
in this particular post. Rather, we're concerned with delivering content to users via e-mails that they
explicitly want to receive.
While e-mail has been around for a very long time, it has evolved over decades into what we have today. And not always in a good way. Complexities abound. There are typically different server applications that handle incoming e-mail (SMTP protocol) versus user access to that e-mail (IMAP and POP3 protocols). Major e-mail services, like Gmail, manage accounts for millions of users while at the same time mixing up their own curious selection of standards that they support or don't support. As the saying goes, the great thing about standards is that there are so many to choose from. And e-mail is the poster child for such thinking!
On the plus side, though, it has been around long enough that e-mail can be used for reliable communications, even if it is far from perfect. There are plenty of examples where people use e-mail to conduct financial transactions, send and receive invoices and other bills, and use e-mail as one of the factors when implementing multi-factor authentication. So while there may be a case for using alternatives, e-mail is going to be with us for some time to come.
For our purposes today, we're generally after sending a single e-mail to a single user,
either notifying them about something they've explicitly chosen to be notified about, or sending them a
confirmation code or something along those lines so that we can authorize a particular action they want to
take. Either way, we're sending an e-mail to a single address, and presumably to an address that has
successfully received an e-mail from us previously.
Submission Mechanism.
There are many available services, libraries, APIs, and other tools that can be used to create and deliver e-mails. Some are free. Some are decidedly not free. Ultimately, what we're after is the ability to deliver an e-mail to a single SMTP server that has the ability to subsequently route the e-mail to its final destination. The recipient of the e-mail will be able to track its origin back to wherever this original SMTP server is and should be able to recognize it as a valid source of the message.
And even if the user never checks for
this, the e-mail client they use, and any and all intermediaries, are likely to do this on their behalf. The
point here is that how an e-mail enters the network is important. Many an e-mail has not reached its destination
because this original source was not recognized, or was blocked for any number of reasons, some justified and
some not.
One way to deal with this is to have your own SMTP server, properly configured and secured, perhaps even with a domain name that matches the domain name of the e-mail address you're using as the sender of the e-mail. If the web server being used to host your TMS WEB Core app is running on some kind of shared virtual hosting system, for example, a mail server can likely be configured in the same way, often with the same tools.
There are many ways to do this, but
ultimately what we're after is the address for an SMTP server, a delivery port (typically port 25 or port 587),
and a username/password combination that has permission to submit an e-mail to the server that has an ultimate
destination elsewhere. Being mindful that if we set up our own SMTP server, we do not want it to operate as an
open relay.
But what app, specifically, is responsible for sending the e-mail?
Like everything else related to e-mail, there are choices here. It is possible, for example, to send e-mail directly from within a TMS WEB Core app to an SMTP server. There are also third-party JavaScript libraries that can be used to make that process a little easier. And third-party service providers can provide an API for doing this kind of thing. However, there are at least a few reasons why sending an e-mail directly from a client to an SMTP server might not be the ideal approach.
- The trigger for the e-mail (why the notification is being sent in the first place) might come from something server-side, so the client might not even know about it, particularly if the user isn't using the app at the time.
- The e-mail may contain content that is only available server-side. For example, if we want the user to confirm a change, one approach is to send them an authorization code. It would be not very secure if the client app sent itself a secret code. We have to assume, always, that anything in a client app is not secret.
- Along a similar line of thinking, if the SMTP server being used requires a username/password, we really
don't want that username/password being used by the client app, as it could easily be revealed.
- Client apps may be running on insecure networks, or slow-performing networks that may not even have access to an SMTP server accessible from where the e-mail needs to be sent. And if it is a large e-mail, we'd rather that the client not incur the cost of delivering such messages, even if the message is ultimately destined for the same user.
- Client devices (and client networks) may have additional restrictions that prevent local apps from reaching
remote SMTP servers. It is common for residential ISPs to block access to port 25 on remote systems, for example, as a means of reducing the spread of spam and virus-laden e-mails.
So, as a general rule, client apps might not be the best place for e-mails to originate from. Even if you're
crafting a TMS WEB Core app that is itself an e-mail app. Better to hand the e-mail off to a server somewhere and have
the server take responsibility for getting that e-mail to a properly configured and secured SMTP server. There
can be exceptions to this, and any particular use case might have its own unique considerations.
If we don't use the client app to send the e-mail directly, however, we'll need a server of some kind to hand the e-mail off to. For TMS WEB Core projects, particularly those that use TMS XData, the obvious choice is to use an XData service endpoint (or several) to help with delivering e-mail. If you don't want to use XData, there are many, many other approaches. Here are a few related to PHP for example. But let's look at what an XData e-mail endpoint might look like.
As an XData app is really just a Windows VCL app (normally), it has access to everything Delphi developers have
had access to for decades, including the very capable Indy library. So we don't really need to reinvent the
wheel here - we can just use Indy to send e-mails the way we've been using Indy to send e-mails without TMS WEB
Core. We just need to present it in a nice XData wrapper - a service endpoint - something like the following.
function TSystemService.SendEMail(MailSubject, MailBody: String): String; var User: IUserIdentity; JWT: String; SMTP1: TIdSMTP; Msg1: TIdMessage; Addr1: TIdEmailAddressItem; Addr2: TidEMailAddressItem; Html1: TIdMessageBuilderHtml; SMTPResult: WideString; begin // Returning JSON, so flag it as such TXDataOperationContext.Current.Response.Headers.SetValue('content-type', 'application/json'); if not(MainForm.MailServerAvailable) then raise EXDataHttpUnauthorized.Create('Mail Services Not Configured'); // Get data from the JWT User := TXDataOperationContext.Current.Request.User; JWT := TXDataOperationContext.Current.Request.Headers.Get('Authorization'); if (User = nil) then raise EXDataHttpUnauthorized.Create('Missing authentication'); // Send the email Msg1 := nil; Addr1 := nil; SMTP1 := TIdSMTP.Create(nil); SMTP1.Host := MainForm.MailServerHost; SMTP1.Port := MainForm.MailServerPort; SMTP1.Username := MainForm.MailServerUser; SMTP1.Password := MainForm.MailServerPass; try Html1 := TIdMessageBuilderHtml.Create; try Html1.Html.Add(MailBody); Html1.HtmlCharSet := 'utf-8'; Msg1 := Html1.NewMessage(nil); Msg1.Subject := MailSubject; Msg1.From.Text := MainForm.MailServerFrom; Msg1.From.Name := MainForm.MailServerName; Addr1 := Msg1.Recipients.Add; Addr1.Address := User.Claims.Find('eml').AsString; SMTP1.Connect; try try SMTP1.Send(Msg1); except on E: Exception do begin SMTPResult := SMTPResult+'[ '+E.ClassName+' ] '+E.Message+Chr(10); end; end; finally SMTP1.Disconnect(); end; finally Addr1.Free; Msg1.Free; Html1.Free; end; except on E: Exception do begin SMTPResult := SMTPResult+'[ '+E.ClassName+' ] '+E.Message+Chr(10); end; end; SMTP1.Free; if SMTPResult = '' then Result := 'Sent' else Result := 'Send Failed: '+SMTPResult; end;
The result of this function is that an e-mail is sent to the user who is currently logged in - it uses the e-mail address in their JWT as the destination. Several variables, like the SMTP Server address, port, username, and password are supplied by the main XData app. In the Template Project, for example, these values can be provided to the XData server via a configuration.json file. This makes it possible to supply different instances of the XData server (production vs. development, for example) with separate values for these. Similarly, when sending e-mails, there's usually a "from" address that you'll want to configure, even if you don't want to have to deal with any responses.
Note that by taking this approach, the client is not able to determine anything about the server-side e-mail
configuration. The username/password information used to connect to the SMTP server, and even the name of
the SMTP server itself, are never sent to the client. This also means that the server can implement any kind of policies it needs to in
terms of rate-limiting, logging outbound e-mails sent, and of course limiting access in the first place to
accounts with a valid JWT. And we're assuming in this example that the JWT has the e-mail included as part of
its payload. This is something we did in the Template Project, but not necessarily something that is always
done.
Possible Uses.
It might seem a little odd to configure an e-mail service endpoint to only allow mail to be sent to the e-mail
address in the JWT. Certainly, a more generic e-mail service endpoint could also be fashioned that doesn't have
this limitation. We'll see that in the next example. But this works well for our purposes, and deliberately
removes any chance that the service endpoint will be used to send e-mail to anyone else - no spam originating
from here.
As provided, this endpoint could be used to send content to the user that was requested by them. For example, maybe you have a chart or a report of some kind in your TMS WEB Core app and you'd like to offer an option where the user could e-mail the chart to themselves, allowing them to mark it up and send it along to someone else. The TMS WEB Core app would then create the e-mail subject and body, potentially embedding additional content, and submit it to the XData server. The XData server would then take care of delivering the message to the user's e-mail without having to worry about any of its contents.
We'll see a little later that directly embedding images and other files as a Data URI makes for a better user
experience, but this same endpoint could also be adjusted to deal with attaching files separately or even adding data that is only available at the server.
Confirmation Codes.
A variation of this endpoint could be used for sending confirmation codes. The extra bit we need is for the server itself to supply (and track) the confirmation code sent to the user. Then, once the e-mail has been received, the user can enter the confirmation code and the client app can access a second XData service endpoint to validate it. The client app never sees the server-generated code other than when the user enters it into the app.
Here's what these endpoints might look like. In the first service endpoint, the e-mail is still generated by the
client app, but the {AUTHORIZATION_CODE} token is replaced in the subject and body of the message with whatever
token the server randomly generates.
function TSystemService.SendConfirmationCode(Reason, EMailAddress, EMailSubject, EMailBody, SessionCode, APIKey: String): String; var DBConn: TFDConnection; Query1: TFDQuery; DatabaseName: String; DatabaseEngine: String; AuthorizationCode: Integer; AuthorizationCodeString1: String; AuthorizationCodeString2: String; ExpiresAt: TDateTime; SMTP1: TIdSMTP; Msg1: TIdMessage; Addr1: TIdEmailAddressItem; Html1: TIdMessageBuilderHtml; SMTPResult: WideString; begin ExpiresAt := Now + (10 * 60) / 86400; // 10 minutes // Returning JSON, so flag it as such TXDataOperationContext.Current.Response.Headers.SetValue('content-type', 'application/json'); if not(MainForm.MailServerAvailable) then raise EXDataHttpUnauthorized.Create('Mail Services Not Configured'); // If the subject doesn't contain a place for the authorization code, then we can't really send an authorization message. if (Pos('{AUTHORIZATION_CODE}', EMailSubject) = 0) then raise EXDataHttpUnauthorized.Create('Invalid Subject: Missing {AUTHORIZATION_CODE}'); // If the message doesn't contain a place for the authorization code, then we can't really send an authorization message. if (Pos('{AUTHORIZATION_CODE}', EMailBody) = 0) then raise EXDataHttpUnauthorized.Create('Invalid Body: Missing {AUTHORIZATION_CODE}'); // Setup DB connection and query DatabaseName := MainForm.DatabaseName; DatabaseEngine := MainForm.DatabaseEngine; try DBSupport.ConnectQuery(DBConn, Query1, DatabaseName, DatabaseEngine); except on E: Exception do begin DBSupport.DisconnectQuery(DBConn, Query1); MainForm.mmInfo.Lines.Add('['+E.Classname+'] '+E.Message); raise EXDataHttpUnauthorized.Create('Internal Error: CQ'); end; end; // Generate an authorization code (6 digits with a gap in the middle) AuthorizationCode := Random(999998)+1; // Don't want zero as a code! AuthorizationCodeString1 := RightStr('000000'+IntToStr(AuthorizationCode),6); AuthorizationCodeString1 := Copy(AuthorizationCodeString1,1,3)+' '+Copy(AuthorizationCodeString1,4,3); AuthorizationCodeString2 := RightStr('000000'+IntToStr(AuthorizationCode),6); AuthorizationCodeString2 := '<span>'+Copy(AuthorizationCodeString2,1,3)+'</span><span style="margin-left:5px">'+Copy(AuthorizationCodeString2,4,3)+'</span>'; // Store the generated Authorization code for later try {$Include sql\system\authcode_insert\authcode_insert.inc} Query1.ParamByName('AUTHCODE').AsString := DBSupport.HashThis(IntToStr(AuthorizationCode)+EMailAddress+SessionCode); Query1.ParamByName('DESTINATION').AsString:= DBSupport.HashThis(EMailAddress+SessionCode); Query1.ParamByName('VALIDAFTER').AsDateTime := TTimeZone.local.ToUniversalTime(ElapsedTime); Query1.ParamByName('VALIDUNTIL').AsDateTime := TTimeZone.local.ToUniversalTime(ExpiresAt); Query1.ParamByName('APPLICATION').AsString := ApplicationName; Query1.ParamByName('VERSION').AsString := MainForm.AppVersion; Query1.ParamByName('IPADDRESS').AsString := TXDataOperationContext.Current.Request.RemoteIP; Query1.ExecSQL; except on E: Exception do begin DBSupport.DisconnectQuery(DBConn, Query1); MainForm.mmInfo.Lines.Add('['+E.Classname+'] '+E.Message); raise EXDataHttpUnauthorized.Create('Internal Error: ACI'); end; end; // Finally, let's send the email Msg1 := nil; Addr1 := nil; SMTP1 := TIdSMTP.Create(nil); SMTP1.Host := MainForm.MailServerHost; SMTP1.Port := MainForm.MailServerPort; SMTP1.Username := MainForm.MailServerUser; SMTP1.Password := MainForm.MailServerPass; try Html1 := TIdMessageBuilderHtml.Create; try Html1.Html.Add('<html>'); Html1.Html.Add('<head>'); Html1.Html.Add('</head>'); Html1.Html.Add('<body>'); Html1.Html.Add(StringReplace(EMailBody, '{AUTHORIZATION_CODE}', AuthorizationCodeString2, [rfReplaceAll, rfIgnoreCase])); Html1.Html.Add('</body>'); Html1.Html.Add('</html>'); Html1.HtmlCharSet := 'utf-8'; Msg1 := Html1.NewMessage(nil); Msg1.Subject := StringReplace(EMailSUbject, '{AUTHORIZATION_CODE}', AuthorizationCodeString1, [rfReplaceAll, rfIgnoreCase]); Msg1.From.Text := MainForm.MailServerFrom; Msg1.From.Name := MainForm.MailServerName; Addr1 := Msg1.Recipients.Add; Addr1.Address := EMailAddress; SMTP1.Connect; try try SMTP1.Send(Msg1); except on E: Exception do begin SMTPResult := SMTPResult+'[ '+E.ClassName+' ] '+E.Message+Chr(10); end; end; finally SMTP1.Disconnect(); end; finally Addr1.Free; // Shouldn't have to free this? Msg1.Free; // Shouldn't have to free this? Html1.Free; // Should have to free this! end; except on E: Exception do begin SMTPResult := SMTPResult+'[ '+E.ClassName+' ] '+E.Message+Chr(10); end; end; SMTP1.Free; // Should have to free this! if SMTPResult = '' then Result := 'Sent' else Result := 'Send Failed: '+SMTPResult; // All Done try DBSupport.DisconnectQuery(DBConn, Query1); except on E: Exception do begin MainForm.mmInfo.Lines.Add('['+E.Classname+'] '+E.Message); raise EXDataHttpUnauthorized.Create('Internal Error: DQ'); end; end; end;
Note that in this case, we're passing in the e-mail address that we're sending the email to, rather than getting it from the JWT. This is because we might use this mechanism for user
registration, where the user doesn't yet have a JWT. The code we're generating is just a six-digit number (not
starting with 0) that we store in a database so that we can verify it with the following endpoint.
function TSystemService.VerifyConfirmationCode(EMailAddress, SessionCode, ConfirmationCode, APIKey, Reason: String): String; var DBConn: TFDConnection; Query1: TFDQuery; DatabaseName: String; DatabaseEngine: String; ElapsedTime: TDateTime; ApplicationName: String; begin // Time this event ElapsedTime := Now; // Returning JSON, so flag it as such TXDataOperationContext.Current.Response.Headers.SetValue('content-type', 'application/json'); if not(MainForm.MailServerAvailable) then raise EXDataHttpUnauthorized.Create('Mail Services Not Configured'); // Setup DB connection and query DatabaseName := MainForm.DatabaseName; DatabaseEngine := MainForm.DatabaseEngine; try DBSupport.ConnectQuery(DBConn, Query1, DatabaseName, DatabaseEngine); except on E: Exception do begin DBSupport.DisconnectQuery(DBConn, Query1); MainForm.mmInfo.Lines.Add('['+E.Classname+'] '+E.Message); raise EXDataHttpUnauthorized.Create('Internal Error: CQ'); end; end; // See if we can find a match try {$Include sql\system\authcode_verify\authcode_verify.inc} Query1.ParamByName('AUTHCODE').AsString := DBSupport.HashThis(ConfirmationCode+EMailAddress+SessionCode); Query1.ParamByName('DESTINATION').AsString:= DBSupport.HashThis(EMailAddress+SessionCode); Query1.ParamByName('APPLICATION').AsString := ApplicationName; Query1.ParamByName('VERSION').AsString := MainForm.AppVersion; Query1.ParamByName('IPADDRESS').AsString := TXDataOperationContext.Current.Request.RemoteIP; Query1.Open; except on E: Exception do begin DBSupport.DisconnectQuery(DBConn, Query1); MainForm.mmInfo.Lines.Add('['+E.Classname+'] '+E.Message); raise EXDataHttpUnauthorized.Create('Internal Error: AKV'); end; end; if Query1.RecordCount = 1 then Result := 'Success' else Result := 'Fail'; if Query1.RowsAffected <> 1 then begin DBSupport.DisconnectQuery(DBConn, Query1); raise EXDataHttpUnauthorized.Create('Internal Error: CEU'); end; // All Done try DBSupport.DisconnectQuery(DBConn, Query1); except on E: Exception do begin MainForm.mmInfo.Lines.Add('['+E.Classname+'] '+E.Message); raise EXDataHttpUnauthorized.Create('Internal Error: DQ'); end; end; end;
XData service endpoint calls are stateless, so the only way to validate the code is to compare it against a persisted value somewhere, typically a database, as we've done here. Additional steps may need to be taken to prevent someone from generating spurious confirmation code e-mails to other accounts, perhaps by also limiting the number of confirmation codes requested from a given IP address.
When generating e-mails for notifications about server-side events, the same approach can be used, just that it
doesn't necessarily need to be in a service endpoint. Or perhaps it may make sense to use them. Endpoints use
multi-threading, so you could call them from the XData server itself and send them out in the same way as you would
from the client, but based on a timer or database trigger of some kind.
There are also many other ways to send e-mail, not necessarily having to use Indy or service endpoints in this
way. This is offered simply as one workable example. If you've used something different for sending e-mails from
Delphi previously, it will likely work just as well here. Please comment below if you've used other approaches
that might be of interest.
E-Mail Content Formatting.
The examples provided above assume that we're sending HTML e-mail rather than plain text e-mail. It is 2023 as of this writing, so there's not really a reason that readily comes to mind for someone not to have access to HTML e-mail. Given that, there are a few other things to take note of. Generally, we want our e-mails, like the rest of our app, to have a bit of a theme. Maybe some custom colors or custom fonts.
The HTML used in e-mail has come a long way, thankfully, and we can now include quite a bit of HTML in our e-mails and have it rendered predictably. Assuming of course that the user is using a modern e-mail client. Which is perhaps where we're likely to encounter the most trouble. People don't readily change e-mail clients very often. And there are many variations of even just Outlook in the world, each supporting (or not) HTML e-mail with differing degrees of fidelity. And even Gmail might struggle with a few things, like support for custom fonts.
The main trick when formatting HTML for e-mails is to use inline CSS. There often isn't support for a separate
CSS file, and any <style> definitions that might be provided in the <head> of an HTML e-mail might
be ignored. Using inline CSS tends to take out some of the variability in how styles are applied. Here's an
example of what an e-mail might look like as it is generated by the client.
procedure TForm1.btnSendEMailClick(Sender: TObject); var MailSubject: String; MailBody: TStringList; begin MailSubject := 'This is the subject of the message'; MailBody := TStringList.Create; MailBody.Add('<!DOCTYPE html>'); MailBody.Add('<html lang="en">'); MailBody.Add(' <body>'); MailBody.Add('Hello!'); MailBody.Add('<p><pre style="font-size:10px; line-height:70%;">'); MailBody.Add('Req » '+FormatDateTime('yyyy-mmm-dd (ddd) hh:nn:ss', Now)+'<br />'); MailBody.Add('</pre></p>'); MailBody.Add(' </body>'); MailBody.Add('</html>'); await(SendRequest('ISystemService.SendEMail',[ MailSubject, MailBody.Text ])); end;
In this example, we're glossing over how the call to the endpoint is made - SendRequest is a method in this project that is just a wrapper for RawInvokeAsync. However, the content of the e-mail is
constructed just like a normal HTML page, with all the usual HTML elements and attributes available. If we want
to change the size of a font, we include it directly in the style attribute of the HTML element we want to
change, rather than applying a CSS class that is defined elsewhere. We can include many other style rules in
the same way, and also links and other content, just as if we were crafting a regular HTML page.
Content and Security.
One of the issues with e-mail generally is that potential problems can arise related to any embedded images.
When the e-mail client (a browser, essentially) tries to download and present an image that is referenced in the
HTML, it needs to go and get that image from a remote server. That remote server is then able to track that the
e-mail has been opened and read, potentially. This is used by bad actors when sending spam or other nefarious
content to validate, at the very least, that someone is reading the e-mail. Marketers might use
this kind of mechanism to help gauge the success of their mass e-mail campaigns. And it is perhaps unfair to
lump marketers and bad actors into the same group, but the e-mail client has no way to tell them apart.
As a result of this undesired behavior, e-mail clients often put up a message or a prompt of some kind, indicating that some content in the e-mail has been blocked, usually with an option to display that content if you wish. Better clients allow you to remember this choice so you don't have to click on a button each time an e-mail from the same person (or from the same domain or whatever criteria they use) is received. Some, like the Mail app on iPhones, don't give you this option and put up the same message each and every time. You can turn off this security feature and automatically download remote images all the time for every sender. Not really good options either way.
Assuming you're working on an app that doesn't have any nefarious purposes, one way around this inconvenience
is to not use links to remote images. Sounds simple right? Instead of adding a link to a remote image, the
image can be embedded directly in the e-mail as a Data URL (aka Data URI). We covered that topic in this
post. But by embedding the image, the e-mail app doesn't have to make any remote image request, and thus
doesn't violate any security rules and can therefore display the image immediately without prompts of any kind. The trade-off here
is that the e-mail will now contain the entire image, making it considerably larger than what it would be with
just a link. So be careful to size the images appropriately.
Font Handling.
One of the ways we might want to personalize an e-mail is by providing a custom font. Perhaps the same font
that our main website uses, for example. Historically, support for different fonts has been sketchy at best, and
even today you're likely to run across e-mail clients that don't support all the custom fonts that we'd like. A good practice is to always ensure that you have a similar fallback font defined. But how do we supply a font in
the first place? We can add one using the <head> section of the HTML that we generate. Let's say we
wanted to include Cairo - a Google Font - as the main font for our e-mail.
MailBody.Add(' <head>'); MailBody.Add(' <style>'); MailBody.Add(' @font-face {'); MailBody.Add(' font-family: "Cairo";'); MailBody.Add(' font-style: normal;'); MailBody.Add(' font-weight: 400;'); MailBody.Add(' src: url(https://fonts.gstatic.com/s/cairo/v28/SLXgc1nY6HkvangtZmpQdkhzfH5lkSs2SgRjCAGMQ1z0hOA-a1PiKg.woff) format("woff");'); MailBody.Add(' }'); MailBody.Add(' </style>'); MailBody.Add(' </head>');
MailBody.Add(' <body>');
MailBody.Add(' <div style="font-family: Cairo, Verdana, sans-serif; font-size: 16px; line-height: 1.2;">');
MailBody.Add(' Hello!');
MailBody.Add(' </div>');
MailBody.Add(' </body>');
MailBody.Add('</html>');
In this case, we're using a WOFF font. We could try other formats as well, like WOFF2 or TTF. I've had good luck with the WOFF variant, so that's what is used here. If you're looking for the underlying files for a Google font, you can use links found in this GitHub repository to get at the underlying files.
Then, in the body of the e-mail, you can see the reference to the font using the "font-family" style rules as usual, being
sure to supply the fallback font at the same time. There is the concept of "web safe fonts" that suggest fallback fonts that are widely supported. Verdana is one
of those, but lists of such fonts often include fonts that are available only on certain platforms, which seems
to defeat the purpose of such a definition. As always, be sure to test any e-mail clients to see that your
custom font, or at least a fallback, is used.
Fonts and Security.
If you do include a custom font, like the Cairo font in the above example, e-mails are likely to trigger the same security warning we encountered with images, even if they have no other images in them. This is because the font itself is another element that the e-mail client has to go out and retrieve, and is subject to the same potential issues around tracking. Even if it is for a Google font. Perhaps especially if it is a Google font.
To get around
that, instead of providing a link to the font file as a URL as we've done above, we can instead convert the
font file into a Base64-encoded file and send that instead. Here, we've got the font stored as a Base64-encoded string that we
can load into a TStringList component and add to our e-mail.
MailFont := TStringList.Create; MailFont.LoadFromFile('fonts/cairo.woff.base64'); MailBody.Add(' @font-face {'); MailBody.Add(' font-family: "Cairo";'); MailBody.Add(' font-style: normal;'); MailBody.Add(' font-weight: 400;'); MailBody.Add(' src: url('+MailFont.Text+') format("woff");'); MailBody.Add(' }');
This is essentially doing the same thing we did previously - the e-mail client doesn't
have to go anywhere to retrieve the font, so no security flag is raised. Note that we've increased the size of
the e-mail by embedding the font directly, adding roughly 25 KB for this particular font.
We can add logos and other images in the same way - Base64-encoded - and thus generate a complete HTML e-mail
without any security warnings. With custom fonts, links, and the rest of it. Here's an example of an e-mail
received on an iPhone with all of these ideas combined.
Example E-Mail with Base64-Encoded Fonts and Images.
In this case, the logo in the footer, the chart itself, and the Cairo font have all been directly included in
the e-mail, and thus no security warning is displayed. The e-mail is larger, certainly. Whether that's a good
tradeoff depends on how many e-mails are sent, whether the extra size is a problem for your users, and whether
the convenience of removing that extra click or tap to show the complete message with all the fonts and images is worth the trouble.
More Notifications.
That gets our notifications on their way via e-mail. Future posts will cover additional communication channels that we might want to use for notifications. And there are other ways to handle e-mails as well. As
always, please drop any comments, questions, or any other feedback below. Perhaps you have a better system for
e-mail? Or you're really curious about browser-based notifications that are now possible with PWA apps in iOS? Lots of topics to explore!
Related Posts
Notifications via E-Mail with XData/Indy
Notifications via Browser Push Notifications
Notifications via SMS with Twilio
Follow Andrew on 𝕏 at @WebCoreAndMore or join our 𝕏 Web Core and More Community.
Andrew Simard
This blog post has received 2 comments.
Andrew Simard
All Blog Posts | Next Post | Previous Post
Randall Ken