I’ve been giving my AI assistant access to more tools recently. Email seems useful: it can send me a finished result or email someone when I’ve explicitly asked it to. I didn’t really want to just hand it my personal SMTP credentials, use a paid SaaS or have it send emails from a random domain.

I ended up building terraform-aws-agent-mail, an OpenTofu/Terraform module which puts a small Lambda between the agent and Amazon SES. This should be relatively cheap and easily customisable to my use case.

There are commercial services for this, such as AgentMail and Resend. They do a lot more, including receiving email, but I didn’t want another service when I only needed a small and fairly restricted way to send it.

The constrained path from the agent to emailThe agent host can invoke only one Lambda. The Lambda validates the message, claims rate-limit counters in DynamoDB, then asks SES to send to one recipient and an audit BCC.agent hostagent-mail -to …AWS credentialsno SES permissionnot grantedses:SendEmaildynamodb:*other LambdasInvokeFunctionagent-mail Lambda1 validaterecipient · size · characters2 claimhour + day counters3 sendfixed From · text onlyDynamoDB down = nothing sentSendEmailSESplain textrecipientone addressaudit BCCrequired by IAMDynamoDB · atomic counters
The agent can invoke one function. It cannot call SES or DynamoDB directly.

Permissions

A large part of letting it send emails from my main domain is sensible permissions. I’ve broken this down into a few sections.

Lambda

The Lambda that sits between the agent and SES brings in its own validation. It takes three fields: to, subject and text. There’s no way to set From, Reply-To, CC, BCC, other headers, HTML or attachments; they just aren’t part of the request. It rejects display names, lists, IP literals and control characters. If that means some weird but technically valid address gets rejected, I’m fine with that.

It also enforces a single sender address, and all messages get BCCed to me for a quick audit trail.

IAM

The agent has its own IAM user, which can only invoke the Lambda:

resource "aws_iam_user_policy" "hermes" {
  user = aws_iam_user.hermes.name
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "lambda:InvokeFunction"
      Resource = module.agent_mail.function_arn
      Condition = {
        IpAddress = { "aws:SourceIp" = local.hermes_host_ipv4_cidr }
      }
    }]
  })
}

The agent runs on a small server, which has static, long-lived AWS keys restricted to its IP. Not perfect, but I don’t fancy paying EC2 prices for integrated IAM.

SES permissions

The Lambda constructs the sender and audit BCC itself, but that still relies on my code being correct. The Lambda’s own IAM policy also restricts ses:SendEmail:

Condition = {
  StringEquals = {
    "ses:FromAddress" = var.from_address
  }
  "ForAnyValue:StringEquals" = {
    "ses:Recipients" = var.bcc_address
  }
}

ses:Recipients includes To, CC and BCC. If a bug causes the Lambda to omit the audit address, AWS refuses the send. I like doing it this way: the code builds the request and IAM double-checks the bits I actually care about.

Rate limiting

I’d like to stop a runaway agent sending large numbers of emails, so rate limiting felt important. Mail to my own domain isn’t rate limited. Everything else is limited to five messages per hour and twenty per day. Claude was easily able to implement this using DynamoDB.

Checks applied to outgoing emailSchema, recipient, size, characters and rate limits can each reject a request with a stable error code. Only a request which passes those checks is sent through SES.payload { to, subject, text }01schemaunknown fields refusedschema_error02recipientone plain addressinvalid_recipient03sizesubject ≤ 200 · body ≤ 20 KiBtoo_large04characterscontrol characters refusedinvalid_characters05rate (external only)hour + day claimed atomicallyrate_limited06SESfixed sender · text onlysent · message_idThe counter is claimed before SES is called. A send SES rejects still uses part of the limit.
Each check returns an error code if it rejects the message.

Setup

The module creates the Lambda, DynamoDB table, IAM role and log group. My personal Terraform looks something like this:

module "agent_mail" {
  source = "git::https://gitlab.com/danwainwright/terraform-aws-agent-mail.git?ref=v1.1.0"

  ses_identity_arn = aws_sesv2_email_identity.mail.arn
  from_address     = "assistant@${aws_sesv2_email_identity.mail.email_identity}"
  from_name        = "Dan's assistant"
  bcc_address      = "assistant-sent@${aws_route53_zone.primary.name}"
  own_domain       = aws_route53_zone.primary.name
}

The module expects an SES identity. Mine has DKIM, a custom MAIL FROM domain and bounce/complaint notifications through SNS. Also worth remembering SES accepting a message doesn’t mean it was delivered.

The agent uses a small Go client:

agent-mail -to someone@example.com -s "Subject" < body.txt

What’s next

I could also add an optional LLM check before SES, looking for things like the agent writing as if it were me rather than my assistant, or asking for something obviously illegal. It would need to fail closed, and it would also mean sending the email content to another model, so I’m not convinced it’s worth the extra complexity yet.

The module is on GitLab if you want to give your own agent a smaller permission than SMTP.