Skip to content

Commit 58135b8

Browse files
committed
First!
1 parent 578d8f8 commit 58135b8

29 files changed

+2040
-0
lines changed

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/rules.d/*.php
2+
/config.php
3+
/server.pem
4+
/nbproject
5+
/.idea

README.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# mail2file
2+
**mail2file** is a simple, quick and dirty "fake" SMTP (and POP3) server, intended to be used to create a file dump, or automate processing of those attachments. It was tested on PHP 7.3 and 7.4.
3+
4+
## what.
5+
**mail2file** implements just enough SMTP to fool mail clients (and other SMTP servers) to deliver mail to it. You can write simple filter rules the will decide if and where to store any attachments received, or what process to execute that will process the received attachment further.
6+
7+
Attachments are written to disk as they are received, so memory consumption should stay low even when receiving potentially huge files.
8+
9+
### but why?
10+
Sometimes, mail is still the easiest way to get a file from A to B, even in 2021.
11+
12+
## config + setup
13+
`config.php` needs to go into the root directory, a template can be found in `stuff/`. Here you can set the FQDN the SMTP and POP3 servers identify with, and the ports to listen on. To use TLS, put the certificate + chain + private key into a file called `server.pem` into the root directory. For implicit TLD add an `ssl://` socket to the config, STARTTLS is supported automatically if `server.pem` exists.
14+
The sample config contains the officially assigned ports + 10,000, since non-root users usually can't listen on ports < 1024, so you need to add some iptables rules to make this work, or similar.
15+
16+
## filters
17+
Filters go into the `rules.d/` directory and are simple PHP snippets. See the `stuff/` directory for a set of examples. Note that processing doesn't end if a filter matches, so you can check the second parameter to your filter to see how many filters already matched.
18+
19+
## POP3 server???
20+
There are basically two ways to use this. You can use whatever mail account is configured on your device and just send mail to your **mail2file** instance, given that port 25 is reachable and you have a DNS entry set up. But what if you don't want to relay your attachments through another mail server? What if you're sending mail from the same LAN the server is hosted on? It would be rather stupid to relay huge attachments to some mail server on the internet and then back home. To use the **mail2file** server directly, you need to configure a new mail account in you mail client, directly using your **mail2file** instance. But most mail clients want a way to *retrieve* mail too if you set up an account, and ask for a IMAP or POP3 server. So **mail2file** contains an even dumber POP3 server that is just good enough to satisfy your average mail client by pretending you have a mailbox that's completely empty. So you set up your mail client with your **mail2file** host name as SMTP and POP3 servers, using any credentials you want, and you're ready to go.
21+
I'm using it at home this way, with a Let's Encrypt cert and then a dnsmasq entry for the dyndns hostname that points to the server's LAN IP address, so I can reach it properly from within my home network.
22+
23+
## open relay! danger! you're using PHP so you don't know what you're doing!
24+
No U! **mail2file** doesn't contain any code to *send* email, so I'd like to see how you can abuse this to send spam. I guess using the exec filter you can actually turn this into a *sending* SMTP server, as well as a dozen other security nightmares, but that's a whole different story. As it stands, the RCPT TO address is just metadata to **mail2file** which can be evaluated in filter rules.
25+
26+
## but really, security
27+
Use this wisely. By using a scripting language a couple of bug classes have been ruled out from the start, but in general try to think first when using this, especially the exec filter. Don't run it as root on a system that hasn't been patched for three years.
28+
Add a new user for it, use the sample systemd service file from `stuff/` as a starting point.
29+
This has been cobbled together in a weekend; I actually spent two more weekends implementing the configurable filter system, as the first version had everything hard-coded, which got annoying fast.
30+
31+
## TODO
32+
Too much to write down right now.

autoloader.inc.php

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
3+
spl_autoload_register(function($class) {
4+
$file = 'inc/' . strtolower(preg_replace('#^.*[/\\\\]#', '', $class)) . '.inc.php';
5+
if (file_exists($file)) require_once $file;
6+
});

inc/abstractsocket.inc.php

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
abstract class AbstractSocket
4+
{
5+
6+
private static $runningId = 0;
7+
8+
/**
9+
* @var int an ID for this very socket, which you can use as an array key
10+
*/
11+
protected $tag;
12+
/**
13+
* @var SocketEvent callback
14+
*/
15+
protected $callback;
16+
17+
public function __construct(SocketEvent $callback)
18+
{
19+
$this->tag = ++self::$runningId;
20+
$this->callback = $callback;
21+
}
22+
23+
public final function __clone()
24+
{
25+
trigger_error('Cannot clone a socket', E_USER_ERROR);
26+
throw new ErrorException('Cannot clone socket');
27+
}
28+
29+
public function setCallback(SocketEvent $callback)
30+
{
31+
$this->callback = $callback;
32+
}
33+
34+
/**
35+
* @return int this socket's unique tag/ID
36+
*/
37+
public function tag() : int
38+
{
39+
return $this->tag;
40+
}
41+
42+
public abstract function sendData(string $data) : bool;
43+
44+
public abstract function close(): void;
45+
46+
public abstract function wantsWrite(): bool;
47+
48+
public abstract function writeHandle();
49+
50+
public abstract function readHandle();
51+
52+
public abstract function isDead() : bool;
53+
54+
public abstract function __socket_event(int $what): void;
55+
56+
}

inc/execfilter.inc.php

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
class ExecFilter extends StoreFilter implements SocketEvent
4+
{
5+
6+
/** @var bool delete file after process exits? */
7+
private $delete;
8+
/** @var string[] command to execute */
9+
private $cmd;
10+
11+
public function __construct($action, \MailData $data)
12+
{
13+
$this->delete = $action['delete'] ?? true;
14+
$this->cmd = $action['cmd'] ?? false;
15+
if ($this->cmd === false)
16+
return;
17+
if (strpos(implode(' ', $action['cmd']), '%TEMPFILE%') !== false) {
18+
$this->filePath = tempnam('/tmp', 'm2f-');
19+
if ($this->filePath !== false) {
20+
$this->fh = fopen($this->filePath, 'wb');
21+
}
22+
foreach ($this->cmd as &$item) {
23+
$item = str_replace('%TEMPFILE%', $this->filePath, $item);
24+
}
25+
}
26+
$rep = ['%DATE%' => date('Y-m-d')];
27+
foreach (get_object_vars($data) as $k => $v) {
28+
$rep['%' . strtoupper($k) . '%'] = $v;
29+
}
30+
foreach ($this->cmd as &$item) {
31+
$item = str_replace(array_keys($rep), array_values($rep), $item);
32+
}
33+
}
34+
35+
public function finished()
36+
{
37+
parent::finished();
38+
if ($this->cmd !== false) {
39+
new Process($this, $this->cmd);
40+
}
41+
}
42+
43+
public function connected(\AbstractSocket $sock) { }
44+
45+
public function dataArrival(\AbstractSocket $sock, string $data)
46+
{
47+
Log::info("EXEC({$this->tag()}): " . substr(str_replace(["\r", "\n"], ' ', $data), 0, 60));
48+
}
49+
50+
public function incomingConnection(\Socket $sock, \Socket $new) { }
51+
52+
public function sendProgress(\AbstractSocket $sock, int $sent, int $remaining) { }
53+
54+
public function socketClosed(\AbstractSocket $sock)
55+
{
56+
Log::info("ExecFilter process finished successfully");
57+
if ($this->delete && !empty($this->filePath)) {
58+
unlink($this->filePath);
59+
$this->filePath = false;
60+
}
61+
}
62+
63+
public function socketError(\AbstractSocket $sock, $error)
64+
{
65+
Log::info("ExecFilter process exited with error $error");
66+
if ($this->delete && !empty($this->filePath)) {
67+
unlink($this->filePath);
68+
$this->filePath = false;
69+
}
70+
}
71+
72+
}

inc/filter.inc.php

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
abstract class Filter
4+
{
5+
6+
private static $rules = [];
7+
8+
public static function load()
9+
{
10+
self::$rules = [];
11+
foreach (glob('rules.d/*.inc.php') as $file) {
12+
$rule = include($file);
13+
if (!is_array($rule) || !isset($rule['action'])) {
14+
Log::warn("Rule without action: $file");
15+
continue;
16+
}
17+
if (isset($rule['filter']) && !is_callable($rule['filter'])) {
18+
Log::warn("Rule filter not callable: $file");
19+
continue;
20+
}
21+
self::$rules[basename($file)] = $rule;
22+
}
23+
}
24+
25+
public static function get(MailData $data)
26+
{
27+
$return = [];
28+
foreach (self::$rules as $file => $rule) {
29+
if (isset($rule['filter']) && !$rule['filter']($data, array_keys($return)))
30+
continue;
31+
$class = $rule['action'] . 'Filter';
32+
if (class_exists($class)) {
33+
$return[$file] = new $class($rule['options'], $data);
34+
} else {
35+
Log::warn("Filter Type $class not found");
36+
}
37+
}
38+
return $return;
39+
}
40+
41+
/*
42+
*
43+
*/
44+
45+
public abstract function __construct($action, MailData $data);
46+
47+
public abstract function feed(string $chunk);
48+
49+
public abstract function finished();
50+
51+
52+
}

inc/log.inc.php

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
class Log
4+
{
5+
6+
const DEBUG = 3;
7+
const INFO = 2;
8+
const WARNING = 1;
9+
const ERROR = 0;
10+
11+
private static $lastDay;
12+
13+
private static $level = 0;
14+
15+
public static function debug($text)
16+
{
17+
if (self::$level < self::DEBUG)
18+
return;
19+
self::output('[DEBUG] ', $text);
20+
}
21+
22+
public static function info($text)
23+
{
24+
if (self::$level < self::INFO)
25+
return;
26+
self::output('[INFO] ', $text);
27+
}
28+
29+
public static function warn($text)
30+
{
31+
if (self::$level < self::WARNING)
32+
return;
33+
self::output('[WARN] ', $text);
34+
}
35+
36+
public static function error($text)
37+
{
38+
self::output('[ERROR] ', $text);
39+
}
40+
41+
public static function setLogLevel($level)
42+
{
43+
self::$level = $level;
44+
}
45+
46+
private static function output($prefix, $text)
47+
{
48+
if (LOG_WITH_TIME) {
49+
if (date('d') !== self::$lastDay) { // New day?
50+
echo '############################# ', date('d.m.Y'), " #############################\n";
51+
self::$lastDay = date('d');
52+
}
53+
echo '[', date('H:i:s'), '] ', $prefix, $text, "\n";
54+
} else {
55+
echo $prefix, $text, "\n";
56+
}
57+
}
58+
59+
}
60+
61+
if (posix_isatty(STDOUT)) {
62+
define('LOG_WITH_TIME', true);
63+
} else {
64+
define('LOG_WITH_TIME', false);
65+
}

inc/maildata.inc.php

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/**
4+
* Information about incoming mail, or
5+
* part of a multi-part mime mail.
6+
*/
7+
class MailData
8+
{
9+
10+
public $from = '';
11+
12+
public $to = '';
13+
14+
public $subject = '';
15+
16+
public $fileName = '';
17+
18+
public $srcAddr = '';
19+
20+
public function __construct(string $srcAddr)
21+
{
22+
$this->srcAddr = preg_replace(['/:\d+$/', '/\]\[/'], '', $srcAddr);
23+
}
24+
25+
}

0 commit comments

Comments
 (0)