Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions fetchmail_s3/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

============
Fetchmail S3
============

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:fc00b1de2ce63f15c742cb4dd93f1d517f8f1f16bd16ceea951bcf470a4811f1
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github
:target: https://github.com/OCA/server-tools/tree/18.0/fetchmail_s3
:alt: OCA/server-tools
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-fetchmail_s3
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

Receive incoming emails from an S3-compatible bucket instead of
IMAP/POP.

This module adds an "S3 Bucket" server type to Odoo's incoming mail
servers. It polls an S3 bucket for raw email files (``.eml``) and
processes them through Odoo's standard mail gateway
(``mail.thread.message_process``).

**Typical use case**: AWS SES inbound email rules store messages in S3.
This module picks them up on a cron schedule, processes them into Odoo
records (leads, tickets, DMS documents, etc.), then archives or deletes
the S3 objects.

Works with any S3-compatible storage (AWS S3, MinIO, Hetzner Object
Storage, DigitalOcean Spaces, etc.).

**Table of contents**

.. contents::
:local:

Configuration
=============

AWS SES Setup
-------------

1. Verify your domain in AWS SES (e.g., ``docs.example.com``)
2. Add MX record:
``docs.example.com MX 10 inbound-smtp.us-east-1.amazonaws.com``
3. Create an SES receipt rule that stores emails in an S3 bucket
4. Create an IAM user with ``s3:GetObject``, ``s3:ListBucket``,
``s3:DeleteObject``, ``s3:PutObject`` permissions on the bucket

Odoo Configuration
------------------

1. Go to **Settings → Technical → Incoming Mail Servers**
2. Create a new server with type **S3 Bucket**
3. Fill in:

- **S3 Bucket Name**: your bucket (e.g., ``my-ses-incoming``)
- **Object Key Prefix**: the prefix SES writes to (e.g., ``emails/``)
- **AWS Region**: the bucket's region (e.g., ``us-east-1``)
- **Access Key ID** and **Secret Access Key**: IAM credentials
- **Endpoint URL**: leave empty for AWS S3, or set for S3-compatible
services
- **Archive Prefix**: where to move processed emails (e.g.,
``processed/``). Leave empty to delete after processing.

4. Click **Test & Confirm** to verify connectivity
5. Set the **Create a New Record** model (e.g., DMS Directory for
document filing)

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/server-tools/issues/new?body=module:%20fetchmail_s3%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Ledo Enterprises

Contributors
------------

- Don Kendall dkendall@ledoweb.com

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/18.0/fetchmail_s3>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions fetchmail_s3/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
20 changes: 20 additions & 0 deletions fetchmail_s3/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2026 Ledo Enterprises
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Fetchmail S3",
"version": "18.0.1.0.0",
"category": "Hidden/Tools",
"summary": "Receive incoming emails from an S3-compatible bucket",
"author": "Ledo Enterprises, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/server-tools",
"license": "AGPL-3",
"depends": ["fetchmail"],
"external_dependencies": {
"python": ["boto3"],
},
"data": [
"views/fetchmail_server_views.xml",
],
"installable": True,
}
1 change: 1 addition & 0 deletions fetchmail_s3/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import fetchmail_server
208 changes: 208 additions & 0 deletions fetchmail_s3/models/fetchmail_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Copyright 2026 Ledo Enterprises
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import logging

import boto3
from botocore.exceptions import ClientError

from odoo import _, api, fields, models
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)


class FetchmailServer(models.Model):
_inherit = "fetchmail.server"

server_type = fields.Selection(
selection_add=[("s3", "S3 Bucket (AWS SES / S3-compatible)")],
ondelete={"s3": "set default"},
)
s3_bucket = fields.Char("S3 Bucket Name")
s3_prefix = fields.Char(
"Object Key Prefix",
default="emails/",
help="Only process objects under this prefix.",
)
s3_region = fields.Char("AWS Region", default="us-east-1")
s3_access_key = fields.Char("Access Key ID")
s3_secret_key = fields.Char("Secret Access Key")
s3_endpoint_url = fields.Char(
"Endpoint URL",
help="For S3-compatible services (MinIO, Hetzner, etc.). "
"Leave empty for AWS S3.",
)
s3_archive_prefix = fields.Char(
"Archive Prefix",
default="processed/",
help="Move processed emails here instead of deleting. "
"Leave empty to delete after processing.",
)

def _compute_server_type_info(self):
s3_servers = self.filtered(lambda s: s.server_type == "s3")
s3_servers.server_type_info = _(
"Poll an S3-compatible bucket for raw email files (.eml). "
"Typically used with AWS SES inbound email rules that store "
"messages in S3. Processed emails are archived or deleted."
)
return super(FetchmailServer, self - s3_servers)._compute_server_type_info()

@api.onchange("server_type", "is_ssl", "object_id")
def onchange_server_type(self):
if self.server_type == "s3":
self.server = False
self.port = 0
self.is_ssl = False
return
return super().onchange_server_type()

def _get_connection_type(self):
self.ensure_one()
if self.server_type == "s3":
return "s3"
return super()._get_connection_type()

def _get_s3_client(self):
"""Create and return a boto3 S3 client."""
self.ensure_one()
kwargs = {"region_name": self.s3_region or "us-east-1"}
if self.s3_access_key and self.s3_secret_key:
kwargs["aws_access_key_id"] = self.s3_access_key
kwargs["aws_secret_access_key"] = self.s3_secret_key
if self.s3_endpoint_url:
kwargs["endpoint_url"] = self.s3_endpoint_url
return boto3.client("s3", **kwargs)

def connect(self, allow_archived=False):
self.ensure_one()
if self._get_connection_type() == "s3":
if not allow_archived and not self.active:
raise UserError(
_(
'The server "%s" cannot be used because it is archived.',
self.display_name,
)
)
return self._get_s3_client()
return super().connect(allow_archived=allow_archived)

def button_confirm_login(self):
s3_servers = self.filtered(lambda s: s._get_connection_type() == "s3")
for server in s3_servers:
try:
client = server._get_s3_client()
client.list_objects_v2(
Bucket=server.s3_bucket,
Prefix=server.s3_prefix or "",
MaxKeys=1,
)
server.write({"state": "done"})
except ClientError as e:
raise UserError(
_("S3 connection failed:\n%s", e.response["Error"]["Message"])
) from e
except Exception as e:
raise UserError(_("S3 connection test failed:\n%s", str(e))) from e
non_s3 = self - s3_servers
if non_s3:
return super(FetchmailServer, non_s3).button_confirm_login()
return True

def fetch_mail(self, raise_exception=True):
"""Extend fetch_mail to handle S3 server type."""
s3_servers = self.filtered(lambda s: s._get_connection_type() == "s3")
non_s3 = self - s3_servers
for server in s3_servers:
server._fetch_mail_s3(raise_exception=raise_exception)
if non_s3:
return super(FetchmailServer, non_s3).fetch_mail(
raise_exception=raise_exception
)
return True

def _fetch_mail_s3(self, raise_exception=True):
"""Fetch and process emails from an S3 bucket."""
self.ensure_one()
_logger.info(
"Start checking for new emails on S3 server %s (bucket: %s, prefix: %s)",
self.name,
self.s3_bucket,
self.s3_prefix,
)
context = {
"fetchmail_cron_running": True,
"default_fetchmail_server_id": self.id,
}
MailThread = self.env["mail.thread"]
count, failed = 0, 0
try:
client = self._get_s3_client()
paginator = client.get_paginator("list_objects_v2")
for page in paginator.paginate(
Bucket=self.s3_bucket, Prefix=self.s3_prefix or ""
):
for obj in page.get("Contents", []):
key = obj["Key"]
if key.endswith("/"):
continue
try:
response = client.get_object(Bucket=self.s3_bucket, Key=key)
raw_email = response["Body"].read()
except ClientError:
_logger.warning(
"Failed to download S3 object %s", key, exc_info=True
)
failed += 1
continue
try:
MailThread.with_context(**context).message_process(
self.object_id.model,
raw_email,
save_original=self.original,
strip_attachments=(not self.attach),
)
except Exception:
_logger.info(
"Failed to process mail from S3 key %s",
key,
exc_info=True,
)
failed += 1
self.env.cr.commit() # pylint: disable=invalid-commit
continue
self._s3_handle_processed(client, key)
self.env.cr.commit() # pylint: disable=invalid-commit
count += 1
_logger.info(
"Fetched %d email(s) on S3 server %s; %d succeeded, %d failed.",
count,
self.name,
count - failed,
failed,
)
except Exception as e:
if raise_exception:
raise UserError(_("Couldn't fetch emails from S3:\n%s", str(e))) from e
_logger.info(
"General failure fetching from S3 server %s.",
self.name,
exc_info=True,
)

def _s3_handle_processed(self, client, key):
"""Archive or delete a processed S3 object."""
self.ensure_one()
try:
if self.s3_archive_prefix:
filename = key.rsplit("/", 1)[-1]
archive_key = f"{self.s3_archive_prefix}{filename}"
client.copy_object(
Bucket=self.s3_bucket,
CopySource={"Bucket": self.s3_bucket, "Key": key},
Key=archive_key,
)
client.delete_object(Bucket=self.s3_bucket, Key=key)
except ClientError:
_logger.warning("Failed to archive/delete S3 object %s", key, exc_info=True)
8 changes: 8 additions & 0 deletions fetchmail_s3/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "odoo-addon-fetchmail_s3"
version = "18.0.1.0.0"
requires-python = ">=3.10"

[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
22 changes: 22 additions & 0 deletions fetchmail_s3/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## AWS SES Setup

1. Verify your domain in AWS SES (e.g., `docs.example.com`)
2. Add MX record: `docs.example.com MX 10 inbound-smtp.us-east-1.amazonaws.com`
3. Create an SES receipt rule that stores emails in an S3 bucket
4. Create an IAM user with `s3:GetObject`, `s3:ListBucket`, `s3:DeleteObject`,
`s3:PutObject` permissions on the bucket

## Odoo Configuration

1. Go to **Settings → Technical → Incoming Mail Servers**
2. Create a new server with type **S3 Bucket**
3. Fill in:
- **S3 Bucket Name**: your bucket (e.g., `my-ses-incoming`)
- **Object Key Prefix**: the prefix SES writes to (e.g., `emails/`)
- **AWS Region**: the bucket's region (e.g., `us-east-1`)
- **Access Key ID** and **Secret Access Key**: IAM credentials
- **Endpoint URL**: leave empty for AWS S3, or set for S3-compatible services
- **Archive Prefix**: where to move processed emails (e.g., `processed/`).
Leave empty to delete after processing.
4. Click **Test & Confirm** to verify connectivity
5. Set the **Create a New Record** model (e.g., DMS Directory for document filing)
1 change: 1 addition & 0 deletions fetchmail_s3/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Don Kendall <dkendall@ledoweb.com>
Loading
Loading