mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-01-23 02:14:26 +00:00
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:
parent
038b2efb75
commit
1ad55ddf76
4 changed files with 536 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue