我有一个在 EC2 实例上运行的 postfix 服务器。我想通过 SES 将所有电子邮件转发到我的个人收件箱。
问题:AWS 只允许在 AWS 控制台中验证的 FROM 地址,并且本例中的 FROM 地址可以是任何地址,例如:twitter.com。我无法将我的服务器的 IP 列入白名单并说:“无论发件人如何,都接受来自此位置的所有电子邮件”(无论如何这都是一个坏主意)
因此,我需要找到一种方法来转发带有经过验证的地址的电子邮件,但我不想丢失原始发件人的地址。
有办法做到这一点吗?
答案1
根据我们在聊天中的讨论,我将向您提供我的黑客定制解决方案,该解决方案将按照我们的预期更改“发件人”地址,然后交付到原始目的地点,但添加“回复”标头。
这是一种非常hackish的方法,但是应该在通过 PostFix 实际将消息发送到需要发送的位置之前,按照您的预期操作消息。
首先,PostFix端口需要更改。我们需要将 Postfix SMTP 端口更改为其他端口,25
以便我们要设置的 python SMTP 处理程序能够在该端口上工作。
编辑/etc/postfix/master.cf
。您将寻找这样的行:
smtp inet n - y - - smtpd
注释掉这一行,并在该行下方使用以下内容:
10025 inet n - y - - smtpd
这告诉 Postfix 我们不希望它监听标准 SMTP 端口。完成此步骤后,重新启动 postfix 服务。
接下来,Python SMTP 处理程序我上面提到过。这将处理传入的消息,操纵它们,并将它们重新发送到系统上的 PostFix。当然,假设所有邮件都在端口 25 上提交,即使是在本地。
该代码存在于GitHub 上的 GIST是基于我在某个地方找到的通用 Python SMTP 服务器代码示例(但抱歉不记得从哪里来的!),并且已经进行了操作。
代码也在这里,如果您好奇的话,它是用 Python 3 编写的,并且是用 Python 3 作为目标 Python 版本编写的:
#!/usr/bin/env python3
# Libraries
import smtplib
import smtpd
import asyncore
import email
import sys
from datetime import datetime
print('Starting custom mail handling server...')
# We need to know where the SMTP server is heh.
SMTP_OUTBOUND = 'localhost'
# We also need to know what we want the "FROM" address to be
FROM_ADDR = "[email protected]"
DESTINATION_ADDRESS = "[email protected]"
#############
#############
# SMTP SERVER
#############
#############
# noinspection PyMissingTypeHints,PyBroadException
class AutoForwardHandlerSMTP(smtpd.SMTPServer):
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
print('MESSAGE RECEIVED - [%s]' % datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
print('Receiving message from:', peer)
print('Message addressed from:', mailfrom)
print('Message addressed to :', rcpttos)
print('Message length :', len(data))
print(data)
# Flush the output buffered (handy with the nohup launch)
sys.stdout.flush()
# Analyze and extract data headers
msg = email.message_from_string(data)
orig_from = ''
try:
orig_from = msg['From']
msg['Reply-To'] = orig_from
# We have to use 'replace header' methods to overwrite existing headers.
msg.replace_header("From", FROM_ADDR)
except:
print("Error manipulating headers:", sys.exc_info()[0])
conn = smtplib.SMTP(SMTP_OUTBOUND, 10025)
conn.sendmail(FROM_ADDR, msg["To"], msg.as_string())
# Flush the output buffered (handy with the nohup launch)
print("\n\n")
sys.stdout.flush()
return
# Listen to port 25 ( 0.0.0.0 can be replaced by the ip of your server but that will work with 0.0.0.0 )
server = AutoForwardHandlerSMTP(('0.0.0.0', 25), None)
# Wait for incoming emails
asyncore.loop()
将其存储为/opt/PythonAutoForwarderSMTP.py
,或任何您想要的名称。首先,以 root 身份运行以下命令(通过sudo
或进入root
用户提示符),以确保它按我们的预期工作:
python3 /opt/PythonAutoForwarderSMTP.py
确认运行后,通过服务器发送电子邮件。它应该被拾取并为您提供来自该脚本的日志数据,表明消息已被接收和处理。然后,您还应该在 Postfix 日志上看到一个连接,并且该连接是在 Postfix 之后传送到某个地方的。如果所有这些看起来都正常,并且您正确处理邮件并在邮件最终结束的地方使用不同的“发件人”地址查看它,那么我们现在就可以让它自动启动! (在继续之前,您只需按Ctrl+即可C关闭 python 进程)。
假设我们希望它在启动时启动,那么我们需要对其进行设置。
作为root
,运行crontab -e
,并将以下内容添加到root
crontab:
@reboot /usr/bin/python3 /opt/PythonAutoForwarderSMTP.py 2>&1 >> /var/log/PythonSMTP.log &
保存 crontab 文件。如果您不想重新启动服务器,请执行刚刚添加的命令行(减去该@reboot
部分)来运行 Python SMTP 处理程序。
无论是否运行cron
,加载 Python 的进程最终都会分叉到后台,并且还将所有数据输出(Python 控制台中的错误或其他情况)以/var/log/PythonSMTP.log
追加模式放入日志文件中。这样,您随时可以根据需要获取日志。
如果一切按预期工作,这将正确添加 Reply-To 标头,并将消息中的“From”标头调整为我们期望的内容。 如果邮件已签名,我不能保证这将正常用于 SPF 和 DKIM 检查,但我可以说这将在使用 Postfix 将邮件转发到其他地方之前正确地“预处理”邮件。
强制性安全问题和功能变更通知:
- 发件人 DKIM 验证可能会失败。只要签名的邮件被操纵,DKIM 签名验证就会失败,这意味着您可能破坏了发件人的 DKIM 签名。这意味着事情可能由于签名验证失败而被视为垃圾邮件。该脚本可能可以自定义为“正常工作”,但我编写此脚本不是为了进行 DKIM/SPF 检查。
- 我们必须运行这个 Python SMTP 服务器
root
。这是必要的,因为在 Linux 中默认情况下我们不能绑定到 1024 以下的端口,除非我们是超级用户;这就是为什么 Postfix 有一个主“root”拥有的进程,并且执行不以 root 用户身份运行很长时间的子进程,只是为了端口绑定。- 端口 25 上的所有邮件最终都会通过此 Python SMTP 服务器。如果 Postfix 也处理从外部到内部的邮件,那么 Python SMTP 服务器将取代它。这可能会带来一些弊端,但最终会达到您的要求。
- 这是一个脆弱的解决方案。虽然它不像其他一些解决方案那么脆弱,但如果 Python 进程终止,它不会自动恢复,因此您必须根据具体情况处理错误,有时如果发生错误,则必须使 Python 进程恢复生机。完全消失。
- 有此中没有 StartTLS 或 SSL/TLS 处理程序。所以一切都是纯文本(这是不安全的!)
与往常一样,除非您知道自己在做什么,否则不应以 root 身份运行任何内容。在这种情况下,我以普通视图提供了此代码,以便您可以自己辨别该脚本的作用,以及您是否想以 root 身份运行它,如果您像我一样以安全为中心且偏执(我是IT 安全专业人员以及系统管理员,因此请原谅这些强制性通知)
答案2
除了 @Thomas Ward 的精彩回答之外,AWS 确实有一种“首选”方式,非常相似,唯一的区别是它使用 AWS 内部工具来完成任务,而不是外部 python 脚本。
此方法与另一种方法之间有一个关键区别,此方法将执行病毒/恶意软件扫描以及 DKIM 和 SPF 检查,您可以实际测试并查看它们是否有效PASS
。
所以,我在README
这里关注了这个 GitHub 存储库:https://github.com/arithmetric/aws-lambda-ses-forwarder
一切都是因为这个剧本。您将其放置在 AWS Lambda 上,它将根据 SES 规则对电子邮件进行后处理。
这是设置部分的副本README
:
笔记: 改变一些事情,比如S3-BUCKET-NAME
.
config
修改顶部对象中的值index.js
以指定用于查找 SES 存储的电子邮件的 S3 存储桶和对象前缀。还提供从原始目的地到新目的地的电子邮件转发映射。在 AWS Lambda 中,添加新函数并跳过选择蓝图。
将函数命名为“SesForwarder”并可选择为其提供描述。确保运行时设置为 Node.js 4.3 或 6.10。
对于 Lambda 函数代码,请将内容复制并粘贴
index.js
到内联代码编辑器中,或者压缩存储库的内容并直接或通过 S3 上传。确保处理程序设置为
index.handler
。对于角色,在创建新角色下选择“基本执行角色”。在弹出窗口中,为角色命名(例如 LambdaSesForwarder)。将角色策略配置为以下内容:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:*:*:*" }, { "Effect": "Allow", "Action": "ses:SendRawEmail", "Resource": "*" }, { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject" ], "Resource": "arn:aws:s3:::S3-BUCKET-NAME/*" } ] }
内存可以保留为 128 MB,但为了安全起见,将超时设置为 10 秒。该任务通常需要大约 30 MB 和几秒钟的时间。测试任务后,您也许可以减少超时限制。
在 AWS SES 中,验证您要接收和转发电子邮件的域。还要配置这些域的 DNS MX 记录以指向电子邮件接收(或入站)SES 端点。看SES 文档 对于每个区域中的电子邮件接收端点。
如果您具有对 SES 的沙箱级别访问权限,则还要验证您要将电子邮件转发到的、不在已验证域中的任何电子邮件地址。
如果您尚未配置入站电子邮件处理,请创建新的规则集。否则,您可以使用现有的。
创建处理电子邮件转发功能的规则。
在收件人配置页面上,添加您要从中转发电子邮件的任何电子邮件地址。
在操作配置页面上,先添加 S3 操作,然后添加 Lambda 操作。
对于 S3 操作:创建或选择现有 S3 存储桶。 (可选)添加对象键前缀。不选中加密消息并将 SNS 主题设置为 [无]。
对于 Lambda 操作:选择 SesForwarder Lambda 函数。将“调用类型”设置为“事件”,将 SNS 主题设置为“[无]”。
最后命名规则,确保其已启用并使用垃圾邮件和病毒检查。
如果您收到“无法写入存储桶”之类的错误,请在完成此步骤之前按照步骤 7 操作
如果 SES 要求您尝试添加访问 lambda:InvokeFunction 的权限,请同意。
需要配置 S3 存储桶策略,以便您的 IAM 用户具有对 S3 存储桶的读写访问权限。当您在 SES 中设置 S3 操作时,它可能会添加一个存储桶策略语句,拒绝除 root 之外的所有用户获取对象的访问权限。这会导致 Lambda 脚本出现访问问题,因此您可能需要使用如下所示的语句来调整存储桶策略语句:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "GiveSESPermissionToWriteEmail", "Effect": "Allow", "Principal": { "Service": "ses.amazonaws.com" }, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::S3-BUCKET-NAME/*", "Condition": { "StringEquals": { "aws:Referer": "AWS-ACCOUNT-ID" } } } ] }
(可选)为此存储桶设置 S3 生命周期,以在几天后删除/过期对象,以清理保存的电子邮件。
我将发布此答案创建时的脚本版本,并进行一两处更改。
我注意到通过经过验证的域路由两次的电子邮件已被此脚本更改,因此为了美观,我修复了该问题
"use strict";
var AWS = require('aws-sdk');
console.log("AWS Lambda SES Forwarder // @arithmetric // Version 4.2.0");
// Configure the S3 bucket and key prefix for stored raw emails, and the
// mapping of email addresses to forward from and to.
//
// Expected keys/values:
//
// - fromEmail: Forwarded emails will come from this verified address
//
// - subjectPrefix: Forwarded emails subject will contain this prefix
//
// - emailBucket: S3 bucket name where SES stores emails.
//
// - emailKeyPrefix: S3 key name prefix where SES stores email. Include the
// trailing slash.
//
// - forwardMapping: Object where the key is the lowercase email address from
// which to forward and the value is an array of email addresses to which to
// send the message.
//
// To match all email addresses on a domain, use a key without the name part
// of an email address before the "at" symbol (i.e. `@example.com`).
//
// To match a mailbox name on all domains, use a key without the "at" symbol
// and domain part of an email address (i.e. `info`).
var defaultConfig = {
fromEmail: "",
subjectPrefix: "",
emailBucket: "ses-sammaye",
emailKeyPrefix: "email/",
forwardMapping: {
"@vvv.com": [
"[email protected]"
],
"@fff.com": [
"[email protected]"
],
"@ggg.com": [
"[email protected]"
],
},
verifiedDomains: [
'vvv.com',
'fff.com',
'ggg.com'
]
};
/**
* Parses the SES event record provided for the `mail` and `receipients` data.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
exports.parseEvent = function(data) {
// Validate characteristics of a SES event record.
if (!data.event ||
!data.event.hasOwnProperty('Records') ||
data.event.Records.length !== 1 ||
!data.event.Records[0].hasOwnProperty('eventSource') ||
data.event.Records[0].eventSource !== 'aws:ses' ||
data.event.Records[0].eventVersion !== '1.0') {
data.log({message: "parseEvent() received invalid SES message:",
level: "error", event: JSON.stringify(data.event)});
return Promise.reject(new Error('Error: Received invalid SES message.'));
}
data.email = data.event.Records[0].ses.mail;
data.recipients = data.event.Records[0].ses.receipt.recipients;
return Promise.resolve(data);
};
/**
* Transforms the original recipients to the desired forwarded destinations.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
exports.transformRecipients = function(data) {
var newRecipients = [];
data.originalRecipients = data.recipients;
data.recipients.forEach(function(origEmail) {
var origEmailKey = origEmail.toLowerCase();
if (data.config.forwardMapping.hasOwnProperty(origEmailKey)) {
newRecipients = newRecipients.concat(
data.config.forwardMapping[origEmailKey]);
data.originalRecipient = origEmail;
} else {
var origEmailDomain;
var origEmailUser;
var pos = origEmailKey.lastIndexOf("@");
if (pos === -1) {
origEmailUser = origEmailKey;
} else {
origEmailDomain = origEmailKey.slice(pos);
origEmailUser = origEmailKey.slice(0, pos);
}
if (origEmailDomain &&
data.config.forwardMapping.hasOwnProperty(origEmailDomain)) {
newRecipients = newRecipients.concat(
data.config.forwardMapping[origEmailDomain]);
data.originalRecipient = origEmail;
} else if (origEmailUser &&
data.config.forwardMapping.hasOwnProperty(origEmailUser)) {
newRecipients = newRecipients.concat(
data.config.forwardMapping[origEmailUser]);
data.originalRecipient = origEmail;
}
}
});
if (!newRecipients.length) {
data.log({message: "Finishing process. No new recipients found for " +
"original destinations: " + data.originalRecipients.join(", "),
level: "info"});
return data.callback();
}
data.recipients = newRecipients;
return Promise.resolve(data);
};
/**
* Fetches the message data from S3.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
exports.fetchMessage = function(data) {
// Copying email object to ensure read permission
data.log({level: "info", message: "Fetching email at s3://" +
data.config.emailBucket + '/' + data.config.emailKeyPrefix +
data.email.messageId});
return new Promise(function(resolve, reject) {
data.s3.copyObject({
Bucket: data.config.emailBucket,
CopySource: data.config.emailBucket + '/' + data.config.emailKeyPrefix +
data.email.messageId,
Key: data.config.emailKeyPrefix + data.email.messageId,
ACL: 'private',
ContentType: 'text/plain',
StorageClass: 'STANDARD'
}, function(err) {
if (err) {
data.log({level: "error", message: "copyObject() returned error:",
error: err, stack: err.stack});
return reject(
new Error("Error: Could not make readable copy of email."));
}
// Load the raw email from S3
data.s3.getObject({
Bucket: data.config.emailBucket,
Key: data.config.emailKeyPrefix + data.email.messageId
}, function(err, result) {
if (err) {
data.log({level: "error", message: "getObject() returned error:",
error: err, stack: err.stack});
return reject(
new Error("Error: Failed to load message body from S3."));
}
data.emailData = result.Body.toString();
return resolve(data);
});
});
});
};
/**
* Processes the message data, making updates to recipients and other headers
* before forwarding message.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
exports.processMessage = function(data) {
var match = data.emailData.match(/^((?:.+\r?\n)*)(\r?\n(?:.*\s+)*)/m);
var header = match && match[1] ? match[1] : data.emailData;
var body = match && match[2] ? match[2] : '';
// Add "Reply-To:" with the "From" address if it doesn't already exists
if (!/^Reply-To: /mi.test(header)) {
match = header.match(/^From: (.*(?:\r?\n\s+.*)*\r?\n)/m);
var from = match && match[1] ? match[1] : '';
if (from) {
header = header + 'Reply-To: ' + from;
data.log({level: "info", message: "Added Reply-To address of: " + from});
} else {
data.log({level: "info", message: "Reply-To address not added because " +
"From address was not properly extracted."});
}
}
// SES does not allow sending messages from an unverified address,
// so replace the message's "From:" header with the original
// recipient (which is a verified domain)
header = header.replace(
/^From: (.*(?:\r?\n\s+.*)*)/mg,
function(match, from) {
var fromText;
var fromEmailDomain = from.replace(/(.*)</, '').replace(/.*@/, "").replace('>', '').trim();
if (data.config.verifiedDomains.indexOf(fromEmailDomain) === -1) {
if (data.config.fromEmail) {
fromText = 'From: ' + from.replace(/<(.*)>/, '').trim() +
' <' + data.config.fromEmail + '>';
} else {
fromText = 'From: ' + from.replace('<', 'at ').replace('>', '') +
' <' + data.originalRecipient + '>';
}
} else {
fromText = 'From: ' + from;
}
return fromText;
});
// Add a prefix to the Subject
if (data.config.subjectPrefix) {
header = header.replace(
/^Subject: (.*)/mg,
function(match, subject) {
return 'Subject: ' + data.config.subjectPrefix + subject;
});
}
// Replace original 'To' header with a manually defined one
if (data.config.toEmail) {
header = header.replace(/^To: (.*)/mg, () => 'To: ' + data.config.toEmail);
}
// Remove the Return-Path header.
header = header.replace(/^Return-Path: (.*)\r?\n/mg, '');
// Remove Sender header.
header = header.replace(/^Sender: (.*)\r?\n/mg, '');
// Remove Message-ID header.
header = header.replace(/^Message-ID: (.*)\r?\n/mig, '');
// Remove all DKIM-Signature headers to prevent triggering an
// "InvalidParameterValue: Duplicate header 'DKIM-Signature'" error.
// These signatures will likely be invalid anyways, since the From
// header was modified.
header = header.replace(/^DKIM-Signature: .*\r?\n(\s+.*\r?\n)*/mg, '');
data.emailData = header + body;
return Promise.resolve(data);
};
/**
* Send email using the SES sendRawEmail command.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
exports.sendMessage = function(data) {
var params = {
Destinations: data.recipients,
Source: data.originalRecipient,
RawMessage: {
Data: data.emailData
}
};
data.log({level: "info", message: "sendMessage: Sending email via SES. " +
"Original recipients: " + data.originalRecipients.join(", ") +
". Transformed recipients: " + data.recipients.join(", ") + "."});
return new Promise(function(resolve, reject) {
data.ses.sendRawEmail(params, function(err, result) {
if (err) {
data.log({level: "error", message: "sendRawEmail() returned error.",
error: err, stack: err.stack});
return reject(new Error('Error: Email sending failed.'));
}
data.log({level: "info", message: "sendRawEmail() successful.",
result: result});
resolve(data);
});
});
};
/**
* Handler function to be invoked by AWS Lambda with an inbound SES email as
* the event.
*
* @param {object} event - Lambda event from inbound email received by AWS SES.
* @param {object} context - Lambda context object.
* @param {object} callback - Lambda callback object.
* @param {object} overrides - Overrides for the default data, including the
* configuration, SES object, and S3 object.
*/
exports.handler = function(event, context, callback, overrides) {
var steps = overrides && overrides.steps ? overrides.steps :
[
exports.parseEvent,
exports.transformRecipients,
exports.fetchMessage,
exports.processMessage,
exports.sendMessage
];
var data = {
event: event,
callback: callback,
context: context,
config: overrides && overrides.config ? overrides.config : defaultConfig,
log: overrides && overrides.log ? overrides.log : console.log,
ses: overrides && overrides.ses ? overrides.ses : new AWS.SES(),
s3: overrides && overrides.s3 ?
overrides.s3 : new AWS.S3({signatureVersion: 'v4'})
};
Promise.series(steps, data)
.then(function(data) {
data.log({level: "info", message: "Process finished successfully."});
return data.callback();
})
.catch(function(err) {
data.log({level: "error", message: "Step returned error: " + err.message,
error: err, stack: err.stack});
return data.callback(new Error("Error: Step returned error."));
});
};
Promise.series = function(promises, initValue) {
return promises.reduce(function(chain, promise) {
if (typeof promise !== 'function') {
return Promise.reject(new Error("Error: Invalid promise item: " +
promise));
}
return chain.then(promise);
}, Promise.resolve(initValue));
};