feat(api): Add SMTP over HTTP REST API endpoint

Adds a new API endpoint POST /api/v1/send/email for sending emails
programmatically via SMTP.

Features:
- Send emails with plain text and/or HTML body
- Support for CC, BCC, and reply-to addresses
- Base64 encoded file attachments
- Configurable SMTP host, port, and authentication
- Full validation of email addresses
- Proper error handling with descriptive messages

Files changed:
- data/web/inc/functions.inc.php: Added smtp_api() function
- data/web/json_api.php: Added 'send' action handler
- data/web/api/openapi.yaml: Added endpoint documentation
- data/web/lang/lang.en-gb.json: Added error/success messages
This commit is contained in:
Mohammad Reza Tayyebi (on Dell Latitude) 2026-01-01 15:48:47 +04:00
parent 038b2efb75
commit 1ad55ddf76
No known key found for this signature in database
4 changed files with 536 additions and 0 deletions

View file

@ -6047,6 +6047,152 @@ paths:
ignore_ssl_error: true
summary: Edit external Identity Provider
/api/v1/send/email:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- type: success
msg:
- smtp_mail_sent
- sender@domain.tld
- recipient@domain.tld
schema:
properties:
type:
enum:
- success
- error
type: string
msg:
items: {}
type: array
type: object
description: OK
"400":
content:
application/json:
schema:
properties:
type:
type: string
example: error
msg:
type: array
items: {}
type: object
description: Bad Request - Validation error or SMTP failure
tags:
- SMTP
description: >-
Send an email via SMTP using the mailcow SMTP API. This endpoint allows
you to send emails programmatically with support for HTML content,
attachments, CC, BCC, and custom SMTP settings.
**Authentication**: Requires a read-write API key.
**Note**: When using authenticated SMTP (port 587 with STARTTLS or port 465 with SSL),
provide the password field. For unauthenticated internal sending (port 25),
the password can be omitted.
operationId: Send email
requestBody:
content:
application/json:
schema:
example:
from: sender@domain.tld
to:
- recipient@domain.tld
subject: Test Subject
body: This is the plain text body
html_body: "<html><body><h1>Hello</h1><p>This is HTML content</p></body></html>"
cc:
- cc@domain.tld
bcc:
- bcc@domain.tld
reply_to: reply@domain.tld
attachments:
- filename: document.pdf
content: base64encodedcontent
content_type: application/pdf
smtp_host: postfix-mailcow
smtp_port: 25
smtp_user: sender@domain.tld
password: yourpassword
properties:
from:
description: Sender email address
type: string
to:
description: Array of recipient email addresses
type: array
items:
type: string
subject:
description: Email subject
type: string
body:
description: Plain text email body
type: string
html_body:
description: HTML email body (optional)
type: string
cc:
description: Array of CC email addresses (optional)
type: array
items:
type: string
bcc:
description: Array of BCC email addresses (optional)
type: array
items:
type: string
reply_to:
description: Reply-to email address (optional)
type: string
attachments:
description: Array of attachments with base64 encoded content (optional)
type: array
items:
type: object
properties:
filename:
type: string
description: Attachment filename
content:
type: string
description: Base64 encoded file content
content_type:
type: string
description: MIME content type
smtp_host:
description: SMTP server host (default postfix-mailcow)
type: string
smtp_port:
description: SMTP server port (default 25 for internal, use 587 for authenticated)
type: integer
smtp_user:
description: SMTP username (default same as from)
type: string
password:
description: SMTP password for authentication
type: string
required:
- from
- to
- subject
- body
type: object
summary: Send email via SMTP
tags:
- name: Domains
description: You can create antispam whitelist and blacklist policies
@ -6094,3 +6240,5 @@ tags:
description: Manage Cross-Origin Resource Sharing (CORS) settings
- name: Identity Provider
description: Manage external Identity Provider settings
- name: SMTP
description: Send emails programmatically via SMTP

View file

@ -3387,6 +3387,326 @@ function reset_password($action, $data = null) {
break;
}
}
function smtp_api($action, $data) {
global $pdo;
$_data_log = $data;
// Mask password in logs
if (isset($_data_log['password'])) {
$_data_log['password'] = '*';
}
switch ($action) {
case 'send':
// Validate required fields
$from = isset($data['from']) ? $data['from'] : '';
$to = isset($data['to']) ? $data['to'] : array();
$subject = isset($data['subject']) ? $data['subject'] : '';
$body = isset($data['body']) ? $data['body'] : '';
// Optional fields
$html_body = isset($data['html_body']) ? $data['html_body'] : null;
$cc = isset($data['cc']) ? $data['cc'] : array();
$bcc = isset($data['bcc']) ? $data['bcc'] : array();
$reply_to = isset($data['reply_to']) ? $data['reply_to'] : null;
$attachments = isset($data['attachments']) ? $data['attachments'] : array();
// SMTP settings - default to port 25 for unauthenticated internal sending
$smtp_host = isset($data['smtp_host']) ? $data['smtp_host'] : 'postfix-mailcow';
$smtp_port = isset($data['smtp_port']) ? intval($data['smtp_port']) : 25;
$smtp_user = isset($data['smtp_user']) ? $data['smtp_user'] : $from;
$smtp_pass = isset($data['password']) ? $data['password'] : '';
// Validate from address
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'smtp_invalid_from'
);
return false;
}
// Check sender authorization (admins can bypass, others must be authorized)
if ($_SESSION['mailcow_cc_role'] != 'admin') {
$username = $_SESSION['mailcow_cc_username'];
$sender_authorized = false;
// Check if from address is the user's own mailbox
if (strtolower($from) == strtolower($username)) {
$sender_authorized = true;
}
if (!$sender_authorized) {
// Check if from address is in user's aliases (goto contains the username)
$stmt = $pdo->prepare("SELECT `address` FROM `alias` WHERE `goto` REGEXP :goto AND `address` = :from_address");
$stmt->execute(array(
':goto' => '(^|,)' . preg_quote($username, '/') . '($|,)',
':from_address' => $from
));
if ($stmt->rowCount() > 0) {
$sender_authorized = true;
}
}
if (!$sender_authorized) {
// Check sender_acl for specific address
$stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `send_as` = :from_address");
$stmt->execute(array(
':username' => $username,
':from_address' => $from
));
if ($stmt->rowCount() > 0) {
$sender_authorized = true;
}
}
if (!$sender_authorized) {
// Check sender_acl for domain wildcard (@domain.tld)
$from_domain = substr(strrchr($from, '@'), 1);
$stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `send_as` = :domain_wildcard");
$stmt->execute(array(
':username' => $username,
':domain_wildcard' => '@' . $from_domain
));
if ($stmt->rowCount() > 0) {
$sender_authorized = true;
}
}
if (!$sender_authorized) {
// Check sender_acl for global wildcard (*)
$stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `send_as` = '*'");
$stmt->execute(array(':username' => $username));
if ($stmt->rowCount() > 0) {
$sender_authorized = true;
}
}
if (!$sender_authorized) {
// Check external sender aliases
$stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `external` = '1' AND `send_as` = :from_address");
$stmt->execute(array(
':username' => $username,
':from_address' => $from
));
if ($stmt->rowCount() > 0) {
$sender_authorized = true;
}
}
if (!$sender_authorized) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_unauthorized_sender', htmlspecialchars($from))
);
return false;
}
}
// Validate to addresses
if (empty($to)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'smtp_missing_recipients'
);
return false;
}
// Ensure to is an array
if (!is_array($to)) {
$to = array($to);
}
foreach ($to as $recipient) {
if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_invalid_recipient', htmlspecialchars($recipient))
);
return false;
}
}
// Validate subject
if (empty($subject)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'smtp_empty_subject'
);
return false;
}
// Validate body
if (empty($body) && empty($html_body)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'smtp_empty_body'
);
return false;
}
// Validate CC addresses if provided
if (!empty($cc)) {
if (!is_array($cc)) {
$cc = array($cc);
}
foreach ($cc as $cc_addr) {
if (!filter_var($cc_addr, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_invalid_cc', htmlspecialchars($cc_addr))
);
return false;
}
}
}
// Validate BCC addresses if provided
if (!empty($bcc)) {
if (!is_array($bcc)) {
$bcc = array($bcc);
}
foreach ($bcc as $bcc_addr) {
if (!filter_var($bcc_addr, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_invalid_bcc', htmlspecialchars($bcc_addr))
);
return false;
}
}
}
// Validate reply-to if provided
if (!empty($reply_to) && !filter_var($reply_to, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'smtp_invalid_reply_to'
);
return false;
}
try {
ini_set('max_execution_time', 0);
ini_set('max_input_time', 0);
$mail = new PHPMailer(true);
$mail->Timeout = 30;
$mail->SMTPOptions = array(
'ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
)
);
$mail->isSMTP();
$mail->Host = $smtp_host;
$mail->Port = $smtp_port;
$mail->CharSet = 'UTF-8';
$mail->XMailer = 'mailcow SMTP API';
// Enable authentication if password is provided
if (!empty($smtp_pass)) {
$mail->SMTPAuth = true;
$mail->Username = $smtp_user;
$mail->Password = $smtp_pass;
if ($smtp_port == 465) {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} elseif ($smtp_port == 587) {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
}
} else {
$mail->SMTPAuth = false;
}
// Set sender
$mail->setFrom($from);
// Add recipients
foreach ($to as $recipient) {
$mail->addAddress($recipient);
}
// Add CC
if (!empty($cc)) {
foreach ($cc as $cc_addr) {
$mail->addCC($cc_addr);
}
}
// Add BCC
if (!empty($bcc)) {
foreach ($bcc as $bcc_addr) {
$mail->addBCC($bcc_addr);
}
}
// Set reply-to
if (!empty($reply_to)) {
$mail->addReplyTo($reply_to);
}
// Set subject
$mail->Subject = $subject;
// Set body
if (!empty($html_body)) {
$mail->isHTML(true);
$mail->Body = $html_body;
$mail->AltBody = $body;
} else {
$mail->Body = $body;
}
// Add attachments
if (!empty($attachments) && is_array($attachments)) {
foreach ($attachments as $attachment) {
if (isset($attachment['content']) && isset($attachment['filename'])) {
$content = base64_decode($attachment['content']);
if ($content === false) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_invalid_attachment', htmlspecialchars($attachment['filename']))
);
return false;
}
$content_type = isset($attachment['content_type']) ? $attachment['content_type'] : 'application/octet-stream';
$mail->addStringAttachment($content, $attachment['filename'], 'base64', $content_type);
}
}
}
// Send
$mail->send();
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_mail_sent', htmlspecialchars($from), implode(', ', $to))
);
return true;
} catch (Exception $e) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => array('smtp_error', htmlspecialchars($mail->ErrorInfo))
);
return false;
}
break;
}
return false;
}
function clear_session(){
session_regenerate_id(true);
session_unset();

View file

@ -2018,6 +2018,62 @@ if (isset($_GET['query'])) {
exit();
}
break;
case "send":
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) {
http_response_code(403);
echo json_encode(array(
'type' => 'error',
'msg' => 'API read/write access denied'
));
exit();
}
function process_send_return($return) {
$generic_failure = json_encode(array(
'type' => 'error',
'msg' => 'Failed to send email'
));
$generic_success = json_encode(array(
'type' => 'success',
'msg' => 'Email sent successfully'
));
if ($return === false) {
http_response_code(400);
echo isset($_SESSION['return']) ? json_encode($_SESSION['return']) : $generic_failure;
}
else {
echo isset($_SESSION['return']) ? json_encode($_SESSION['return']) : $generic_success;
}
}
if (!isset($_POST['attr'])) {
echo $request_incomplete;
exit;
}
else {
$attr = (array)json_decode($_POST['attr'], true);
unset($attr['csrf_token']);
}
// only allow POST requests
if ($_SERVER['REQUEST_METHOD'] != 'POST') {
http_response_code(405);
echo json_encode(array(
'type' => 'error',
'msg' => 'only POST method is allowed'
));
exit();
}
switch ($category) {
case "email":
process_send_return(smtp_api('send', $attr));
break;
default:
http_response_code(404);
echo json_encode(array(
'type' => 'error',
'msg' => 'route not found'
));
exit();
}
break;
// return no route found if no case is matched
default:
http_response_code(404);

View file

@ -542,6 +542,17 @@
"tls_policy_map_dest_invalid": "Policy destination is invalid",
"tls_policy_map_entry_exists": "A TLS policy map entry \"%s\" exists",
"tls_policy_map_parameter_invalid": "Policy parameter is invalid",
"smtp_invalid_from": "Invalid sender email address",
"smtp_unauthorized_sender": "You are not authorized to send as %s",
"smtp_missing_recipients": "At least one recipient is required",
"smtp_invalid_recipient": "Invalid recipient email address: %s",
"smtp_empty_subject": "Email subject cannot be empty",
"smtp_empty_body": "Email body cannot be empty",
"smtp_invalid_cc": "Invalid CC email address: %s",
"smtp_invalid_bcc": "Invalid BCC email address: %s",
"smtp_invalid_reply_to": "Invalid reply-to email address",
"smtp_invalid_attachment": "Invalid base64 attachment: %s",
"smtp_error": "SMTP error: %s",
"to_invalid": "Recipient must not be empty",
"totp_verification_failed": "TOTP verification failed",
"transport_dest_exists": "Transport destination \"%s\" exists",
@ -1198,6 +1209,7 @@
"saved_settings": "Saved settings",
"settings_map_added": "Added settings map entry",
"settings_map_removed": "Removed settings map ID %s",
"smtp_mail_sent": "Email sent successfully from %s to %s",
"sogo_profile_reset": "SOGo profile for user %s was reset",
"template_added": "Added template %s",
"template_modified": "Changes to template %s have been saved",