How I finally got Google to accept my Ghost contact form mail

There are lots of services for webforms that will take your money and handle your forms. This is not a post about any of them. This is a post about how I rolled my own, and the 'fun' I had doing it.

How I finally got Google to accept my Ghost contact form mail

First off, let me say the obvious: There are lots of services for webforms that will take your money and handle your forms. This is not a post about any of them.

I have a Ubuntu virtual server. I can, in principle, install anything I want. [Of course, it has limits, but I figured it could handle a few contact form submissions a month, in addition to the handful of websites on it.] And I figured it'd be easy, and I'm a full stack webdev! I can build my own contact form backend!

💡
The actual contact form part was easy. So was the backend. But getting my email delivered instead of bounced as spam turned out to be a big pain. Before you start happily coding along with me, you should jump down to all the DNS settings and do them first, so that they'll have propagated by the time you've got the front- and back-end code ready.

Making the form

I'm running on Ghost, so I just made a page with this HTML:

<form id="contact-form"> 
    <div id="responsemsg"></div>
<div class="form-group"><label for="name">Name</label>
            <input type="text" name="name" class="form-control" id="name" placeholder="name" required>
          </div>
          
          <div class="form-group">
              <label for="email">Email</label>
              <input type="email" name="email" class="form-control" id="email" aria-describedby="emailHelp" placeholder="email" required>
            </div>
          <div class="form-group">
          <label for="message">Message</label>
          <textarea class="form-control" name="message" id="message" rows="3" required></textarea>
          </div>
        
    <button class="form-btn" type="submit"  id="submit" value="Send" onclick="processForm();return false;" >Send</button>
        </form>

And then I did code injection on that specific page to add some CSS styling (not shown) and to submit the form (this goes in the footer):

<script>
    
    function processForm() {
        if (validateForm() === true) {
            sendData();
        } else {
            formAlert("Please fill out all the fields.")
        }
    }
    
    function sendData() {
        console.log('data was sent')
        formAlert("One second...");
        var postURL = "https://MY_CONTACT_FORM_URL";
        var http = new XMLHttpRequest();
        http.open("POST", postURL, true);
        http.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        var params = "name=" + document.forms["contact-form"]["name"].value +
            "&email=" + document.forms["contact-form"]["email"].value +
            "&message=" + document.forms["contact-form"]["message"].value +
            "&who=MYSPECIFICFORMNAME" ;
        console.log(params)
        http.send(params);
        http.onload = function() {
            formAlert("Thank you, your message has been sent!");
             document.getElementById("contact-form").reset();
        }
    }

    function validateForm() {
        var returner = false;
        var x = document.forms["contact-form"]["name"].value.length * document.forms["contact-form"]["email"].value.length * document.forms["contact-form"]["message"].value.length;
        
        if (x !== 0 ) {
            returner = true;
        }
        return returner;
    }
    
    function formAlert(text) {
        document.getElementById("responsemsg").innerHTML = "<br><p><em>" + text + "</em></p>";
    }
</script>

Front end code based in part on Dana Houshmand's Zapier-using tutorial.

Making the back-end

Then I set up the back end.

🛑
Super important: I do NOT let the webform tell the back end what email address to deliver to, nor do I send the email as if it's from the user's email in the webform input. That's an opening for spammers to exploit your webform. (In the example below, I have several possible destinations that could be listed on the webform, but I don't take user input for the to/from address the backend will use. If you had a lot of them or wanted them to be customer-editable, you could look up the incoming 'who' value against a database.)

I had node and express installed already, so I just needed to add nodemailer. Here's the back-end code I ended up with, after some fussing with CORS:

const express = require("express");
const nodeMail = require("nodemailer");
const path = require("path");
const cors = require('cors')
const app= express() ;
const corsOptions = {
        origin: ['https://cathy.sarisky.link','
                 othersite.com',
                 'stillanothersite.com'],
        optionsSuccessStatus: 200
		}

app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "public")));

async function mainMail(name, email, message, who) {
    const transporter = await nodeMail.createTransport({
          port: 25, host: 'localhost', 
          tls: {rejectUnauthorized: false}
          });
    
    let mailOption
    if ( who == 'MYSPECIFICFORMNAME' ) {
    mailOption = {
          from: 'no-reply@someemail',
          to: 'the email of the webform owner',
          subject: 'NEW PORTFOLIO WEBFORM' ,
          html: `Your portfolio got a message from<p>
          Email : ${email}<p>
          Name: ${name}<p>
          Message: ${message}<p>`,
          };
        } else if ( who == 'SOME OTHER FORM' ) {
           mailOption = {
               /* options for a second webform, just like above. 
               Change the 'to' line, 'from', 'subject', etc.*/
			};
        } else if (who == 'carthacks' ) {
           mailOption = {
  				/*just like above */
                                       };
        }
          else {console.log('webform called without a matching WHO') ;
                return 'Could not send this message, sorry!'
		}
    
 	try {
        await transporter.sendMail(mailOption);
        return Promise.resolve("Message Sent Successfully!");
    } catch (error) {
        return Promise.reject(error);
   }
}

app.get("/", (req, res) => {
          res.render(index.html);
});

app.post("/contact", cors(corsOptions), async (req, res, next) => {
          const { name, email, message, who } = req.body;
          try {
                      await mainMail(name, email, message, who);
                      res.send("Message Successfully Sent!");
                    } catch (error) {
                                res.send("Message Could not be sent");
                              }
});

app.listen(YOURFAVORITEPORT, () => console.log("Server is running!"));   

Note that this is different than most of the tutorials I saw online, which had nodemailer authenticating with Google using a username and password to send email as the gmail user.

Instead, nodemailer is dropping my email directly to the mail server running on my virtual server, and letting it handle delivery. I already had a mail server running in the virtual server so that Ghost could send occasional messages and password resets. Seemed like a great idea, right?

🛑
Before you set up a mail server, you really should know what an open relay is, and why it'll get your stuff disappeared from the internet if you have one. Fortunately, most packages now start off with the mail server not acting as an open relay, but it's not too hard to screw it up, so look carefully and ask an online tool to test your mail server for open relaying if you do set one up.

DNS entries & trying not to look like spam

OK, with that out of the way, I was done, right? Well, yes and no. The front end successfully made the request to the back end, and I could see in my mail server log that the message had gotten to the mail server.

That's when things got tricky, and my one hour project became an all day project. If I'd wanted the 'to' address to be an address on my own server, I'd have been fine, but no, I wanted delivery to my Gmail address and a Microsoft-hosted address. These companies are really good at blocking spam, and they're perfectly happy to block email from an unknown, new, and imperfectly configured server. Here's what happened in my logs:

postfix/smtpd[16495]: E4A346774D: client=localhost[127.0.0.1]
postfix/cleanup[16499]: E4A346774D: message-id=<[email protected]>
postfix/qmgr[16492]: E4A346774D: from=<[email protected]>, size=645, nrcpt=1 (queue active)
postfix/smtpd[16495]: disconnect from localhost[127.0.0.1] ehlo=2 starttls=1 mail=1 rcpt=1 data=1 commands=6
postfix/smtp[16500]: E4A346774D: to=<[email protected]>, relay=gmail-smtp-in.l.google.com[2607:f8b0:4023:c0b::1b]:25, delay=1.4, delays=0.09/0.01/0.47/0.78, dsn=5.7.25, status=bounced (host gmail-smtp-in.l.google.com[2607:f8b0:4023:c0b::1b] said: 
	550-5.7.25 [2a02:4780:10:1a38::1] The IP address sending this message does not 
    have a PTR record setup, or the corresponding forward DNS entry does 
    not point to the sending IP. As a policy, Gmail does not accept 
    messages from IPs with missing PTR records. Please visit 
    https://support.google.com/mail/answer/81126#ip-practices for more 
    information. iz22-20020a170902ef9600b001708d35847bsi12724017plb.278 - gsmtp (in reply to end of DATA command))

The first four lines are perfect. That's nodemailer handing the message off to the local mailserver. The next line (broken over multiple lines for legibility) is the problem. That's Google telling me it isn't taking my mail, and it's doing so long after nodemailer (and all the front end code) has already declared success.

🗨️
I don't have a great fix for this. You can't bounce the failures to the webform sender to let them know it didn't work, because you'll attract spammers. It's worth making the from address an address (on the local server) that will receive the bounce messages. Check it at least occasionally for things going wrong! [Unfortunately, if things are going wrong, trying to forward those bounce messages to a Gmail account probably isn't going to work either.]

Actually, over the course of 24 hours, Google threw me a whole series of different error messages. This is just the last of them. Here's the list of all the things I had to do to make it work:

As you set up your DNS, you'll probably want to set the TTL settings to short on all this stuff. I like 600s, which is ten minutes. That way I don't wait overnight for a typo correction to get fixed. When it's all working perfectly (and has been for several days), then you can go back and set the TTLs to several hours.

Fix the mydomain.tld DNS to include my IPv6 address

I didn't have an AAAA record (with my IPv6 address) for my site. Google's SMTP server didn't like that when my mailserver occasionally decided to use the IPv6 address instead of the IPv4 address. This contributed to 'sometimes it works, sometimes it doesn't' behavior and extra fun debugging.

Add an SPF record

An SPF record says who allowed to send email on your domain's behalf. Mine looks like this:

TXT @ "v=spf1 ip4:194.113.64.66 ip6:2a02:4780:10:1a38::1 ~all" 600

Note that the ip4 and ip6 both go in the same text file, if your email could come from both.

Add a DKIM record, and install opendkim

This is a key that your server uses to sign email. The DNS piece looks like this:

TXT mail._domainkey "v=DKIM1; h=sha256; k=rsa; p=MIIBI...(big string that's a public key goes here)"

You also need to tell your mail server to use the key. I followed this opendkim & postfix tutorial.

Add a DMARC record

DMARC records tell other mailservers what you think they should do with your email. Here's my DNS:

TXT _dmarc "v=DMARC1; p=none; rua=mailto:[email protected],mailto:[email protected]; adkim=r;aspf=r;"

The rua tells other servers you'd like to get emails (at email addresses ON YOUR DOMAIN) about their bounces. They're informative, but you don't get them right when the bounce happens. p=none means "don't quarantine/block mail that fails your tests". In my experience, Google will bounce it anyway, but Google does appear to like sites with DMARC records, so here you go.

Fix PTR records

Pointer (PTR) records are the only DNS thing you don't directly control. They're for reverse lookup - they take an IP address and return a name. If you're lucky, your webserver may have a control panel page that lets you set them. They're probably on your server control panel page. You won't find them with the other DNS records.

Google gets grouchy if they don't match your sending domain, so you're going to want to fix yours. Make sure you fix IPv4 and IPv6, if there's any chance your mailserver might use both.

Once the email is actually getting delivered... 

It's probably going to the recipient's Spam/Clutter/Junk. The email recipient (you? your client?) needs to find it, and then do as many of these as apply to the situation: mark it as not spam, move it to the inbox, release it from quarantine, put it on the 'safe senders' list, put a gold star on it (Gmail), and add the sender (even if a no-reply address) to their contacts list. All of those things improve the chances of future emails from the webform actually showing up in the inbox. Don't be surprised if the email recipient has to do that a couple times.

Remember that DNS takes a while to propagate. If you have a bunch of 14400s TTLs already in your DNS settings, you're waiting four hours (and it can be closer to 24 hours) before the new settings kick in.

I'm happy to report that my contact form is now working great! Since I'm saving about $10/month by not paying a third party form provider, I'll have recouped the time in about oh... 2025.

Up next: adding ReCAPTCHA to my webform.


Hey, before you go... If your finances allow you to keep this tea-drinking ghost and the freelancer behind her supplied with our hot beverage of choice, we'd both appreciate it!