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.
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.
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.