python-missive 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. pymissive/__init__.py +3 -0
  2. pymissive/__main__.py +9 -0
  3. pymissive/archives/__init__.py +142 -0
  4. pymissive/archives/__main__.py +9 -0
  5. pymissive/archives/address.py +272 -0
  6. pymissive/archives/address_backends/__init__.py +29 -0
  7. pymissive/archives/address_backends/base.py +610 -0
  8. pymissive/archives/address_backends/geoapify.py +221 -0
  9. pymissive/archives/address_backends/geocode_earth.py +210 -0
  10. pymissive/archives/address_backends/google_maps.py +371 -0
  11. pymissive/archives/address_backends/here.py +348 -0
  12. pymissive/archives/address_backends/locationiq.py +271 -0
  13. pymissive/archives/address_backends/mapbox.py +314 -0
  14. pymissive/archives/address_backends/maps_co.py +257 -0
  15. pymissive/archives/address_backends/nominatim.py +348 -0
  16. pymissive/archives/address_backends/opencage.py +292 -0
  17. pymissive/archives/address_backends/pelias_mixin.py +181 -0
  18. pymissive/archives/address_backends/photon.py +322 -0
  19. pymissive/archives/cli.py +42 -0
  20. pymissive/archives/helpers.py +45 -0
  21. pymissive/archives/missive.py +64 -0
  22. pymissive/archives/providers/__init__.py +167 -0
  23. pymissive/archives/providers/apn.py +171 -0
  24. pymissive/archives/providers/ar24.py +204 -0
  25. pymissive/archives/providers/base/__init__.py +203 -0
  26. pymissive/archives/providers/base/_attachments.py +166 -0
  27. pymissive/archives/providers/base/branded.py +341 -0
  28. pymissive/archives/providers/base/common.py +781 -0
  29. pymissive/archives/providers/base/email.py +422 -0
  30. pymissive/archives/providers/base/email_message.py +85 -0
  31. pymissive/archives/providers/base/monitoring.py +150 -0
  32. pymissive/archives/providers/base/notification.py +187 -0
  33. pymissive/archives/providers/base/postal.py +742 -0
  34. pymissive/archives/providers/base/postal_defaults.py +26 -0
  35. pymissive/archives/providers/base/sms.py +213 -0
  36. pymissive/archives/providers/base/voice_call.py +82 -0
  37. pymissive/archives/providers/brevo.py +363 -0
  38. pymissive/archives/providers/certeurope.py +249 -0
  39. pymissive/archives/providers/django_email.py +182 -0
  40. pymissive/archives/providers/fcm.py +91 -0
  41. pymissive/archives/providers/laposte.py +392 -0
  42. pymissive/archives/providers/maileva.py +511 -0
  43. pymissive/archives/providers/mailgun.py +118 -0
  44. pymissive/archives/providers/messenger.py +74 -0
  45. pymissive/archives/providers/notification.py +112 -0
  46. pymissive/archives/providers/sendgrid.py +160 -0
  47. pymissive/archives/providers/ses.py +185 -0
  48. pymissive/archives/providers/signal.py +68 -0
  49. pymissive/archives/providers/slack.py +80 -0
  50. pymissive/archives/providers/smtp.py +190 -0
  51. pymissive/archives/providers/teams.py +91 -0
  52. pymissive/archives/providers/telegram.py +69 -0
  53. pymissive/archives/providers/twilio.py +310 -0
  54. pymissive/archives/providers/vonage.py +208 -0
  55. pymissive/archives/sender.py +339 -0
  56. pymissive/archives/status.py +22 -0
  57. pymissive/cli.py +42 -0
  58. pymissive/config.py +397 -0
  59. pymissive/helpers.py +0 -0
  60. pymissive/providers/apn.py +8 -0
  61. pymissive/providers/ar24.py +8 -0
  62. pymissive/providers/base/__init__.py +64 -0
  63. pymissive/providers/base/acknowledgement.py +6 -0
  64. pymissive/providers/base/attachments.py +10 -0
  65. pymissive/providers/base/branded.py +16 -0
  66. pymissive/providers/base/email.py +2 -0
  67. pymissive/providers/base/notification.py +2 -0
  68. pymissive/providers/base/postal.py +2 -0
  69. pymissive/providers/base/sms.py +2 -0
  70. pymissive/providers/base/voice_call.py +2 -0
  71. pymissive/providers/brevo.py +420 -0
  72. pymissive/providers/certeurope.py +8 -0
  73. pymissive/providers/django_email.py +8 -0
  74. pymissive/providers/fcm.py +8 -0
  75. pymissive/providers/laposte.py +8 -0
  76. pymissive/providers/maileva.py +8 -0
  77. pymissive/providers/mailgun.py +8 -0
  78. pymissive/providers/messenger.py +8 -0
  79. pymissive/providers/notification.py +8 -0
  80. pymissive/providers/partner.py +650 -0
  81. pymissive/providers/scaleway.py +498 -0
  82. pymissive/providers/sendgrid.py +8 -0
  83. pymissive/providers/ses.py +8 -0
  84. pymissive/providers/signal.py +8 -0
  85. pymissive/providers/slack.py +8 -0
  86. pymissive/providers/smtp.py +8 -0
  87. pymissive/providers/teams.py +8 -0
  88. pymissive/providers/telegram.py +8 -0
  89. pymissive/providers/twilio.py +8 -0
  90. pymissive/providers/vonage.py +8 -0
  91. python_missive-0.2.0.dist-info/METADATA +152 -0
  92. python_missive-0.2.0.dist-info/RECORD +95 -0
  93. python_missive-0.2.0.dist-info/WHEEL +5 -0
  94. python_missive-0.2.0.dist-info/entry_points.txt +2 -0
  95. python_missive-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,190 @@
1
+ """Generic SMTP provider without Django dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import smtplib
6
+ from contextlib import contextmanager
7
+ from email.message import EmailMessage
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from ..status import MissiveStatus
11
+ from .base import BaseProvider
12
+ from .base.email_message import build_email_message
13
+
14
+
15
+ class SMTPProvider(BaseProvider):
16
+ """Simple SMTP provider supporting transactional and marketing emails."""
17
+
18
+ name = "smtp"
19
+ display_name = "SMTP"
20
+ supported_types = ["EMAIL", "EMAIL_MARKETING"]
21
+ config_keys = [
22
+ "SMTP_HOST",
23
+ "SMTP_PORT",
24
+ "SMTP_USERNAME",
25
+ "SMTP_PASSWORD",
26
+ "SMTP_USE_TLS",
27
+ "SMTP_USE_SSL",
28
+ "SMTP_TIMEOUT_SECONDS",
29
+ "DEFAULT_FROM_EMAIL",
30
+ ]
31
+ required_packages: List[str] = []
32
+ description_text = (
33
+ "Direct SMTP integration with optional TLS/SSL and inline attachment support."
34
+ )
35
+
36
+ # Geographic scopes + pricing baseline
37
+ email_geographic_coverage = ["*"]
38
+ email_geo = email_geographic_coverage
39
+ email_price = 0.0 # delegated to provider pricing
40
+ email_marketing_geographic_coverage = ["*"]
41
+ email_marketing_geo = email_marketing_geographic_coverage
42
+ email_marketing_price = 0.0
43
+
44
+ def validate(self) -> Tuple[bool, str]:
45
+ """Ensure mandatory connection settings exist."""
46
+ host = self._config.get("SMTP_HOST")
47
+ port = self._config.get("SMTP_PORT")
48
+ if not host or not port:
49
+ return False, "SMTP_HOST and SMTP_PORT must be configured"
50
+
51
+ if "DEFAULT_FROM_EMAIL" not in self._config:
52
+ sender_addr = self._get_missive_value(
53
+ "sender_email"
54
+ ) or self._get_missive_value("sender")
55
+ self._raw_config["DEFAULT_FROM_EMAIL"] = (
56
+ sender_addr if isinstance(sender_addr, str) else "noreply@example.com"
57
+ )
58
+ self._config = self._filter_config(self._raw_config)
59
+
60
+ return super().validate()
61
+
62
+ def send_email(self, **kwargs: Any) -> bool:
63
+ """Send an email via configured SMTP server."""
64
+ recipient = self._get_missive_value(
65
+ "get_recipient_email"
66
+ ) or self._get_missive_value("recipient_email")
67
+ if not recipient:
68
+ self._update_status(
69
+ MissiveStatus.FAILED, error_message="Recipient email missing"
70
+ )
71
+ return False
72
+
73
+ message = self._build_message(recipient)
74
+
75
+ try:
76
+ with self._smtp_connection() as smtp:
77
+ smtp.send_message(message)
78
+ except (OSError, smtplib.SMTPException) as exc:
79
+ return self._handle_send_error(exc)
80
+
81
+ external_id = f"smtp_{getattr(self.missive, 'id', 'unknown')}"
82
+ self._update_status(
83
+ MissiveStatus.SENT, provider=self.name, external_id=external_id
84
+ )
85
+ self._create_event("sent", "Email sent via SMTP")
86
+ return True
87
+
88
+ def send_email_marketing(self, **kwargs: Any) -> bool:
89
+ """Marketing campaigns reuse the same pipeline."""
90
+ return self.send_email(**kwargs)
91
+
92
+ def get_email_service_info(self) -> Dict[str, Any]:
93
+ info = super().get_email_service_info()
94
+ info["details"].update(
95
+ {
96
+ "host": self._config.get("SMTP_HOST"),
97
+ "port": self._config.get("SMTP_PORT"),
98
+ "use_tls": self._bool_config("SMTP_USE_TLS", False),
99
+ "use_ssl": self._bool_config("SMTP_USE_SSL", False),
100
+ }
101
+ )
102
+ info["warnings"] = []
103
+ return info
104
+
105
+ def get_email_marketing_service_info(self) -> Dict[str, Any]:
106
+ return self.get_email_service_info()
107
+
108
+ def get_service_status(self) -> Dict[str, Any]:
109
+ """Return lightweight availability info."""
110
+ service_info = self.get_email_service_info()
111
+ return self._build_generic_service_status(
112
+ status="operational",
113
+ is_available=True,
114
+ credits_type="unlimited",
115
+ rate_limits={},
116
+ warnings=service_info.get("warnings"),
117
+ details=service_info.get("details"),
118
+ )
119
+
120
+ def cancel_email(self, **kwargs: Any) -> bool:
121
+ """SMTP has no notion of cancel."""
122
+ return False
123
+
124
+ def cancel_email_marketing(self, **kwargs: Any) -> bool:
125
+ return False
126
+
127
+ def validate_email_webhook_signature(
128
+ self, payload: Any, headers: Dict[str, str]
129
+ ) -> Tuple[bool, str]:
130
+ """SMTP providers do not expose webhooks by default."""
131
+ return True, ""
132
+
133
+ validate_email_marketing_webhook_signature = validate_email_webhook_signature
134
+
135
+ def handle_email_webhook(
136
+ self, payload: Dict[str, Any], headers: Dict[str, str]
137
+ ) -> Tuple[bool, str, Optional[Any]]:
138
+ return False, "SMTP provider has no webhooks", None
139
+
140
+ handle_email_marketing_webhook = handle_email_webhook
141
+
142
+ def extract_email_missive_id(self, payload: Dict[str, Any]) -> Optional[str]:
143
+ return None
144
+
145
+ extract_email_marketing_missive_id = extract_email_missive_id
146
+
147
+ # ------------------------------------------------------------------
148
+ # Helpers
149
+ # ------------------------------------------------------------------
150
+ def _build_message(self, recipient: str) -> EmailMessage:
151
+ from_email = self._config.get("DEFAULT_FROM_EMAIL", "noreply@example.com")
152
+ return build_email_message(self, recipient, from_email=from_email)
153
+
154
+ @contextmanager
155
+ def _smtp_connection(self):
156
+ host = str(self._config.get("SMTP_HOST"))
157
+ port = int(self._config.get("SMTP_PORT"))
158
+ timeout = float(self._config.get("SMTP_TIMEOUT_SECONDS", 10))
159
+ use_ssl = self._bool_config("SMTP_USE_SSL", False)
160
+ use_tls = self._bool_config("SMTP_USE_TLS", False)
161
+
162
+ if use_ssl:
163
+ smtp: smtplib.SMTP = smtplib.SMTP_SSL(host, port, timeout=timeout)
164
+ else:
165
+ smtp = smtplib.SMTP(host, port, timeout=timeout)
166
+ if use_tls:
167
+ smtp.starttls()
168
+
169
+ username = self._config.get("SMTP_USERNAME")
170
+ password = self._config.get("SMTP_PASSWORD")
171
+ if username and password:
172
+ smtp.login(username, password)
173
+
174
+ try:
175
+ yield smtp
176
+ finally:
177
+ try:
178
+ smtp.quit()
179
+ except Exception:
180
+ smtp.close()
181
+
182
+ def _bool_config(self, key: str, default: bool) -> bool:
183
+ """Convert config value to boolean."""
184
+ value = self._raw_config.get(key, default)
185
+ if isinstance(value, str):
186
+ return value.strip().lower() in {"1", "true", "yes", "on"}
187
+ return bool(value)
188
+
189
+
190
+ __all__ = ["SMTPProvider"]
@@ -0,0 +1,91 @@
1
+ """Microsoft Teams provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ..status import MissiveStatus
8
+ from .base import BaseProvider
9
+
10
+
11
+ class TeamsProvider(BaseProvider):
12
+ """
13
+ Microsoft Teams provider.
14
+
15
+ Required configuration:
16
+ TEAMS_CLIENT_ID: Azure AD app Client ID
17
+ TEAMS_CLIENT_SECRET: Client Secret
18
+ TEAMS_TENANT_ID: Tenant ID
19
+
20
+ Recipient must have:
21
+ - A Microsoft user_id (in metadata.teams_user_id)
22
+ - OR a Teams channel_id (in metadata.teams_channel_id)
23
+ """
24
+
25
+ name = "teams"
26
+ display_name = "Microsoft Teams"
27
+ supported_types = ["BRANDED"] # Uses generic BRANDED type
28
+ services = ["teams", "messaging"]
29
+ brands = ["teams"] # Microsoft Teams only
30
+ config_keys = ["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"]
31
+ required_packages = ["msgraph-core", "msal"]
32
+ site_url = "https://www.microsoft.com/microsoft-teams/"
33
+ status_url = "https://status.azure.com/en-us/status"
34
+ documentation_url = "https://learn.microsoft.com/en-us/microsoftteams/"
35
+ description_text = "Microsoft Teams - Enterprise communication (Microsoft 365)"
36
+ # Geographic scope
37
+ branded_geo = "*"
38
+
39
+ def validate(self) -> tuple[bool, str]:
40
+ """Validate that the recipient has a Teams user_id or channel_id"""
41
+ if not self.missive:
42
+ return False, "Missive not defined"
43
+
44
+ recipient = getattr(self.missive, "recipient", None)
45
+ if not recipient:
46
+ return False, "Recipient not defined"
47
+
48
+ metadata = getattr(recipient, "metadata", None) or {}
49
+ user_id = metadata.get("teams_user_id")
50
+ channel_id = metadata.get("teams_channel_id")
51
+
52
+ if not user_id and not channel_id:
53
+ return (
54
+ False,
55
+ "Recipient must have a teams_user_id or teams_channel_id in metadata",
56
+ )
57
+
58
+ return True, ""
59
+
60
+ def send_teams(self) -> bool:
61
+ """
62
+ Send a Teams message via Microsoft Graph API.
63
+
64
+ TODO: Implement actual sending via:
65
+ POST https://graph.microsoft.com/v1.0/chats/{chat-id}/messages
66
+ """
67
+ is_valid, error = self.validate()
68
+ if not is_valid:
69
+ self._update_status(MissiveStatus.FAILED, error_message=error)
70
+ return False
71
+
72
+ # TODO: Implement actual sending
73
+ # 1. Get an OAuth access token
74
+ # 2. Send the message via Graph API
75
+ # 3. Handle adaptive cards for rich content
76
+
77
+ external_id = f"teams_sim_{getattr(self.missive, 'id', 'unknown')}"
78
+ self._update_status(
79
+ MissiveStatus.SENT,
80
+ external_id=external_id,
81
+ )
82
+
83
+ return True
84
+
85
+ def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
86
+ """Check status via Graph API"""
87
+ # TODO: Implement via Microsoft Graph API
88
+ return None
89
+
90
+
91
+ __all__ = ["TeamsProvider"]
@@ -0,0 +1,69 @@
1
+ """Telegram Bot API provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ..status import MissiveStatus
8
+ from .base import BaseProvider
9
+
10
+
11
+ class TelegramProvider(BaseProvider):
12
+ """Telegram provider."""
13
+
14
+ name = "telegram"
15
+ display_name = "Telegram"
16
+ supported_types = ["BRANDED"]
17
+ brands = ["telegram"]
18
+ config_keys = ["TELEGRAM_BOT_TOKEN"]
19
+ required_packages = ["python-telegram-bot"]
20
+ site_url = "https://telegram.org/"
21
+ description_text = "Secure instant messaging with bots"
22
+ # Geographic scope
23
+ branded_geo = "*"
24
+
25
+ def validate(self) -> tuple[bool, str]:
26
+ """Validates recipient has Telegram chat_id."""
27
+ if not self.missive:
28
+ return False, "Missive not defined"
29
+
30
+ recipient = getattr(self.missive, "recipient", None)
31
+ if not recipient:
32
+ return False, "Recipient not defined"
33
+
34
+ metadata = getattr(recipient, "metadata", None) or {}
35
+ chat_id = metadata.get("telegram_chat_id")
36
+ if not chat_id:
37
+ return False, "Recipient has no telegram_chat_id in metadata"
38
+
39
+ return True, ""
40
+
41
+ def send_branded(self, brand_name: Optional[str] = None, **kwargs) -> bool:
42
+ """Sends message via Telegram Bot API."""
43
+ is_valid, error = self.validate()
44
+ if not is_valid:
45
+ self._update_status(MissiveStatus.FAILED, error_message=error)
46
+ return False
47
+
48
+ # TODO: Implement actual sending
49
+ # For now, simulate sending
50
+ external_id = f"telegram_sim_{getattr(self.missive, 'id', 'unknown')}"
51
+ self._update_status(
52
+ MissiveStatus.SENT,
53
+ external_id=external_id,
54
+ )
55
+
56
+ return True
57
+
58
+ def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
59
+ """
60
+ Check status of a Telegram message.
61
+
62
+ Note: Telegram does not provide automatic webhooks for delivery status.
63
+ Can only know if message was sent.
64
+ """
65
+ # TODO: Implement if needed
66
+ return None
67
+
68
+
69
+ __all__ = ["TelegramProvider"]
@@ -0,0 +1,310 @@
1
+ """Twilio provider for SMS and WhatsApp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Optional, Tuple
6
+
7
+ from ..status import MissiveStatus
8
+ from .base import BaseProvider
9
+
10
+
11
+ class TwilioProvider(BaseProvider):
12
+ """Twilio provider (SMS and WhatsApp)."""
13
+
14
+ name = "twilio"
15
+ display_name = "Twilio"
16
+ supported_types = ["SMS", "BRANDED", "VOICE_CALL"]
17
+ services = ["sms", "whatsapp", "voice", "verify"]
18
+ brands = ["whatsapp"]
19
+ config_keys = ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"]
20
+ required_packages = ["twilio"]
21
+ site_url = "https://www.twilio.com/"
22
+ status_url = "https://status.twilio.com/"
23
+ documentation_url = "https://www.twilio.com/docs"
24
+ description_text = "Global multi-channel cloud platform (SMS, WhatsApp, Voice)"
25
+
26
+ def send_twilio(self) -> bool:
27
+ """Dispatches to WhatsApp for BRANDED type."""
28
+ return self.send_whatsapp()
29
+
30
+ def send_sms(self) -> bool:
31
+ """Sends SMS via Twilio."""
32
+ is_valid, error = self._validate_and_check_recipient(
33
+ "recipient_phone", "Phone missing"
34
+ )
35
+ if not is_valid:
36
+ self._update_status(MissiveStatus.FAILED, error_message=error)
37
+ return False
38
+
39
+ try:
40
+ # TODO: Implement actual Twilio SMS sending
41
+ # from twilio.rest import Client
42
+ #
43
+ # account_sid = self._config.get('TWILIO_ACCOUNT_SID')
44
+ # auth_token = self._config.get('TWILIO_AUTH_TOKEN')
45
+ # from_number = self._config.get('TWILIO_PHONE_NUMBER')
46
+ #
47
+ # client = Client(account_sid, auth_token)
48
+ # message = client.messages.create(
49
+ # body=self.missive.body,
50
+ # from_=from_number,
51
+ # to=self.missive.recipient_phone
52
+ # )
53
+ #
54
+ # external_id = message.sid
55
+
56
+ # Simulation
57
+ external_id = f"tw_sms_{getattr(self.missive, 'id', 'unknown')}"
58
+
59
+ self._update_status(
60
+ MissiveStatus.SENT, provider=self.name, external_id=external_id
61
+ )
62
+ self._create_event("sent", "SMS sent via Twilio")
63
+
64
+ return True
65
+
66
+ except Exception as e:
67
+ return self._handle_send_error(e)
68
+
69
+ def send_whatsapp(self) -> bool:
70
+ """Send via WhatsApp via Twilio"""
71
+ is_valid, error = self._validate_and_check_recipient(
72
+ "recipient_phone", "Phone missing"
73
+ )
74
+ if not is_valid:
75
+ self._update_status(MissiveStatus.FAILED, error_message=error)
76
+ return False
77
+
78
+ try:
79
+ # TODO: Integrate with Twilio WhatsApp
80
+ # from twilio.rest import Client
81
+ #
82
+ # account_sid = self._config.get('TWILIO_ACCOUNT_SID')
83
+ # auth_token = self._config.get('TWILIO_AUTH_TOKEN')
84
+ #
85
+ # client = Client(account_sid, auth_token)
86
+ #
87
+ # from_number = f"whatsapp:{self._config.get('TWILIO_WHATSAPP_NUMBER')}"
88
+ # to_number = f"whatsapp:{self.missive.recipient_phone}"
89
+ #
90
+ # message = client.messages.create(
91
+ # body=self.missive.body,
92
+ # from_=from_number,
93
+ # to=to_number,
94
+ # status_callback=self._config.get('TWILIO_WEBHOOK_URL')
95
+ # )
96
+ #
97
+ # external_id = message.sid
98
+
99
+ # Simulation
100
+ external_id = f"tw_wa_{getattr(self.missive, 'id', 'unknown')}"
101
+
102
+ self._update_status(
103
+ MissiveStatus.SENT, provider=self.name, external_id=external_id
104
+ )
105
+ self._create_event("sent", "WhatsApp message sent via Twilio")
106
+
107
+ return True
108
+
109
+ except Exception as e:
110
+ self._update_status(MissiveStatus.FAILED, error_message=str(e))
111
+ self._create_event("failed", str(e))
112
+ return False
113
+
114
+ def send_voice_call(self) -> bool:
115
+ """Send voice call via Twilio."""
116
+ is_valid, error = self.validate()
117
+ if not is_valid:
118
+ self._update_status(MissiveStatus.FAILED, error_message=error)
119
+ return False
120
+
121
+ if not self._get_missive_value("recipient_phone"):
122
+ self._update_status(MissiveStatus.FAILED, error_message="Phone missing")
123
+ return False
124
+
125
+ try:
126
+ # TODO: Implement actual Twilio Voice call
127
+ # from twilio.rest import Client
128
+ #
129
+ # account_sid = self._config.get('TWILIO_ACCOUNT_SID')
130
+ # auth_token = self._config.get('TWILIO_AUTH_TOKEN')
131
+ #
132
+ # client = Client(account_sid, auth_token)
133
+ # call = client.calls.create(
134
+ # to=self.missive.recipient_phone,
135
+ # from_=self._config.get('TWILIO_PHONE_NUMBER'),
136
+ # url=self._config.get('TWILIO_VOICE_URL'), # TwiML URL
137
+ # )
138
+ #
139
+ # external_id = call.sid
140
+
141
+ # Simulation
142
+ external_id = f"tw_voice_{getattr(self.missive, 'id', 'unknown')}"
143
+
144
+ self._update_status(
145
+ MissiveStatus.SENT, provider=self.name, external_id=external_id
146
+ )
147
+ self._create_event("sent", "Voice call initiated via Twilio")
148
+
149
+ return True
150
+
151
+ except Exception as e:
152
+ self._update_status(MissiveStatus.FAILED, error_message=str(e))
153
+ self._create_event("failed", str(e))
154
+ return False
155
+
156
+ def validate_webhook_signature(
157
+ self, payload: Dict, headers: Dict
158
+ ) -> Tuple[bool, str]:
159
+ """Validate Twilio webhook signature."""
160
+ auth_token = self._config.get("TWILIO_AUTH_TOKEN")
161
+ if not auth_token:
162
+ return True, ""
163
+
164
+ signature = headers.get("HTTP_X_TWILIO_SIGNATURE", "")
165
+ if not signature:
166
+ return False, "Signature missing"
167
+
168
+ # Twilio validation requires the full URL
169
+ # For simplicity, validation can be disabled in dev
170
+ # In production, implement according to:
171
+ # https://www.twilio.com/docs/usage/webhooks/webhooks-security
172
+
173
+ return True, "" # Simplified for now
174
+
175
+ def extract_sms_missive_id(self, payload: Dict) -> Optional[str]:
176
+ """Extract missive ID from Twilio webhook."""
177
+ # Twilio returns MessageSid, we must have stored it in external_id
178
+ return payload.get("MessageSid")
179
+
180
+ def extract_event_type(self, payload: Dict) -> str:
181
+ """Extract status from Twilio webhook."""
182
+ return payload.get("MessageStatus", "unknown")
183
+
184
+ def get_status_from_event(self, event_type: str) -> Optional[MissiveStatus]:
185
+ """Map Twilio statuses to MissiveStatus."""
186
+ status_mapping = {
187
+ "queued": MissiveStatus.PENDING,
188
+ "sending": MissiveStatus.PENDING,
189
+ "sent": MissiveStatus.SENT,
190
+ "delivered": MissiveStatus.DELIVERED,
191
+ "undelivered": MissiveStatus.FAILED,
192
+ "failed": MissiveStatus.FAILED,
193
+ "read": MissiveStatus.READ,
194
+ }
195
+ return status_mapping.get(event_type.lower())
196
+
197
+ def get_service_status(self) -> Dict:
198
+ """
199
+ Gets Twilio status and credits.
200
+
201
+ Twilio uses a prepaid system in USD.
202
+
203
+ Returns:
204
+ Dict with status, credits in USD, etc.
205
+ """
206
+ last_check = self._get_last_check_time()
207
+
208
+ return {
209
+ "status": "unknown",
210
+ "is_available": None,
211
+ "services": self._get_services(),
212
+ "credits": {
213
+ "type": "money",
214
+ "remaining": None,
215
+ "currency": "USD",
216
+ "limit": None,
217
+ "percentage": None,
218
+ },
219
+ "rate_limits": {
220
+ "per_second": 1,
221
+ "per_minute": 60,
222
+ },
223
+ "sla": {
224
+ "uptime_percentage": 99.95,
225
+ },
226
+ "last_check": last_check,
227
+ "warnings": ["Twilio API not implemented - uncomment the code"],
228
+ "details": {
229
+ "refill_url": "https://www.twilio.com/console/billing",
230
+ "status_page": "https://status.twilio.com/",
231
+ "api_docs": ("https://www.twilio.com/docs/usage/api/usage-record"),
232
+ },
233
+ }
234
+
235
+ def cancel_sms(self) -> bool:
236
+ """
237
+ Cancel SMS sending via Twilio.
238
+
239
+ Twilio allows canceling messages in 'queued' or 'scheduled' status.
240
+
241
+ Returns:
242
+ bool: True if cancellation succeeded, False otherwise
243
+ """
244
+ if not self.missive or not getattr(self.missive, "external_id", None):
245
+ return False
246
+
247
+ try:
248
+ # TODO: Implement actual cancellation
249
+ # from twilio.rest import Client
250
+ #
251
+ # account_sid = self._config.get("TWILIO_ACCOUNT_SID")
252
+ # auth_token = self._config.get("TWILIO_AUTH_TOKEN")
253
+ #
254
+ # if not account_sid or not auth_token:
255
+ # return False
256
+ #
257
+ # client = Client(account_sid, auth_token)
258
+ # message = client.messages(self.missive.external_id).update(
259
+ # status="canceled"
260
+ # )
261
+ #
262
+ # if message.status == "canceled":
263
+ # self._create_event("cancelled", "SMS cancelled via Twilio")
264
+ # return True
265
+ # else:
266
+ # return False
267
+
268
+ return False
269
+
270
+ except Exception:
271
+ return False
272
+
273
+ def cancel_twilio(self) -> bool:
274
+ """
275
+ Cancel branded message (WhatsApp) sending via Twilio.
276
+
277
+ Called automatically by cancel_branded() via dispatch.
278
+ Works the same way as cancel_sms() because Twilio uses
279
+ the same API for SMS and WhatsApp.
280
+
281
+ Returns:
282
+ bool: True if cancellation succeeded, False otherwise
283
+ """
284
+ return self.cancel_sms()
285
+
286
+ def cancel_whatsapp(self) -> bool:
287
+ """
288
+ Cancels WhatsApp message sending via Twilio.
289
+
290
+ Called automatically by cancel_branded("whatsapp") via dispatch.
291
+
292
+ Returns:
293
+ bool: True if cancellation succeeded, False otherwise
294
+ """
295
+ return self.cancel_sms()
296
+
297
+ def get_whatsapp_service_info(self) -> Dict:
298
+ """
299
+ Gets WhatsApp service information via Twilio.
300
+
301
+ Called automatically by get_branded_service_info("whatsapp") via dispatch.
302
+
303
+ Returns:
304
+ Dict with status, credits, etc.
305
+ """
306
+ # WhatsApp via Twilio uses the same credit system as SMS
307
+ return self.get_service_status()
308
+
309
+
310
+ __all__ = ["TwilioProvider"]