This commit is contained in:
MohammadReza Tayyebi 2026-01-07 21:56:48 +01:00 committed by GitHub
commit 00ebb3bcca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 553 additions and 0 deletions

View file

@ -6047,6 +6047,164 @@ 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:
- log:
- smtp_api
- send
- from: sender@domain.tld
to:
- recipient@domain.tld
subject: Test Subject
body: "***"
password: "*"
msg:
- smtp_mail_sent
- sender@domain.tld
- recipient@domain.tld
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
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**: SMTP authentication with mailbox credentials is required.
Use port 587 for STARTTLS or port 465 for SSL/TLS.
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: 587
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 587 for authenticated; set explicitly if different)
type: integer
smtp_user:
description: SMTP username (mailbox login; required)
type: string
password:
description: >
SMTP password (mailbox password or mailbox app password; required).
App passwords are recommended for API usage.
type: string
example: "app-password-or-mailbox-password"
app_password:
description: >
Alias for `password`.
SMTP password (mailbox password or mailbox app password; required).
App passwords are recommended for API usage.
type: string
example: "app-password-or-mailbox-password"
required:
- from
- to
- subject
- body
- smtp_user
- password
type: object
summary: Send email via SMTP
tags:
- name: Domains
description: You can create antispam whitelist and blacklist policies
@ -6094,3 +6252,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,331 @@ 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 - mailbox-level auth; default to 587 (STARTTLS)
$smtp_host = isset($data['smtp_host']) ? $data['smtp_host'] : 'postfix-mailcow';
$smtp_user = isset($data['smtp_user']) ? $data['smtp_user'] : '';
$smtp_pass = $data['app_password'] ?? $data['password'] ?? '';
$smtp_port = isset($data['smtp_port']) ? intval($data['smtp_port']) : 587;
// 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;
}
// Require SMTP auth with mailbox credentials
if (empty($smtp_user) || empty($smtp_pass)) {
$_SESSION['return'][] = array(
'type' => 'error',
'log' => array(__FUNCTION__, $action, $_data_log),
'msg' => 'smtp_auth_required'
);
return false;
}
// Check sender authorization (mailbox-level)
$username = strtolower(trim($smtp_user));
$from_l = strtolower(trim($from));
$sender_authorized = false;
// 1) Sender matches mailbox user
if ($from_l === $username) {
$sender_authorized = true;
}
// 2) Alias points to this mailbox
if (!$sender_authorized) {
$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;
}
}
// 3) sender_acl specific address
if (!$sender_authorized) {
$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;
}
}
// 4) sender_acl domain wildcard
if (!$sender_authorized) {
$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;
}
}
// 5) sender_acl global wildcard
if (!$sender_authorized) {
$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;
}
}
// 6) External sender aliases
if (!$sender_authorized) {
$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';
// Authentication is required
$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;
}
// 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,61 @@ 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) {
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,18 @@
"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_auth_required": "SMTP username and password are required",
"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 +1210,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",